bitsofchris Claude Opus 4.7 (1M context) commited on
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>

Files changed (2) hide show
  1. app.py +76 -6
  2. 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
- nws_series = (
81
- nws_df[m["nws_col"]] if (m["nws_col"] and m["nws_col"] in nws_df.columns) else None
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
- return figs[0], figs[1], figs[2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )