Overlay past Toto and NWS forecasts on the historical chart
Browse filesEvery refresh already logs each forecast (p10/p50/p90) keyed by both
forecast_made_at and target_ts. Use that history to plot, for each past
hour, the most-recent forecast that was issued *before* that hour — for
both Toto and NWS — alongside the Ecowitt actuals on every metric panel.
- src/forecast_log.py: historical_predictions(source, metric, since_unix)
joins the forecast table back onto itself per (source, target_ts) to
pick the latest pre-target snapshot, returns a tidy ts-indexed DF.
- app.py: build past_toto / past_nws dicts per metric over the visible
history window, pass them into combined_figure.
- src/weather_ui.py: render two new dimmer overlay traces — a solid
semi-transparent blue line for past Toto p50, dashed semi-transparent
red for past NWS — so the viewer can see at a glance how each model
has been doing on this exact station.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app.py +22 -1
- src/forecast_log.py +39 -0
- src/weather_ui.py +26 -0
|
@@ -186,12 +186,33 @@ def refresh(cycle_label: str = "Hourly", horizon_label: str = "24 h"):
|
|
| 186 |
forecast_log.record_nws(log_conn, m["col"], ns)
|
| 187 |
|
| 188 |
now = pd.Timestamp.now(tz="UTC").floor("h")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
fig = combined_figure(
|
| 190 |
-
history=
|
| 191 |
totos=totos,
|
| 192 |
nws_df=nws_future,
|
| 193 |
metrics=METRICS,
|
| 194 |
now=now,
|
|
|
|
|
|
|
| 195 |
)
|
| 196 |
|
| 197 |
hero = hero_markdown(PLACE_NAME, history, nws_first, DISPLAY_TZ, realtime=realtime)
|
|
|
|
| 186 |
forecast_log.record_nws(log_conn, m["col"], ns)
|
| 187 |
|
| 188 |
now = pd.Timestamp.now(tz="UTC").floor("h")
|
| 189 |
+
|
| 190 |
+
# Look back: for each metric, build a series of past predictions issued
|
| 191 |
+
# for actual hours that have already passed. Lets us overlay what each
|
| 192 |
+
# model thought vs what happened.
|
| 193 |
+
visible_history = history.tail(int(hist_days * 24 / step_hours))
|
| 194 |
+
since_unix = (
|
| 195 |
+
int(visible_history.index.min().timestamp()) if not visible_history.empty else None
|
| 196 |
+
)
|
| 197 |
+
past_toto: dict[str, pd.DataFrame] = {}
|
| 198 |
+
past_nws: dict[str, pd.DataFrame] = {}
|
| 199 |
+
for m in METRICS:
|
| 200 |
+
col = m["col"]
|
| 201 |
+
pt = forecast_log.historical_predictions(log_conn, "toto", col, since_unix=since_unix)
|
| 202 |
+
if not pt.empty:
|
| 203 |
+
past_toto[col] = pt
|
| 204 |
+
pn = forecast_log.historical_predictions(log_conn, "nws", col, since_unix=since_unix)
|
| 205 |
+
if not pn.empty:
|
| 206 |
+
past_nws[col] = pn
|
| 207 |
+
|
| 208 |
fig = combined_figure(
|
| 209 |
+
history=visible_history,
|
| 210 |
totos=totos,
|
| 211 |
nws_df=nws_future,
|
| 212 |
metrics=METRICS,
|
| 213 |
now=now,
|
| 214 |
+
past_toto=past_toto,
|
| 215 |
+
past_nws=past_nws,
|
| 216 |
)
|
| 217 |
|
| 218 |
hero = hero_markdown(PLACE_NAME, history, nws_first, DISPLAY_TZ, realtime=realtime)
|
|
@@ -160,6 +160,45 @@ def scoreboard(
|
|
| 160 |
return df
|
| 161 |
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
def scoreboard_summary(
|
| 164 |
conn: sqlite3.Connection,
|
| 165 |
metric: str = "temp_f",
|
|
|
|
| 160 |
return df
|
| 161 |
|
| 162 |
|
| 163 |
+
def historical_predictions(
|
| 164 |
+
conn: sqlite3.Connection,
|
| 165 |
+
source: str,
|
| 166 |
+
metric: str,
|
| 167 |
+
since_unix: int | None = None,
|
| 168 |
+
) -> pd.DataFrame:
|
| 169 |
+
"""For each past target_ts, return the most-recent forecast issued for it.
|
| 170 |
+
|
| 171 |
+
Useful for overlaying 'what we predicted vs what actually happened' on the
|
| 172 |
+
historical portion of the chart. Result is a DataFrame indexed by UTC
|
| 173 |
+
timestamp with columns p10, p50, p90 (NWS rows have NULL p10/p90).
|
| 174 |
+
"""
|
| 175 |
+
params: list = [source, metric]
|
| 176 |
+
where_extra = ""
|
| 177 |
+
if since_unix is not None:
|
| 178 |
+
where_extra = " AND target_ts >= ?"
|
| 179 |
+
params.append(since_unix)
|
| 180 |
+
sql = f"""
|
| 181 |
+
WITH latest AS (
|
| 182 |
+
SELECT source, target_ts, metric,
|
| 183 |
+
MAX(forecast_made_at) AS forecast_made_at
|
| 184 |
+
FROM forecast_snapshots
|
| 185 |
+
WHERE source = ? AND metric = ? AND forecast_made_at <= target_ts
|
| 186 |
+
{where_extra}
|
| 187 |
+
GROUP BY source, target_ts, metric
|
| 188 |
+
)
|
| 189 |
+
SELECT f.target_ts, f.p10, f.p50, f.p90
|
| 190 |
+
FROM forecast_snapshots f
|
| 191 |
+
JOIN latest l USING (source, target_ts, metric, forecast_made_at)
|
| 192 |
+
ORDER BY f.target_ts
|
| 193 |
+
"""
|
| 194 |
+
df = pd.read_sql_query(sql, conn, params=params)
|
| 195 |
+
if df.empty:
|
| 196 |
+
return df
|
| 197 |
+
df.index = pd.to_datetime(df["target_ts"], unit="s", utc=True)
|
| 198 |
+
df = df.drop(columns=["target_ts"])
|
| 199 |
+
return df
|
| 200 |
+
|
| 201 |
+
|
| 202 |
def scoreboard_summary(
|
| 203 |
conn: sqlite3.Connection,
|
| 204 |
metric: str = "temp_f",
|
|
@@ -187,6 +187,8 @@ def combined_figure(
|
|
| 187 |
nws_df: pd.DataFrame | None,
|
| 188 |
metrics: list[dict],
|
| 189 |
now: pd.Timestamp | None = None,
|
|
|
|
|
|
|
| 190 |
) -> go.Figure:
|
| 191 |
"""Three stacked subplots sharing the x-axis."""
|
| 192 |
fig = make_subplots(
|
|
@@ -212,6 +214,30 @@ def combined_figure(
|
|
| 212 |
),
|
| 213 |
row=i, col=1,
|
| 214 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
if toto is not None:
|
| 216 |
fig.add_trace(
|
| 217 |
go.Scatter(
|
|
|
|
| 187 |
nws_df: pd.DataFrame | None,
|
| 188 |
metrics: list[dict],
|
| 189 |
now: pd.Timestamp | None = None,
|
| 190 |
+
past_toto: dict[str, pd.DataFrame] | None = None,
|
| 191 |
+
past_nws: dict[str, pd.DataFrame] | None = None,
|
| 192 |
) -> go.Figure:
|
| 193 |
"""Three stacked subplots sharing the x-axis."""
|
| 194 |
fig = make_subplots(
|
|
|
|
| 214 |
),
|
| 215 |
row=i, col=1,
|
| 216 |
)
|
| 217 |
+
# Past Toto forecasts vs the same hours' actuals.
|
| 218 |
+
if past_toto and col in past_toto:
|
| 219 |
+
pt = past_toto[col]
|
| 220 |
+
fig.add_trace(
|
| 221 |
+
go.Scatter(
|
| 222 |
+
x=pt.index, y=pt["p50"].values,
|
| 223 |
+
name="🤖 Toto (past forecasts)", mode="lines",
|
| 224 |
+
line=dict(color="rgba(31,119,180,0.55)", width=1.5),
|
| 225 |
+
showlegend=showlegend, legendgroup="toto-past",
|
| 226 |
+
),
|
| 227 |
+
row=i, col=1,
|
| 228 |
+
)
|
| 229 |
+
# Past NWS forecasts vs the same hours' actuals.
|
| 230 |
+
if past_nws and col in past_nws:
|
| 231 |
+
pn = past_nws[col]
|
| 232 |
+
fig.add_trace(
|
| 233 |
+
go.Scatter(
|
| 234 |
+
x=pn.index, y=pn["p50"].values,
|
| 235 |
+
name="🌎 NWS (past forecasts)", mode="lines",
|
| 236 |
+
line=dict(color="rgba(214,39,40,0.55)", width=1.5, dash="dash"),
|
| 237 |
+
showlegend=showlegend, legendgroup="nws-past",
|
| 238 |
+
),
|
| 239 |
+
row=i, col=1,
|
| 240 |
+
)
|
| 241 |
if toto is not None:
|
| 242 |
fig.add_trace(
|
| 243 |
go.Scatter(
|