bitsofchris Claude Opus 4.7 (1M context) commited on
Commit
e3bb26c
·
1 Parent(s): b620a9c

Clarify now-snapshot, align Toto vs NWS forecast in text and chart

Browse files

- src/weather_ui.py: hero now shows two explicit rows — '📡 Ecowitt now'
(measured) and '🌎 NWS this hour' (predicted) — with the gap between
them surfaced inline so the model error is visible at a glance.
- src/weather_ui.py: new aligned_comparison_markdown produces a +6h /
+12h / +18h / +24h table with Toto and NWS predictions for the same
wall-clock hour, side by side, plus a Δ column. Replaces the previous
high/low layout where the two models' peak times rarely matched.
- src/weather_ui.py: combined_figure traces now set mode='lines'
explicitly and a fully-transparent line on the band polygon so Plotly
doesn't auto-add markers at the band vertices. Toto median switches
from dashed to solid; NWS forecast from dotted to dashed.

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

Files changed (2) hide show
  1. app.py +10 -3
  2. src/weather_ui.py +98 -27
app.py CHANGED
@@ -20,6 +20,7 @@ import pandas as pd
20
  from src import ecowitt, forecast_log, nws, persist
21
  from src.forecast import forecast_series
22
  from src.weather_ui import (
 
23
  combined_figure,
24
  emoji_strip_markdown,
25
  headline_forecast_blocks,
@@ -149,15 +150,20 @@ def refresh(cycle_label: str = "Hourly", horizon_label: str = "24 h"):
149
  nws_temp=nws_aligned.get("temp_f"),
150
  tz=DISPLAY_TZ,
151
  )
 
 
 
 
 
152
  else:
153
- toto_md, nws_md = "", ""
154
  strip = emoji_strip_markdown(nws_df_raw, DISPLAY_TZ, n=12)
155
  scoreboard = render_scoreboard(log_conn)
156
 
157
  # Backup the SQLite log to the HF dataset (non-blocking).
158
  persist.push_db_async()
159
 
160
- return hero, toto_md, nws_md, strip, fig, scoreboard
161
 
162
 
163
  # --- scoreboard ----------------------------------------------------------
@@ -230,6 +236,7 @@ with gr.Blocks(title="Toto Weather Forecast", theme=gr.themes.Soft()) as demo:
230
  with gr.Row():
231
  toto_headline_md = gr.Markdown()
232
  nws_headline_md = gr.Markdown()
 
233
  strip_md = gr.Markdown()
234
 
235
  with gr.Row():
@@ -246,7 +253,7 @@ with gr.Blocks(title="Toto Weather Forecast", theme=gr.themes.Soft()) as demo:
246
  scoreboard_md = gr.Markdown()
247
  plot = gr.Plot(label="Forecast")
248
 
249
- outputs = [hero_md, toto_headline_md, nws_headline_md, strip_md, plot, scoreboard_md]
250
  inputs = [cycle_dd, horizon_dd]
251
  demo.load(refresh, inputs=inputs, outputs=outputs)
252
  refresh_btn.click(refresh, inputs=inputs, outputs=outputs)
 
20
  from src import ecowitt, forecast_log, nws, persist
21
  from src.forecast import forecast_series
22
  from src.weather_ui import (
23
+ aligned_comparison_markdown,
24
  combined_figure,
25
  emoji_strip_markdown,
26
  headline_forecast_blocks,
 
150
  nws_temp=nws_aligned.get("temp_f"),
151
  tz=DISPLAY_TZ,
152
  )
153
+ comparison_md = "### 🆚 Same hour, side-by-side (temperature)\n\n" + aligned_comparison_markdown(
154
+ toto=totos["temp_f"],
155
+ nws_temp=nws_aligned.get("temp_f"),
156
+ tz=DISPLAY_TZ,
157
+ )
158
  else:
159
+ toto_md, nws_md, comparison_md = "", "", ""
160
  strip = emoji_strip_markdown(nws_df_raw, DISPLAY_TZ, n=12)
161
  scoreboard = render_scoreboard(log_conn)
162
 
163
  # Backup the SQLite log to the HF dataset (non-blocking).
164
  persist.push_db_async()
165
 
166
+ return hero, toto_md, nws_md, comparison_md, strip, fig, scoreboard
167
 
168
 
169
  # --- scoreboard ----------------------------------------------------------
 
236
  with gr.Row():
237
  toto_headline_md = gr.Markdown()
238
  nws_headline_md = gr.Markdown()
239
+ comparison_md = gr.Markdown()
240
  strip_md = gr.Markdown()
241
 
242
  with gr.Row():
 
253
  scoreboard_md = gr.Markdown()
254
  plot = gr.Plot(label="Forecast")
255
 
256
+ outputs = [hero_md, toto_headline_md, nws_headline_md, comparison_md, strip_md, plot, scoreboard_md]
257
  inputs = [cycle_dd, horizon_dd]
258
  demo.load(refresh, inputs=inputs, outputs=outputs)
259
  refresh_btn.click(refresh, inputs=inputs, outputs=outputs)
src/weather_ui.py CHANGED
@@ -65,7 +65,9 @@ def hero_markdown(
65
  nws_first: pd.Series | None,
66
  tz: str,
67
  ) -> str:
68
- """A 'now' tile: current temp/RH/P with hour-over-hour delta and weather words."""
 
 
69
  if history.empty:
70
  return "_(no current readings yet)_"
71
  last = history.dropna(how="all").index.max()
@@ -78,47 +80,115 @@ def hero_markdown(
78
  return ""
79
  d = cur[col] - prev[col]
80
  arrow = "▲" if d > 0 else ("▼" if d < 0 else "·")
81
- return f" <span style='opacity:0.6'>{arrow} {d:{fmt}} {unit}/h</span>"
82
 
83
- short = ""
 
 
 
 
84
  glyph = "🌡"
85
  if nws_first is not None and not nws_first.empty:
86
- first_row = nws_first.iloc[0] if isinstance(nws_first, pd.DataFrame) else None
87
- if isinstance(first_row, pd.Series) and "short_forecast" in first_row:
88
- short = str(first_row["short_forecast"])
89
- glyph = emoji_for(short)
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
- when = last.tz_convert(tz).strftime("%-I:%M %p %Z")
92
  lines = [
93
- f"### {glyph} {place} · {cur['temp_f']:.1f}°F",
94
- f"<span style='font-size:1.1em'>{cur['humidity']:.0f}% RH · {cur['pressure_inhg']:.2f} inHg{delta('temp_f','°F')}{delta('humidity','%','+.0f')}{delta('pressure_inhg','inHg','+.3f')}</span>",
95
- f"<span style='opacity:0.6'>Last reading {when} · NWS now: {short or '—'}</span>",
 
 
 
 
 
 
 
 
96
  ]
97
  return "\n\n".join(lines)
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  def headline_forecast_blocks(
101
  toto: TotoForecast,
102
  nws_temp: pd.Series | None,
103
  tz: str,
104
  ) -> tuple[str, str]:
105
- """Return (toto_md, nws_md) for placement in side-by-side columns."""
 
106
  t_hi, t_hi_t, t_lo, t_lo_t = hi_lo(toto.median, tz)
107
  width24 = float(toto.p90.iloc[-1] - toto.p10.iloc[-1])
108
  toto_md = (
109
- "### 🤖 Toto says (next 24h)\n\n"
110
- f"High **{t_hi:.0f}°F** at {t_hi_t} · Low **{t_lo:.0f}°F** at {t_lo_t}\n\n"
111
- f"<span style='opacity:0.6'>80% interval at +24h: ±{width24/2:.1f}°F</span>"
 
112
  )
113
-
114
  if nws_temp is None or nws_temp.empty:
115
- nws_md = "### 🌎 NWS\n\n_(unavailable for this metric)_"
116
  else:
117
  n_hi, n_hi_t, n_lo, n_lo_t = hi_lo(nws_temp, tz)
118
  nws_md = (
119
- "### 🌎 NWS says (next 24h)\n\n"
120
- f"High **{n_hi:.0f}°F** at {n_hi_t} · Low **{n_lo:.0f}°F** at {n_lo_t}\n\n"
121
- "<span style='opacity:0.6'>Point forecast (no interval)</span>"
 
122
  )
123
  return toto_md, nws_md
124
 
@@ -160,7 +230,7 @@ def combined_figure(
160
  fig.add_trace(
161
  go.Scatter(
162
  x=hist.index, y=hist.values,
163
- name="Ecowitt (past)", mode="lines",
164
  line=dict(color="#222", width=2),
165
  showlegend=showlegend, legendgroup="hist",
166
  ),
@@ -172,8 +242,9 @@ def combined_figure(
172
  x=list(toto.p90.index) + list(toto.p10.index[::-1]),
173
  y=list(toto.p90.values) + list(toto.p10.values[::-1]),
174
  fill="toself", fillcolor="rgba(31,119,180,0.18)",
175
- line=dict(width=0), hoverinfo="skip",
176
- name="Toto 10–90% interval",
 
177
  showlegend=showlegend, legendgroup="toto-band",
178
  ),
179
  row=i, col=1,
@@ -181,8 +252,8 @@ def combined_figure(
181
  fig.add_trace(
182
  go.Scatter(
183
  x=toto.median.index, y=toto.median.values,
184
- name="Toto median", mode="lines",
185
- line=dict(color="#1f77b4", width=2, dash="dash"),
186
  showlegend=showlegend, legendgroup="toto-med",
187
  ),
188
  row=i, col=1,
@@ -193,8 +264,8 @@ def combined_figure(
193
  fig.add_trace(
194
  go.Scatter(
195
  x=ns.index, y=ns.values,
196
- name="NWS forecast", mode="lines",
197
- line=dict(color="#d62728", width=2, dash="dot"),
198
  showlegend=showlegend, legendgroup="nws",
199
  ),
200
  row=i, col=1,
 
65
  nws_first: pd.Series | None,
66
  tz: str,
67
  ) -> str:
68
+ """A 'now' tile that explicitly distinguishes Ecowitt's measured value
69
+ from NWS's forecast for the same hour, so the viewer can see the model
70
+ error live."""
71
  if history.empty:
72
  return "_(no current readings yet)_"
73
  last = history.dropna(how="all").index.max()
 
80
  return ""
81
  d = cur[col] - prev[col]
82
  arrow = "▲" if d > 0 else ("▼" if d < 0 else "·")
83
+ return f" <span style='opacity:0.55'>({arrow} {d:{fmt}} {unit}/h)</span>"
84
 
85
+ when = last.tz_convert(tz).strftime("%-I:%M %p %Z")
86
+
87
+ # Pull NWS's forecast for the same wall-clock hour (its first period).
88
+ nws_temp_str = "—"
89
+ nws_short = "—"
90
  glyph = "🌡"
91
  if nws_first is not None and not nws_first.empty:
92
+ row = nws_first.iloc[0] if isinstance(nws_first, pd.DataFrame) else nws_first
93
+ if isinstance(row, pd.Series):
94
+ if "temp_f" in row and pd.notna(row["temp_f"]):
95
+ nws_temp_str = f"{row['temp_f']:.0f}°F"
96
+ if "short_forecast" in row:
97
+ nws_short = str(row["short_forecast"])
98
+ glyph = emoji_for(nws_short)
99
+
100
+ # Highlight the gap between actual and NWS prediction for this hour.
101
+ gap_str = ""
102
+ if nws_first is not None and not nws_first.empty:
103
+ row = nws_first.iloc[0] if isinstance(nws_first, pd.DataFrame) else nws_first
104
+ if isinstance(row, pd.Series) and "temp_f" in row and pd.notna(row["temp_f"]):
105
+ gap = float(cur["temp_f"]) - float(row["temp_f"])
106
+ sign = "+" if gap >= 0 else ""
107
+ gap_str = f"<span style='opacity:0.55'>(NWS off by {sign}{gap:.1f}°F)</span>"
108
 
 
109
  lines = [
110
+ f"### {glyph} {place}",
111
+ (
112
+ "| | Temperature | Humidity | Pressure | Conditions |\n"
113
+ "|---|---|---|---|---|\n"
114
+ f"| **📡 Ecowitt now** | **{cur['temp_f']:.1f}°F**{delta('temp_f','°F')}"
115
+ f" | **{cur['humidity']:.0f}%**{delta('humidity','%','+.0f')}"
116
+ f" | **{cur['pressure_inhg']:.2f} inHg**{delta('pressure_inhg','inHg','+.3f')}"
117
+ f" | _(measured)_ |\n"
118
+ f"| **🌎 NWS this hour** | **{nws_temp_str}** {gap_str} | — | — | {nws_short} |"
119
+ ),
120
+ f"<span style='opacity:0.55'>Last Ecowitt reading: {when}</span>",
121
  ]
122
  return "\n\n".join(lines)
123
 
124
 
125
+ def aligned_comparison_markdown(
126
+ toto: TotoForecast,
127
+ nws_temp: pd.Series | None,
128
+ tz: str,
129
+ offsets_hours: list[int] = (6, 12, 18, 24),
130
+ ) -> str:
131
+ """Apples-to-apples table: at the same future hour, show Toto and NWS.
132
+
133
+ For each requested offset h, find the forecast point in each series
134
+ closest to t0 + h hours and report both numbers in the same row.
135
+ """
136
+ if toto is None or toto.median.empty:
137
+ return ""
138
+ base = toto.median.index[0] - (toto.median.index[1] - toto.median.index[0]) if len(toto.median) > 1 else toto.median.index[0]
139
+
140
+ def _nearest(series: pd.Series, target: pd.Timestamp):
141
+ if series is None or series.empty:
142
+ return None, None
143
+ idx = series.index.get_indexer([target], method="nearest")[0]
144
+ if idx < 0 or idx >= len(series):
145
+ return None, None
146
+ return series.index[idx], float(series.iloc[idx])
147
+
148
+ rows = ["| When | 🤖 Toto | 🌎 NWS | Δ |", "|---|---|---|---|"]
149
+ for h in offsets_hours:
150
+ target = base + pd.Timedelta(hours=h)
151
+ t_idx, t_val = _nearest(toto.median, target)
152
+ n_idx, n_val = _nearest(nws_temp, target) if nws_temp is not None else (None, None)
153
+ if t_val is None and n_val is None:
154
+ continue
155
+ when_label = (t_idx or n_idx).tz_convert(tz).strftime("%-I %p %a")
156
+ toto_str = f"**{t_val:.0f}°F**" if t_val is not None else "—"
157
+ nws_str = f"**{n_val:.0f}°F**" if n_val is not None else "—"
158
+ if t_val is not None and n_val is not None:
159
+ d = t_val - n_val
160
+ sign = "+" if d >= 0 else ""
161
+ delta_str = f"{sign}{d:.1f}°F"
162
+ else:
163
+ delta_str = "—"
164
+ rows.append(f"| +{h}h · {when_label} | {toto_str} | {nws_str} | {delta_str} |")
165
+ return "\n".join(rows)
166
+
167
+
168
  def headline_forecast_blocks(
169
  toto: TotoForecast,
170
  nws_temp: pd.Series | None,
171
  tz: str,
172
  ) -> tuple[str, str]:
173
+ """Side-by-side high/low summaries same 24h window for both, so
174
+ different peak/trough times become a real comparison."""
175
  t_hi, t_hi_t, t_lo, t_lo_t = hi_lo(toto.median, tz)
176
  width24 = float(toto.p90.iloc[-1] - toto.p10.iloc[-1])
177
  toto_md = (
178
+ "### 🤖 Toto's 24h forecast\n\n"
179
+ f"High **{t_hi:.0f}°F** at {t_hi_t}\n\n"
180
+ f"Low **{t_lo:.0f}°F** at {t_lo_t}\n\n"
181
+ f"<span style='opacity:0.55'>80% interval at +24h: ±{width24/2:.1f}°F</span>"
182
  )
 
183
  if nws_temp is None or nws_temp.empty:
184
+ nws_md = "### 🌎 NWS 24h forecast\n\n_(unavailable)_"
185
  else:
186
  n_hi, n_hi_t, n_lo, n_lo_t = hi_lo(nws_temp, tz)
187
  nws_md = (
188
+ "### 🌎 NWS 24h forecast\n\n"
189
+ f"High **{n_hi:.0f}°F** at {n_hi_t}\n\n"
190
+ f"Low **{n_lo:.0f}°F** at {n_lo_t}\n\n"
191
+ "<span style='opacity:0.55'>Point forecast (no interval)</span>"
192
  )
193
  return toto_md, nws_md
194
 
 
230
  fig.add_trace(
231
  go.Scatter(
232
  x=hist.index, y=hist.values,
233
+ name="📡 Ecowitt (measured)", mode="lines",
234
  line=dict(color="#222", width=2),
235
  showlegend=showlegend, legendgroup="hist",
236
  ),
 
242
  x=list(toto.p90.index) + list(toto.p10.index[::-1]),
243
  y=list(toto.p90.values) + list(toto.p10.values[::-1]),
244
  fill="toself", fillcolor="rgba(31,119,180,0.18)",
245
+ mode="lines", line=dict(width=0, color="rgba(0,0,0,0)"),
246
+ hoverinfo="skip",
247
+ name="🤖 Toto 80% interval",
248
  showlegend=showlegend, legendgroup="toto-band",
249
  ),
250
  row=i, col=1,
 
252
  fig.add_trace(
253
  go.Scatter(
254
  x=toto.median.index, y=toto.median.values,
255
+ name="🤖 Toto median", mode="lines",
256
+ line=dict(color="#1f77b4", width=2.5),
257
  showlegend=showlegend, legendgroup="toto-med",
258
  ),
259
  row=i, col=1,
 
264
  fig.add_trace(
265
  go.Scatter(
266
  x=ns.index, y=ns.values,
267
+ name="🌎 NWS forecast", mode="lines",
268
+ line=dict(color="#d62728", width=2.5, dash="dash"),
269
  showlegend=showlegend, legendgroup="nws",
270
  ),
271
  row=i, col=1,