Commit ·
5c4da67
1
Parent(s): 00be188
Log forecast snapshots and scoreboard Toto vs NWS
Browse files- src/forecast_log.py: SQLite tables for forecast_snapshots and actuals,
upsert helpers, and a scoreboard query computing rolling MAE per source
using each target's most-recently-issued forecast.
- app.py: write actuals + Toto + NWS forecasts on every refresh; render a
markdown scoreboard panel above the plots; daemon thread that re-runs
refresh() hourly so snapshots accumulate without a user visit.
- app.py: drop the NWS first period that overlaps Ecowitt's last actual
hour so all forecasts begin one bucket past the last observed value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app.py +76 -6
- src/forecast_log.py +175 -0
app.py
CHANGED
|
@@ -9,19 +9,22 @@ forecast for the same window.
|
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import os
|
|
|
|
| 12 |
import time
|
|
|
|
| 13 |
from datetime import datetime, timedelta, timezone
|
| 14 |
|
| 15 |
import gradio as gr
|
| 16 |
import pandas as pd
|
| 17 |
|
| 18 |
-
from src import ecowitt, nws
|
| 19 |
from src.forecast import forecast_series
|
| 20 |
from src.plotting import metric_figure
|
| 21 |
|
| 22 |
CACHE_TTL_SECONDS = 60 * 60 # 1 hour
|
| 23 |
HISTORY_DAYS = 7
|
| 24 |
HORIZON_HOURS = 24
|
|
|
|
| 25 |
|
| 26 |
# Three metrics to forecast. Maps Ecowitt history column → plot config.
|
| 27 |
METRICS = [
|
|
@@ -73,13 +76,26 @@ def refresh():
|
|
| 73 |
nws_df = fetch_nws()
|
| 74 |
now = pd.Timestamp.now(tz="UTC").floor("h")
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
figs = []
|
| 77 |
for m in METRICS:
|
| 78 |
series = history[m["col"]].dropna()
|
| 79 |
toto = forecast_series(series, horizon=HORIZON_HOURS)
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
figs.append(
|
| 84 |
metric_figure(
|
| 85 |
history=series.tail(HISTORY_DAYS * 24),
|
|
@@ -90,7 +106,59 @@ def refresh():
|
|
| 90 |
now=now,
|
| 91 |
)
|
| 92 |
)
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
# --- UI -------------------------------------------------------------------
|
|
@@ -109,14 +177,16 @@ with gr.Blocks(title="Toto Weather Forecast") as demo:
|
|
| 109 |
gr.Markdown(HOOK)
|
| 110 |
gr.Markdown(SUBTITLE)
|
| 111 |
refresh_btn = gr.Button("Refresh forecast", variant="primary")
|
|
|
|
| 112 |
temp_plot = gr.Plot(label="Temperature")
|
| 113 |
humidity_plot = gr.Plot(label="Humidity")
|
| 114 |
pressure_plot = gr.Plot(label="Pressure")
|
| 115 |
|
| 116 |
-
outputs = [temp_plot, humidity_plot, pressure_plot]
|
| 117 |
demo.load(refresh, outputs=outputs)
|
| 118 |
refresh_btn.click(refresh, outputs=outputs)
|
| 119 |
|
| 120 |
|
| 121 |
if __name__ == "__main__":
|
|
|
|
| 122 |
demo.launch()
|
|
|
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import os
|
| 12 |
+
import threading
|
| 13 |
import time
|
| 14 |
+
import traceback
|
| 15 |
from datetime import datetime, timedelta, timezone
|
| 16 |
|
| 17 |
import gradio as gr
|
| 18 |
import pandas as pd
|
| 19 |
|
| 20 |
+
from src import ecowitt, forecast_log, nws
|
| 21 |
from src.forecast import forecast_series
|
| 22 |
from src.plotting import metric_figure
|
| 23 |
|
| 24 |
CACHE_TTL_SECONDS = 60 * 60 # 1 hour
|
| 25 |
HISTORY_DAYS = 7
|
| 26 |
HORIZON_HOURS = 24
|
| 27 |
+
AUTO_REFRESH_SECONDS = 60 * 60 # log a fresh forecast snapshot every hour
|
| 28 |
|
| 29 |
# Three metrics to forecast. Maps Ecowitt history column → plot config.
|
| 30 |
METRICS = [
|
|
|
|
| 76 |
nws_df = fetch_nws()
|
| 77 |
now = pd.Timestamp.now(tz="UTC").floor("h")
|
| 78 |
|
| 79 |
+
# NWS's first period and Ecowitt's last bucket describe the same wall-clock
|
| 80 |
+
# hour; drop the overlap so all forecasts begin one hour after the last
|
| 81 |
+
# observed actual.
|
| 82 |
+
last_actual = history.dropna(how="all").index.max()
|
| 83 |
+
nws_future = nws_df[nws_df.index > last_actual] if last_actual is not None else nws_df
|
| 84 |
+
|
| 85 |
+
log_conn = forecast_log.connect()
|
| 86 |
+
forecast_log.record_actuals(log_conn, history)
|
| 87 |
+
|
| 88 |
figs = []
|
| 89 |
for m in METRICS:
|
| 90 |
series = history[m["col"]].dropna()
|
| 91 |
toto = forecast_series(series, horizon=HORIZON_HOURS)
|
| 92 |
+
forecast_log.record_toto(log_conn, m["col"], toto)
|
| 93 |
+
|
| 94 |
+
nws_series = None
|
| 95 |
+
if m["nws_col"] and m["nws_col"] in nws_future.columns:
|
| 96 |
+
nws_series = nws_future[m["nws_col"]].dropna()
|
| 97 |
+
forecast_log.record_nws(log_conn, m["col"], nws_series)
|
| 98 |
+
|
| 99 |
figs.append(
|
| 100 |
metric_figure(
|
| 101 |
history=series.tail(HISTORY_DAYS * 24),
|
|
|
|
| 106 |
now=now,
|
| 107 |
)
|
| 108 |
)
|
| 109 |
+
scoreboard_md = render_scoreboard(log_conn)
|
| 110 |
+
return figs[0], figs[1], figs[2], scoreboard_md
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# --- scoreboard ----------------------------------------------------------
|
| 114 |
+
def render_scoreboard(conn) -> str:
|
| 115 |
+
lines = ["### Forecast scoreboard (rolling 48h MAE, lower = better)"]
|
| 116 |
+
any_data = False
|
| 117 |
+
for metric, label, unit in [
|
| 118 |
+
("temp_f", "Temperature", "°F"),
|
| 119 |
+
("humidity", "Humidity", "%"),
|
| 120 |
+
("pressure_inhg", "Pressure", "inHg"),
|
| 121 |
+
]:
|
| 122 |
+
summ = forecast_log.scoreboard_summary(conn, metric=metric, window_hours=48)
|
| 123 |
+
if summ.empty:
|
| 124 |
+
continue
|
| 125 |
+
any_data = True
|
| 126 |
+
by = {row["source"]: row for _, row in summ.iterrows()}
|
| 127 |
+
toto = by.get("toto")
|
| 128 |
+
nws = by.get("nws")
|
| 129 |
+
parts = [f"**{label}**"]
|
| 130 |
+
if toto is not None:
|
| 131 |
+
parts.append(f"Toto {toto['mae']:.2f} {unit} (n={int(toto['n'])})")
|
| 132 |
+
if nws is not None:
|
| 133 |
+
parts.append(f"NWS {nws['mae']:.2f} {unit} (n={int(nws['n'])})")
|
| 134 |
+
if toto is not None and nws is not None:
|
| 135 |
+
diff = toto["mae"] - nws["mae"]
|
| 136 |
+
winner = "Toto" if diff < 0 else "NWS"
|
| 137 |
+
parts.append(f"→ **{winner}** by {abs(diff):.2f} {unit}")
|
| 138 |
+
lines.append(" · ".join(parts))
|
| 139 |
+
if not any_data:
|
| 140 |
+
lines.append("_No scored forecasts yet — the scoreboard fills in once forecasts have target hours in the past with matching Ecowitt actuals (typically after the first hour of running)._")
|
| 141 |
+
return "\n\n".join(lines)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# --- auto-refresh background thread --------------------------------------
|
| 145 |
+
def _autorefresh_loop():
|
| 146 |
+
"""Call refresh() on a schedule so we accumulate forecast snapshots even
|
| 147 |
+
when nobody is loading the page. Errors are logged and swallowed so a
|
| 148 |
+
transient API failure doesn't kill the thread."""
|
| 149 |
+
while True:
|
| 150 |
+
try:
|
| 151 |
+
refresh()
|
| 152 |
+
except Exception: # noqa: BLE001
|
| 153 |
+
print("[autorefresh] error during refresh:")
|
| 154 |
+
traceback.print_exc()
|
| 155 |
+
time.sleep(AUTO_REFRESH_SECONDS)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _start_autorefresh():
|
| 159 |
+
t = threading.Thread(target=_autorefresh_loop, daemon=True, name="autorefresh")
|
| 160 |
+
t.start()
|
| 161 |
+
print(f"[autorefresh] started, interval={AUTO_REFRESH_SECONDS}s")
|
| 162 |
|
| 163 |
|
| 164 |
# --- UI -------------------------------------------------------------------
|
|
|
|
| 177 |
gr.Markdown(HOOK)
|
| 178 |
gr.Markdown(SUBTITLE)
|
| 179 |
refresh_btn = gr.Button("Refresh forecast", variant="primary")
|
| 180 |
+
scoreboard_md = gr.Markdown()
|
| 181 |
temp_plot = gr.Plot(label="Temperature")
|
| 182 |
humidity_plot = gr.Plot(label="Humidity")
|
| 183 |
pressure_plot = gr.Plot(label="Pressure")
|
| 184 |
|
| 185 |
+
outputs = [temp_plot, humidity_plot, pressure_plot, scoreboard_md]
|
| 186 |
demo.load(refresh, outputs=outputs)
|
| 187 |
refresh_btn.click(refresh, outputs=outputs)
|
| 188 |
|
| 189 |
|
| 190 |
if __name__ == "__main__":
|
| 191 |
+
_start_autorefresh()
|
| 192 |
demo.launch()
|
src/forecast_log.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite logging of Toto + NWS forecasts and Ecowitt actuals.
|
| 2 |
+
|
| 3 |
+
Every refresh appends:
|
| 4 |
+
- actuals(target_ts, metric, value) ← Ecowitt history rows
|
| 5 |
+
- forecast_snapshots(forecast_made_at, target_ts, source, metric, p10, p50, p90)
|
| 6 |
+
← one row per future hour, per source, per metric
|
| 7 |
+
|
| 8 |
+
A scoreboard joins the two and computes per-source MAE over a rolling window.
|
| 9 |
+
|
| 10 |
+
NOTE: On HuggingFace Spaces' free CPU tier the DB lives in ephemeral storage
|
| 11 |
+
and resets when the Space rebuilds (i.e. on `git push`). Restarts without
|
| 12 |
+
rebuild keep the file. For longer-lived tracking, mount persistent storage
|
| 13 |
+
or push to an HF Dataset.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sqlite3
|
| 20 |
+
import time
|
| 21 |
+
from typing import Iterable
|
| 22 |
+
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
from .forecast import TotoForecast
|
| 26 |
+
|
| 27 |
+
DEFAULT_DB_PATH = os.environ.get("FORECAST_LOG_DB", "data/forecasts.db")
|
| 28 |
+
|
| 29 |
+
SCHEMA = """
|
| 30 |
+
CREATE TABLE IF NOT EXISTS forecast_snapshots (
|
| 31 |
+
forecast_made_at INTEGER NOT NULL,
|
| 32 |
+
target_ts INTEGER NOT NULL,
|
| 33 |
+
source TEXT NOT NULL, -- 'toto' | 'nws'
|
| 34 |
+
metric TEXT NOT NULL, -- 'temp_f' | 'humidity' | 'pressure_inhg'
|
| 35 |
+
p10 REAL,
|
| 36 |
+
p50 REAL,
|
| 37 |
+
p90 REAL,
|
| 38 |
+
PRIMARY KEY (forecast_made_at, target_ts, source, metric)
|
| 39 |
+
);
|
| 40 |
+
CREATE INDEX IF NOT EXISTS idx_fs_target ON forecast_snapshots(target_ts, metric, source);
|
| 41 |
+
|
| 42 |
+
CREATE TABLE IF NOT EXISTS actuals (
|
| 43 |
+
target_ts INTEGER NOT NULL,
|
| 44 |
+
metric TEXT NOT NULL,
|
| 45 |
+
value REAL NOT NULL,
|
| 46 |
+
PRIMARY KEY (target_ts, metric)
|
| 47 |
+
);
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def connect(path: str = DEFAULT_DB_PATH) -> sqlite3.Connection:
|
| 52 |
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
| 53 |
+
conn = sqlite3.connect(path)
|
| 54 |
+
conn.executescript(SCHEMA)
|
| 55 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 56 |
+
conn.execute("PRAGMA synchronous=NORMAL")
|
| 57 |
+
return conn
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _ts(t) -> int:
|
| 61 |
+
return int(pd.Timestamp(t).tz_convert("UTC").timestamp())
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def record_actuals(conn: sqlite3.Connection, history: pd.DataFrame) -> int:
|
| 65 |
+
"""Upsert actuals from a history DataFrame (UTC-indexed; column = metric)."""
|
| 66 |
+
rows = []
|
| 67 |
+
for metric in history.columns:
|
| 68 |
+
s = history[metric].dropna()
|
| 69 |
+
for ts, val in s.items():
|
| 70 |
+
rows.append((_ts(ts), metric, float(val)))
|
| 71 |
+
if not rows:
|
| 72 |
+
return 0
|
| 73 |
+
conn.executemany(
|
| 74 |
+
"INSERT OR REPLACE INTO actuals (target_ts, metric, value) VALUES (?,?,?)",
|
| 75 |
+
rows,
|
| 76 |
+
)
|
| 77 |
+
conn.commit()
|
| 78 |
+
return len(rows)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def record_toto(
|
| 82 |
+
conn: sqlite3.Connection,
|
| 83 |
+
metric: str,
|
| 84 |
+
fcst: TotoForecast,
|
| 85 |
+
forecast_made_at: int | None = None,
|
| 86 |
+
) -> int:
|
| 87 |
+
made = forecast_made_at if forecast_made_at is not None else int(time.time())
|
| 88 |
+
rows = [
|
| 89 |
+
(made, _ts(t), "toto", metric, float(p10), float(p50), float(p90))
|
| 90 |
+
for t, p10, p50, p90 in zip(fcst.median.index, fcst.p10.values, fcst.median.values, fcst.p90.values)
|
| 91 |
+
]
|
| 92 |
+
conn.executemany(
|
| 93 |
+
"INSERT OR REPLACE INTO forecast_snapshots "
|
| 94 |
+
"(forecast_made_at, target_ts, source, metric, p10, p50, p90) "
|
| 95 |
+
"VALUES (?,?,?,?,?,?,?)",
|
| 96 |
+
rows,
|
| 97 |
+
)
|
| 98 |
+
conn.commit()
|
| 99 |
+
return len(rows)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def record_nws(
|
| 103 |
+
conn: sqlite3.Connection,
|
| 104 |
+
metric: str,
|
| 105 |
+
series: pd.Series,
|
| 106 |
+
forecast_made_at: int | None = None,
|
| 107 |
+
) -> int:
|
| 108 |
+
"""NWS gives a point forecast only — store as p50 with NULL p10/p90."""
|
| 109 |
+
made = forecast_made_at if forecast_made_at is not None else int(time.time())
|
| 110 |
+
s = series.dropna()
|
| 111 |
+
rows = [(made, _ts(t), "nws", metric, None, float(v), None) for t, v in s.items()]
|
| 112 |
+
if not rows:
|
| 113 |
+
return 0
|
| 114 |
+
conn.executemany(
|
| 115 |
+
"INSERT OR REPLACE INTO forecast_snapshots "
|
| 116 |
+
"(forecast_made_at, target_ts, source, metric, p10, p50, p90) "
|
| 117 |
+
"VALUES (?,?,?,?,?,?,?)",
|
| 118 |
+
rows,
|
| 119 |
+
)
|
| 120 |
+
conn.commit()
|
| 121 |
+
return len(rows)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def scoreboard(
|
| 125 |
+
conn: sqlite3.Connection,
|
| 126 |
+
metric: str = "temp_f",
|
| 127 |
+
window_hours: int = 48,
|
| 128 |
+
) -> pd.DataFrame:
|
| 129 |
+
"""Per-source MAE over the last `window_hours`, restricted to forecasts
|
| 130 |
+
whose target time is in the past and where we have an actual value.
|
| 131 |
+
|
| 132 |
+
Each (target_ts, source) pair is scored against the *most recent* forecast
|
| 133 |
+
issued for that target — i.e. the latest snapshot before target_ts.
|
| 134 |
+
"""
|
| 135 |
+
cutoff = int(time.time()) - window_hours * 3600
|
| 136 |
+
sql = """
|
| 137 |
+
WITH latest AS (
|
| 138 |
+
SELECT source, target_ts, metric,
|
| 139 |
+
MAX(forecast_made_at) AS forecast_made_at
|
| 140 |
+
FROM forecast_snapshots
|
| 141 |
+
WHERE metric = ?
|
| 142 |
+
AND forecast_made_at <= target_ts
|
| 143 |
+
AND target_ts <= ?
|
| 144 |
+
AND target_ts >= ?
|
| 145 |
+
GROUP BY source, target_ts, metric
|
| 146 |
+
)
|
| 147 |
+
SELECT f.source,
|
| 148 |
+
f.target_ts,
|
| 149 |
+
f.p50 AS prediction,
|
| 150 |
+
a.value AS actual,
|
| 151 |
+
ABS(f.p50 - a.value) AS abs_err
|
| 152 |
+
FROM forecast_snapshots f
|
| 153 |
+
JOIN latest l USING (source, target_ts, metric, forecast_made_at)
|
| 154 |
+
JOIN actuals a USING (target_ts, metric)
|
| 155 |
+
"""
|
| 156 |
+
now = int(time.time())
|
| 157 |
+
df = pd.read_sql_query(sql, conn, params=[metric, now, cutoff])
|
| 158 |
+
if df.empty:
|
| 159 |
+
return df
|
| 160 |
+
return df
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def scoreboard_summary(
|
| 164 |
+
conn: sqlite3.Connection,
|
| 165 |
+
metric: str = "temp_f",
|
| 166 |
+
window_hours: int = 48,
|
| 167 |
+
) -> pd.DataFrame:
|
| 168 |
+
df = scoreboard(conn, metric=metric, window_hours=window_hours)
|
| 169 |
+
if df.empty:
|
| 170 |
+
return pd.DataFrame(columns=["source", "n", "mae"])
|
| 171 |
+
return (
|
| 172 |
+
df.groupby("source")["abs_err"]
|
| 173 |
+
.agg(n="count", mae="mean")
|
| 174 |
+
.reset_index()
|
| 175 |
+
)
|