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>
- app.py +10 -3
- src/weather_ui.py +98 -27
|
@@ -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)
|
|
@@ -65,7 +65,9 @@ def hero_markdown(
|
|
| 65 |
nws_first: pd.Series | None,
|
| 66 |
tz: str,
|
| 67 |
) -> str:
|
| 68 |
-
"""A 'now' tile
|
|
|
|
|
|
|
| 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.
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
glyph = "🌡"
|
| 85 |
if nws_first is not None and not nws_first.empty:
|
| 86 |
-
|
| 87 |
-
if isinstance(
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
when = last.tz_convert(tz).strftime("%-I:%M %p %Z")
|
| 92 |
lines = [
|
| 93 |
-
f"### {glyph} {place}
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
| 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
|
| 110 |
-
f"High **{t_hi:.0f}°F** at {t_hi_t}
|
| 111 |
-
f"
|
|
|
|
| 112 |
)
|
| 113 |
-
|
| 114 |
if nws_temp is None or nws_temp.empty:
|
| 115 |
-
nws_md = "### 🌎 NWS\n\n_(unavailable
|
| 116 |
else:
|
| 117 |
n_hi, n_hi_t, n_lo, n_lo_t = hi_lo(nws_temp, tz)
|
| 118 |
nws_md = (
|
| 119 |
-
"### 🌎 NWS
|
| 120 |
-
f"High **{n_hi:.0f}°F** at {n_hi_t}
|
| 121 |
-
"
|
|
|
|
| 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 (
|
| 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
|
| 176 |
-
|
|
|
|
| 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
|
| 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="
|
| 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,
|