52 lines
6.5 KiB
Python
52 lines
6.5 KiB
Python
#!/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')
|