feat: archive CW-033 deployed sign map prototype v0.1.0

This commit is contained in:
mpmedia committed 2026-10-28 15:15:26 -05:00
commit 2f1c437a55
17 files changed
+1562

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Read-only Vontas GetStop adapter. Never logs authenticated request URLs."""
import argparse,datetime,json,os,pathlib,re,time,urllib.parse,urllib.request
p=argparse.ArgumentParser();p.add_argument('--players',type=pathlib.Path,required=True);p.add_argument('--output',type=pathlib.Path,required=True);a=p.parse_args()
base=os.environ.get('RTC_NATIVE_API_BASE','').rstrip('/');key=os.environ.get('RTC_NATIVE_API_KEY','')
if not base or not key:raise SystemExit('Set RTC_NATIVE_API_BASE (ASMX service URL) and RTC_NATIVE_API_KEY securely.')
url=urllib.parse.urlsplit(base)
if url.scheme!='https' or url.query or url.fragment:raise SystemExit('API base must be HTTPS with no credential query or fragment.')
raw=json.loads(a.players.read_text());players=raw['rows'] if isinstance(raw,dict) else raw;ids=set()
for player in players:
m=re.search(r'-\s*S(\d+(?:\s*,\s*\d+)*)\s*$',player.get('player',''),re.I)
if m:ids.update(re.findall(r'\d+',m[1]))
results={};errors={}
for sid in sorted(ids,key=int):
try:
request=base+'/GetStop?'+urllib.parse.urlencode({'apikey':key,'stopid':sid})
with urllib.request.urlopen(request,timeout=25) as response:stop=json.load(response)
if not isinstance(stop,dict) or str(stop.get('StopID'))!=sid or stop.get('SrcStopID') is None:errors[sid]='Invalid stop response'
else:results[sid]=stop
except Exception as exc:errors[sid]=type(exc).__name__
time.sleep(.1)
report={'endpoint':base+'/GetStop','retrieved_at_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'stops':results,'errors':errors}
a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(json.dumps(report,indent=2));print('Read',len(results),'stops;',len(errors),'errors. Credentials excluded.')
if errors:raise SystemExit(2)
+51
View File
@@ -0,0 +1,51 @@
#!/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')
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Render the captured map from validated normalized data. No credentials are bundled by default."""
import argparse,json,os,pathlib
ROOT=pathlib.Path(__file__).resolve().parents[1]
p=argparse.ArgumentParser();p.add_argument('--data',type=pathlib.Path,required=True);p.add_argument('--output',type=pathlib.Path,required=True);p.add_argument('--include-carto-key',action='store_true');a=p.parse_args()
s=(ROOT/'templates/map.html').read_text();key=os.environ.get('RTC_CARTO_BASEMAP_KEY','') if a.include_carto_key else ''
# Retain the prototype's RTC-specific UI; this renderer does not claim multi-agency support.
s=s.replace('const CARTO_BASEMAP_KEY=','let CARTO_BASEMAP_KEY=')
s=s.replace("baseMode=mode;$('basemap').value=mode;","if(mode.startsWith('positron')&&!CARTO_BASEMAP_KEY){const k=window.prompt('Enter your CARTO basemap key (kept in this tab only):');if(!k){$('basemap').value=baseMode;return;}CARTO_BASEMAP_KEY=k.trim();}baseMode=mode;$('basemap').value=mode;")
if not key:
s=s.replace("let savedBase='positron';","let savedBase='esri';").replace("||savedBase}catch{}setBasemap(","||savedBase}catch{}if(savedBase.startsWith('positron')&&!CARTO_BASEMAP_KEY)savedBase='esri';setBasemap(")
files={'/*LEAFLET_CSS*/':'leaflet.css','/*MAPLIBRE_CSS*/':'maplibre-gl.css','/*LEAFLET_JS*/':'leaflet.js','/*MAPLIBRE_JS*/':'maplibre-gl.js','/*MAPLIBRE_LEAFLET_JS*/':'leaflet-maplibre-gl.js'}
for token,name in files.items():s=s.replace(token,(ROOT/'vendor'/name).read_text().replace('</script>','<\\/script>'))
s=s.replace('/*DATA*/',json.dumps(json.loads(a.data.read_text()),separators=(',',':')).replace('</script>','<\\/script>')).replace('/*CARTO_KEY*/',json.dumps(key))
a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(s);print('Rendered',a.output,'with embedded CARTO key' if key else 'without embedded credentials')
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Validate map coordinate, ID, service and shape integrity against the captured source inputs."""
import argparse,collections,csv,io,json,pathlib,zipfile
p=argparse.ArgumentParser();p.add_argument('--data',type=pathlib.Path,required=True);p.add_argument('--native',type=pathlib.Path,required=True);p.add_argument('--gtfs',type=pathlib.Path,required=True);a=p.parse_args();d=json.loads(a.data.read_text());n=json.loads(a.native.read_text());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={r['route_id']:r for r in rows('routes')};active=set(d['meta']['active_services']);sr=collections.defaultdict(set);sh=collections.defaultdict(list);rs=collections.defaultdict(set)
for r in rows('stop_times'):
if trips[r['trip_id']]['service_id'] in active:sr[r['stop_id']].add(trips[r['trip_id']]['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 r in rows('shapes'):sh[r['shape_id']].append((int(r['shape_pt_sequence']),float(r['shape_pt_lat']),float(r['shape_pt_lon'])))
for player in d['players']:
assert player['resolved_stop_ids']==[str(n['stops'][sid]['SrcStopID']) for sid in player['source_ids']]
assert set(player['routes'])==(set().union(*(sr[s] for s in player['resolved_stop_ids'])) if player['source_ids'] else set())
if player['kind']=='kiosk':assert all(stops[s]['parent_station']==player['candidate_ids'][0] for s in player['resolved_stop_ids'])
for sid,s in d['stops'].items():assert [s['lat'],s['lon']]==[float(stops[sid]['stop_lat']),float(stops[sid]['stop_lon'])]
for r in d['routes']:
assert r['color']=='#'+routes[r['id']]['route_color'] and set(r['shape_ids'])==rs[r['id']]
for sid,line in zip(r['shape_ids'],r['lines']):assert line==[[lat,lon] for _,lat,lon in sorted(sh[sid])]
assert len(d['audit'])==sum(len(p['source_ids']) for p in d['players'])
print('PASS: native ID joins, GTFS coordinates, mapped-bay routes, active-service geometry and route colors')