42Cummer commited on
Commit
1d8fe63
·
verified ·
1 Parent(s): 64e5831

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +388 -0
app.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime # type: ignore
2
+ import sys # type: ignore
3
+ from pathlib import Path
4
+
5
+ # Add parent directory to path to allow imports from api/
6
+ sys.path.insert(0, str(Path(__file__).parent.parent))
7
+ # Add src directory to path to allow imports from same directory
8
+ sys.path.insert(0, str(Path(__file__).parent))
9
+
10
+ from api.bus_cache import AsyncBusCache # type: ignore
11
+ from api.utils import hms_to_seconds, get_service_day_start_ts, translate_occupancy # type: ignore
12
+ from db_manager import init_db # type: ignore
13
+ from dotenv import load_dotenv # type: ignore
14
+ from fastapi import FastAPI, HTTPException # type: ignore
15
+ from fastapi.middleware.cors import CORSMiddleware # type: ignore
16
+
17
+ load_dotenv()
18
+
19
+ ttc_cache = AsyncBusCache(ttl=20)
20
+
21
+ # Initialize database connection globally
22
+ db = init_db()
23
+
24
+ app = FastAPI(title="WheresMyBus v2.0 API")
25
+
26
+ # Setup CORS for your React frontend
27
+ app.add_middleware(
28
+ CORSMiddleware,
29
+ allow_origins=["*"], # In production, use your actual React URL
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+ @app.get("/")
35
+ async def health_check():
36
+ """Simple health check endpoint"""
37
+ return "backend is running"
38
+
39
+ @app.get("/api/vehicles")
40
+ async def get_vehicles():
41
+ data = await ttc_cache.get_data()
42
+ vehicles = data.get("vehicles", [])
43
+ return {
44
+ "status": "success",
45
+ "count": len(vehicles),
46
+ "vehicles": vehicles
47
+ }
48
+
49
+ @app.get("/api/routes")
50
+ async def get_all_routes():
51
+ """
52
+ Returns a complete list of TTC routes with their display names and colors.
53
+ """
54
+ try:
55
+ # Run the query against DuckDB
56
+ # We handle missing colors by providing defaults (TTC Red: #FF0000)
57
+ query = """
58
+ SELECT
59
+ route_id,
60
+ route_short_name,
61
+ route_long_name,
62
+ COALESCE(route_color, 'FF0000') as route_color,
63
+ COALESCE(route_text_color, 'FFFFFF') as route_text_color
64
+ FROM routes
65
+ ORDER BY
66
+ CASE
67
+ WHEN CAST(route_short_name AS VARCHAR) ~ '^[0-9]+$' THEN CAST(route_short_name AS INTEGER)
68
+ ELSE 999
69
+ END,
70
+ route_short_name;
71
+ """
72
+
73
+ results = db.execute(query).fetchall()
74
+
75
+ # Convert to a clean list of dictionaries
76
+ route_list = [
77
+ {
78
+ "id": r[0],
79
+ "number": r[1],
80
+ "name": r[2],
81
+ "color": f"#{r[3]}",
82
+ "text_color": f"#{r[4]}"
83
+ }
84
+ for r in results
85
+ ]
86
+
87
+ return {
88
+ "status": "success",
89
+ "count": len(route_list),
90
+ "routes": route_list
91
+ }
92
+
93
+ except Exception as e:
94
+ return {"status": "error", "message": str(e)}
95
+
96
+ @app.get("/api/routes/{route_id}")
97
+ async def get_route_view(route_id: str):
98
+ data = await ttc_cache.get_data()
99
+ all_buses = data.get("vehicles", [])
100
+ route_buses = [v for v in all_buses if v['route'] == route_id]
101
+
102
+ if not route_buses:
103
+ return {"route": route_id, "vehicles": []}
104
+
105
+ # IMPORTANT: Cast Trip IDs to strings to ensure they match the DB
106
+ trip_ids = [str(v['trip_id']) for v in route_buses]
107
+ placeholders = ','.join(['?'] * len(trip_ids))
108
+
109
+ # We use CAST(? AS VARCHAR) to force DuckDB to match strings to strings
110
+ query = f"""
111
+ SELECT
112
+ CAST(st.trip_id AS VARCHAR),
113
+ CAST(st.stop_id AS VARCHAR),
114
+ st.arrival_time as scheduled_time,
115
+ t.trip_headsign
116
+ FROM stop_times st
117
+ JOIN trips t ON CAST(st.trip_id AS VARCHAR) = CAST(t.trip_id AS VARCHAR)
118
+ WHERE CAST(st.trip_id AS VARCHAR) IN ({placeholders})
119
+ """
120
+ db_rows = db.execute(query, trip_ids).fetchall()
121
+
122
+ # Check if we got ANYTHING back from the DB
123
+ if not db_rows:
124
+ print(f"DEBUG: No matches in DB for Trip IDs: {trip_ids[:3]}")
125
+
126
+ schedule_map = {(r[0], r[1]): r[2] for r in db_rows}
127
+ name_map = {r[0]: r[3] for r in db_rows}
128
+
129
+ service_day_ts = get_service_day_start_ts()
130
+ enriched = []
131
+
132
+ for bus in route_buses:
133
+ # Default delay is 0 if no prediction exists
134
+ raw_delay_mins = 0
135
+
136
+ pred_time = bus.get('predicted_time')
137
+ stop_id = bus.get('next_stop_id')
138
+
139
+ if pred_time and stop_id:
140
+ sched_hms = schedule_map.get((str(bus['trip_id']), str(stop_id)))
141
+ if sched_hms:
142
+ # DELAY = SCHEDULED - PREDICTED (negative = late, positive = early)
143
+ # Handle GTFS times >= 24 hours (next day)
144
+ h, m, s = map(int, sched_hms.split(':'))
145
+ extra_days = h // 24
146
+ plan_ts = service_day_ts + (extra_days * 86400) + hms_to_seconds(sched_hms)
147
+ raw_delay_mins = round((plan_ts - pred_time) / 60)
148
+
149
+ enriched.append({
150
+ "number": bus['id'],
151
+ "name": name_map.get(str(bus['trip_id']), "Not in Schedule"), # This is the destination
152
+ "location": {"lat": bus['lat'], "lon": bus['lon']},
153
+ "delay_mins": raw_delay_mins, # Actual integer: 5 = 5m late, -2 = 2m early
154
+ "fullness": translate_occupancy(bus['occupancy'])
155
+ })
156
+
157
+ return {
158
+ "route": route_id,
159
+ "count": len(enriched),
160
+ "vehicles": enriched
161
+ }
162
+
163
+ @app.get("/api/vehicles/{vehicle_id}")
164
+ async def get_vehicle_view(vehicle_id: str):
165
+ # 1. Pull latest from cache
166
+ data = await ttc_cache.get_data()
167
+ vehicles = data.get("vehicles", [])
168
+
169
+ # 2. Find this specific bus in the list
170
+ bus = next((v for v in vehicles if str(v['id']) == vehicle_id), None)
171
+
172
+ if not bus:
173
+ raise HTTPException(status_code=404, detail="Vehicle not active or not found")
174
+
175
+ trip_id = str(bus['trip_id'])
176
+ next_stop_id = bus.get('next_stop_id')
177
+ predicted_time = bus.get('predicted_time')
178
+
179
+ # 3. Handshake with Database (Cast to VARCHAR to avoid type errors)
180
+ # We get the destination name and the specific scheduled arrival time
181
+ destination = "Not in Schedule"
182
+ delay_mins = 0
183
+
184
+ if next_stop_id:
185
+ query = """
186
+ SELECT
187
+ t.trip_headsign,
188
+ st.arrival_time as scheduled_time
189
+ FROM trips t
190
+ JOIN stop_times st ON CAST(t.trip_id AS VARCHAR) = CAST(st.trip_id AS VARCHAR)
191
+ WHERE CAST(t.trip_id AS VARCHAR) = ?
192
+ AND CAST(st.stop_id AS VARCHAR) = ?
193
+ LIMIT 1
194
+ """
195
+ row = db.execute(query, [trip_id, str(next_stop_id)]).fetchone()
196
+
197
+ if row:
198
+ destination = row[0]
199
+ scheduled_hms = row[1]
200
+
201
+ # DELAY = SCHEDULED - PREDICTED (negative = late, positive = early)
202
+ if predicted_time:
203
+ service_day_ts = get_service_day_start_ts()
204
+ # Handle GTFS times >= 24 hours (next day)
205
+ h, m, s = map(int, scheduled_hms.split(':'))
206
+ extra_days = h // 24
207
+ plan_ts = service_day_ts + (extra_days * 86400) + hms_to_seconds(scheduled_hms)
208
+ delay_mins = round((plan_ts - predicted_time) / 60)
209
+ else:
210
+ # If no next_stop_id, try to get destination from trip_id only
211
+ query = """
212
+ SELECT trip_headsign
213
+ FROM trips
214
+ WHERE CAST(trip_id AS VARCHAR) = ?
215
+ LIMIT 1
216
+ """
217
+ row = db.execute(query, [trip_id]).fetchone()
218
+ if row:
219
+ destination = row[0]
220
+
221
+ return {
222
+ "vehicle_number": vehicle_id,
223
+ "route_id": bus['route'],
224
+ "name": destination,
225
+ "location": {
226
+ "lat": bus['lat'],
227
+ "lon": bus['lon']
228
+ },
229
+ "delay_mins": delay_mins,
230
+ "fullness": translate_occupancy(bus['occupancy']),
231
+ "trip_id": trip_id
232
+ }
233
+
234
+ @app.get("/api/stop/{stop_code}")
235
+ async def get_stop_view(stop_code: str):
236
+ # 1. Translate Pole Number to Database ID
237
+ stop_info = db.execute("SELECT stop_id, stop_name, stop_lat, stop_lon FROM stops WHERE CAST(stop_code AS VARCHAR) = ? LIMIT 1", [str(stop_code)]).fetchone()
238
+ if not stop_info:
239
+ return {"error": "Stop code not found"}
240
+
241
+ target_id = str(stop_info[0])
242
+ stop_name = stop_info[1]
243
+ stop_lat = stop_info[2]
244
+ stop_lon = stop_info[3]
245
+
246
+ # 2. Get the Cache structure (dict with vehicles, predictions, alerts)
247
+ cached_data = await ttc_cache.get_data()
248
+ vehicles_list = cached_data.get("vehicles", [])
249
+ predictions = cached_data.get("predictions", {})
250
+
251
+ # Build vehicles map for quick lookup
252
+ vehicles = {str(v['trip_id']): v for v in vehicles_list}
253
+
254
+ from datetime import timezone
255
+ now = datetime.now(timezone.utc).timestamp()
256
+ two_hours_out = now + 7200
257
+ arrivals = []
258
+
259
+ # 3. Search the FULL itineraries for our target_id
260
+ for trip_id, itinerary in predictions.items():
261
+ if target_id in itinerary:
262
+ pred_time = itinerary[target_id]
263
+
264
+ # Only include if the bus hasn't passed the stop yet and is within 2 hours
265
+ if now <= pred_time <= two_hours_out:
266
+
267
+ # 4. Handshake with DB for destination and schedule
268
+ query = """
269
+ SELECT t.trip_headsign, st.arrival_time as scheduled_time, r.route_short_name
270
+ FROM trips t
271
+ JOIN stop_times st ON CAST(t.trip_id AS VARCHAR) = CAST(st.trip_id AS VARCHAR)
272
+ JOIN routes r ON t.route_id = r.route_id
273
+ WHERE CAST(t.trip_id AS VARCHAR) = ? AND CAST(st.stop_id AS VARCHAR) = ?
274
+ LIMIT 1
275
+ """
276
+ row = db.execute(query, [trip_id, target_id]).fetchone()
277
+
278
+ if row:
279
+ # Find the actual bus for fullness (if it's on the road)
280
+ bus = vehicles.get(trip_id)
281
+
282
+ # DELAY = SCHEDULED - PREDICTED (negative = late, positive = early)
283
+ service_day_ts = get_service_day_start_ts()
284
+ scheduled_hms = row[1]
285
+ # Handle GTFS times >= 24 hours (next day)
286
+ h, m, s = map(int, scheduled_hms.split(':'))
287
+ extra_days = h // 24
288
+ plan_ts = service_day_ts + (extra_days * 86400) + hms_to_seconds(scheduled_hms)
289
+
290
+ arrivals.append({
291
+ "route": row[2],
292
+ "destination": row[0],
293
+ "eta_mins": round((pred_time - now) / 60),
294
+ "delay_mins": round((plan_ts - pred_time) / 60),
295
+ "fullness": translate_occupancy(bus['occupancy']) if bus else "Unknown",
296
+ "vehicle_id": bus['id'] if bus else "In Transit"
297
+ })
298
+
299
+ arrivals.sort(key=lambda x: x['eta_mins'])
300
+ return {
301
+ "stop_name": stop_name,
302
+ "stop_code": stop_code,
303
+ "location": {
304
+ "lat": stop_lat,
305
+ "lon": stop_lon
306
+ },
307
+ "arrivals": arrivals
308
+ }
309
+
310
+ @app.get("/api/alerts")
311
+ async def get_all_alerts():
312
+ """
313
+ Returns every active service alert for the entire TTC network.
314
+ """
315
+ from datetime import timezone
316
+ data = await ttc_cache.get_data()
317
+ return {
318
+ "timestamp": datetime.now(timezone.utc).timestamp(),
319
+ "count": len(data["alerts"]),
320
+ "alerts": data["alerts"]
321
+ }
322
+
323
+ @app.get("/api/alerts/{route_id}")
324
+ async def get_alerts_for_route(route_id: str):
325
+ data = await ttc_cache.get_data()
326
+ alerts = data.get("alerts", {})
327
+ route_alerts = alerts.get(route_id, [])
328
+
329
+ if not route_alerts:
330
+ return {
331
+ "route_id": route_id,
332
+ "count": 0,
333
+ "alerts": "No alerts"
334
+ }
335
+
336
+ return {
337
+ "route_id": route_id,
338
+ "count": len(route_alerts),
339
+ "alerts": route_alerts
340
+ }
341
+
342
+ @app.get("/api/nearby")
343
+ async def get_nearby_context(lat: float, lon: float):
344
+ # Bounding box approximation for ~500m radius (fast, no Haversine needed)
345
+ # At Toronto's latitude (~43.6°): 1° lat ≈ 111km, 1° lon ≈ 55.4km
346
+ # For 500m: lat_range = 0.0045°, lon_range ≈ 0.0072°
347
+ lat_range = 0.0045 # ~500m in latitude (constant globally)
348
+ lon_range = 0.0072 # ~500m in longitude at Toronto's latitude
349
+
350
+ # 1. Find all stops within the bounding box
351
+ query_stops = """
352
+ SELECT stop_id, stop_code, stop_name, stop_lat, stop_lon
353
+ FROM stops
354
+ WHERE stop_lat BETWEEN ? AND ?
355
+ AND stop_lon BETWEEN ? AND ?
356
+ """
357
+ stops = db.execute(query_stops, [lat - lat_range, lat + lat_range, lon - lon_range, lon + lon_range]).fetchall()
358
+
359
+ stop_ids = [str(s[0]) for s in stops]
360
+ if not stop_ids:
361
+ return {"stops": [], "routes": []}
362
+
363
+ # 2. Find all unique routes serving these specific stops
364
+ placeholders = ','.join(['?'] * len(stop_ids))
365
+ query_routes = f"""
366
+ SELECT DISTINCT r.route_id, r.route_short_name, r.route_long_name, r.route_color
367
+ FROM routes r
368
+ JOIN trips t ON r.route_id = t.route_id
369
+ JOIN stop_times st ON t.trip_id = st.trip_id
370
+ WHERE CAST(st.stop_id AS VARCHAR) IN ({placeholders})
371
+ """
372
+ routes = db.execute(query_routes, stop_ids).fetchall()
373
+
374
+ return {
375
+ "stops": [
376
+ {"id": s[0], "code": s[1], "name": s[2], "lat": s[3], "lon": s[4]}
377
+ for s in stops
378
+ ],
379
+ "routes": [
380
+ {"id": r[0], "short_name": r[1], "long_name": r[2], "color": f"#{r[3]}"}
381
+ for r in routes
382
+ ]
383
+ }
384
+
385
+ if __name__ == "__main__":
386
+ import uvicorn # type: ignore
387
+ # Start the server
388
+ uvicorn.run(app, host="0.0.0.0", port=7860)