Hero gauges + 'next round hour' semantics
Browse files- src/weather_ui.py: new hero_gauges() returns three side-by-side
Plotly Indicator gauges β Ecowitt now (black), Toto next hour
(blue), NWS next hour (red). Forecast gauges show a delta vs the
current reading. Background bands tint cool/warm so the eye can
read the temperature regime without checking the axis.
- src/weather_ui.py: hero_markdown target switches from now+1h to
now.ceil('h'). At 3:55 the hero forecast is for 4 PM; at 4:01 it
flips to 5 PM. Row labels read '(next hour)' instead of '(1 h-ahead)'.
- app.py: build the gauge figure each refresh from realtime + the
nearest Toto/NWS values to the next round hour. Render the gauge
trio directly above the hero table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app.py +28 -2
- src/weather_ui.py +56 -3
|
@@ -22,6 +22,7 @@ from src.forecast import forecast_series
|
|
| 22 |
from src.weather_ui import (
|
| 23 |
aligned_comparison_markdown,
|
| 24 |
combined_figure,
|
|
|
|
| 25 |
hero_markdown,
|
| 26 |
residual_figure,
|
| 27 |
)
|
|
@@ -274,6 +275,30 @@ def refresh():
|
|
| 274 |
nws_temp=week["nws_aligned"].get("temp_f"),
|
| 275 |
horizon_hours=1,
|
| 276 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
if "temp_f" in week["totos"]:
|
| 278 |
comparison_md = (
|
| 279 |
"### π Toto vs NWS β same hour, side-by-side\n\n"
|
|
@@ -293,7 +318,7 @@ def refresh():
|
|
| 293 |
resid_fig = residual_figure(resid_df) if not resid_df.empty else None
|
| 294 |
|
| 295 |
persist.push_db_async()
|
| 296 |
-
return hero, comparison_md, week["fig"], scoreboard, resid_fig
|
| 297 |
|
| 298 |
|
| 299 |
# --- scoreboard ----------------------------------------------------------
|
|
@@ -435,6 +460,7 @@ with gr.Blocks(title="Toto Weather Forecast", theme=gr.themes.Soft()) as demo:
|
|
| 435 |
gr.Markdown(SUBTITLE)
|
| 436 |
|
| 437 |
hero_md = gr.Markdown()
|
|
|
|
| 438 |
gr.Markdown(f"### π
{VIEW_WEEK['label']}")
|
| 439 |
week_plot = gr.Plot(label="Weekly")
|
| 440 |
comparison_md = gr.Markdown()
|
|
@@ -504,7 +530,7 @@ with gr.Blocks(title="Toto Weather Forecast", theme=gr.themes.Soft()) as demo:
|
|
| 504 |
"Full spec: [`docs/toto-inference.md`](https://huggingface.co/spaces/bitsofchris/time-series-ai-weather-forecast/blob/main/docs/toto-inference.md)."
|
| 505 |
)
|
| 506 |
|
| 507 |
-
outputs = [hero_md, comparison_md, week_plot, scoreboard_md, residual_plot]
|
| 508 |
demo.load(refresh, outputs=outputs)
|
| 509 |
|
| 510 |
|
|
|
|
| 22 |
from src.weather_ui import (
|
| 23 |
aligned_comparison_markdown,
|
| 24 |
combined_figure,
|
| 25 |
+
hero_gauges,
|
| 26 |
hero_markdown,
|
| 27 |
residual_figure,
|
| 28 |
)
|
|
|
|
| 275 |
nws_temp=week["nws_aligned"].get("temp_f"),
|
| 276 |
horizon_hours=1,
|
| 277 |
)
|
| 278 |
+
|
| 279 |
+
# Gauge trio above the hero table: current Ecowitt + each model's
|
| 280 |
+
# next-round-hour prediction.
|
| 281 |
+
cur_temp_for_gauge = (
|
| 282 |
+
float(realtime["temp_f"]) if realtime and realtime.get("temp_f") is not None
|
| 283 |
+
else float("nan")
|
| 284 |
+
)
|
| 285 |
+
next_hour_utc = pd.Timestamp.now(tz="UTC").ceil("h")
|
| 286 |
+
|
| 287 |
+
def _nearest_value(series, target):
|
| 288 |
+
if series is None or series.empty:
|
| 289 |
+
return None
|
| 290 |
+
idx = series.index.get_indexer([target], method="nearest")[0]
|
| 291 |
+
if idx < 0 or idx >= len(series):
|
| 292 |
+
return None
|
| 293 |
+
return float(series.iloc[idx])
|
| 294 |
+
|
| 295 |
+
toto_temp_fcst = week["totos"].get("temp_f")
|
| 296 |
+
toto_next = _nearest_value(
|
| 297 |
+
toto_temp_fcst.median if toto_temp_fcst is not None else None,
|
| 298 |
+
next_hour_utc,
|
| 299 |
+
)
|
| 300 |
+
nws_next = _nearest_value(week["nws_aligned"].get("temp_f"), next_hour_utc)
|
| 301 |
+
gauge_fig = hero_gauges(cur_temp_for_gauge, toto_next, nws_next)
|
| 302 |
if "temp_f" in week["totos"]:
|
| 303 |
comparison_md = (
|
| 304 |
"### π Toto vs NWS β same hour, side-by-side\n\n"
|
|
|
|
| 318 |
resid_fig = residual_figure(resid_df) if not resid_df.empty else None
|
| 319 |
|
| 320 |
persist.push_db_async()
|
| 321 |
+
return hero, gauge_fig, comparison_md, week["fig"], scoreboard, resid_fig
|
| 322 |
|
| 323 |
|
| 324 |
# --- scoreboard ----------------------------------------------------------
|
|
|
|
| 460 |
gr.Markdown(SUBTITLE)
|
| 461 |
|
| 462 |
hero_md = gr.Markdown()
|
| 463 |
+
gauge_plot = gr.Plot(label="Now vs next-hour forecasts", show_label=False)
|
| 464 |
gr.Markdown(f"### π
{VIEW_WEEK['label']}")
|
| 465 |
week_plot = gr.Plot(label="Weekly")
|
| 466 |
comparison_md = gr.Markdown()
|
|
|
|
| 530 |
"Full spec: [`docs/toto-inference.md`](https://huggingface.co/spaces/bitsofchris/time-series-ai-weather-forecast/blob/main/docs/toto-inference.md)."
|
| 531 |
)
|
| 532 |
|
| 533 |
+
outputs = [hero_md, gauge_plot, comparison_md, week_plot, scoreboard_md, residual_plot]
|
| 534 |
demo.load(refresh, outputs=outputs)
|
| 535 |
|
| 536 |
|
|
@@ -93,7 +93,9 @@ def hero_markdown(
|
|
| 93 |
if isinstance(row, pd.Series) and "short_forecast" in row:
|
| 94 |
glyph = emoji_for(str(row["short_forecast"]))
|
| 95 |
|
| 96 |
-
target =
|
|
|
|
|
|
|
| 97 |
|
| 98 |
def _nearest(series, target_ts):
|
| 99 |
if series is None or series.empty:
|
|
@@ -115,8 +117,8 @@ def hero_markdown(
|
|
| 115 |
"| Source | Temperature | When |\n"
|
| 116 |
"|---|---|---|\n"
|
| 117 |
f"| π‘ Ecowitt (now) | **{cur_temp:.1f}Β°F** | {eco_when} |\n"
|
| 118 |
-
f"{_row(
|
| 119 |
-
f"{_row(
|
| 120 |
)
|
| 121 |
return f"### {glyph} {place}\n\n{table}"
|
| 122 |
|
|
@@ -178,6 +180,57 @@ def emoji_strip_markdown(nws_df: pd.DataFrame, tz: str, n: int = 12) -> str:
|
|
| 178 |
return f"| {hours} |\n{sep}\n| {glyphs} |\n| {temps} |"
|
| 179 |
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
def residual_figure(
|
| 182 |
df: pd.DataFrame,
|
| 183 |
title: str = "Forecast residual β 1 h-ahead prediction minus Ecowitt actual, last 48 h (Β°F)",
|
|
|
|
| 93 |
if isinstance(row, pd.Series) and "short_forecast" in row:
|
| 94 |
glyph = emoji_for(str(row["short_forecast"]))
|
| 95 |
|
| 96 |
+
# 'Next round hour' β if it's 3:55, target = 4 PM. If it's 4:01,
|
| 97 |
+
# target = 5 PM. Matches what people intuit by 'in the next hour'.
|
| 98 |
+
target = pd.Timestamp.now(tz="UTC").ceil("h")
|
| 99 |
|
| 100 |
def _nearest(series, target_ts):
|
| 101 |
if series is None or series.empty:
|
|
|
|
| 117 |
"| Source | Temperature | When |\n"
|
| 118 |
"|---|---|---|\n"
|
| 119 |
f"| π‘ Ecowitt (now) | **{cur_temp:.1f}Β°F** | {eco_when} |\n"
|
| 120 |
+
f"{_row('π€ Toto (next hour)', toto_val, toto_idx)}\n"
|
| 121 |
+
f"{_row('π NWS (next hour)', nws_val, nws_idx)}"
|
| 122 |
)
|
| 123 |
return f"### {glyph} {place}\n\n{table}"
|
| 124 |
|
|
|
|
| 180 |
return f"| {hours} |\n{sep}\n| {glyphs} |\n| {temps} |"
|
| 181 |
|
| 182 |
|
| 183 |
+
def hero_gauges(
|
| 184 |
+
cur_temp: float,
|
| 185 |
+
toto_next: float | None,
|
| 186 |
+
nws_next: float | None,
|
| 187 |
+
temp_range: tuple[float, float] = (20.0, 100.0),
|
| 188 |
+
) -> go.Figure:
|
| 189 |
+
"""Three side-by-side gauges: current Ecowitt temperature, plus each
|
| 190 |
+
model's prediction for the next round hour. Each forecast gauge also
|
| 191 |
+
shows its delta vs the current reading."""
|
| 192 |
+
cool_to_warm = [
|
| 193 |
+
{"range": [20, 40], "color": "rgba(31,119,180,0.18)"},
|
| 194 |
+
{"range": [40, 60], "color": "rgba(31,119,180,0.08)"},
|
| 195 |
+
{"range": [60, 80], "color": "rgba(214,39,40,0.08)"},
|
| 196 |
+
{"range": [80, 100], "color": "rgba(214,39,40,0.18)"},
|
| 197 |
+
]
|
| 198 |
+
specs = [[{"type": "indicator"}, {"type": "indicator"}, {"type": "indicator"}]]
|
| 199 |
+
fig = make_subplots(rows=1, cols=3, specs=specs)
|
| 200 |
+
|
| 201 |
+
def _ind(value, title, bar_color, with_delta: bool):
|
| 202 |
+
ind = go.Indicator(
|
| 203 |
+
mode="gauge+number+delta" if with_delta else "gauge+number",
|
| 204 |
+
value=value if value is not None else float("nan"),
|
| 205 |
+
title={"text": title, "font": {"size": 14}},
|
| 206 |
+
number={"suffix": " Β°F", "font": {"size": 30}},
|
| 207 |
+
gauge=dict(
|
| 208 |
+
axis=dict(range=list(temp_range), tickwidth=1, tickcolor="#888"),
|
| 209 |
+
bar=dict(color=bar_color, thickness=0.25),
|
| 210 |
+
bgcolor="white",
|
| 211 |
+
borderwidth=1,
|
| 212 |
+
bordercolor="#e0e0e0",
|
| 213 |
+
steps=cool_to_warm,
|
| 214 |
+
threshold=dict(line=dict(color=bar_color, width=4), value=value or 0),
|
| 215 |
+
),
|
| 216 |
+
delta=(
|
| 217 |
+
dict(reference=cur_temp, suffix=" Β°F", increasing={"color": "#d62728"}, decreasing={"color": "#1f77b4"})
|
| 218 |
+
if with_delta else None
|
| 219 |
+
),
|
| 220 |
+
)
|
| 221 |
+
return ind
|
| 222 |
+
|
| 223 |
+
fig.add_trace(_ind(cur_temp, "π‘ Ecowitt (now)", "#222", False), row=1, col=1)
|
| 224 |
+
fig.add_trace(_ind(toto_next, "π€ Toto (next hour)", "#1f77b4", True), row=1, col=2)
|
| 225 |
+
fig.add_trace(_ind(nws_next, "π NWS (next hour)", "#d62728", True), row=1, col=3)
|
| 226 |
+
fig.update_layout(
|
| 227 |
+
height=260,
|
| 228 |
+
margin=dict(l=10, r=10, t=50, b=10),
|
| 229 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 230 |
+
)
|
| 231 |
+
return fig
|
| 232 |
+
|
| 233 |
+
|
| 234 |
def residual_figure(
|
| 235 |
df: pd.DataFrame,
|
| 236 |
title: str = "Forecast residual β 1 h-ahead prediction minus Ecowitt actual, last 48 h (Β°F)",
|