seriffic Claude Opus 4.7 (1M context) commited on
Commit
e0b07b6
Β·
1 Parent(s): 81cdb1f

Stones C6: add step_ttm_battery_surge specialist

Browse files

Wraps msradam/Granite-TTM-r2-Battery-Surge β€” Apache-2.0 fine-tune of
ibm-granite/granite-timeseries-ttm-r2 trained on AMD MI300X via AMD
Developer Cloud. Test MAE 0.1091 m on held-out 2023-2024 windows
(-41% vs persistence, -25% vs zero-shot TTM r2).

app/live/ttm_battery_surge.py
fetch() pulls the trailing 1024 h of hourly verified water level +
harmonic tide predictions from NOAA station 8518750 (The Battery),
computes surge residual, runs the fine-tuned TTM, and returns the
96 h forecast plus peak-residual / peak-time scalars the reconciler
can paraphrase. Gated by RIPRAP_TTM_BATTERY_SURGE_ENABLE.

app/fsm.py
New @action step_ttm_battery_surge wired into build_app's actions
dict next to the existing TTM specialists. Plumbed through
step_reconcile reads, the snap dict, run() output, and
iter_steps' final-state dict so the SSE stream surfaces the new
state key end-to-end.

app/reconcile.py
build_documents() emits a `ttm_battery` doc in the Lodestone block
(after floodnet_forecast, before the policy section). Doc body
paraphrases the source/citation, context window, horizon, recent
residual, peak forecast, and peak-time-UTC. Gated on
interesting=True so calm-day forecasts stay silent.
trim_docs_to_plan PREFIXES_BY_SPECIALIST gains a ttm_battery_surge
-> ttm_battery mapping.

web/static/agent.js
STEP_LABELS / SOURCE_LABELS / SOURCE_URLS / SOURCE_VINTAGES extended
for the new specialist; the Source URL points at the published HF
artifact.

Doc-order snapshot stays clean β€” `ttm_battery` lands last in Lodestone,
no doc_id collisions, no other Stone groups perturbed.

Both TTM specialists (zero-shot ttm_forecast, fine-tuned
ttm_battery_surge) coexist intentionally β€” different cadences, different
horizons, different gauges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

app/fsm.py CHANGED
@@ -499,6 +499,44 @@ def step_ttm_forecast(state: State) -> State:
499
  rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
500
 
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  @action(reads=["lat", "lon"], writes=["floodnet_forecast", "trace"])
503
  def step_floodnet_forecast(state: State) -> State:
504
  """TTM r2 forecast of flood-event recurrence at the nearest FloodNet
@@ -893,7 +931,8 @@ def _label_counts(gliner_out: dict[str, dict]) -> dict[str, int]:
893
  "ida_hwm", "prithvi_water", "prithvi_live", "terramind",
894
  "terramind_lulc", "terramind_buildings",
895
  "noaa_tides", "nws_alerts", "nws_obs", "ttm_forecast",
896
- "ttm_311_forecast", "floodnet_forecast", "mta_entrances",
 
897
  "nycha_developments", "doe_schools", "doh_hospitals",
898
  "rag", "gliner"],
899
  writes=["paragraph", "audit", "mellea", "trace"])
@@ -917,6 +956,7 @@ def step_reconcile(state: State) -> State:
917
  "ttm_forecast": state.get("ttm_forecast"),
918
  "ttm_311_forecast": state.get("ttm_311_forecast"),
919
  "floodnet_forecast": state.get("floodnet_forecast"),
 
920
  "rag": state.get("rag"),
921
  "gliner": state.get("gliner"),
922
  "prithvi_live": state.get("prithvi_live"),
@@ -1027,6 +1067,7 @@ def build_app(query: str):
1027
  "ttm_forecast": step_ttm_forecast,
1028
  "ttm_311_forecast": step_ttm_311_forecast,
1029
  "floodnet_forecast": step_floodnet_forecast,
 
1030
  "microtopo": step_microtopo,
1031
  "ida_hwm": step_ida_hwm,
1032
  "mta_entrances": step_mta_entrances,
@@ -1091,6 +1132,7 @@ def run(query: str) -> dict[str, Any]:
1091
  "ttm_forecast": final_state.get("ttm_forecast"),
1092
  "ttm_311_forecast": final_state.get("ttm_311_forecast"),
1093
  "floodnet_forecast": final_state.get("floodnet_forecast"),
 
1094
  "mta_entrances": final_state.get("mta_entrances"),
1095
  "nycha_developments": final_state.get("nycha_developments"),
1096
  "doe_schools": final_state.get("doe_schools"),
@@ -1214,6 +1256,7 @@ def iter_steps(query: str):
1214
  "ttm_forecast": state.get("ttm_forecast"),
1215
  "ttm_311_forecast": state.get("ttm_311_forecast"),
1216
  "floodnet_forecast": state.get("floodnet_forecast"),
 
1217
  "mta_entrances": state.get("mta_entrances"),
1218
  "nycha_developments": state.get("nycha_developments"),
1219
  "doe_schools": state.get("doe_schools"),
 
499
  rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
500
 
501
 
502
+ @action(reads=["lat", "lon"], writes=["ttm_battery_surge", "trace"])
503
+ def step_ttm_battery_surge(state: State) -> State:
504
+ """Granite TTM r2 fine-tune β€” 96 h hourly Battery surge nowcast.
505
+
506
+ Same TTM r2 backbone family as step_ttm_forecast but a different
507
+ artefact: msradam/Granite-TTM-r2-Battery-Surge, trained on AMD
508
+ MI300X. Hourly cadence vs the zero-shot's 6-min, 4-day vs 9.6 h
509
+ horizon. Both can fire on the same query β€” the reconciler frames
510
+ each as a distinct forecast in the briefing."""
511
+ rec, trace = _step(state, "ttm_battery_surge")
512
+ try:
513
+ if state.get("lat") is None:
514
+ rec["ok"] = False; rec["err"] = "no coords"
515
+ return state.update(ttm_battery_surge=None, trace=trace)
516
+ # Battery gauge is a single point; the forecast applies citywide
517
+ # to NYC harbor entrance, so we don't gate by NYC bbox.
518
+ from app.live import ttm_battery_surge
519
+ s = ttm_battery_surge.fetch()
520
+ rec["ok"] = bool(s.get("available"))
521
+ if not rec["ok"]:
522
+ rec["err"] = s.get("reason", "unavailable")
523
+ return state.update(ttm_battery_surge=None, trace=trace)
524
+ rec["result"] = {
525
+ "context_h": s.get("context_hours"),
526
+ "horizon_h": s.get("horizon_hours"),
527
+ "forecast_peak_m": s.get("forecast_peak_m"),
528
+ "forecast_peak_hours_ahead": s.get("forecast_peak_hours_ahead"),
529
+ "interesting": s.get("interesting"),
530
+ }
531
+ return state.update(ttm_battery_surge=s, trace=trace)
532
+ except Exception as e:
533
+ rec["ok"] = False; rec["err"] = str(e)
534
+ log.exception("ttm_battery_surge failed")
535
+ return state.update(ttm_battery_surge=None, trace=trace)
536
+ finally:
537
+ rec["elapsed_s"] = round(time.time() - rec["started_at"], 2)
538
+
539
+
540
  @action(reads=["lat", "lon"], writes=["floodnet_forecast", "trace"])
541
  def step_floodnet_forecast(state: State) -> State:
542
  """TTM r2 forecast of flood-event recurrence at the nearest FloodNet
 
931
  "ida_hwm", "prithvi_water", "prithvi_live", "terramind",
932
  "terramind_lulc", "terramind_buildings",
933
  "noaa_tides", "nws_alerts", "nws_obs", "ttm_forecast",
934
+ "ttm_311_forecast", "floodnet_forecast", "ttm_battery_surge",
935
+ "mta_entrances",
936
  "nycha_developments", "doe_schools", "doh_hospitals",
937
  "rag", "gliner"],
938
  writes=["paragraph", "audit", "mellea", "trace"])
 
956
  "ttm_forecast": state.get("ttm_forecast"),
957
  "ttm_311_forecast": state.get("ttm_311_forecast"),
958
  "floodnet_forecast": state.get("floodnet_forecast"),
959
+ "ttm_battery_surge": state.get("ttm_battery_surge"),
960
  "rag": state.get("rag"),
961
  "gliner": state.get("gliner"),
962
  "prithvi_live": state.get("prithvi_live"),
 
1067
  "ttm_forecast": step_ttm_forecast,
1068
  "ttm_311_forecast": step_ttm_311_forecast,
1069
  "floodnet_forecast": step_floodnet_forecast,
1070
+ "ttm_battery_surge": step_ttm_battery_surge,
1071
  "microtopo": step_microtopo,
1072
  "ida_hwm": step_ida_hwm,
1073
  "mta_entrances": step_mta_entrances,
 
1132
  "ttm_forecast": final_state.get("ttm_forecast"),
1133
  "ttm_311_forecast": final_state.get("ttm_311_forecast"),
1134
  "floodnet_forecast": final_state.get("floodnet_forecast"),
1135
+ "ttm_battery_surge": final_state.get("ttm_battery_surge"),
1136
  "mta_entrances": final_state.get("mta_entrances"),
1137
  "nycha_developments": final_state.get("nycha_developments"),
1138
  "doe_schools": final_state.get("doe_schools"),
 
1256
  "ttm_forecast": state.get("ttm_forecast"),
1257
  "ttm_311_forecast": state.get("ttm_311_forecast"),
1258
  "floodnet_forecast": state.get("floodnet_forecast"),
1259
+ "ttm_battery_surge": state.get("ttm_battery_surge"),
1260
  "mta_entrances": state.get("mta_entrances"),
1261
  "nycha_developments": state.get("nycha_developments"),
1262
  "doe_schools": state.get("doe_schools"),
app/live/ttm_battery_surge.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Granite TTM r2 β€” Battery 96 h surge nowcast (NYC fine-tune).
2
+
3
+ Wraps the Apache-2.0 [`msradam/Granite-TTM-r2-Battery-Surge`](https://huggingface.co/msradam/Granite-TTM-r2-Battery-Surge)
4
+ fine-tune. Fetches the past 1024 hours (~43 days) of hourly verified
5
+ water level + harmonic tide predictions at NOAA station 8518750 (The
6
+ Battery), computes surge residual (observed βˆ’ predicted), and forecasts
7
+ the next 96 hours.
8
+
9
+ Distinct from `app.live.ttm_forecast` β€” that's the *zero-shot* TTM r2
10
+ on 6-min cadence (~9.6 h horizon) at the closest of three NYC gauges.
11
+ This module is the *fine-tuned* model on hourly cadence (~4-day horizon)
12
+ at a single gauge (Battery only β€” see MODEL_CARD honest-limitations).
13
+
14
+ Both nowcasts coexist in the FSM. The zero-shot is shorter-horizon and
15
+ covers every coastal NYC query; the fine-tuned is longer-horizon and
16
+ specialised to the Battery's storm-surge regime, which is the dominant
17
+ driver of NYC inundation. The reconciler frames each as a separate
18
+ forecast in the briefing.
19
+
20
+ Gated by RIPRAP_TTM_BATTERY_SURGE_ENABLE β€” deployments without the
21
+ heavy ML deps (granite-tsfm / transformers) silently no-op via the
22
+ same skipped-result shape every other heavy specialist emits.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import os
28
+ import threading
29
+ import time
30
+ from datetime import datetime, timedelta
31
+ from typing import Any
32
+
33
+ log = logging.getLogger("riprap.ttm_battery_surge")
34
+
35
+ ENABLE = os.environ.get(
36
+ "RIPRAP_TTM_BATTERY_SURGE_ENABLE", "1"
37
+ ).lower() in ("1", "true", "yes")
38
+ DEVICE = os.environ.get("RIPRAP_TTM_BATTERY_SURGE_DEVICE", "cpu")
39
+ REPO = "msradam/Granite-TTM-r2-Battery-Surge"
40
+
41
+ DOC_ID = "ttm_battery"
42
+ CITATION = (
43
+ "msradam/Granite-TTM-r2-Battery-Surge (Apache-2.0, fine-tune of "
44
+ "ibm-granite/granite-timeseries-ttm-r2). Trained on AMD Instinct "
45
+ "MI300X via AMD Developer Cloud. Test MAE 0.1091 m on held-out "
46
+ "2023-2024 windows (vs 0.1467 zero-shot, 0.1861 persistence)."
47
+ )
48
+
49
+ # NOAA Battery (NY) β€” the canonical NYC storm-surge gauge.
50
+ STATION_ID = "8518750"
51
+ STATION_NAME = "The Battery, NY"
52
+ NOAA_API = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"
53
+
54
+ # TTM r2 1024-96-r2 backbone: 1024 hours of context, 96 hours of horizon.
55
+ CONTEXT_LENGTH = 1024
56
+ PREDICTION_LENGTH = 96
57
+
58
+ # Doc emission gate: only cite the forecast if the predicted peak surge
59
+ # is meaningful (positive β‰₯0.3 m or negative ≀-0.3 m). On a calm day the
60
+ # model still runs but the reconciler sees no doc.
61
+ MIN_INTERESTING_RESIDUAL_M = float(
62
+ os.environ.get("RIPRAP_TTM_BATTERY_MIN_INTERESTING_M", "0.3"))
63
+
64
+ _MODEL = None
65
+ _INIT_LOCK = threading.Lock()
66
+
67
+
68
+ def _has_required_deps() -> tuple[bool, str | None]:
69
+ missing: list[str] = []
70
+ for name in ("tsfm_public", "huggingface_hub", "torch", "requests",
71
+ "pandas"):
72
+ try:
73
+ __import__(name)
74
+ except ImportError:
75
+ missing.append(name)
76
+ if missing:
77
+ return False, ", ".join(missing)
78
+ return True, None
79
+
80
+
81
+ _DEPS_OK, _DEPS_MISSING = _has_required_deps()
82
+
83
+
84
+ def _ensure_model():
85
+ """Load the fine-tuned TTM r2 once and cache. Failure is sticky β€”
86
+ a downloaded-then-broken model leaves _MODEL=None so subsequent
87
+ fetches re-attempt rather than silently serving a half-built one."""
88
+ global _MODEL
89
+ if _MODEL is not None:
90
+ return _MODEL
91
+ with _INIT_LOCK:
92
+ if _MODEL is not None:
93
+ return _MODEL
94
+ from huggingface_hub import snapshot_download
95
+ from tsfm_public import TinyTimeMixerForPrediction
96
+ log.info("ttm_battery_surge: downloading %s", REPO)
97
+ local_dir = snapshot_download(REPO)
98
+ log.info("ttm_battery_surge: loading model from %s", local_dir)
99
+ model = TinyTimeMixerForPrediction.from_pretrained(local_dir).eval()
100
+ if DEVICE == "cuda":
101
+ try:
102
+ import torch
103
+ if torch.cuda.is_available():
104
+ model = model.cuda()
105
+ except Exception:
106
+ log.exception("ttm_battery_surge: cuda move failed; "
107
+ "staying on CPU")
108
+ _MODEL = model
109
+ return _MODEL
110
+
111
+
112
+ def _fetch_chunk(start: datetime, end: datetime, product: str):
113
+ """Pull one ≀30-day chunk from the NOAA CO-OPS datagetter.
114
+
115
+ Two products: `water_level` (verified, 6-min β€” we ask for hourly
116
+ via interval=h) and `predictions` (hourly harmonic tide). Both come
117
+ back in metres if `units=metric`.
118
+ """
119
+ import pandas as pd
120
+ import requests
121
+ params = {
122
+ "station": STATION_ID,
123
+ "begin_date": start.strftime("%Y%m%d"),
124
+ "end_date": end.strftime("%Y%m%d"),
125
+ "product": product,
126
+ "datum": "MLLW",
127
+ "units": "metric",
128
+ "time_zone": "gmt",
129
+ "format": "json",
130
+ "application": "riprap-nyc",
131
+ "interval": "h",
132
+ }
133
+ resp = requests.get(NOAA_API, params=params, timeout=30)
134
+ resp.raise_for_status()
135
+ data = resp.json()
136
+ key = "data" if "data" in data else "predictions"
137
+ if key not in data:
138
+ return pd.DataFrame()
139
+ df = pd.DataFrame(data[key])
140
+ if df.empty:
141
+ return df
142
+ df["timestamp"] = pd.to_datetime(df["t"])
143
+ df["value"] = pd.to_numeric(df["v"], errors="coerce")
144
+ return df[["timestamp", "value"]].dropna()
145
+
146
+
147
+ def _fetch_battery_history(hours: int) -> Any:
148
+ """Pull the last `hours` hours of (water_level, predicted) at the
149
+ Battery and return a DataFrame with columns
150
+ `timestamp / water_level_m / predicted_m / surge_residual_m`."""
151
+ import pandas as pd
152
+
153
+ end_d = datetime.utcnow().replace(minute=0, second=0, microsecond=0)
154
+ n_days = max(1, hours // 24 + 3) # padding in case of NOAA gaps
155
+
156
+ chunks_wl, chunks_pr = [], []
157
+ cur = end_d - timedelta(days=n_days)
158
+ while cur < end_d:
159
+ nxt = min(cur + timedelta(days=30), end_d)
160
+ try:
161
+ chunks_wl.append(_fetch_chunk(cur, nxt, "water_level"))
162
+ chunks_pr.append(_fetch_chunk(cur, nxt, "predictions"))
163
+ except Exception as e:
164
+ log.warning("ttm_battery_surge: NOAA chunk %s..%s failed: %s",
165
+ cur.date(), nxt.date(), e)
166
+ cur = nxt
167
+
168
+ wl = pd.concat(chunks_wl, ignore_index=True) if chunks_wl else pd.DataFrame()
169
+ pr = pd.concat(chunks_pr, ignore_index=True) if chunks_pr else pd.DataFrame()
170
+ if wl.empty or pr.empty:
171
+ return pd.DataFrame()
172
+ wl = wl.rename(columns={"value": "water_level_m"})
173
+ pr = pr.rename(columns={"value": "predicted_m"})
174
+ df = wl.merge(pr, on="timestamp", how="inner").sort_values("timestamp")
175
+ df["surge_residual_m"] = df["water_level_m"] - df["predicted_m"]
176
+ df = df.dropna(subset=["surge_residual_m"])
177
+ if len(df) > hours:
178
+ df = df.iloc[-hours:].reset_index(drop=True)
179
+ return df
180
+
181
+
182
+ def _summarize(history_df, forecast_arr) -> dict[str, Any]:
183
+ """Build the public dict the FSM specialist hands to the reconciler.
184
+
185
+ Includes both raw arrays (for downstream charts in the trace UI)
186
+ and human-readable scalars (peak / peak time / interesting flag)
187
+ that the reconciler can paraphrase without overshooting evidence.
188
+ """
189
+ import numpy as np
190
+ history_arr = history_df["surge_residual_m"].to_numpy()
191
+ history_recent = float(history_arr[-1]) if len(history_arr) else None
192
+ history_peak_abs = float(np.max(np.abs(history_arr))) if len(history_arr) else None
193
+
194
+ fc = np.asarray(forecast_arr, dtype="float64").reshape(-1)
195
+ if fc.size == 0:
196
+ return {"available": False, "reason": "empty forecast"}
197
+ peak_idx = int(np.argmax(np.abs(fc)))
198
+ peak = float(fc[peak_idx])
199
+ peak_h_ahead = peak_idx + 1 # hourly cadence; index 0 = +1 h
200
+
201
+ last_ts = (history_df["timestamp"].iloc[-1]
202
+ if len(history_df) else datetime.utcnow())
203
+ peak_time = last_ts + timedelta(hours=peak_h_ahead)
204
+
205
+ interesting = bool(abs(peak) >= MIN_INTERESTING_RESIDUAL_M)
206
+
207
+ return {
208
+ "available": True,
209
+ "interesting": interesting,
210
+ "model": REPO,
211
+ "station_id": STATION_ID,
212
+ "station_name": STATION_NAME,
213
+ "context_hours": int(len(history_arr)),
214
+ "horizon_hours": int(fc.size),
215
+ "history_recent_m": (round(history_recent, 3)
216
+ if history_recent is not None else None),
217
+ "history_peak_abs_m": (round(history_peak_abs, 3)
218
+ if history_peak_abs is not None else None),
219
+ "forecast_peak_m": round(peak, 3),
220
+ "forecast_peak_hours_ahead": peak_h_ahead,
221
+ "forecast_peak_time_utc": peak_time.isoformat(timespec="minutes"),
222
+ "forecast_array_m": [round(float(v), 4) for v in fc.tolist()],
223
+ }
224
+
225
+
226
+ def fetch(timeout_s: float = 60.0) -> dict[str, Any]:
227
+ """Run the specialist. Always returns a dict with at minimum
228
+ `{available: bool, reason | ...}`. Caller should treat
229
+ `available=False` as silence-over-confabulation."""
230
+ if not ENABLE:
231
+ return {"available": False,
232
+ "reason": "RIPRAP_TTM_BATTERY_SURGE_ENABLE=0"}
233
+ if not _DEPS_OK:
234
+ return {"available": False,
235
+ "reason": f"deps unavailable on this deployment: "
236
+ f"{_DEPS_MISSING}"}
237
+ t0 = time.time()
238
+ try:
239
+ df = _fetch_battery_history(CONTEXT_LENGTH)
240
+ if len(df) < CONTEXT_LENGTH:
241
+ return {"available": False,
242
+ "reason": f"insufficient NOAA history: "
243
+ f"got {len(df)} hours, need {CONTEXT_LENGTH}"}
244
+ if time.time() - t0 > timeout_s:
245
+ return {"available": False,
246
+ "reason": "NOAA fetch exceeded budget"}
247
+
248
+ import torch
249
+ model = _ensure_model()
250
+ # [B=1, T=1024, C=1] tensor of metres surge residual.
251
+ residuals = df["surge_residual_m"].to_numpy().astype("float32")
252
+ past = torch.from_numpy(residuals).unsqueeze(0).unsqueeze(-1)
253
+ if DEVICE == "cuda":
254
+ try:
255
+ if torch.cuda.is_available():
256
+ past = past.cuda()
257
+ except Exception:
258
+ log.exception("ttm_battery_surge: cuda move failed")
259
+ with torch.no_grad():
260
+ out = model(past_values=past)
261
+ forecast = out.prediction_outputs.squeeze(-1).squeeze(0).cpu().numpy()
262
+ result = _summarize(df, forecast)
263
+ result["elapsed_s"] = round(time.time() - t0, 2)
264
+ return result
265
+ except Exception as e:
266
+ log.exception("ttm_battery_surge fetch failed")
267
+ return {"available": False,
268
+ "reason": f"{type(e).__name__}: {e}",
269
+ "elapsed_s": round(time.time() - t0, 2)}
270
+
271
+
272
+ def warm():
273
+ """Optional pre-load β€” amortizes the first-query model build cost."""
274
+ if not ENABLE or not _DEPS_OK:
275
+ return
276
+ try:
277
+ _ensure_model()
278
+ except Exception:
279
+ log.exception("ttm_battery_surge: warm() failed")
app/reconcile.py CHANGED
@@ -270,6 +270,7 @@ def trim_docs_to_plan(doc_msgs: list[dict],
270
  "ttm_forecast": ("ttm_forecast",),
271
  "ttm_311_forecast": ("ttm_311_forecast",),
272
  "floodnet_forecast": ("floodnet_forecast",),
 
273
  "terramind": ("terramind", "syn_"),
274
  "terramind_lulc": ("tm_lulc",),
275
  "terramind_buildings": ("tm_buildings",),
@@ -1000,6 +1001,39 @@ def build_documents(state: dict[str, Any]) -> list[dict]:
1000
  ]
1001
  docs.append(_doc_message(doc_id, body))
1002
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
  # ---- Policy context (RAG + GLiNER, ancillary to the four Stones) ---
1004
  # Retrieved policy paragraphs and GLiNER typed-entity extractions.
1005
  # These don't belong to a specific Stone β€” they ground the
 
270
  "ttm_forecast": ("ttm_forecast",),
271
  "ttm_311_forecast": ("ttm_311_forecast",),
272
  "floodnet_forecast": ("floodnet_forecast",),
273
+ "ttm_battery_surge": ("ttm_battery",),
274
  "terramind": ("terramind", "syn_"),
275
  "terramind_lulc": ("tm_lulc",),
276
  "terramind_buildings": ("tm_buildings",),
 
1001
  ]
1002
  docs.append(_doc_message(doc_id, body))
1003
 
1004
+ # Granite TTM r2 β€” Battery surge fine-tune (msradam/Granite-TTM-r2-
1005
+ # Battery-Surge, Apache-2.0, fine-tuned on AMD MI300X). Hourly
1006
+ # cadence, 96 h horizon β€” distinct from the existing zero-shot
1007
+ # ttm_forecast above, which runs at 6-min cadence over a 9.6 h
1008
+ # horizon. Both can fire on the same query.
1009
+ tbs = state.get("ttm_battery_surge")
1010
+ if (not out_of_nyc and tbs and tbs.get("available")
1011
+ and tbs.get("interesting")):
1012
+ body = [
1013
+ "Source: msradam/Granite-TTM-r2-Battery-Surge (Apache-2.0). "
1014
+ "Fine-tune of ibm-granite/granite-timeseries-ttm-r2 trained "
1015
+ "on AMD Instinct MI300X via AMD Developer Cloud. Test MAE "
1016
+ "0.1091 m, -41% vs persistence and -25% vs zero-shot TTM r2.",
1017
+ f"Gauge: {tbs['station_name']} (NOAA {tbs['station_id']}).",
1018
+ f"Context window: {tbs['context_hours']} hours "
1019
+ f"(~{tbs['context_hours']/24:.1f} days) of hourly surge "
1020
+ "residual (verified water level minus harmonic tide).",
1021
+ f"Forecast horizon: {tbs['horizon_hours']} hours "
1022
+ f"(~{tbs['horizon_hours']/24:.1f} days ahead).",
1023
+ f"Recent residual: {tbs['history_recent_m']} m.",
1024
+ f"Recent peak |residual| in context: "
1025
+ f"{tbs['history_peak_abs_m']} m.",
1026
+ f"Forecast peak surge residual: {tbs['forecast_peak_m']} m, "
1027
+ f"expected {tbs['forecast_peak_hours_ahead']} hours from "
1028
+ f"now (at {tbs['forecast_peak_time_utc']} UTC).",
1029
+ "INTERPRETATION: positive residual is the meteorological "
1030
+ "component (storm surge, atmospheric pressure, wind setup) "
1031
+ "on top of astronomical tide. The Battery is the dominant "
1032
+ "NYC harbor-entrance gauge β€” its surge characterises Sandy "
1033
+ "and Ida conditions citywide.",
1034
+ ]
1035
+ docs.append(_doc_message("ttm_battery", body))
1036
+
1037
  # ---- Policy context (RAG + GLiNER, ancillary to the four Stones) ---
1038
  # Retrieved policy paragraphs and GLiNER typed-entity extractions.
1039
  # These don't belong to a specific Stone β€” they ground the
web/static/agent.js CHANGED
@@ -16,6 +16,7 @@ const STEP_LABELS = {
16
  ttm_forecast: ["Granite TTM r2 β€” surge nowcast", "9.6h forecast at the closest of Battery / Kings Pt / Sandy Hook"],
17
  ttm_311_forecast: ["Granite TTM r2 β€” 311 forecast", "4-week per-address flood-complaint forecast (52w history)"],
18
  floodnet_forecast: ["Granite TTM r2 β€” FloodNet forecast", "flood-event recurrence forecast at nearest FloodNet sensor"],
 
19
  mta_entrance_exposure: ["MTA subway entrances", "subway-entrance exposure (point-in-polygon Sandy + DEP)"],
20
  nycha_development_exposure: ["NYCHA developments", "NYCHA campus footprint Γ— Sandy + DEP overlap %"],
21
  doe_school_exposure: ["NYC DOE schools", "school-point exposure (Sandy + DEP)"],
@@ -82,6 +83,7 @@ const SOURCE_LABELS = {
82
  ttm_forecast: "Granite TimeSeries TTM r2 β€” surge residual nowcast",
83
  ttm_311_forecast: "Granite TimeSeries TTM r2 β€” per-address 311 weekly forecast",
84
  floodnet_forecast: "Granite TimeSeries TTM r2 β€” FloodNet sensor recurrence forecast",
 
85
  dob_permits: "NYC DOB Permit Issuance (Socrata ipu4-2q9a)",
86
  live_target: "Riprap planner β€” live target",
87
  rag_comptroller: 'NYC Comptroller β€” "Is NYC Ready for Rain?" (2024)',
@@ -133,6 +135,7 @@ const SOURCE_URLS = {
133
  ttm_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
134
  ttm_311_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
135
  floodnet_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
 
136
  dob_permits: "https://data.cityofnewyork.us/Housing-Development/DOB-Permit-Issuance/ipu4-2q9a",
137
  rag_comptroller: "https://comptroller.nyc.gov/reports/is-new-york-city-ready-for-rain/",
138
  rag_npcc4: "https://nyaspubs.onlinelibrary.wiley.com/toc/17496632/2024/1539/1",
@@ -181,6 +184,7 @@ const SOURCE_VINTAGES = {
181
  ttm_forecast: "live TTM forecast based on trailing 51 h at the closest NOAA gauge to this address (Battery / Kings Pt / Sandy Hook)",
182
  ttm_311_forecast: "live TTM forecast based on trailing 52 weeks of NYC 311 flood complaints within 200 m of this address",
183
  floodnet_forecast: "live TTM forecast based on the 512-day daily flood-event series at the nearest FloodNet sensor",
 
184
  dob_permits: "live NYC DOB Permit Issuance, trailing 18-month window (per-permit issuance dates in payload)",
185
  rag_comptroller: "NYC Comptroller report 'Is NYC Ready for Rain?' (2024)",
186
  rag_npcc4: "NPCC4 β€” NYC Climate Assessment 4th edition, Annals NYAS vol. 1539 (2024)",
 
16
  ttm_forecast: ["Granite TTM r2 β€” surge nowcast", "9.6h forecast at the closest of Battery / Kings Pt / Sandy Hook"],
17
  ttm_311_forecast: ["Granite TTM r2 β€” 311 forecast", "4-week per-address flood-complaint forecast (52w history)"],
18
  floodnet_forecast: ["Granite TTM r2 β€” FloodNet forecast", "flood-event recurrence forecast at nearest FloodNet sensor"],
19
+ ttm_battery_surge: ["Granite TTM r2 β€” Battery surge (NYC fine-tune)", "96 h hourly surge nowcast at NOAA Battery (msradam/Granite-TTM-r2-Battery-Surge)"],
20
  mta_entrance_exposure: ["MTA subway entrances", "subway-entrance exposure (point-in-polygon Sandy + DEP)"],
21
  nycha_development_exposure: ["NYCHA developments", "NYCHA campus footprint Γ— Sandy + DEP overlap %"],
22
  doe_school_exposure: ["NYC DOE schools", "school-point exposure (Sandy + DEP)"],
 
83
  ttm_forecast: "Granite TimeSeries TTM r2 β€” surge residual nowcast",
84
  ttm_311_forecast: "Granite TimeSeries TTM r2 β€” per-address 311 weekly forecast",
85
  floodnet_forecast: "Granite TimeSeries TTM r2 β€” FloodNet sensor recurrence forecast",
86
+ ttm_battery: "Granite TTM r2 NYC fine-tune β€” 96 h Battery surge nowcast (msradam/Granite-TTM-r2-Battery-Surge)",
87
  dob_permits: "NYC DOB Permit Issuance (Socrata ipu4-2q9a)",
88
  live_target: "Riprap planner β€” live target",
89
  rag_comptroller: 'NYC Comptroller β€” "Is NYC Ready for Rain?" (2024)',
 
135
  ttm_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
136
  ttm_311_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
137
  floodnet_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
138
+ ttm_battery: "https://huggingface.co/msradam/Granite-TTM-r2-Battery-Surge",
139
  dob_permits: "https://data.cityofnewyork.us/Housing-Development/DOB-Permit-Issuance/ipu4-2q9a",
140
  rag_comptroller: "https://comptroller.nyc.gov/reports/is-new-york-city-ready-for-rain/",
141
  rag_npcc4: "https://nyaspubs.onlinelibrary.wiley.com/toc/17496632/2024/1539/1",
 
184
  ttm_forecast: "live TTM forecast based on trailing 51 h at the closest NOAA gauge to this address (Battery / Kings Pt / Sandy Hook)",
185
  ttm_311_forecast: "live TTM forecast based on trailing 52 weeks of NYC 311 flood complaints within 200 m of this address",
186
  floodnet_forecast: "live TTM forecast based on the 512-day daily flood-event series at the nearest FloodNet sensor",
187
+ ttm_battery: "live NYC fine-tuned TTM forecast based on the trailing 1024 hours (~43 days) of hourly surge residual at the Battery; 96 h horizon",
188
  dob_permits: "live NYC DOB Permit Issuance, trailing 18-month window (per-permit issuance dates in payload)",
189
  rag_comptroller: "NYC Comptroller report 'Is NYC Ready for Rain?' (2024)",
190
  rag_npcc4: "NPCC4 β€” NYC Climate Assessment 4th edition, Annals NYAS vol. 1539 (2024)",