#!/usr/bin/env python3 """Build normalized map data from read-only OnSign export, native stops, and a dated GTFS ZIP.""" import argparse,collections,csv,datetime,difflib,hashlib,io,json,math,pathlib,re,zipfile p=argparse.ArgumentParser();p.add_argument('--players',type=pathlib.Path,required=True);p.add_argument('--native',type=pathlib.Path,required=True);p.add_argument('--gtfs',type=pathlib.Path,required=True);p.add_argument('--service-date',type=datetime.date.fromisoformat,required=True);p.add_argument('--output-dir',type=pathlib.Path,required=True);a=p.parse_args() z=zipfile.ZipFile(a.gtfs) def rows(name):return list(csv.DictReader(io.TextIOWrapper(z.open(name+'.txt'),encoding='utf-8-sig'))) stops={r['stop_id']:r for r in rows('stops')};trips={r['trip_id']:r for r in rows('trips')};routes=rows('routes');feed=rows('feed_info')[0];ds=a.service_date.strftime('%Y%m%d');day=a.service_date.strftime('%A').lower() if not feed.get('feed_start_date','00000000')<=ds<=feed.get('feed_end_date','99999999'):raise SystemExit('Requested date is outside feed validity. Download the current official feed before a live rebuild.') active={r['service_id'] for r in rows('calendar') if r['start_date']<=ds<=r['end_date'] and r[day]=='1'} for c in rows('calendar_dates'): if c['date']==ds: if c['exception_type']=='1':active.add(c['service_id']) else:active.discard(c['service_id']) if not active:raise SystemExit('No service active on requested date.') sr=collections.defaultdict(set);rs=collections.defaultdict(set);shapes=collections.defaultdict(list) for st in rows('stop_times'): t=trips[st['trip_id']] if t['service_id'] in active:sr[st['stop_id']].add(t['route_id']) for t in trips.values(): if t['service_id'] in active and t['shape_id']:rs[t['route_id']].add(t['shape_id']) for sh in rows('shapes'):shapes[sh['shape_id']].append((int(sh['shape_pt_sequence']),float(sh['shape_pt_lat']),float(sh['shape_pt_lon']))) payload=json.loads(a.players.read_text());native=json.loads(a.native.read_text());players=payload['rows'];audit=[];used=set() def distance(coords,s): lat,lon=coords.get('latitude'),coords.get('longitude') if lat is None or lon is None:return None x,y=float(s['stop_lat']),float(s['stop_lon']);rad=math.pi/180;h=math.sin((lat-x)*rad/2)**2+math.cos(lat*rad)*math.cos(x*rad)*math.sin((lon-y)*rad/2)**2 return round(6371000*2*math.asin(min(1,math.sqrt(h))),2) for player in players: m=re.search(r'-\s*S(\d+(?:\s*,\s*\d+)*)\s*$',player['player'],re.I);ids=re.findall(r'\d+',m[1]) if m else [] player.update(source_ids=ids,kind='kiosk' if 'kiosk' in player['player'].lower() else 'sign',excluded=not bool(m),exclude_reason='Detached test player' if player.get('detached') else ('Showroom or unassigned player; no field stop ID' if not m else ''),crosswalk=[]) for sid in ids: n=native['stops'].get(sid);target=str(n.get('SrcStopID')) if n else '' if not n or str(n.get('StopID'))!=sid or target not in stops:raise SystemExit('Unresolved native stop '+sid+'; do not guess an ID, coordinate, or route.') s=stops[target];geo=n.get('GeoLoc') or {};delta=distance(geo,s);norm=lambda v:re.sub('[^a-z0-9]','',v.lower());ratio=difflib.SequenceMatcher(None,norm(n.get('Name','')),norm(s['stop_name'])).ratio() c={'native_stop_id':sid,'gtfs_stop_id':target,'native_name':n.get('Name'),'gtfs_name':s['stop_name'],'native_coordinates':[geo.get('latitude'),geo.get('longitude')],'gtfs_coordinates':[float(s['stop_lat']),float(s['stop_lon'])],'coordinate_delta_m':delta,'coordinate_review':delta is not None and delta>25,'name_review':ratio<.85,'routes':sorted(sr[target]),'basis':'Native GetStop SrcStopID → GTFS stop_id'};player['crosswalk'].append(c) player['resolved_stop_ids']=[c['gtfs_stop_id'] for c in player['crosswalk']] player['candidate_ids']=player['resolved_stop_ids'][:];player['match']='excluded' if player['excluded'] else 'API verified' if player['kind']=='kiosk': parents={stops[t]['parent_station'] for t in player['resolved_stop_ids']} if len(parents)!=1 or not next(iter(parents)) or next(iter(parents)) not in stops:raise SystemExit('Kiosk bays do not resolve to exactly one GTFS parent station; manual review required.') player['candidate_ids']=sorted(parents);player['match']='API verified · station reference' player['routes']=sorted(set().union(*(sr[t] for t in player['resolved_stop_ids']))) if ids else [];player['coordinate_review']=any(c['coordinate_review'] for c in player['crosswalk']);used.update(player['candidate_ids']+player['resolved_stop_ids']) for c in player['crosswalk']: audit.append({'player_id':player['player_id'],'player_name':player['player'],'status':player['status'],'native_id':c['native_stop_id'],'id_result':'resolved via native API','gtfs_stop_id':c['gtfs_stop_id'],'gtfs_name':c['gtfs_name'],'native_name':c['native_name'],'mapping_basis':c['basis'],'gtfs_lat':c['gtfs_coordinates'][0],'gtfs_lon':c['gtfs_coordinates'][1],'coordinate_delta_m':c['coordinate_delta_m'],'coordinate_review':c['coordinate_review'],'name_review':c['name_review'],'station_reference':player['candidate_ids'][0] if player['kind']=='kiosk' else ''}) route_data=[{'id':r['route_id'],'short':r['route_short_name'],'name':r['route_long_name'],'color':'#'+r['route_color'],'shape_ids':sorted(rs[r['route_id']]),'lines':[[[lat,lon] for _,lat,lon in sorted(shapes[sh])] for sh in sorted(rs[r['route_id']])]} for r in routes] meta={'account':players[0].get('account_id') if players else None,'as_of':payload.get('as_of'),'cache_age_s':payload.get('cache_age_s'),'service_date':a.service_date.isoformat(),'feed_info':feed,'feed_url':'https://data.trilliumtransit.com/gtfs/rtc-nv-us/rtc-nv-us.zip','official_page':'https://rtcwashoe.com/resources-and-reports/','sha256':hashlib.sha256(a.gtfs.read_bytes()).hexdigest(),'active_services':sorted(active),'native_retrieved_at_utc':native['retrieved_at_utc'],'resolved_native_ids':len(audit),'coordinate_reviews':sum(c['coordinate_review'] for c in audit),'unmatched':0,'ambiguous_signs':0,'coordinate_authority':'GTFS stops.txt only; OnSign location unused'} d={'meta':meta,'players':players,'stops':{sid:{'id':sid,'name':stops[sid]['stop_name'],'lat':float(stops[sid]['stop_lat']),'lon':float(stops[sid]['stop_lon']),'routes':sorted(sr[sid]),'headings':[]} for sid in used},'routes':route_data,'audit':audit} a.output_dir.mkdir(parents=True,exist_ok=True);(a.output_dir/'rtc-map-data.json').write_text(json.dumps(d,separators=(',',':'))) with (a.output_dir/'resolved-stop-crosswalk.csv').open('w') as f: w=csv.DictWriter(f,fieldnames=list(audit[0]));w.writeheader();w.writerows(audit) print('Prepared',len(players),'players,',len(audit),'native stop mappings,',len(route_data),'GTFS routes')