bitsofchris Claude Opus 4.7 (1M context) commited on
Commit
03bc9fe
Β·
1 Parent(s): 683db75

Two fixed views: zoom (36h+12h @ 30min) and week (7d+72h @ hourly)

Browse files

- Remove cadence + horizon dropdowns. Two views always render together
on every refresh.
- _build_view orchestrates fetch + Toto inference + plot for one config;
refresh() calls it twice and stitches the page.
- Weekly view (hourly, 72h horizon) is the canonical scoreboard source β€”
hourly target_ts aligns with NWS hourly periods. The zoomed view at
30-min cadence is display-only; its forecasts are not logged so the
scoreboard math stays unchanged.
- Hero + same-hour comparison table still use the weekly view's data.

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

Files changed (1) hide show
  1. app.py +77 -62
app.py CHANGED
@@ -30,15 +30,21 @@ CACHE_TTL_SECONDS = AUTO_REFRESH_SECONDS - 60 # so autorefresh always refetches
30
  DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York")
31
  PLACE_NAME = os.environ.get("PLACE_NAME", "Yaphank, NY")
32
 
33
- # Display cadence options. Each maps to (Ecowitt cycle_type, pandas resample,
34
- # history_days). The GW3000B uploads every 30 min, so '5min' cycle resamples
35
- # to 30min anyway β€” included for completeness.
36
- CYCLE_CONFIG: dict[str, tuple[str, str, int]] = {
37
- "Hourly": ("30min", "1h", 7),
38
- "30-min": ("30min", "30min", 7),
39
- "4-hour": ("4hour", "4h", 30),
 
 
 
 
 
 
 
40
  }
41
- HORIZON_CONFIG: dict[str, int] = {"24 h": 24, "48 h": 48, "72 h": 72}
42
 
43
  METRICS = [
44
  {"col": "temp_f", "title": "Outdoor temperature", "y": "Β°F", "nws_col": "temp_f"},
@@ -69,10 +75,10 @@ def cached(ttl: int):
69
 
70
  # --- data fetchers --------------------------------------------------------
71
  @cached(CACHE_TTL_SECONDS)
72
- def fetch_history(cycle_type: str, resample: str, days: int) -> pd.DataFrame:
73
  cfg = ecowitt.EcowittConfig.from_env()
74
  end = datetime.now(timezone.utc).replace(tzinfo=None)
75
- start = end - timedelta(days=days)
76
  raw = ecowitt.fetch_history(
77
  cfg, start, end, cycle_type=cycle_type,
78
  call_back="outdoor,pressure,rainfall_piezo",
@@ -142,33 +148,24 @@ def _resample_nws_to(nws_df: pd.DataFrame, resample: str) -> pd.DataFrame:
142
 
143
 
144
  # --- main refresh ---------------------------------------------------------
145
- def refresh(cycle_label: str = "Hourly", horizon_label: str = "24 h"):
146
- cycle_type, resample, hist_days = CYCLE_CONFIG[cycle_label]
147
- horizon_hours = HORIZON_CONFIG[horizon_label]
 
 
148
  step_hours = _resample_hours(resample)
 
149
  horizon_steps = max(1, int(round(horizon_hours / step_hours)))
 
150
 
151
- history = fetch_history(cycle_type, resample, hist_days)
152
- realtime = fetch_realtime_snapshot()
153
  nws_df_raw = fetch_nws(horizon_hours)
154
  nws_df = _resample_nws_to(nws_df_raw, resample)
155
-
156
  last_actual = history.dropna(how="all").index.max()
157
  nws_future = nws_df[nws_df.index > last_actual] if last_actual is not None else nws_df
158
 
159
- # The NWS response often starts with a period that began in the past
160
- # (forecasts are issued every ~hour but each period is 1h long). For
161
- # the hero, find the period that *contains* "now".
162
- now_utc = pd.Timestamp.now(tz="UTC")
163
- if not nws_df_raw.empty:
164
- covering = nws_df_raw[nws_df_raw.index <= now_utc]
165
- nws_first = covering.tail(1) if not covering.empty else nws_df_raw.head(1)
166
- else:
167
- nws_first = None
168
-
169
- # Log to SQLite (always at the chosen cadence)
170
- log_conn = forecast_log.connect()
171
- forecast_log.record_actuals(log_conn, history)
172
 
173
  totos: dict[str, object] = {}
174
  nws_aligned: dict[str, pd.Series] = {}
@@ -178,18 +175,17 @@ def refresh(cycle_label: str = "Hourly", horizon_label: str = "24 h"):
178
  continue
179
  toto = forecast_series(series, horizon=horizon_steps)
180
  totos[m["col"]] = toto
181
- forecast_log.record_toto(log_conn, m["col"], toto)
 
182
  if m["nws_col"] and m["nws_col"] in nws_future.columns:
183
  ns = nws_future[m["nws_col"]].dropna()
184
  nws_aligned[m["col"]] = ns
185
- forecast_log.record_nws(log_conn, m["col"], ns)
186
-
187
- now = pd.Timestamp.now(tz="UTC").floor("h")
188
 
189
- # Look back: for each metric, build a series of past predictions issued
190
- # for actual hours that have already passed. Lets us overlay what each
191
- # model thought vs what happened.
192
- visible_history = history.tail(int(hist_days * 24 / step_hours))
193
  since_unix = (
194
  int(visible_history.index.min().timestamp()) if not visible_history.empty else None
195
  )
@@ -208,23 +204,49 @@ def refresh(cycle_label: str = "Hourly", horizon_label: str = "24 h"):
208
  now=now,
209
  past_toto=past_toto,
210
  )
 
 
 
 
 
 
 
211
 
212
- hero = hero_markdown(PLACE_NAME, history, nws_first, DISPLAY_TZ, realtime=realtime)
213
- if "temp_f" in totos:
214
- comparison_md = "### πŸ†š 24-hour temperature forecast β€” same hour, side-by-side\n\n" + aligned_comparison_markdown(
215
- toto=totos["temp_f"],
216
- nws_temp=nws_aligned.get("temp_f"),
217
- tz=DISPLAY_TZ,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  )
219
  else:
220
  comparison_md = ""
221
  scoreboard = render_scoreboard(log_conn)
222
 
223
- # Backup forecast log to HF Dataset (non-blocking).
224
  persist.push_db_async()
225
- # The full archive sync + push happens in the autorefresh thread.
226
-
227
- return hero, comparison_md, fig, scoreboard
228
 
229
 
230
  # --- scoreboard ----------------------------------------------------------
@@ -338,21 +360,17 @@ with gr.Blocks(title="Toto Weather Forecast", theme=gr.themes.Soft()) as demo:
338
  '</div>'
339
  )
340
 
341
- with gr.Row():
342
- cycle_dd = gr.Dropdown(
343
- choices=list(CYCLE_CONFIG.keys()), value="Hourly",
344
- label="Display cadence", scale=1,
345
- )
346
- horizon_dd = gr.Dropdown(
347
- choices=list(HORIZON_CONFIG.keys()), value="24 h",
348
- label="Forecast horizon", scale=1,
349
- )
350
  gr.Markdown(
351
  "<span style='opacity:0.55'>πŸ”„ Live data + forecast auto-refresh every 15 minutes.</span>"
352
  )
353
 
354
  scoreboard_md = gr.Markdown()
355
- plot = gr.Plot(label="Forecast")
 
 
 
 
 
356
 
357
  with gr.Accordion("How the scoreboard is calculated", open=False):
358
  gr.Markdown(
@@ -396,11 +414,8 @@ with gr.Blocks(title="Toto Weather Forecast", theme=gr.themes.Soft()) as demo:
396
  "Full spec: [`docs/toto-inference.md`](https://huggingface.co/spaces/bitsofchris/time-series-ai-weather-forecast/blob/main/docs/toto-inference.md)."
397
  )
398
 
399
- outputs = [hero_md, comparison_md, plot, scoreboard_md]
400
- inputs = [cycle_dd, horizon_dd]
401
- demo.load(refresh, inputs=inputs, outputs=outputs)
402
- cycle_dd.change(refresh, inputs=inputs, outputs=outputs)
403
- horizon_dd.change(refresh, inputs=inputs, outputs=outputs)
404
 
405
 
406
  if __name__ == "__main__":
 
30
  DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York")
31
  PLACE_NAME = os.environ.get("PLACE_NAME", "Yaphank, NY")
32
 
33
+ # Two fixed views β€” no more dropdowns.
34
+ VIEW_ZOOM = {
35
+ "label": "Last 36 h Β· 12 h forecast (30-min cadence)",
36
+ "cycle_type": "30min",
37
+ "resample": "30min",
38
+ "history_hours": 36,
39
+ "horizon_hours": 12,
40
+ }
41
+ VIEW_WEEK = {
42
+ "label": "Past 7 days Β· 72 h forecast (hourly cadence)",
43
+ "cycle_type": "30min",
44
+ "resample": "1h",
45
+ "history_days": 7,
46
+ "horizon_hours": 72,
47
  }
 
48
 
49
  METRICS = [
50
  {"col": "temp_f", "title": "Outdoor temperature", "y": "Β°F", "nws_col": "temp_f"},
 
75
 
76
  # --- data fetchers --------------------------------------------------------
77
  @cached(CACHE_TTL_SECONDS)
78
+ def fetch_history(cycle_type: str, resample: str, hours: float) -> pd.DataFrame:
79
  cfg = ecowitt.EcowittConfig.from_env()
80
  end = datetime.now(timezone.utc).replace(tzinfo=None)
81
+ start = end - timedelta(hours=hours)
82
  raw = ecowitt.fetch_history(
83
  cfg, start, end, cycle_type=cycle_type,
84
  call_back="outdoor,pressure,rainfall_piezo",
 
148
 
149
 
150
  # --- main refresh ---------------------------------------------------------
151
+ def _build_view(view: dict, log_conn, log_to_scoreboard: bool) -> dict:
152
+ """Fetch + forecast for one view config. Returns intermediate pieces so
153
+ the caller can stitch the page together."""
154
+ cycle_type = view["cycle_type"]
155
+ resample = view["resample"]
156
  step_hours = _resample_hours(resample)
157
+ horizon_hours = view["horizon_hours"]
158
  horizon_steps = max(1, int(round(horizon_hours / step_hours)))
159
+ hours = view["history_hours"] if "history_hours" in view else view["history_days"] * 24
160
 
161
+ history = fetch_history(cycle_type, resample, hours)
 
162
  nws_df_raw = fetch_nws(horizon_hours)
163
  nws_df = _resample_nws_to(nws_df_raw, resample)
 
164
  last_actual = history.dropna(how="all").index.max()
165
  nws_future = nws_df[nws_df.index > last_actual] if last_actual is not None else nws_df
166
 
167
+ if log_to_scoreboard:
168
+ forecast_log.record_actuals(log_conn, history)
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  totos: dict[str, object] = {}
171
  nws_aligned: dict[str, pd.Series] = {}
 
175
  continue
176
  toto = forecast_series(series, horizon=horizon_steps)
177
  totos[m["col"]] = toto
178
+ if log_to_scoreboard:
179
+ forecast_log.record_toto(log_conn, m["col"], toto)
180
  if m["nws_col"] and m["nws_col"] in nws_future.columns:
181
  ns = nws_future[m["nws_col"]].dropna()
182
  nws_aligned[m["col"]] = ns
183
+ if log_to_scoreboard:
184
+ forecast_log.record_nws(log_conn, m["col"], ns)
 
185
 
186
+ now = pd.Timestamp.now(tz="UTC").floor(resample)
187
+ visible_steps = int(round(hours / step_hours))
188
+ visible_history = history.tail(visible_steps)
 
189
  since_unix = (
190
  int(visible_history.index.min().timestamp()) if not visible_history.empty else None
191
  )
 
204
  now=now,
205
  past_toto=past_toto,
206
  )
207
+ return {
208
+ "fig": fig,
209
+ "history": history,
210
+ "totos": totos,
211
+ "nws_aligned": nws_aligned,
212
+ "nws_df_raw": nws_df_raw,
213
+ }
214
 
215
+
216
+ def refresh():
217
+ realtime = fetch_realtime_snapshot()
218
+ log_conn = forecast_log.connect()
219
+
220
+ # Weekly view is the canonical one logged to the scoreboard (hourly
221
+ # cadence keeps target_ts aligned with NWS hourly periods).
222
+ week = _build_view(VIEW_WEEK, log_conn, log_to_scoreboard=True)
223
+ zoom = _build_view(VIEW_ZOOM, log_conn, log_to_scoreboard=False)
224
+
225
+ # Hero uses the weekly history + the NWS period containing "now".
226
+ nws_df_raw = week["nws_df_raw"]
227
+ now_utc = pd.Timestamp.now(tz="UTC")
228
+ if not nws_df_raw.empty:
229
+ covering = nws_df_raw[nws_df_raw.index <= now_utc]
230
+ nws_first = covering.tail(1) if not covering.empty else nws_df_raw.head(1)
231
+ else:
232
+ nws_first = None
233
+
234
+ hero = hero_markdown(PLACE_NAME, week["history"], nws_first, DISPLAY_TZ, realtime=realtime)
235
+ if "temp_f" in week["totos"]:
236
+ comparison_md = (
237
+ "### πŸ†š 24-hour temperature forecast β€” same hour, side-by-side\n\n"
238
+ + aligned_comparison_markdown(
239
+ toto=week["totos"]["temp_f"],
240
+ nws_temp=week["nws_aligned"].get("temp_f"),
241
+ tz=DISPLAY_TZ,
242
+ )
243
  )
244
  else:
245
  comparison_md = ""
246
  scoreboard = render_scoreboard(log_conn)
247
 
 
248
  persist.push_db_async()
249
+ return hero, comparison_md, zoom["fig"], week["fig"], scoreboard
 
 
250
 
251
 
252
  # --- scoreboard ----------------------------------------------------------
 
360
  '</div>'
361
  )
362
 
 
 
 
 
 
 
 
 
 
363
  gr.Markdown(
364
  "<span style='opacity:0.55'>πŸ”„ Live data + forecast auto-refresh every 15 minutes.</span>"
365
  )
366
 
367
  scoreboard_md = gr.Markdown()
368
+
369
+ gr.Markdown(f"### πŸ” Zoomed-in view β€” {VIEW_ZOOM['label']}")
370
+ zoom_plot = gr.Plot(label="Zoomed-in")
371
+
372
+ gr.Markdown(f"### πŸ“… Weekly view β€” {VIEW_WEEK['label']}")
373
+ week_plot = gr.Plot(label="Weekly")
374
 
375
  with gr.Accordion("How the scoreboard is calculated", open=False):
376
  gr.Markdown(
 
414
  "Full spec: [`docs/toto-inference.md`](https://huggingface.co/spaces/bitsofchris/time-series-ai-weather-forecast/blob/main/docs/toto-inference.md)."
415
  )
416
 
417
+ outputs = [hero_md, comparison_md, zoom_plot, week_plot, scoreboard_md]
418
+ demo.load(refresh, outputs=outputs)
 
 
 
419
 
420
 
421
  if __name__ == "__main__":