CREST_runner / server.py
vincewin's picture
Health-check fix batch (CREST_AI 3dca271)
2ac66f1 verified
Raw History Blame Contribute Delete
46.3 kB
"""CREST_demo β€” FastAPI backend for the map-centric dashboard.
Serves the Leaflet frontend + JSON/SSE APIs, reusing the hf_data layer + agents.
POST /api/query NL query -> region + event pins + gauge pins (agent-driven)
GET /api/gauges gauge pins for a map viewport (map-first, no AI needed)
POST /api/simulate selected gauges -> sim job (cap 10; ~4 concurrent)
GET /api/stream SSE live status + hydrograph + 2-D overlays
POST /api/eventinfo LLM event brief (damage/fatalities/links) for the AI info feed
POST /api/calibrate AI calibration job (NSE<0.3 flow); GET /api/calstream SSE
/login /auth/callback /logout /api/me /api/profile HF OAuth + profile store
GET/POST /api/favorites registered-user focused basins (up to 5 gauges)
Run: python server.py (uvicorn on :7860, Docker Space entrypoint)
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import asyncio
import json
import math
import secrets
import time
from datetime import datetime, timedelta
from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse
from starlette.middleware.sessions import SessionMiddleware
from hf_data import caljobs, crashlog, datamgr, gauges, persist, simjobs
from hf_data.pipeline import parse_query
from hf_data.statecache import CACHE_DIR
crashlog.init() # optional SENTRY_DSN mirror (Sentry/GlitchTip/Bugsink)
crashlog.install_thread_hook() # record any thread that dies unwrapped
datamgr.start_janitor() # hourly cache cleanup + result compaction
persist.start() # restore states/results/params/users from the
# private dataset, then keep them synced
HERE = os.path.dirname(os.path.abspath(__file__))
FRONTEND = os.path.join(HERE, "frontend")
MAX_SIMS = 10 # demo cap (selection)
app = FastAPI(title="CREST_demo")
app.add_middleware(SessionMiddleware,
secret_key=os.environ.get("SESSION_SECRET", secrets.token_hex(32)),
max_age=14 * 24 * 3600, same_site="lax", https_only=False)
# ---- HF OAuth (Space sets OAUTH_* env when README has hf_oauth: true) ------
OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID")
_oauth = None
if OAUTH_CLIENT_ID:
from authlib.integrations.starlette_client import OAuth
_oauth = OAuth()
_oauth.register(
name="huggingface",
client_id=OAUTH_CLIENT_ID,
client_secret=os.environ.get("OAUTH_CLIENT_SECRET"),
server_metadata_url=(os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
+ "/.well-known/openid-configuration"),
client_kwargs={"scope": os.environ.get("OAUTH_SCOPES", "openid profile")},
)
def _profile_path(username: str) -> str:
d = os.path.join(CACHE_DIR, "users")
os.makedirs(d, exist_ok=True)
safe = "".join(c for c in username if c.isalnum() or c in "-_")
return os.path.join(d, f"{safe}.json")
def _load_profile(username: str) -> dict:
try:
with open(_profile_path(username), encoding="utf-8") as fh:
return json.load(fh)
except Exception:
return {}
# ---- admin role: HF usernames allowed to browse the FULL result archive -----
# (user directive 2026-09-08: the owner's Hugging Face account only, for now;
# widen with the ADMIN_USERS Space variable, comma-separated usernames)
ADMIN_USERS = {u.strip().lower() for u in
os.environ.get("ADMIN_USERS", "vincewin").split(",") if u.strip()}
def _sid(request: Request) -> str:
"""Per-BROWSER identity (UX isolation, user 2026-09-28): a random id kept
in the session cookie, independent of the HF login β€” people on a shared
account or a shared machine still get their own jobs, tabs and panels."""
sid = request.session.get("sid")
if not sid:
sid = secrets.token_hex(8)
request.session["sid"] = sid
return sid
def _owner(request: Request) -> dict:
"""Owner record stamped on a job at start; the token goes back to the
browser that started it (survives a session reset / a shared account)."""
u = request.session.get("user") or {}
return {"sid": _sid(request), "user": u.get("username"),
"token": secrets.token_hex(12)}
def _owns(job, request: Request, tok: str = "", strict: bool = False) -> bool:
"""May this browser act on the job? Its own job (same sid, or the token it
was handed) always; the same signed-in account on another device only
for non-strict reads (an explicit Open from the history list)."""
o = getattr(job, "owner", None) or {}
if not o:
return True # legacy job, no owner
if tok and tok == o.get("token"):
return True
if o.get("sid") and o.get("sid") == request.session.get("sid"):
return True
if strict:
return False
u = (request.session.get("user") or {}).get("username")
return bool(u) and u == o.get("user")
_FORBIDDEN = {"error": "this simulation belongs to another browser session"}
def _is_admin(request: Request) -> bool:
u = request.session.get("user") or {}
return bool(u.get("username")) and u["username"].lower() in ADMIN_USERS
@app.middleware("http")
async def _error_recorder(request: Request, call_next):
"""Error watchdog: every unhandled server error is recorded with context."""
try:
return await call_next(request)
except Exception as e:
crashlog.capture(f"http:{request.url.path}", e, method=request.method)
raise
@app.get("/api/errors")
def api_errors(n: int = 50):
"""Recent recorded errors (the error-watchdog log)."""
return {"errors": crashlog.recent(min(n, 200)), **crashlog.stats()}
class ClientError(BaseModel):
message: str = ""
source: str | None = None
line: int | None = None
stack: str | None = None
_client_err_ts: list[float] = [] # flood guard for the browser error beacon
@app.post("/api/clienterror")
def api_clienterror(err: ClientError):
"""Browser-side error beacon (app.js window.onerror/unhandledrejection) β€”
frontend crashes land in the same watchdog log as server errors."""
now = time.time()
_client_err_ts[:] = [t for t in _client_err_ts if now - t < 60]
if len(_client_err_ts) >= 30: # global: max 30/min
return {"ok": False, "throttled": True}
_client_err_ts.append(now)
crashlog.capture("client:js", message=(err.message or "")[:400],
source=err.source, line=err.line, stack=err.stack)
return {"ok": True}
@app.get("/api/datastats")
def api_datastats():
"""Data-manager view: per-category disk usage + caps."""
return datamgr.stats()
@app.post("/api/datacleanup")
def api_datacleanup():
"""Run a janitor pass now (cleanup + result compaction)."""
rep = datamgr.cleanup()
rep["compact"] = datamgr.compact_results()
return rep
@app.get("/api/report/{sim_id}/{gauge_id}")
def api_report(sim_id: str, gauge_id: str, request: Request, tok: str = ""):
"""Downloadable simulation report (AQUAH report-writer agent -> PDF).
Generated on first request (LLM + pandoc, ~30-90 s), then cached. Signed-in
users get a persistent copy in their report library; anonymous reports stay
in the ephemeral cache and are discarded when they close the app."""
job = simjobs.get_job(sim_id)
if job is None:
return JSONResponse({"error": "simulation not found (it may have been "
"restarted away) β€” re-run it, then download "
"the report"}, status_code=404)
if not _owns(job, request, tok):
return JSONResponse(_FORBIDDEN, status_code=403)
if not (job.hydro.get(gauge_id) or []):
return JSONResponse({"error": "no results for this gauge yet"}, status_code=409)
from hf_data import report
try:
path = report.generate(job, gauge_id)
except Exception as e:
crashlog.capture("report", e, sim_id=sim_id, gauge=gauge_id)
return JSONResponse({"error": f"report generation failed: {e}"}, status_code=500)
saved = False
u = request.session.get("user")
if u: # registered-user benefit: keep the report
try:
report.save_for_user(u["username"], gauge_id, job, path)
persist.poke()
saved = True
except Exception as e:
crashlog.capture("report:save", e, user=u.get("username"))
ext = os.path.splitext(path)[1]
return FileResponse(
path,
media_type="application/pdf" if ext == ".pdf" else "text/markdown",
filename=f"CREST_report_{gauge_id}_{job.t_start:%Y%m%d}{ext}",
headers={"X-Report-Saved": "1" if saved else "0"})
@app.get("/api/myreports")
def api_myreports(request: Request):
"""Signed-in user's saved report library."""
u = request.session.get("user")
if not u:
return JSONResponse({"error": "not signed in"}, status_code=401)
from hf_data import report
return {"reports": report.list_saved(u["username"]),
"max": report.MAX_SAVED_PER_USER}
@app.get("/api/myreports/{name}")
def api_myreports_get(name: str, request: Request):
u = request.session.get("user")
if not u:
return JSONResponse({"error": "not signed in"}, status_code=401)
from hf_data import report
p = report.saved_path(u["username"], name)
if not p:
return JSONResponse({"error": "report not found"}, status_code=404)
return FileResponse(p, media_type="application/pdf" if p.endswith(".pdf")
else "text/markdown", filename=os.path.basename(p))
@app.delete("/api/myreports/{name}")
def api_myreports_delete(name: str, request: Request):
u = request.session.get("user")
if not u:
return JSONResponse({"error": "not signed in"}, status_code=401)
from hf_data import report
p = report.saved_path(u["username"], name)
if p:
os.remove(p)
persist.poke() # mirror the deletion to the private dataset
return {"ok": True, "reports": report.list_saved(u["username"])}
@app.post("/api/report_discard/{sim_id}")
def api_report_discard(sim_id: str):
"""App-close beacon from anonymous users: drop their ephemeral reports.
Saved (registered) copies are untouched; everything here is regenerable."""
from hf_data import report
return {"ok": True, "removed": report.discard_sim(sim_id)}
@app.get("/api/persist")
def api_persist_status():
"""Durable-cache sync status (private-dataset mirror)."""
return persist.status()
@app.post("/api/persist")
def api_persist_now():
"""Push a sync pass right now (normally every 10 min / after each run)."""
try:
return {"ok": True, **persist.backup()}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
APP_VERSION = str(int(time.time())) # changes every deploy/restart
@app.get("/")
def index():
"""index.html is never browser-cached, and its asset URLs carry a version
that changes on every deploy β€” users always get the latest frontend
without needing a hard refresh."""
with open(os.path.join(FRONTEND, "index.html"), encoding="utf-8") as fh:
html = fh.read().replace("__V__", APP_VERSION)
return Response(html, media_type="text/html",
headers={"Cache-Control": "no-cache"})
@app.get("/login")
async def login(request: Request):
if _oauth is None: # local dev fallback: fake session, no HF round-trip
request.session["user"] = {"username": "dev-user", "name": "Dev User",
"picture": None, "dev": True}
return RedirectResponse("/")
redirect_uri = str(request.url_for("auth_callback")).replace("http://", "https://") \
if os.environ.get("SPACE_HOST") else request.url_for("auth_callback")
return await _oauth.huggingface.authorize_redirect(request, redirect_uri)
@app.get("/auth/callback")
async def auth_callback(request: Request):
if _oauth is None:
return RedirectResponse("/")
try:
token = await _oauth.huggingface.authorize_access_token(request)
info = token.get("userinfo") or {}
request.session["user"] = {
"username": info.get("preferred_username") or info.get("sub", "user"),
"name": info.get("name") or info.get("preferred_username", "user"),
"picture": info.get("picture"),
}
except Exception as e:
crashlog.capture("auth:callback", e) # user lands signed-out; know why
return RedirectResponse("/")
@app.get("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/")
@app.get("/api/me")
def api_me(request: Request):
sid = _sid(request) # per-browser id (set on first visit)
u = request.session.get("user")
if not u:
return {"user": None, "oauth": _oauth is not None, "admin": False, "sid": sid}
return {"user": u, "profile": _load_profile(u["username"]), "oauth": _oauth is not None,
"admin": _is_admin(request), "sid": sid}
class ProfileUpdate(BaseModel):
display_name: str | None = None
affiliation: str | None = None
email: str | None = None
bio: str | None = None
@app.post("/api/profile")
def api_profile(request: Request, p: ProfileUpdate):
u = request.session.get("user")
if not u:
return JSONResponse({"error": "not signed in"}, status_code=401)
prof = _load_profile(u["username"])
prof.update({k: v for k, v in p.model_dump().items() if v is not None})
with open(_profile_path(u["username"]), "w", encoding="utf-8") as fh:
json.dump(prof, fh, indent=1)
persist.poke()
return {"ok": True, "profile": prof}
# ---- registered-user benefit: focused basins (favorite gauges) -------------
MAX_FAVORITES = 5
def _fav_pins(ids: list) -> list:
"""Favorite gauge ids -> map pins with catalog metadata (unknown ids skipped)."""
cat = gauges.load_catalog()
out = []
for gid in ids:
row = cat.loc[cat.STAID == gid]
if row.empty:
continue
r = row.iloc[0]
out.append({"id": gid, "name": str(r.STANAME), "lat": float(r.LAT_GAGE),
"lon": float(r.LNG_GAGE), "area_km2": float(r.DRAIN_SQKM)})
return out
@app.get("/api/favorites")
def api_favorites(request: Request):
u = request.session.get("user")
if not u:
return JSONResponse({"error": "not signed in"}, status_code=401)
ids = _load_profile(u["username"]).get("favorites", [])
return {"favorites": _fav_pins(ids), "max": MAX_FAVORITES}
class FavoriteReq(BaseModel):
gauge_id: str
action: str = "add" # add | remove
@app.post("/api/favorites")
def api_favorites_post(request: Request, f: FavoriteReq):
u = request.session.get("user")
if not u:
return JSONResponse({"error": "Sign in to save favorite gauges."}, status_code=401)
digits = "".join(c for c in f.gauge_id if c.isdigit())
if not digits:
return JSONResponse({"error": "Enter a USGS gauge number, e.g. 08167000."},
status_code=422)
gid = digits.zfill(8) # catalog STAIDs are zero-padded to >= 8 digits
prof = _load_profile(u["username"])
favs = [g for g in prof.get("favorites", []) if g]
if f.action == "remove":
favs = [g for g in favs if g != gid]
elif gid not in favs:
if gauges.get_gauge_coordinates(gid) is None:
return JSONResponse(
{"error": f"Gauge {gid} isn't in the GAGES-II catalog β€” "
"double-check the USGS station number."}, status_code=404)
if len(favs) >= MAX_FAVORITES:
return JSONResponse(
{"error": f"You already have {MAX_FAVORITES} focused basins β€” "
"remove one first."}, status_code=409)
favs.append(gid)
prof["favorites"] = favs
with open(_profile_path(u["username"]), "w", encoding="utf-8") as fh:
json.dump(prof, fh, indent=1)
persist.poke() # favorites are account data β€” sync soon
return {"ok": True, "favorites": _fav_pins(favs), "max": MAX_FAVORITES}
# ---- map-first gauge pins ---------------------------------------------------
def _pins_for_bbox(w: float, s: float, e: float, n: float, limit: int = 300):
cat = gauges.load_catalog((w, s, e, n))
if len(cat) > limit: # keep the map responsive
cx, cy = (w + e) / 2, (s + n) / 2
cat = cat.assign(_d=((cat.LAT_GAGE - cy) ** 2 + (cat.LNG_GAGE - cx) ** 2)).nsmallest(limit, "_d")
from hf_data import gaugetz
return [{
"id": str(r.STAID).zfill(8), "name": str(r.STANAME),
"lat": float(r.LAT_GAGE), "lon": float(r.LNG_GAGE),
"area_km2": float(r.DRAIN_SQKM),
"tz": gaugetz.tz_of(str(r.STAID).zfill(8)),
} for r in cat.itertuples()]
@app.get("/api/gauges")
def api_gauges(w: float, s: float, e: float, n: float):
"""USGS gauge pins for the current viewport β€” works with zero AI interaction.
vp_pins: ungauged virtual points (HydroBASINS pour points) that fill the
coverage gaps β€” hindcast-simulatable via upstream-gauge injection."""
pins = _pins_for_bbox(w, s, e, n)
try:
from hf_data import virtualpoints
vps = virtualpoints.for_bbox(w, s, e, n)
except Exception:
vps = []
return {"gauge_pins": pins, "vp_pins": vps,
"n_gauges": len(pins), "max_sims": MAX_SIMS}
class Query(BaseModel):
query: str
radius_deg: float = 1.0
@app.post("/api/query")
def api_query(q: Query):
"""Parse the query into a map view + event pins + nearby USGS gauge pins."""
try:
ctx = parse_query(q.query)
except ValueError as e:
return JSONResponse({"detail": str(e)}, status_code=422)
lat, lon = ctx.anchor
r = q.radius_deg
bbox = [lon - r, lat - r, lon + r, lat + r]
gauge_pins = _pins_for_bbox(*bbox)
event_pins = [{"lat": lat, "lon": lon, "label": ctx.label}]
return {
"label": ctx.label, "center": [lat, lon], "bbox": bbox,
"t_start": ctx.t_start.isoformat(), "t_end": ctx.t_end.isoformat(),
"time_known": ctx.time_known,
"event_pins": event_pins, "gauge_pins": gauge_pins,
"n_gauges": len(gauge_pins), "max_sims": MAX_SIMS,
}
class FeedbackReq(BaseModel):
text: str
contact: str | None = None
@app.post("/api/feedback")
def api_feedback(req: FeedbackReq, request: Request):
"""Test-user improvement comments β€” recorded locally + persisted to the HF
dataset for the daily review job."""
text = (req.text or "").strip()
if not text:
return JSONResponse({"error": "empty comment"}, status_code=422)
from hf_data import feedback
u = request.session.get("user")
rec = feedback.record(text, user=u["username"] if u else None,
contact=req.contact,
context={"ua": request.headers.get("user-agent", "")[:120]})
return {"ok": True, "id": rec["id"]}
@app.get("/api/feedback")
def api_feedback_list(n: int = 100):
from hf_data import feedback
return {"feedback": feedback.recent(min(n, 500))}
class ChatReq(BaseModel):
message: str
history: list = []
context: dict = {}
@app.post("/api/chat")
def api_chat(req: ChatReq):
"""Conversational agent turn: guides users to an event, answers questions
about the current simulation, and routes locate/set_time actions.
{"action": "fallback"} tells the client to use its rule-based path."""
from hf_data import chatagent
ctx = dict(req.context or {})
try: # live risk summary so the agent can route
from hf_data import nowcaststore
hs = nowcaststore.hotspots() # "hotspot" questions with no location
if hs.get("ok"):
ctx["nowcast_risk"] = {
"t0": hs["t0"],
"n_hotspots": hs["n_hotspots"], # true total (dynamic, may be 0)
"hotspots": [{k: h[k] for k in ("center", "score", "n_gauges",
"n_flood", "n_minor", "n_elevated")}
for h in hs["hotspots"][:10]]} # top 10 for context
except Exception:
pass
try:
d = chatagent.respond(req.message, req.history, ctx)
except Exception as e:
crashlog.capture("chat", e, message_text=req.message[:200])
return {"action": "fallback", "error": str(e)}
return d if d else {"action": "fallback"}
class ExplainReq(BaseModel):
error: str
where: str | None = None # e.g. "simulation" | "calibration"
context: dict = {}
@app.post("/api/explain")
def api_explain(req: ExplainReq):
"""Translate a backend error into a human-readable chat explanation."""
from hf_data import llm
if not llm.available():
return {"text": None}
try:
sys_p = ("You are the assistant in a flood-simulation dashboard (CREST/EF5 "
"hydrologic model, data streamed from Hugging Face). A backend step "
"failed. Explain to a NON-developer user in 2-4 short sentences: "
"(1) what went wrong in plain words, (2) the most likely cause, "
"(3) what to do β€” e.g. simply try again (transient network reads are "
"common), pick a different time period/gauge, or use the πŸ’‘ Feedback "
"button to report it. No stack traces, no jargon, no blame.")
user_p = (f"Failed step: {req.where or 'simulation'}\n"
f"Raw error: {req.error[:1500]}\n"
f"Context: {str(req.context)[:500]}")
text, provider = llm.chat([{"role": "system", "content": sys_p},
{"role": "user", "content": user_p}],
temperature=0.2)
return {"text": text, "provider": provider}
except Exception as e:
crashlog.capture("explain", e)
return {"text": None}
class EventInfoReq(BaseModel):
label: str
t_start: str
t_end: str
@app.post("/api/eventinfo")
def api_eventinfo(req: EventInfoReq):
"""LLM brief about the event (damage, fatalities, links) for the AI info feed."""
from hf_data import llm
if not llm.available():
return {"text": "_(no LLM configured β€” event background unavailable)_", "provider": None}
try:
text, provider = llm.event_brief(req.label, req.t_start[:10], req.t_end[:10])
return {"text": text, "provider": provider}
except Exception as e:
return {"text": f"_(event lookup failed: {e})_", "provider": None}
class SimRequest(BaseModel):
gauge_ids: list[str]
t_start: str | None = None
t_end: str | None = None
hours: int = 48
model: str = "auto"
snow: str = "auto"
timestep: str = "1h"
warmup_days: int = 90
overrides: dict | None = None
label: str | None = None # event label for the history entry
prev_sim_id: str | None = None # caller's previous job β€” superseded (cancelled)
prev_tok: str | None = None # ... only with its token / from the same browser
scheme: str = "full" # "full" = whole basin; "speed" = domain
# truncated at boundary gauges (obs = inflow)
MAX_HISTORY = 20
def _save_history(username: str, entry: dict):
"""Registered-user benefit: keep their simulation history server-side."""
prof = _load_profile(username)
hist = [h for h in prof.get("history", []) if h.get("sim_id") != entry["sim_id"]]
hist.insert(0, entry)
prof["history"] = hist[:MAX_HISTORY]
with open(_profile_path(username), "w", encoding="utf-8") as fh:
json.dump(prof, fh, indent=1)
# generous ceiling: a full water year fits; only guards the shared demo CPU
# against extreme multi-year requests (0 disables the cap entirely)
MAX_SIM_HOURS = int(os.environ.get("CREST_MAX_SIM_HOURS", "8784")) # 366 days
@app.post("/api/simulate")
def api_simulate(req: SimRequest, request: Request):
warnings = []
if len(req.gauge_ids) > MAX_SIMS:
warnings.append(f"This demo simulates at most {MAX_SIMS} gauges at once; "
f"running the first {MAX_SIMS} of {len(req.gauge_ids)} selected.")
t0 = datetime.fromisoformat(req.t_start) if req.t_start else datetime(2025, 7, 3)
t1 = datetime.fromisoformat(req.t_end) if req.t_end else t0 + timedelta(hours=req.hours)
if t1 <= t0: # nonsensical window
t1 = t0 + timedelta(hours=req.hours)
warnings.append(f"End time was not after the start β€” using {req.hours} h instead.")
if MAX_SIM_HOURS > 0 and (t1 - t0) > timedelta(hours=MAX_SIM_HOURS):
t1 = t0 + timedelta(hours=MAX_SIM_HOURS) # shared-CPU sanity ceiling
warnings.append(f"Simulation window capped at {MAX_SIM_HOURS // 24} days "
f"(now ends {t1:%Y-%m-%d %H:%M}).")
# supersede the caller's previous run: a stale in-flight job would otherwise
# hold the per-gauge lock and the new run would queue behind it indefinitely
owner = _owner(request)
if req.prev_sim_id:
prev = simjobs.get_job(req.prev_sim_id)
if prev and not prev.done.is_set() and _owns(prev, request, req.prev_tok or "",
strict=True):
prev.cancel.set()
warnings.append("Your previous simulation was still running β€” "
"it was stopped and replaced by this one.")
opts = {"model": req.model, "hours": req.hours, "snow": req.snow,
"timestep": req.timestep, "warmup_days": req.warmup_days,
"overrides": req.overrides,
"scheme": req.scheme if req.scheme in ("full", "speed") else "full"}
job = simjobs.start_job(req.gauge_ids, t0, t1, opts, owner=owner)
u = request.session.get("user")
if u: # signed-in -> history entry
try:
_save_history(u["username"], {
"sim_id": job.id, "gauge_ids": job.gauge_ids,
"t_start": t0.isoformat(), "t_end": t1.isoformat(),
"label": req.label, "model": req.model, "snow": req.snow,
"when": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")})
except Exception as e:
crashlog.capture("history:save", e, user=u.get("username"))
return {"sim_id": job.id, "token": owner["token"], "gauge_ids": job.gauge_ids,
"t_start": t0.isoformat(), "t_end": t1.isoformat(),
"warning": " ".join(warnings) or None,
"max_concurrent": simjobs.MAX_CONCURRENT,
"per_user": simjobs.PER_USER_CONCURRENT}
@app.get("/api/nowcast/{sim_id}/{gauge_id}")
def api_nowcast(sim_id: str, gauge_id: str, request: Request, tok: str = ""):
"""AI nowcast tail (+6 h) from the CREST_nowcast Space for a finished
gauge. Always answers; {"ok": false} just means no tail is shown."""
job = simjobs.get_job(sim_id)
if not job:
return JSONResponse({"ok": False, "reason": "unknown job"}, status_code=404)
if not _owns(job, request, tok):
return JSONResponse({"ok": False, **_FORBIDDEN}, status_code=403)
from hf_data import nowcast as _nc
return _nc.for_job(job, gauge_id)
@app.get("/api/nowcast_risk")
def api_nowcast_risk():
"""CONUS-wide flood-risk snapshot for Nowcast mode: which gauges' AI
next-6-h peak exceeds their 10-yr return flood (red density layer /
red pins)."""
from hf_data import nowcaststore
return nowcaststore.all_risk()
@app.get("/api/nowcast_now")
def api_nowcast_now(w: float, s: float, e: float, n: float, limit: int = 100,
obs_hours: int = 0, ids: str = ""):
"""Precomputed fleet nowcasts for every gauge in the viewport/rectangle
(or an explicit `ids` comma list) β€” instant (the updater Space refreshes
them hourly), no simulation needed. obs_hours>0 attaches recent observed
series (<=25 gauges) for plotting."""
from hf_data import nowcaststore
return nowcaststore.for_bbox(w, s, e, n, limit, obs_hours, ids)
@app.get("/api/ungauged_now")
def api_ungauged_now(w: float, s: float, e: float, n: float, limit: int = 200,
ids: str = ""):
"""Precomputed ungauged routed nowcasts for the viewport/rectangle (or an
explicit `ids` comma list) β€” served instantly from the keep-warm Space's
hourly parquet, no live EF5 run. Each point carries its ~7-day routed
history + 12-h forecast so the panel plots the split hydrograph directly."""
from hf_data import ungaugednow_store
return (ungaugednow_store.by_ids(ids) if ids
else ungaugednow_store.for_bbox(w, s, e, n, limit))
@app.get("/api/mrms_frames")
def api_mrms_frames():
"""Nowcast-mode rain-radar animation: which MRMS Pass1 PNG frames exist
(rolling 7 days, rendered hourly by the updater) + the CDN base URL."""
from hf_data import mrmsframes
return mrmsframes.frames()
@app.get("/api/nowcast_hotspots")
def api_nowcast_hotspots():
"""Ranked spatial clusters of flagged flood-risk gauges β€” the chat agent's
answer to "bring me to the flood risk hotspot" (no location needed)."""
from hf_data import nowcaststore
return nowcaststore.hotspots()
@app.get("/api/nowcast_brief")
def api_nowcast_brief(refresh: int = 0):
"""Nowcast-mode opening card: structured CONUS flood snapshot (flagged
gauges by state, hotspots, rain leaders, 2-D events) + the LLM's national
summary cross-checked against current news (web search when an OpenAI key
is configured). Cached per hourly issue; `building` => poll again."""
from hf_data import floodbrief
return floodbrief.brief(force=bool(refresh))
# V30 runner/dashboard split: when EVENT_RUNNER_URL points at the runner
# Space, THIS Space is the pure-reader dashboard β€” event triggers proxy
# there and the runner's live status is merged into /api/events. UI deploys
# then never kill an in-flight solve or reset the tick clock.
EVENT_RUNNER_URL = os.environ.get("EVENT_RUNNER_URL", "").rstrip("/")
_runner_cache: dict = {"t": 0.0, "status": None}
def _remote_runner_status():
import time as _time
if not EVENT_RUNNER_URL:
return None
if _time.time() - _runner_cache["t"] < 20:
return _runner_cache["status"]
try:
import requests
r = requests.get(f"{EVENT_RUNNER_URL}/api/events?full=0",
timeout=15).json()
_runner_cache.update(t=_time.time(), status=r.get("runner"))
except Exception:
_runner_cache.update(t=_time.time(), status=None)
return _runner_cache["status"]
@app.get("/api/events")
def api_events(full: int = 0):
"""V25: published 2-D inundation events (CREST-iMAP v2) + runner state.
Frame/manifest files resolve at {base}/<event_id>/<file>."""
from hf_data import eventqueue, eventsim, eventstore
try:
q = eventqueue.queue_status()
except Exception:
q = []
runner = _remote_runner_status() or eventsim.status(bool(full))
try: # CREST misses: trigger fired, EF5 did not
missed = eventstore.load_missed()[-30:]
except Exception:
missed = []
try: # auto-calibrations those misses triggered
autocal = eventstore.load_autocal()[-10:]
except Exception:
autocal = []
return {"events": eventstore.load_index(), "queue": q,
"runner": runner, "missed": missed, "autocal": autocal,
"base": (f"https://huggingface.co/datasets/{eventstore.REPO}/"
f"resolve/main/{eventstore.PREFIX}")}
@app.get("/api/admin/archive")
def api_admin_archive(request: Request, refresh: int = 0):
"""Admin only: every stored 2-D inundation result β€” the listed events plus
those that rolled off the public list (results kept RESULTS_MAX_AGE_D)."""
if not _is_admin(request):
return JSONResponse({"error": "admin only"}, status_code=403)
from hf_data import eventstore
return eventstore.archive_index(force=bool(refresh))
@app.get("/api/riskprobe/{event_id}")
def api_riskprobe(event_id: str, lat: float, lon: float, t: str = ""):
"""Risk-view lens probe: per-PIXEL depth/speed/flow-direction, DEFRA
hazard class, WorldPop 2026-2030 population, and OSM infrastructure
values for the solver cell at (lat, lon) of a published event. `t`
(frame timestamp from the manifest) reads that frame's depth; default
is the episode max. Returns the pixel's footprint for the highlight."""
from hf_data import riskprobe
try:
return riskprobe.probe(event_id, lat, lon, t)
except Exception as e:
crashlog.capture("riskprobe", e, event=event_id)
return JSONResponse({"error": f"{type(e).__name__}: {e}"},
status_code=500)
@app.get("/api/health")
def api_health():
"""V30 pipeline self-monitoring: nowcast/radar freshness, MRMS Pass2 /
PET / TEMP archive freshness, event-tick liveness, queue depth + oldest
unclaimed age, worker heartbeats, event throughput β€” each with an ok
flag. The daily review routine leads its digest with this."""
from hf_data import health
return health.snapshot()
@app.post("/api/event_tick")
def api_event_tick():
"""Hourly auto-trigger (pinged by the updater after each data refresh):
re-simulates every ACTIVE inundation episode at the fresh t0 and starts
newly flagged ones. An episode ends when observed flow recedes to
normal, NOT when precipitation stops. On the split dashboard
(EVENT_TICK=off) this forwards to the runner Space."""
if os.environ.get("EVENT_TICK", "1") != "1":
if EVENT_RUNNER_URL:
try:
import requests
return requests.post(f"{EVENT_RUNNER_URL}/api/event_tick",
timeout=30).json()
except Exception as e:
return JSONResponse({"ok": False, "reason":
f"runner unreachable: {type(e).__name__}"},
status_code=502)
return {"ok": False, "reason": "event tick disabled on this Space"}
try:
import crestimap # noqa: F401
except ImportError:
return JSONResponse({"ok": False, "reason":
"crestimap not installed on this Space build"},
status_code=501)
from hf_data import eventsim
if eventsim.status()["running"]:
# stamp liveness even here: the ping IS arriving, we are just busy.
# Without this a wedged runner looked identical to a dead tick loop.
eventsim.note_tick_attempt()
return {"ok": False, "reason": "runner busy",
"running": eventsim.status()["running"],
"busy_h": eventsim._busy_h()}
import threading
threading.Thread(target=eventsim.hourly_tick, daemon=True).start()
return {"ok": True, "started": "tick"}
@app.post("/api/event_run")
def api_event_run(gid: str = "", t0: str = "", defer: int = 0,
episode: str = "", cold: int = 0):
"""V25 manual/agent trigger: 2-D inundation event for `gid`, or
auto-detect from the nowcast flood tier when gid is empty. The run takes
minutes (EF5 + hydrodynamic solver) and executes in a worker thread;
poll /api/events for progress.
t0: backdate the event window ("YYYY-MM-DDTHH:MM" UTC) β€” refill a flood
the trigger pipeline missed; the window is [t0-backset, t0+horizon].
defer=1: enqueue for the GPU worker only (no CPU fallback) β€” the runner
stays free and the result publishes whenever the worker gets to it.
episode=<event id>: re-simulate an EXISTING listed episode under its own
id (anchored at its recorded start; the folder is replaced in place) β€”
e.g. re-running the whole list after an engine/domain change.
cold=1 (with episode): no warm-start frame β€” the whole anchored span is
re-solved and every published frame replaced."""
if os.environ.get("EVENT_TICK", "1") != "1" and EVENT_RUNNER_URL:
try: # split dashboard: forward to the runner
import requests
return requests.post(
f"{EVENT_RUNNER_URL}/api/event_run",
params={"gid": gid, "t0": t0, "defer": defer,
"episode": episode, "cold": cold},
timeout=30).json()
except Exception as e:
return JSONResponse({"ok": False, "reason":
f"runner unreachable: {type(e).__name__}"},
status_code=502)
try:
import crestimap # noqa: F401 (installed from the CREST-iMAP fork, v2)
except ImportError:
return JSONResponse({"ok": False, "reason":
"crestimap not installed on this Space build"},
status_code=501)
from hf_data import eventsim
st = eventsim.status()
if st["running"]:
return {"ok": False, "reason": "an event simulation is already running",
**st}
t0_dt = None
if t0:
import datetime as _dt
try:
t0_dt = _dt.datetime.strptime(t0, "%Y-%m-%dT%H:%M")
except ValueError:
return JSONResponse({"ok": False,
"reason": "t0 must be YYYY-MM-DDTHH:MM (UTC)"},
status_code=422)
import threading
if episode and not gid:
gid = episode.split("_", 1)[-1] # ids are <YYYYMMDDHH>_<gid>
if gid:
threading.Thread(target=eventsim.run_one, args=(gid, t0_dt),
kwargs={"defer": bool(defer),
"episode_id": episode or None,
"cold": bool(cold and episode)},
daemon=True).start()
else:
threading.Thread(target=eventsim.run_detected, daemon=True).start()
return {"ok": True, "started": gid or "auto-detect", "t0": t0 or "now",
"defer": bool(defer), "episode": episode or None,
"cold": bool(cold and episode)}
@app.get("/api/upstream")
def api_upstream(gid: str):
"""River network upstream of a gauge (HydroRIVERS topology walk) β€” drawn
on the Nowcast map when the gauge is focused. Static data, LRU-cached.
JSONResponse directly: jsonable_encoder walking ~50k coord floats costs
~2 s; plain json.dumps is ~50 ms."""
from hf_data import rivernet
return JSONResponse(rivernet.upstream(gid))
@app.post("/api/cancel/{sim_id}")
def api_cancel(sim_id: str, request: Request, tok: str = ""):
"""Stop a running simulation job: the EF5 processes are killed and the
per-gauge run locks released so a new run can start immediately. Only
the browser that started it (or holds its token) may stop it."""
job = simjobs.get_job(sim_id)
if not job:
return JSONResponse({"error": "unknown job"}, status_code=404)
if not _owns(job, request, tok, strict=True):
return JSONResponse(_FORBIDDEN, status_code=403)
if job.done.is_set():
return {"ok": True, "already_done": True}
job.cancel.set()
return {"ok": True}
@app.get("/api/history")
def api_history(request: Request):
"""Signed-in users: their saved simulations, with live job status."""
u = request.session.get("user")
if not u:
return JSONResponse({"error": "not signed in"}, status_code=401)
hist = _load_profile(u["username"]).get("history", [])
sid = _sid(request)
for h in hist:
job = simjobs.get_job(h.get("sim_id", ""))
h["status"] = ("done" if job and job.done.is_set() else
"running" if job else "expired") # expired -> cache re-run
# started from THIS browser? (a shared account sees the others' runs
# listed, but only its own browser follows them automatically)
h["this_browser"] = bool(job) and (getattr(job, "owner", None) or {}).get("sid") == sid
return {"history": hist}
def _sse_json(ev) -> str:
"""json.dumps emits bare NaN/Infinity, which JSON.parse rejects β€” one such
value breaks the whole event stream in the browser. Missing values travel as
null, matching the convention the rest of the pipeline already uses."""
def clean(v):
if isinstance(v, float) and (math.isnan(v) or math.isinf(v)):
return None
if isinstance(v, dict):
return {k: clean(x) for k, x in v.items()}
if isinstance(v, (list, tuple)):
return [clean(x) for x in v]
return v
return json.dumps(clean(ev), allow_nan=False)
async def _drain(job, cursor: int = 0):
"""Replay the job's event log from `cursor`, then follow it live. Multiple
clients (or a reopened browser) can each attach with their own cursor."""
try:
while True:
if cursor < len(job.events):
ev = job.events[cursor]
cursor += 1
yield {"data": _sse_json(ev)}
if ev.get("kind") in ("all_done", "cal_done"):
break
elif job.done.is_set():
break
else:
await asyncio.sleep(0.15)
except asyncio.CancelledError: # client closed the tab β€” not an error
raise
except Exception as e: # SSE runs outside the HTTP middleware
crashlog.capture("sse:drain", e, job=getattr(job, "id", "?"), cursor=cursor)
@app.get("/api/stream/{sim_id}")
async def api_stream(sim_id: str, request: Request, cursor: int = 0, tok: str = ""):
job = simjobs.get_job(sim_id)
if not job:
return Response(status_code=404)
if not _owns(job, request, tok):
return Response(status_code=403)
return EventSourceResponse(_drain(job, cursor))
@app.get("/api/job/{sim_id}")
def api_job(sim_id: str, request: Request, tok: str = ""):
"""Job descriptor for reattaching after the browser was closed. The run
keeps going server-side; the client replays the event log via /api/stream.
Another browser session gets 403 and forgets the id."""
job = simjobs.get_job(sim_id)
if not job:
return JSONResponse({"error": "unknown or expired job"}, status_code=404)
if not _owns(job, request, tok):
return JSONResponse(_FORBIDDEN, status_code=403)
return {"sim_id": job.id, "gauge_ids": job.gauge_ids,
"t_start": job.t_start.isoformat(), "t_end": job.t_end.isoformat(),
"done": job.done.is_set(), "n_events": len(job.events),
"age_s": int(time.time() - job.created)}
class CalRequest(BaseModel):
gauge_id: str
t_start: str
t_end: str
model: str = "auto"
snow: str = "auto"
# budget = rounds*k candidate runs + 1 baseline (defaults: 4*3+1 = 13 EF5
# runs). If the best NSE is still < 0.3 afterwards, calibrate.py appends
# one extended stage of 5 rounds x 4 candidates (+20 runs) automatically.
# Space variables: CREST_CAL_ROUNDS / CREST_CAL_K (standard stage) and
# CREST_CAL_EXT_ROUNDS / CREST_CAL_EXT_K / CREST_CAL_EXT_NSE (extension) β€”
# no redeploy needed (candidates warm-start from the saved state, so each
# extra run costs seconds-to-minutes, not a full warm-up).
rounds: int = int(os.environ.get("CREST_CAL_ROUNDS", "4"))
k: int = int(os.environ.get("CREST_CAL_K", "3"))
@app.post("/api/calibrate")
def api_calibrate(req: CalRequest, request: Request):
from hf_data import virtualpoints
if virtualpoints.is_virtual(req.gauge_id):
return JSONResponse({"error": "ungauged points have no observations β€” "
"calibration is not possible"}, status_code=422)
owner = _owner(request)
job = caljobs.start_job(req.gauge_id,
datetime.fromisoformat(req.t_start),
datetime.fromisoformat(req.t_end),
{"model": req.model, "snow": req.snow,
"rounds": req.rounds, "k": req.k}, owner=owner)
return {"cal_id": job.id, "token": owner["token"], "gauge_id": req.gauge_id}
@app.get("/api/calstream/{cal_id}")
async def api_calstream(cal_id: str, request: Request, cursor: int = 0, tok: str = ""):
job = caljobs.get_job(cal_id)
if not job:
return Response(status_code=404)
if not _owns(job, request, tok):
return Response(status_code=403)
return EventSourceResponse(_drain(job, cursor))
@app.get("/api/overlay/{sim_id}/{gauge_id}.png")
def api_overlay(sim_id: str, gauge_id: str):
job = simjobs.get_job(sim_id)
png = job.overlay_png(gauge_id) if job else None
if png is None:
return Response(status_code=404)
return Response(content=png, media_type="image/png",
headers={"Cache-Control": "no-store"})
@app.get("/api/frame/{sim_id}/{gauge_id}/{idx}.png")
def api_frame(sim_id: str, gauge_id: str, idx: int):
"""A single pre-rendered animation frame (fixed color scale)."""
job = simjobs.get_job(sim_id)
png = job.frame_png(gauge_id, idx) if job else None
if png is None:
return Response(status_code=404)
return Response(content=png, media_type="image/png",
headers={"Cache-Control": "max-age=3600"})
if os.path.isdir(FRONTEND):
app.mount("/static", StaticFiles(directory=FRONTEND), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7860)