TTC-ScheduleWatch / generate_precomputed_map.py
neil-simmons
Deploying clean version to HF Spaces
93d2c2d
Raw
History Blame Contribute Delete
16.3 kB
import sys
import subprocess
import importlib
# --- AUTO-INSTALLER ---
def ensure_packages():
packages = {
'pandas': 'pandas',
'numpy': 'numpy',
'geopandas': 'geopandas',
'shapely': 'shapely',
'pyarrow': 'pyarrow',
'huggingface_hub': 'huggingface-hub'
}
for module_name, pip_name in packages.items():
try:
importlib.import_module(module_name)
except ImportError:
print(f"📦 Missing {module_name}. Installing {pip_name}...")
subprocess.check_call([sys.executable, "-m", "pip", "install", pip_name])
print(f"✅ Successfully installed {pip_name}.")
ensure_packages()
import pandas as pd
import numpy as np
import geopandas as gpd
from shapely.geometry import LineString, Point
from shapely.ops import substring
import pyarrow.parquet as pq
import pyarrow.compute as pc
import pyarrow.dataset as ds
import pyarrow as pa
from huggingface_hub import hf_hub_download
import json
import warnings
warnings.filterwarnings("ignore")
# --- CONFIGURATION ---
HF_REPO = "neil-simmons/ttc-avl-data"
HF_REPO_TYPE = "dataset"
PARQUET_HISTORY = "ttc_all_streetcars_history.parquet"
START_DATE = '2026-03-15'
END_DATE = '2026-05-02 23:59:59'
STAT_HOLIDAYS = ['2026-04-03']
MAX_TRACK_DEVIATION_M = 150
MAX_ALLOWED_PING_GAP_SEC = 120
UTM_PROJ = "EPSG:32617"
LATLON_PROJ = "EPSG:4326"
# --- ANALYSIS PARAMETERS (ENTIRE DAY) ---
DAY_TYPE = "Weekdays"
TIME_MODE = "Overlap Mode"
FORCE_T0 = False
WINDOW_EARLY = -15
WINDOW_LATE = 120
FILTER_START_SEC = 0
FILTER_END_SEC = 28 * 3600 # Covers the complete 24-hour operating day (including past midnight)
def _hf(filename):
print(f"Downloading {filename}...")
return hf_hub_download(repo_id=HF_REPO, filename=filename, repo_type=HF_REPO_TYPE)
def parse_gtfs_time(time_str):
if pd.isna(time_str): return np.nan
h, m, s = map(int, time_str.split(':'))
return h * 3600 + m * 60 + s
def load_data():
stops = pd.read_csv(_hf("stops.txt"), usecols=['stop_id', 'stop_name', 'stop_lat', 'stop_lon'], dtype={'stop_id': 'string[pyarrow]', 'stop_name': 'string[pyarrow]'})
stops['stop_id'] = stops['stop_id'].astype('category')
stops['stop_lat'] = pd.to_numeric(stops['stop_lat'])
stops['stop_lon'] = pd.to_numeric(stops['stop_lon'])
trips = pd.read_csv(_hf("trips.txt"), usecols=['route_id', 'trip_id', 'shape_id', 'trip_headsign'], dtype='string[pyarrow]')
trips['trip_id'] = trips['trip_id'].str.replace(r'\.0$', '', regex=True).str.strip().astype('category')
stop_times = pd.read_csv(_hf("stop_times.txt"), usecols=['trip_id', 'stop_id', 'arrival_time', 'stop_sequence', 'shape_dist_traveled'], dtype='string[pyarrow]')
stop_times['trip_id'] = stop_times['trip_id'].str.replace(r'\.0$', '', regex=True).str.strip().astype('category')
stop_times['stop_id'] = stop_times['stop_id'].astype('category')
stop_times['shape_dist_traveled'] = pd.to_numeric(stop_times['shape_dist_traveled'], downcast='float')
stop_times['stop_sequence'] = pd.to_numeric(stop_times['stop_sequence'], downcast='integer')
shapes = pd.read_csv(_hf("shapes.txt"), usecols=['shape_id', 'shape_pt_lat', 'shape_pt_lon', 'shape_pt_sequence'], dtype={'shape_id': 'string[pyarrow]'})
shapes['shape_pt_lat'] = pd.to_numeric(shapes['shape_pt_lat'], downcast='float')
shapes['shape_pt_lon'] = pd.to_numeric(shapes['shape_pt_lon'], downcast='float')
shapes['shape_pt_sequence'] = pd.to_numeric(shapes['shape_pt_sequence'], downcast='integer')
return stops, trips, stop_times, shapes
def load_route_data(path, selected_route):
schema = pq.read_schema(path)
route_id_type = schema.field('route_id').type
if pa.types.is_integer(route_id_type): filter_val = [int(selected_route)]
elif pa.types.is_floating(route_id_type): filter_val = [float(selected_route)]
else: filter_val = [str(selected_route), f"{selected_route}.0"]
table = ds.dataset(path, format="parquet").to_table(columns=['trip_id', 'system_time', 'latitude', 'longitude'], filter=ds.field('route_id').isin(filter_val))
df = table.to_pandas()
df['trip_id'] = df['trip_id'].astype(str).str.replace(r'\.0$', '', regex=True).str.strip().astype('category')
df['latitude'] = df['latitude'].astype(np.float32)
df['longitude'] = df['longitude'].astype(np.float32)
df['system_time'] = df['system_time'].astype(np.int32)
local_time = pd.to_datetime(df['system_time'], unit='s', utc=True).dt.tz_convert('America/Toronto')
mask = ((local_time.dt.tz_localize(None) >= pd.to_datetime(START_DATE)) & (local_time.dt.tz_localize(None) <= pd.to_datetime(END_DATE)))
df = df[mask].copy()
local_time = local_time[mask]
hour = local_time.dt.hour.astype(np.int32)
sec_since_midnight = (hour * 3600 + local_time.dt.minute.astype(np.int32) * 60 + local_time.dt.second.astype(np.int32)).astype(np.int32)
df['op_seconds'] = np.where(hour < 4, sec_since_midnight + 86400, sec_since_midnight).astype(np.int32)
op_date = np.where(hour < 4, (local_time - pd.Timedelta(days=1)).dt.date, local_time.dt.date)
df['op_date'] = pd.Series(op_date).astype(str).astype('category')
df['day_of_week'] = pd.to_datetime(df['op_date']).dt.dayofweek.astype(np.int8)
df['is_holiday'] = df['op_date'].astype(str).isin(STAT_HOLIDAYS)
return df
def apply_route_offset(geom, route_index, total_routes):
"""Applies a visual offset to geometries that share the same physical tracks."""
if total_routes <= 1 or geom is None: return geom
offset_step = 0.00008
offset_val = (route_index - (total_routes - 1) / 2.0) * offset_step
if offset_val == 0: return geom
try:
if hasattr(geom, 'offset_curve'): return geom.offset_curve(offset_val)
else: return geom.parallel_offset(abs(offset_val), 'left' if offset_val > 0 else 'right')
except:
return geom
def run_precompute():
print("--- TTC Network Precompute Script ---")
parquet_path = _hf(PARQUET_HISTORY)
stops, trips, stop_times, shapes = load_data()
table = pq.read_table(parquet_path, columns=['route_id'])
available_routes = sorted(list(set(str(r).replace('.0', '').strip() for r in pc.unique(table.column('route_id')).to_pylist() if pd.notna(r) and str(r) != 'nan')))
all_stops, all_segments = [], []
for route in available_routes:
print(f"\nProcessing Route {route}...")
df_hist_raw = load_route_data(parquet_path, route)
if DAY_TYPE == "Saturdays": day_mask = (df_hist_raw['day_of_week'] == 5) & (~df_hist_raw['is_holiday'])
elif DAY_TYPE == "Sundays & Holidays": day_mask = (df_hist_raw['day_of_week'] == 6) | (df_hist_raw['is_holiday'])
else: day_mask = (df_hist_raw['day_of_week'] <= 4) & (~df_hist_raw['is_holiday'])
df_hist = df_hist_raw[day_mask]
# Case-insensitive filtration excludes Short, SHORT, or short variations automatically
directions = [d for d in trips[trips['route_id'] == route]['trip_headsign'].dropna().unique() if "short" not in str(d).lower()]
for direction in directions:
print(f" -> Direction: {direction}")
gtfs_route_trips = trips[(trips['route_id'] == route) & (trips['trip_headsign'] == direction)]
valid_trips = gtfs_route_trips[gtfs_route_trips['trip_id'].isin(df_hist['trip_id'].unique())]
if valid_trips.empty: continue
valid_st = stop_times[stop_times['trip_id'].isin(valid_trips['trip_id'])].copy()
valid_st = valid_st.merge(stops, on='stop_id', how='left')
valid_st['arrival_sec'] = valid_st['arrival_time'].apply(parse_gtfs_time)
valid_st['relative_sec'] = valid_st['arrival_sec'] - valid_st.groupby('trip_id')['arrival_sec'].transform('min')
valid_st = valid_st.sort_values(['trip_id', 'stop_sequence'])
sig_dict = {}
for t_id, df_group in valid_st.groupby('trip_id'):
sig = tuple(zip(df_group['stop_id'], df_group['relative_sec']))
if sig: sig_dict.setdefault(sig, []).append(t_id)
trip_start_dict = dict(zip(valid_st.groupby('trip_id').first().reset_index()['trip_id'], valid_st.groupby('trip_id').first().reset_index()['arrival_sec']))
trip_hist_counts = df_hist.groupby('trip_id')['op_date'].nunique().to_dict()
sig_list = []
for sig, t_ids in sig_dict.items():
if sum(trip_hist_counts.get(tid, 0) for tid in t_ids) == 0: continue
starts = [trip_start_dict[tid] for tid in t_ids]
if max(starts) < FILTER_START_SEC or min(starts) > FILTER_END_SEC: continue
sig_list.append({'t_ids': t_ids})
for sig in sig_list:
trip_h = df_hist[df_hist['trip_id'].isin(sig['t_ids'])].copy()
st_filtered = valid_st[valid_st['trip_id'] == sig['t_ids'][0]].copy().sort_values('stop_sequence')
if st_filtered['shape_dist_traveled'].max() > 500: st_filtered['shape_dist_traveled'] /= 1000.0
if len(st_filtered) < 2: continue
shp_id = gtfs_route_trips[gtfs_route_trips['trip_id'] == sig['t_ids'][0]]['shape_id'].iloc[0]
shp_pts = shapes[shapes['shape_id'] == shp_id].copy().sort_values('shape_pt_sequence')
target_line_utm = gpd.GeoDataFrame(index=[0], crs=LATLON_PROJ, geometry=[LineString(list(zip(shp_pts['shape_pt_lon'].astype(float), shp_pts['shape_pt_lat'].astype(float))))]).to_crs(UTM_PROJ).geometry.iloc[0]
trip_h_gdf = gpd.GeoDataFrame(trip_h, crs=LATLON_PROJ, geometry=gpd.points_from_xy(trip_h.longitude, trip_h.latitude)).to_crs(UTM_PROJ)
trip_h = trip_h[trip_h_gdf.distance(target_line_utm) <= MAX_TRACK_DEVIATION_M].copy()
if trip_h.empty: continue
trip_h['official_dist_km'] = trip_h_gdf[trip_h_gdf.distance(target_line_utm) <= MAX_TRACK_DEVIATION_M].geometry.apply(lambda pt: target_line_utm.project(pt)) / 1000.0
actual_times = {sid: [] for sid in st_filtered['stop_id']}
for (op_date, t_id), group in trip_h.groupby(['op_date', 'trip_id']):
group = group.sort_values('system_time').reset_index(drop=True)
if len(group) < 3 or group['official_dist_km'].isna().all(): continue
group = group.loc[:group['official_dist_km'].idxmax()].copy()
group['official_dist_km'] = group['official_dist_km'].cummax()
group = group.drop_duplicates(subset=['official_dist_km'], keep='first')
if len(group) < 2: continue
interp = {sid: t for sid, t in zip(st_filtered['stop_id'], np.interp(st_filtered['shape_dist_traveled'].values, group['official_dist_km'].values, group['op_seconds'].values, left=np.nan, right=np.nan)) if not np.isnan(t)}
if not interp: continue
anchor_dist = st_filtered.iloc[1]['shape_dist_traveled'] if len(st_filtered) > 1 else st_filtered.iloc[0]['shape_dist_traveled']
if group['official_dist_km'].iloc[0] > anchor_dist: continue
anchor_sec = trip_start_dict.get(t_id)
if (TIME_MODE == "Trip Start Mode" and FILTER_START_SEC <= anchor_sec <= FILTER_END_SEC) or (TIME_MODE != "Trip Start Mode" and any(FILTER_START_SEC <= t <= FILTER_END_SEC for t in interp.values())):
for s_id, t in interp.items(): actual_times[s_id].append(t - anchor_sec)
# Geometry Mapping Updates
rel_dict, rel_vals, sample_sizes = {}, {}, {}
for stop in st_filtered.itertuples():
arr = actual_times[stop.stop_id]
sample_sizes[stop.stop_id] = len(arr)
if not arr:
rel_vals[stop.stop_id] = 0.0
continue
hits = sum(1 for t in arr if WINDOW_EARLY <= (t - stop.relative_sec) <= WINDOW_LATE)
rel_vals[stop.stop_id] = (hits / len(arr)) * 100
stops_df = st_filtered[['stop_id', 'stop_name', 'stop_lat', 'stop_lon']].copy()
stops_df['true_lat'] = stops_df['stop_lat']
stops_df['true_lon'] = stops_df['stop_lon']
stops_df['reliability'] = stops_df['stop_id'].map(rel_vals)
stops_df['sample_size'] = stops_df['stop_id'].map(sample_sizes)
all_stops.append(stops_df)
# shapely Path Tracing
shape_coords = list(zip(shp_pts['shape_pt_lon'].astype(float), shp_pts['shape_pt_lat'].astype(float)))
full_route_line = LineString(shape_coords) if len(shape_coords) > 1 else None
segs = []
for i in range(len(st_filtered) - 1):
s1, s2 = st_filtered.iloc[i], st_filtered.iloc[i + 1]
if s1.stop_lon == s2.stop_lon and s1.stop_lat == s2.stop_lat: continue
lon1, lat1 = float(s1.stop_lon), float(s1.stop_lat)
lon2, lat2 = float(s2.stop_lon), float(s2.stop_lat)
geom = None
if full_route_line:
d1 = full_route_line.project(Point(lon1, lat1))
d2 = full_route_line.project(Point(lon2, lat2))
start_d, end_d = min(d1, d2), max(d1, d2)
if end_d > start_d:
geom = substring(full_route_line, start_d, end_d)
if geom is None or geom.is_empty:
geom = LineString([(lon1, lat1), (lon2, lat2)])
segs.append({
'route_id': route,
'segment': f"{s1.stop_name} to {s2.stop_name}",
'avg_reliability': (rel_vals[s1.stop_id] + rel_vals[s2.stop_id]) / 2.0,
'geometry': geom
})
if segs: all_segments.append(gpd.GeoDataFrame(segs, geometry='geometry', crs=LATLON_PROJ))
print("\nAggregating final network...")
master_stops = pd.concat(all_stops, ignore_index=True)
master_stops['rel_w'] = master_stops['reliability'] * master_stops['sample_size']
master_stops = master_stops.groupby(['stop_id', 'stop_name', 'stop_lat', 'stop_lon', 'true_lat', 'true_lon'], as_index=False).agg({'rel_w': 'sum', 'sample_size': 'sum'})
master_stops['reliability'] = np.where(master_stops['sample_size'] > 0, master_stops['rel_w'] / master_stops['sample_size'], 0)
master_stops.drop(columns=['rel_w'], inplace=True)
master_segments = gpd.GeoDataFrame()
if all_segments:
master_segments = pd.concat(all_segments, ignore_index=True)
# Group by route_id AND segment to ensure overlapping short-turns merge seamlessly, but separate routes stay distinct
master_segments = master_segments.groupby(['route_id', 'segment'], as_index=False).agg({'avg_reliability': 'mean', 'geometry': 'first'})
# APPLY OFFSET FOR PARALLEL ROUTES
unique_routes = sorted(master_segments['route_id'].unique())
total_routes = len(unique_routes)
route_idx_map = {r: i for i, r in enumerate(unique_routes)}
master_segments['geometry'] = master_segments.apply(lambda row: apply_route_offset(row['geometry'], route_idx_map[row['route_id']], total_routes), axis=1)
master_segments = gpd.GeoDataFrame(master_segments, geometry='geometry', crs=LATLON_PROJ)
output = {
"stops": master_stops.to_dict(orient='records'),
"segments": json.loads(master_segments.to_json()) if not master_segments.empty else {},
"config": {} # Left empty to ensure app.py overrides it with fresh styling
}
with open('precomputed_network.json', 'w') as f:
json.dump(output, f)
print("Done! Saved to precomputed_network.json. Please upload this file to your Hugging Face repository.")
if __name__ == "__main__":
run_precompute()