Spaces:
Sleeping
Sleeping
| # ============================================================================= | |
| # DEPLOYED FILE — canonical source of truth for the Tuas Crossing Space BACKEND. | |
| # Ships to munyew/tuas-crossing as app.py via: | |
| # python C:\TuasData\deploy_to_hf.py | |
| # Edit THIS file only. Do NOT edit the archived decoys: | |
| # C:\TuasData\tuas_app_content.py.UNUSED and *.UNUSED-decoy-with-alerts | |
| # ============================================================================= | |
| """Tuas Crossing — FastAPI app for a Hugging Face Docker Space. | |
| Serves a dashboard UI plus two JSON APIs: | |
| /api/live — real-time corridor state from LTA DataMall (server-side key) | |
| /api/history — day x hour heatmap aggregated from the public HF Dataset CSVs | |
| The DataMall key is read from the DATAMALL_KEY env var (set as a Space secret). | |
| It is never sent to the client. The Dataset is public, so history reads need no token. | |
| """ | |
| import os | |
| import time | |
| import json | |
| import io | |
| import threading | |
| from collections import defaultdict | |
| from datetime import datetime, timezone, timedelta | |
| SGT = timezone(timedelta(hours=8)) # Singapore time; HF containers run in UTC | |
| def sgt_now(): | |
| """Current wall-clock time in Singapore, regardless of server timezone.""" | |
| return datetime.now(timezone.utc).astimezone(SGT).strftime("%Y-%m-%d %H:%M:%S") | |
| import httpx | |
| import pandas as pd | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| # --- config ----------------------------------------------------------------- | |
| BASE = "https://datamall2.mytransport.sg/ltaodataservice" | |
| KEY = os.environ.get("DATAMALL_KEY", "") | |
| H = {"AccountKey": KEY, "accept": "application/json"} | |
| ROADS = {"AYER RAJAH EXPRESSWAY", "TUAS SECOND CROSSING", "TUAS VIADUCT"} | |
| BOX = dict(latmin=1.325, latmax=1.360, lonmin=103.620, lonmax=103.675) | |
| HF_USER = os.environ.get("HF_USER", "munyew") | |
| DATASET_REPO = os.environ.get("DATASET_REPO", f"{HF_USER}/tuas-data") | |
| ALERTS_REPO = os.environ.get("ALERTS_REPO", f"{HF_USER}/tuas-alerts") # PRIVATE | |
| HF_WRITE_TOKEN = os.environ.get("HF_WRITE_TOKEN", "") # Space secret; never sent to client | |
| app = FastAPI(title="Tuas Crossing") | |
| # Two-tier live cache. The "fast" part (cameras/ETA/incidents) is cheap to fetch | |
| # and must stay fresh because camera URLs expire ~5 min. The "bands" part is the | |
| # expensive 288-page speed-band sweep, which LTA only updates ~every 5 min, so we | |
| # refresh it far less often. Each is kept warm by its own background thread. | |
| _fast_cache = {"t": 0.0, "data": None} | |
| _fast_lock = threading.Lock() | |
| _corridor_cache = {"t": 0.0, "data": None} | |
| _queue_cache = {"t": 0.0, "data": None} | |
| _queue_lock = threading.Lock() | |
| _corridor_lock = threading.Lock() | |
| _hist_cache = {"t": 0.0, "data": None} | |
| _hist_lock = threading.Lock() | |
| _fresh_cache = {"t": 0.0, "data": None} | |
| _fresh_lock = threading.Lock() | |
| FAST_TTL = 60 # cameras / travel time / incidents (live LTA, cheap) | |
| CORRIDOR_TTL = 180 # corridor speed bands, read from the public dataset (no LTA) | |
| QUEUE_TTL = 120 # camera-queue estimate, read from the public dataset (no LTA) | |
| HIST_TTL = 600 # history changes slowly; recompute every 10 min | |
| FRESH_TTL = 300 # collection-freshness check (reads the dataset) | |
| # Warn if the newest *collected* sample is older than this. The collector runs | |
| # every 15 min and the push every 15 min, so normal lag is <=~30 min; 60 | |
| # tolerates a missed cycle while still catching a real stall (the earlier | |
| # silent failure left data DAYS old). | |
| STALE_MIN = 60 | |
| # --- LTA helpers ------------------------------------------------------------ | |
| def in_box(lat, lon): | |
| return (BOX["latmin"] <= lat <= BOX["latmax"] | |
| and BOX["lonmin"] <= lon <= BOX["lonmax"]) | |
| PAGE = 500 # DataMall fixed page size | |
| def _page(client, path, skip, tries=3): | |
| """Fetch one page, retrying briefly on 429/5xx (LTA throttles bursts).""" | |
| last = None | |
| for i in range(tries): | |
| try: | |
| r = client.get(f"{BASE}/{path}", headers=H, params={"$skip": skip}) | |
| if r.status_code in (429, 500, 502, 503): | |
| last = httpx.HTTPStatusError(f"{r.status_code}", request=r.request, response=r) | |
| time.sleep(0.4 * (i + 1)) | |
| continue | |
| r.raise_for_status() | |
| return r.json().get("value", []) | |
| except httpx.TransportError as e: | |
| last = e | |
| time.sleep(0.4 * (i + 1)) | |
| raise last | |
| def lta(client, path, keep=None): | |
| """Sequential paginated DataMall fetch for small/short feeds. Some feeds | |
| (Traffic-Imagesv2) ignore $skip and return the same page, so we stop on a | |
| short page (<500) and guard against a repeated first record.""" | |
| out, skip, prev_first = [], 0, None | |
| while True: | |
| v = _page(client, path, skip) | |
| if not v: | |
| break | |
| cur_first = json.dumps(v[0], sort_keys=True) | |
| if cur_first == prev_first: # $skip not honoured -> avoid runaway | |
| break | |
| prev_first = cur_first | |
| out += (v if keep is None else [x for x in v if keep(x)]) | |
| skip += len(v) | |
| if len(v) < PAGE: # last page reached | |
| break | |
| return out | |
| def band_state(avg): | |
| """Map an average SpeedBand (1 worst .. 8 free-flow) to a label + colour.""" | |
| if avg is None: | |
| return ("No data", "#475569") | |
| if avg >= 6.5: | |
| return ("Free flow", "#22c55e") | |
| if avg >= 5: | |
| return ("Moderate", "#84cc16") | |
| if avg >= 3: | |
| return ("Slow", "#f59e0b") | |
| return ("Heavy", "#ef4444") | |
| def build_fast(): | |
| """Cheap feeds that must stay fresh: travel time, incidents, cameras.""" | |
| limits = httpx.Limits(max_connections=10, max_keepalive_connections=10) | |
| with httpx.Client(timeout=30, limits=limits) as c: | |
| ett = lta(c, "EstTravelTimes", | |
| keep=lambda x: x["Name"] == "AYE" | |
| and "TUAS" in (x.get("FarEndPoint") or "")) | |
| inc = lta(c, "TrafficIncidents") | |
| img = lta(c, "Traffic-Imagesv2", | |
| keep=lambda x: in_box(float(x["Latitude"]), float(x["Longitude"]))) | |
| eta = sum(int(x["EstTime"]) for x in ett) if ett else None | |
| pad = 0.02 | |
| incidents = [] | |
| for x in inc: | |
| try: | |
| lat, lon = float(x["Latitude"]), float(x["Longitude"]) | |
| except (KeyError, ValueError): | |
| lat = lon = None | |
| near = (lat is not None and | |
| BOX["latmin"] - pad <= lat <= BOX["latmax"] + pad and | |
| BOX["lonmin"] - pad <= lon <= BOX["lonmax"] + pad) | |
| incidents.append({"type": x.get("Type"), "msg": x.get("Message"), | |
| "lat": lat, "lon": lon, "near": near}) | |
| incidents.sort(key=lambda i: not i["near"]) | |
| cameras = [{"id": x["CameraID"], "url": x["ImageLink"], | |
| "lat": float(x["Latitude"]), "lon": float(x["Longitude"])} | |
| for x in img] | |
| return {"etaMinutes": eta, "ettSegments": len(ett), | |
| "incidents": incidents, | |
| "incidentsNear": sum(1 for i in incidents if i["near"]), | |
| "cameras": cameras} | |
| def _newest_dataset_csv(prefix): | |
| """Download the newest data/<prefix>_<date>.csv from the dataset. | |
| Returns (DataFrame, date_str) or (None, None).""" | |
| import re | |
| from huggingface_hub import HfApi, hf_hub_download | |
| api = HfApi() | |
| try: | |
| files = api.list_repo_files(DATASET_REPO, repo_type="dataset") | |
| except Exception: | |
| return None, None | |
| dated = [] | |
| for f in files: | |
| if not f.startswith("data/"): | |
| continue | |
| m = re.match(rf"{prefix}_(\d{{4}}-\d{{2}}-\d{{2}})\.csv$", os.path.basename(f)) | |
| if m: | |
| dated.append((m.group(1), f)) | |
| if not dated: | |
| return None, None | |
| date_str, fname = max(dated) | |
| try: | |
| path = hf_hub_download(DATASET_REPO, fname, repo_type="dataset") | |
| return pd.read_csv(path), date_str | |
| except Exception: | |
| return None, None | |
| def build_corridor(): | |
| """Corridor speed-band cards, read from the PUBLIC DATASET — the collector on | |
| the laptop is the single v4/TrafficSpeedBands client, so the Space never | |
| sweeps that feed. Uses the most recent snapshot in the newest speedbands CSV, | |
| filtered to the 3 corridor roads inside the box, and reports its age.""" | |
| df, _ = _newest_dataset_csv("speedbands") | |
| empty = {"corridor": [], "corridorTs": None, "corridorAgeMin": None} | |
| if df is None or df.empty or "ts" not in df.columns: | |
| return empty | |
| df = df.copy() | |
| df["ts"] = pd.to_datetime(df["ts"], errors="coerce") | |
| df = df.dropna(subset=["ts"]) | |
| if df.empty: | |
| return empty | |
| latest = df["ts"].max() | |
| snap = df[df["ts"] == latest].copy() | |
| snap = snap[snap["RoadName"].isin(ROADS)] | |
| for col in ("StartLat", "StartLon", "EndLat", "EndLon", "SpeedBand"): | |
| snap[col] = pd.to_numeric(snap[col], errors="coerce") | |
| snap = snap[(snap["StartLat"] >= BOX["latmin"]) & (snap["StartLat"] <= BOX["latmax"]) & | |
| (snap["StartLon"] >= BOX["lonmin"]) & (snap["StartLon"] <= BOX["lonmax"])] | |
| snap = snap.dropna(subset=["EndLat", "EndLon"]) | |
| # OUTBOUND filter: segment END closer to JB than START → heading SG→JB | |
| _JB_LAT, _JB_LON = 1.422, 103.629 | |
| def _dist_jb(lat, lon): | |
| return (lat - _JB_LAT) ** 2 + (lon - _JB_LON) ** 2 | |
| snap = snap[_dist_jb(snap["EndLat"], snap["EndLon"]) < | |
| _dist_jb(snap["StartLat"], snap["StartLon"])] | |
| corridor = [] | |
| for road in ["AYER RAJAH EXPRESSWAY", "TUAS VIADUCT", "TUAS SECOND CROSSING"]: | |
| bands = snap[snap["RoadName"] == road]["SpeedBand"].dropna() | |
| avg = round(float(bands.mean()), 2) if len(bands) else None | |
| label, color = band_state(avg) | |
| corridor.append({"road": road, "avgBand": avg, "n": int(len(bands)), | |
| "state": label, "color": color, | |
| "subtitle": "outbound (SG→JB) only"}) | |
| now_sgt = pd.Timestamp(datetime.now(timezone.utc).astimezone(SGT).replace(tzinfo=None)) | |
| age_min = max(0, int((now_sgt - latest).total_seconds() // 60)) | |
| return {"corridor": corridor, | |
| "corridorTs": latest.strftime("%Y-%m-%d %H:%M:%S"), | |
| "corridorAgeMin": age_min} | |
| def refresh_fast(): | |
| with _fast_lock: | |
| data = build_fast() | |
| _fast_cache.update(t=time.time(), data=data) | |
| return data | |
| def refresh_corridor(): | |
| with _corridor_lock: | |
| data = build_corridor() | |
| _corridor_cache.update(t=time.time(), data=data) | |
| return data | |
| def build_queue(): | |
| """Booth-queue estimate, read from the public dataset (computed on the laptop | |
| by Queue-Estimate.py from saved cam 4713 frames). Age is recomputed here from | |
| the frame timestamp so a stalled job shows as stale, not fresh.""" | |
| from huggingface_hub import hf_hub_download | |
| try: | |
| p = hf_hub_download(DATASET_REPO, "queue_estimate.json", | |
| repo_type="dataset", force_download=True) | |
| with open(p, "r", encoding="utf-8") as f: | |
| q = json.load(f) | |
| except Exception: | |
| return {"available": False} | |
| if not q.get("available"): | |
| return {"available": False} | |
| age_sec = None | |
| try: | |
| ft = datetime.strptime(q["frameTs"], "%Y-%m-%d %H:%M:%S") | |
| now = datetime.now(timezone.utc).astimezone(SGT).replace(tzinfo=None) | |
| age_sec = max(0, int((now - ft).total_seconds())) | |
| except Exception: | |
| pass | |
| return {"available": True, "bucket": q.get("bucket"), | |
| "minutesBand": q.get("minutesBand"), "score": q.get("score"), | |
| "ageSec": age_sec, "frameTs": q.get("frameTs"), | |
| "cross4703Score": q.get("cross4703Score"), | |
| "source": q.get("source", "cam4713-density"), "provisional": True} | |
| def refresh_queue(): | |
| with _queue_lock: | |
| data = build_queue() | |
| _queue_cache.update(t=time.time(), data=data) | |
| return data | |
| def live(): | |
| if not KEY: | |
| return JSONResponse({"error": "DATAMALL_KEY not configured"}, status_code=503) | |
| # The fast tier (ETA / cameras / incidents) is the headline and is reliable; | |
| # build it inline on cold start. A bands failure must NOT take down the live | |
| # view, so bands are best-effort and the warmer keeps retrying in the dark. | |
| if not _fast_cache["data"]: | |
| try: | |
| refresh_fast() | |
| except Exception as e: | |
| if not _fast_cache["data"]: | |
| return JSONResponse({"warming": True, "error": str(e)}, status_code=503) | |
| if not _corridor_cache["data"]: | |
| try: | |
| refresh_corridor() # reads the dataset, no LTA | |
| except Exception: | |
| pass # serve ETA + cameras now; corridor fills in from the dataset | |
| if not _queue_cache["data"]: | |
| try: | |
| refresh_queue() # reads the dataset, no LTA | |
| except Exception: | |
| pass | |
| now = time.time() | |
| fast = _fast_cache["data"] or {} | |
| corridor = _corridor_cache["data"] or {} | |
| rows = corridor.get("corridor", []) | |
| data = { | |
| "updated": sgt_now(), | |
| "corridor": rows, | |
| "corridorTs": corridor.get("corridorTs"), | |
| "corridorAgeMin": corridor.get("corridorAgeMin"), | |
| "corridorSource": "dataset", | |
| "etaMinutes": fast.get("etaMinutes"), | |
| "ettSegments": fast.get("ettSegments"), | |
| "incidents": fast.get("incidents", []), | |
| "incidentsNear": fast.get("incidentsNear", 0), | |
| "cameras": fast.get("cameras", []), | |
| "ageSec": round(now - _fast_cache["t"]), | |
| "corridorAvailable": any(c.get("avgBand") is not None for c in rows), | |
| "collected": _fresh_cache["data"], # populated by the freshness warmer | |
| "queueEstimate": _queue_cache["data"], # cam-4713 booth-queue estimate | |
| } | |
| return data | |
| def _warm_loop(refresh_fn, ttl, err_backoff=10): | |
| while True: | |
| try: | |
| refresh_fn() | |
| except Exception: | |
| # Upstream error: back off before retrying. Bands uses a long backoff | |
| # so we don't hammer LTA's TrafficSpeedBands while it is flapping/500ing. | |
| time.sleep(err_backoff) | |
| continue | |
| time.sleep(max(15, ttl - 10)) | |
| def _start_warmers(): | |
| if KEY: | |
| threading.Thread(target=_warm_loop, args=(refresh_fast, FAST_TTL, 10), daemon=True).start() | |
| # corridor + freshness read the PUBLIC DATASET (no LTA), so they run even | |
| # without the key. The Space no longer touches v4/TrafficSpeedBands at all. | |
| threading.Thread(target=_warm_loop, args=(refresh_corridor, CORRIDOR_TTL, 60), daemon=True).start() | |
| threading.Thread(target=_warm_loop, args=(refresh_queue, QUEUE_TTL, 60), daemon=True).start() | |
| threading.Thread(target=_warm_loop, args=(refresh_freshness, FRESH_TTL, 60), daemon=True).start() | |
| # --- collection freshness --------------------------------------------------- | |
| def _latest_collected_ts(): | |
| """Newest sample timestamp across the most recent date's collected CSVs in | |
| the dataset. Uses the feeds that stamp every 15-min run (travel time / | |
| images / incidents) plus speed bands, taking the MAX so an intermittent | |
| speed-band feed never makes collection look stalled.""" | |
| import re | |
| from huggingface_hub import HfApi, hf_hub_download | |
| api = HfApi() | |
| try: | |
| files = api.list_repo_files(DATASET_REPO, repo_type="dataset") | |
| except Exception: | |
| return None | |
| prefixes = ("esttraveltimes", "images", "incidents", "speedbands") | |
| cand = [] | |
| for f in files: | |
| b = os.path.basename(f) | |
| for p in prefixes: | |
| m = re.match(rf"{p}_(\d{{4}}-\d{{2}}-\d{{2}})\.csv$", b) | |
| if m: | |
| cand.append((m.group(1), f)) | |
| if not cand: | |
| return None | |
| maxdate = max(d for d, _ in cand) | |
| maxts = None | |
| for d, f in cand: | |
| if d != maxdate: # only read the newest date's files (cheap) | |
| continue | |
| try: | |
| path = hf_hub_download(DATASET_REPO, f, repo_type="dataset") | |
| df = pd.read_csv(path, usecols=["ts"]) | |
| t = pd.to_datetime(df["ts"], errors="coerce").max() | |
| if pd.notna(t) and (maxts is None or t > maxts): | |
| maxts = t | |
| except Exception: | |
| continue | |
| return maxts | |
| def build_freshness(): | |
| maxts = _latest_collected_ts() | |
| if maxts is None: | |
| return {"lastCollected": None, "ageMin": None, "stale": True, | |
| "staleAfterMin": STALE_MIN} | |
| now_sgt = datetime.now(timezone.utc).astimezone(SGT).replace(tzinfo=None) | |
| age_min = max(0, int((pd.Timestamp(now_sgt) - maxts).total_seconds() // 60)) | |
| return {"lastCollected": maxts.strftime("%Y-%m-%d %H:%M:%S"), | |
| "ageMin": age_min, "stale": age_min > STALE_MIN, | |
| "staleAfterMin": STALE_MIN} | |
| def refresh_freshness(): | |
| with _fresh_lock: | |
| data = build_freshness() | |
| _fresh_cache.update(t=time.time(), data=data) | |
| return data | |
| def freshness(): | |
| if _fresh_cache["data"]: | |
| return _fresh_cache["data"] | |
| try: | |
| return refresh_freshness() | |
| except Exception as e: | |
| return JSONResponse({"error": str(e), "stale": None}, status_code=502) | |
| # --- history ---------------------------------------------------------------- | |
| def _read_dataset_csvs(prefix): | |
| """Download and concat all data/<prefix>_*.csv files from the public dataset.""" | |
| from huggingface_hub import HfApi, hf_hub_download | |
| api = HfApi() | |
| try: | |
| files = api.list_repo_files(DATASET_REPO, repo_type="dataset") | |
| except Exception: | |
| return pd.DataFrame() | |
| targets = [f for f in files | |
| if f.startswith("data/") and os.path.basename(f).startswith(prefix) | |
| and f.endswith(".csv")] | |
| frames = [] | |
| for f in targets: | |
| try: | |
| path = hf_hub_download(DATASET_REPO, f, repo_type="dataset") | |
| frames.append(pd.read_csv(path)) | |
| except Exception: | |
| continue | |
| return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() | |
| def build_history(): | |
| sb = _read_dataset_csvs("speedbands") | |
| DOW = ["Monday", "Tuesday", "Wednesday", "Thursday", | |
| "Friday", "Saturday", "Sunday"] | |
| heatmap = {d: {} for d in DOW} | |
| today = {"dow": None, "hours": {}} | |
| days = 0 | |
| rows = 0 | |
| if not sb.empty and "ts" in sb.columns: | |
| sb = sb[sb["RoadName"].isin(ROADS)].copy() | |
| sb["ts"] = pd.to_datetime(sb["ts"], errors="coerce") | |
| sb = sb.dropna(subset=["ts"]) | |
| sb["hour"] = sb["ts"].dt.hour | |
| sb["date"] = sb["ts"].dt.date | |
| sb["dow"] = sb["ts"].dt.day_name() | |
| sb["SpeedBand"] = pd.to_numeric(sb["SpeedBand"], errors="coerce") | |
| rows = int(len(sb)) | |
| days = int(sb["date"].nunique()) | |
| grp = sb.groupby(["dow", "hour"])["SpeedBand"].mean() | |
| for (d, h), val in grp.items(): | |
| if d in heatmap: | |
| heatmap[d][int(h)] = round(float(val), 2) | |
| # today's sparkline = most recent date present | |
| if rows: | |
| last_date = sb["date"].max() | |
| tdf = sb[sb["date"] == last_date] | |
| today["dow"] = tdf["dow"].iloc[0] | |
| for h, val in tdf.groupby("hour")["SpeedBand"].mean().items(): | |
| today["hours"][int(h)] = round(float(val), 2) | |
| # estimated travel-time-by-hour (predicted crossing minutes) | |
| eta_by_hour = {d: {} for d in DOW} | |
| ett = _read_dataset_csvs("esttraveltimes") | |
| if not ett.empty and "ts" in ett.columns: | |
| col = "EstTimeMin" if "EstTimeMin" in ett.columns else ( | |
| "EstTime" if "EstTime" in ett.columns else None) | |
| if col: | |
| ett = ett.copy() | |
| ett["ts"] = pd.to_datetime(ett["ts"], errors="coerce") | |
| ett = ett.dropna(subset=["ts"]) | |
| ett[col] = pd.to_numeric(ett[col], errors="coerce") | |
| ett["dow"] = ett["ts"].dt.day_name() | |
| ett["hour"] = ett["ts"].dt.hour | |
| ett["minute"] = ett["ts"].dt.floor("min") | |
| # sum segments per snapshot, then average by dow,hour | |
| per_snap = ett.groupby(["minute", "dow", "hour"])[col].sum().reset_index() | |
| grp = per_snap.groupby(["dow", "hour"])[col].mean() | |
| for (d, h), val in grp.items(): | |
| if d in eta_by_hour: | |
| eta_by_hour[d][int(h)] = round(float(val), 1) | |
| return { | |
| "updated": sgt_now(), | |
| "heatmap": heatmap, | |
| "etaByHour": eta_by_hour, | |
| "today": today, | |
| "days": days, | |
| "rows": rows, | |
| "dowOrder": DOW, | |
| } | |
| def history(): | |
| now = time.time() | |
| if _hist_cache["data"] and now - _hist_cache["t"] < HIST_TTL: | |
| return _hist_cache["data"] | |
| with _hist_lock: | |
| now = time.time() | |
| if _hist_cache["data"] and now - _hist_cache["t"] < HIST_TTL: | |
| return _hist_cache["data"] | |
| try: | |
| data = build_history() | |
| except Exception as e: | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| _hist_cache.update(t=time.time(), data=data) | |
| return data | |
| def health(): | |
| return {"ok": True, "keyConfigured": bool(KEY), "dataset": DATASET_REPO, | |
| "alertsConfigured": bool(HF_WRITE_TOKEN)} | |
| # --- Web Push alerts: receive subscription + test request ------------------- | |
| # The Space only STORES these in the PRIVATE tuas-alerts dataset. The always-on | |
| # laptop (which holds the VAPID private key) actually sends pushes via | |
| # Send-Alerts.py — free Spaces sleep, so the Space is never the sender. | |
| def _alerts_write(path_in_repo, data_bytes, msg): | |
| from huggingface_hub import HfApi | |
| HfApi(token=HF_WRITE_TOKEN).upload_file( | |
| path_or_fileobj=data_bytes, path_in_repo=path_in_repo, | |
| repo_id=ALERTS_REPO, repo_type="dataset", commit_message=msg) | |
| async def subscribe(req: Request): | |
| if not HF_WRITE_TOKEN: | |
| return JSONResponse({"error": "alerts not configured"}, status_code=503) | |
| try: | |
| body = await req.json() | |
| except Exception: | |
| return JSONResponse({"error": "bad json"}, status_code=400) | |
| sub = body.get("subscription") or {} | |
| if not sub.get("endpoint"): | |
| return JSONResponse({"error": "missing subscription"}, status_code=400) | |
| payload = {"subscription": sub, "ua": body.get("ua"), "savedAt": sgt_now()} | |
| try: | |
| _alerts_write("subscription.json", | |
| json.dumps(payload, indent=2).encode(), "save push subscription") | |
| except Exception as e: | |
| return JSONResponse({"error": f"save failed: {e}"}, status_code=502) | |
| return {"ok": True} | |
| async def request_test(req: Request): | |
| if not HF_WRITE_TOKEN: | |
| return JSONResponse({"error": "alerts not configured"}, status_code=503) | |
| try: | |
| _alerts_write("test_request.json", | |
| json.dumps({"ts": sgt_now()}).encode(), "test push requested") | |
| except Exception as e: | |
| return JSONResponse({"error": f"request failed: {e}"}, status_code=502) | |
| return {"ok": True} | |
| # --- Alert RULE (day/time window + GO/WAIT thresholds) ---------------------- | |
| DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] | |
| def _alerts_read(name): | |
| """Read a JSON file from the PRIVATE alerts dataset (needs the write token to | |
| read a private repo). Returns the parsed object or None.""" | |
| from huggingface_hub import hf_hub_download | |
| try: | |
| path = hf_hub_download(ALERTS_REPO, name, repo_type="dataset", | |
| token=HF_WRITE_TOKEN, force_download=True) | |
| with open(path, "r", encoding="utf-8") as f: | |
| return json.load(f) | |
| except Exception: | |
| return None | |
| def _alerts_delete(name): | |
| from huggingface_hub import HfApi | |
| HfApi(token=HF_WRITE_TOKEN).delete_file( | |
| name, ALERTS_REPO, repo_type="dataset", commit_message=f"delete {name}") | |
| def _valid_rule(r): | |
| """Validate + normalise a posted rule. Returns (rule, error).""" | |
| if not isinstance(r, dict): | |
| return None, "rule must be an object" | |
| days = [d for d in (r.get("days") or []) if d in DAYS] | |
| if not days: | |
| return None, "pick at least one day" | |
| import re | |
| hhmm = re.compile(r"^([01]?\d|2[0-3]):[0-5]\d$") | |
| frm, to = str(r.get("from", "")), str(r.get("to", "")) | |
| if not (hhmm.match(frm) and hhmm.match(to)): | |
| return None, "from/to must be HH:MM" | |
| def num(v): | |
| try: | |
| return int(v) | |
| except (TypeError, ValueError): | |
| return None | |
| go, wait = num(r.get("go")), num(r.get("wait")) | |
| if go is None and wait is None: | |
| return None, "set a GO and/or a WAIT threshold" | |
| return ({"days": [d for d in DAYS if d in days], "from": frm, "to": to, | |
| "go": go, "wait": wait, "savedAt": sgt_now()}, None) | |
| async def save_rule(req: Request): | |
| if not HF_WRITE_TOKEN: | |
| return JSONResponse({"error": "alerts not configured"}, status_code=503) | |
| try: | |
| body = await req.json() | |
| except Exception: | |
| return JSONResponse({"error": "bad json"}, status_code=400) | |
| rule, err = _valid_rule(body) | |
| if err: | |
| return JSONResponse({"error": err}, status_code=400) | |
| try: | |
| _alerts_write("rule.json", json.dumps(rule, indent=2).encode(), "save alert rule") | |
| # a new/edited rule resets the debounce so it can fire fresh | |
| try: | |
| _alerts_delete("alert_state.json") | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| return JSONResponse({"error": f"save failed: {e}"}, status_code=502) | |
| return {"ok": True, "rule": rule} | |
| def get_rule(): | |
| if not HF_WRITE_TOKEN: | |
| return JSONResponse({"error": "alerts not configured"}, status_code=503) | |
| return {"rule": _alerts_read("rule.json")} | |
| def delete_rule(): | |
| if not HF_WRITE_TOKEN: | |
| return JSONResponse({"error": "alerts not configured"}, status_code=503) | |
| try: | |
| _alerts_delete("rule.json") | |
| except Exception: | |
| pass | |
| try: | |
| _alerts_delete("alert_state.json") | |
| except Exception: | |
| pass | |
| return {"ok": True} | |
| # --- Calibration: log my ACTUAL crossing time ------------------------------- | |
| # Stored in the PRIVATE dataset (personal data). Each entry pairs the camera | |
| # bucket/score at crossing time with the real minutes I took, so Stage-2 | |
| # calibration can replace the placeholder bucket->minutes bands with my data. | |
| async def crossing_log(req: Request): | |
| if not HF_WRITE_TOKEN: | |
| return JSONResponse({"error": "alerts not configured"}, status_code=503) | |
| try: | |
| body = await req.json() | |
| except Exception: | |
| return JSONResponse({"error": "bad json"}, status_code=400) | |
| try: | |
| minutes = int(round(float(body.get("minutes")))) | |
| except (TypeError, ValueError): | |
| return JSONResponse({"error": "minutes required"}, status_code=400) | |
| if not (1 <= minutes <= 300): | |
| return JSONResponse({"error": "minutes out of range"}, status_code=400) | |
| entry = { | |
| "loggedAt": sgt_now(), | |
| "minutes": minutes, | |
| "bucket": body.get("bucket"), # camera bucket at crossing time | |
| "score": body.get("score"), | |
| "startTs": body.get("startTs"), | |
| "endTs": body.get("endTs"), | |
| "mode": body.get("mode", "manual"), | |
| } | |
| logs = _alerts_read("crossing_logs.json") or [] | |
| if not isinstance(logs, list): | |
| logs = [] | |
| logs.append(entry) | |
| try: | |
| _alerts_write("crossing_logs.json", | |
| json.dumps(logs, indent=2).encode(), "log crossing") | |
| except Exception as e: | |
| return JSONResponse({"error": f"save failed: {e}"}, status_code=502) | |
| return {"ok": True, "count": len(logs)} | |
| def crossing_log_list(): | |
| if not HF_WRITE_TOKEN: | |
| return JSONResponse({"error": "alerts not configured"}, status_code=503) | |
| logs = _alerts_read("crossing_logs.json") or [] | |
| if not isinstance(logs, list): | |
| logs = [] | |
| return {"count": len(logs), "recent": logs[-5:]} | |
| # --- ETT chart: today vs last week same weekday -------------------------------- | |
| DATA_DIR = os.environ.get("ETT_DATA_DIR", r"C:\TuasData") | |
| def _ett_hourly_from_csv(csv_path): | |
| """Read one esttraveltimes CSV and return {hour: avg_summed_minutes}. | |
| Filters AYE -> TUAS CHECKPOINT, sums segments per snapshot, averages by hour.""" | |
| try: | |
| df = pd.read_csv(csv_path) | |
| except Exception: | |
| return {} | |
| col = "EstTimeMin" if "EstTimeMin" in df.columns else ( | |
| "EstTime" if "EstTime" in df.columns else None) | |
| if col is None: | |
| return {} | |
| df = df[(df["Expressway"] == "AYE") & | |
| (df["FarEndPoint"].str.contains("TUAS", na=False))].copy() | |
| if df.empty: | |
| return {} | |
| df["ts"] = pd.to_datetime(df["ts"], errors="coerce") | |
| df = df.dropna(subset=["ts"]) | |
| df[col] = pd.to_numeric(df[col], errors="coerce") | |
| df["minute"] = df["ts"].dt.floor("min") | |
| df["hour"] = df["ts"].dt.hour | |
| per_snap = df.groupby(["minute", "hour"])[col].sum().reset_index() | |
| by_hour = per_snap.groupby("hour")[col].mean().round(1) | |
| return {int(h): float(v) for h, v in by_hour.items()} | |
| def build_ett(): | |
| """Return today's hourly ETT and (if available) last-week same-weekday ETT.""" | |
| now_sgt = datetime.now(timezone.utc).astimezone(SGT) | |
| today_str = now_sgt.strftime("%Y-%m-%d") | |
| lastweek_str = (now_sgt - timedelta(days=7)).strftime("%Y-%m-%d") | |
| def _csv_path(date_str): | |
| return os.path.join(DATA_DIR, f"esttraveltimes_{date_str}.csv") | |
| today_hours = {} | |
| lastweek_hours = {} | |
| # Try local filesystem first | |
| today_local = _csv_path(today_str) | |
| if os.path.isfile(today_local): | |
| today_hours = _ett_hourly_from_csv(today_local) | |
| lastweek_local = _csv_path(lastweek_str) | |
| if os.path.isfile(lastweek_local): | |
| lastweek_hours = _ett_hourly_from_csv(lastweek_local) | |
| # Fall back to HF dataset for any missing date | |
| if not today_hours or not lastweek_hours: | |
| try: | |
| from huggingface_hub import HfApi, hf_hub_download | |
| api = HfApi() | |
| files = api.list_repo_files(DATASET_REPO, repo_type="dataset") | |
| for f in files: | |
| b = os.path.basename(f) | |
| if b == f"esttraveltimes_{today_str}.csv" and not today_hours: | |
| path = hf_hub_download(DATASET_REPO, f, repo_type="dataset") | |
| today_hours = _ett_hourly_from_csv(path) | |
| elif b == f"esttraveltimes_{lastweek_str}.csv" and not lastweek_hours: | |
| path = hf_hub_download(DATASET_REPO, f, repo_type="dataset") | |
| lastweek_hours = _ett_hourly_from_csv(path) | |
| except Exception: | |
| pass | |
| return { | |
| "today": today_str, | |
| "lastWeek": lastweek_str if lastweek_hours else None, | |
| "todayHours": today_hours, | |
| "lastWeekHours": lastweek_hours, | |
| } | |
| _ett_cache = {"t": 0.0, "data": None} | |
| _ett_lock = threading.Lock() | |
| ETT_TTL = 300 # recompute every 5 min | |
| def ett_chart(): | |
| now = time.time() | |
| if _ett_cache["data"] and now - _ett_cache["t"] < ETT_TTL: | |
| return _ett_cache["data"] | |
| with _ett_lock: | |
| now = time.time() | |
| if _ett_cache["data"] and now - _ett_cache["t"] < ETT_TTL: | |
| return _ett_cache["data"] | |
| try: | |
| data = build_ett() | |
| except Exception as e: | |
| return JSONResponse({"error": str(e)}, status_code=502) | |
| _ett_cache.update(t=time.time(), data=data) | |
| return data | |
| # static UI (must be mounted last so /api/* wins) | |
| app.mount("/", StaticFiles(directory="static", html=True), name="static") | |