bitsofchris's picture
Residual chart: back to 48h, moved above rolling table; '48 h' → '48h'
4b323a5
"""Helpers for the weather-page-style UI: emoji mapping, headline forecast
formatting, current-conditions hero block, and the combined Plotly figure."""
from __future__ import annotations
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from .forecast import TotoForecast
# Order matters — match more specific terms first.
_EMOJI_RULES: list[tuple[str, str]] = [
("Thunder", "⛈"),
("Tornado", "🌪"),
("Hurricane", "🌀"),
("Tropical", "🌀"),
("Snow", "❄️"),
("Sleet", "🌨"),
("Freezing", "🌨"),
("Hail", "🌨"),
("Rain", "🌧"),
("Showers", "🌧"),
("Drizzle", "🌦"),
("Fog", "🌫"),
("Haze", "🌫"),
("Smoke", "🌫"),
("Mostly Cloudy", "☁️"),
("Partly Cloudy", "⛅"),
("Mostly Sunny", "🌤"),
("Partly Sunny", "⛅"),
("Cloud", "☁️"),
("Sunny", "☀️"),
("Clear", "🌙"),
("Windy", "💨"),
("Breezy", "💨"),
]
def emoji_for(short_forecast: str | None) -> str:
if not short_forecast:
return "🌡"
for needle, glyph in _EMOJI_RULES:
if needle.lower() in short_forecast.lower():
return glyph
return "🌡"
def _fmt_hour_local(ts: pd.Timestamp, tz: str) -> str:
return ts.tz_convert(tz).strftime("%-I %p")
def hi_lo(series: pd.Series, tz: str) -> tuple[float, str, float, str]:
"""Return (high, hour-of-high, low, hour-of-low) over the series."""
s = series.dropna()
hi_t = s.idxmax()
lo_t = s.idxmin()
return float(s.max()), _fmt_hour_local(hi_t, tz), float(s.min()), _fmt_hour_local(lo_t, tz)
def hero_markdown(
place: str,
history: pd.DataFrame,
nws_first: pd.Series | None,
tz: str,
realtime: dict | None = None,
toto_temp: TotoForecast | None = None,
nws_temp: pd.Series | None = None,
horizon_hours: int = 1,
) -> str:
"""Three-row 'now / N h-ahead' table: measured Ecowitt + each model's
prediction for the same wall-clock hour `horizon_hours` from now."""
cur_temp: float | None = None
when_ts: pd.Timestamp | None = None
if realtime and realtime.get("temp_f") is not None and realtime.get("last_ts") is not None:
cur_temp = float(realtime["temp_f"])
when_ts = realtime["last_ts"]
elif not history.empty:
last = history.dropna(how="all").index.max()
cur_temp = float(history.loc[last, "temp_f"])
when_ts = last
if cur_temp is None or when_ts is None:
return "_(no current readings yet)_"
eco_when = when_ts.tz_convert(tz).strftime("%-I:%M %p %Z, %a %b %-d")
glyph = "🌡"
if nws_first is not None and not nws_first.empty:
row = nws_first.iloc[0] if isinstance(nws_first, pd.DataFrame) else nws_first
if isinstance(row, pd.Series) and "short_forecast" in row:
glyph = emoji_for(str(row["short_forecast"]))
# 'Next round hour' — if it's 3:55, target = 4 PM. If it's 4:01,
# target = 5 PM. Matches what people intuit by 'in the next hour'.
target = pd.Timestamp.now(tz="UTC").ceil("h")
def _nearest(series, target_ts):
if series is None or series.empty:
return None, None
idx = series.index.get_indexer([target_ts], method="nearest")[0]
if idx < 0 or idx >= len(series):
return None, None
return series.index[idx], float(series.iloc[idx])
toto_idx, toto_val = _nearest(toto_temp.median if toto_temp is not None else None, target)
nws_idx, nws_val = _nearest(nws_temp, target)
def _row(label: str, val: float | None, ts):
when = ts.tz_convert(tz).strftime("%-I %p %Z, %a %b %-d") if ts is not None else "—"
cell = f"**{val:.1f}°F**" if val is not None else "—"
return f"| {label} | {cell} | {when} |"
table = (
"| Source | Temperature | When |\n"
"|---|---|---|\n"
f"| 📡 Ecowitt (now) | **{cur_temp:.1f}°F** | {eco_when} |\n"
f"{_row('🤖 Toto (next hour)', toto_val, toto_idx)}\n"
f"{_row('🌎 NWS (next hour)', nws_val, nws_idx)}"
)
return f"### {glyph} {place}\n\n{table}"
def aligned_comparison_markdown(
toto: TotoForecast,
nws_temp: pd.Series | None,
tz: str,
offsets_hours: tuple[int, ...] = (1, 3, 12),
) -> str:
"""Future forecast table — same wall-clock hour for both models, at
the same lookaheads we score on the scoreboard (1h / 3h / 12h)."""
if toto is None or toto.median.empty:
return ""
now_utc = pd.Timestamp.now(tz="UTC")
base_day = now_utc.tz_convert(tz).strftime("%a")
def _nearest(series: pd.Series | None, target: pd.Timestamp):
if series is None or series.empty:
return None, None
idx = series.index.get_indexer([target], method="nearest")[0]
if idx < 0 or idx >= len(series):
return None, None
return series.index[idx], float(series.iloc[idx])
rows = ["| Lookahead | When | 🤖 Toto | 🌎 NWS | Δ |", "|---|---|---|---|---|"]
for h in offsets_hours:
target = now_utc + pd.Timedelta(hours=h)
t_idx, t_val = _nearest(toto.median, target)
n_idx, n_val = _nearest(nws_temp, target)
if t_val is None and n_val is None:
continue
local = (t_idx or n_idx).tz_convert(tz)
if local.strftime("%a") == base_day:
when_label = local.strftime("%-I %p")
else:
when_label = local.strftime("%a %-I %p")
toto_str = f"**{t_val:.0f}°F**" if t_val is not None else "—"
nws_str = f"**{n_val:.0f}°F**" if n_val is not None else "—"
if t_val is not None and n_val is not None:
d = t_val - n_val
sign = "+" if d >= 0 else ""
delta_str = f"{sign}{d:.1f}°F"
else:
delta_str = "—"
rows.append(f"| **{h} h** | {when_label} | {toto_str} | {nws_str} | {delta_str} |")
return "\n".join(rows)
def emoji_strip_markdown(nws_df: pd.DataFrame, tz: str, n: int = 12) -> str:
"""Compact horizontal strip: hour | emoji | temp for the next n NWS hours."""
if nws_df is None or nws_df.empty:
return ""
df = nws_df.head(n)
hours = " | ".join(_fmt_hour_local(t, tz) for t in df.index)
glyphs = " | ".join(emoji_for(s) for s in df.get("short_forecast", pd.Series([None]*len(df))))
temps = " | ".join(f"{t:.0f}°" for t in df["temp_f"])
sep = "|---" * len(df) + "|"
return f"| {hours} |\n{sep}\n| {glyphs} |\n| {temps} |"
def residual_figure(
df: pd.DataFrame,
title: str = "Forecast residual — 1h-ahead prediction minus Ecowitt actual, last 48h (°F)",
) -> go.Figure:
"""Plot signed residuals over time for Toto and NWS. Zero is perfect."""
fig = go.Figure()
fig.add_hline(y=0, line=dict(color="#888", width=1))
fig.add_trace(
go.Scatter(
x=df.index, y=df["toto_residual"],
name="🤖 Toto residual", mode="lines+markers",
line=dict(color="#1f77b4", width=2),
marker=dict(size=5),
)
)
fig.add_trace(
go.Scatter(
x=df.index, y=df["nws_residual"],
name="🌎 NWS residual", mode="lines+markers",
line=dict(color="#d62728", width=2, dash="dash"),
marker=dict(size=5),
)
)
fig.update_layout(
title=title,
height=320,
hovermode="x unified",
yaxis_title="°F (signed error)",
margin=dict(l=50, r=20, t=50, b=50),
legend=dict(orientation="h", yanchor="bottom", y=1.04, xanchor="right", x=1),
)
fig.update_xaxes(tickformat="%b %-d\n%-I %p", showgrid=True)
return fig
def combined_figure(
history: pd.DataFrame,
totos: dict[str, TotoForecast],
nws_df: pd.DataFrame | None,
metrics: list[dict],
now: pd.Timestamp | None = None,
) -> go.Figure:
"""Three stacked subplots sharing the x-axis."""
fig = make_subplots(
rows=len(metrics), cols=1,
shared_xaxes=True,
vertical_spacing=0.06,
subplot_titles=[m["title"] for m in metrics],
)
showlegend = True
for i, m in enumerate(metrics, start=1):
col = m["col"]
if col not in history.columns:
continue
hist = history[col].dropna()
toto = totos.get(col)
fig.add_trace(
go.Scatter(
x=hist.index, y=hist.values,
name="📡 Ecowitt (measured)", mode="lines",
line=dict(color="#222", width=2),
showlegend=showlegend, legendgroup="hist",
),
row=i, col=1,
)
if toto is not None:
fig.add_trace(
go.Scatter(
x=list(toto.p90.index) + list(toto.p10.index[::-1]),
y=list(toto.p90.values) + list(toto.p10.values[::-1]),
fill="toself", fillcolor="rgba(31,119,180,0.18)",
mode="lines", line=dict(width=0, color="rgba(0,0,0,0)"),
hoverinfo="skip",
name="🤖 Toto 80% interval",
showlegend=showlegend, legendgroup="toto-band",
),
row=i, col=1,
)
fig.add_trace(
go.Scatter(
x=toto.median.index, y=toto.median.values,
name="🤖 Toto median", mode="lines",
line=dict(color="#1f77b4", width=2.5),
showlegend=showlegend, legendgroup="toto-med",
),
row=i, col=1,
)
if nws_df is not None and m.get("nws_col") and m["nws_col"] in nws_df.columns:
ns = nws_df[m["nws_col"]].dropna()
if not ns.empty:
fig.add_trace(
go.Scatter(
x=ns.index, y=ns.values,
name="🌎 NWS forecast", mode="lines",
line=dict(color="#d62728", width=2.5, dash="dash"),
showlegend=showlegend, legendgroup="nws",
),
row=i, col=1,
)
if now is not None:
fig.add_vline(x=now, line=dict(color="#888", dash="dot", width=1), row=i, col=1)
fig.update_yaxes(title_text=m["y"], row=i, col=1)
showlegend = False # only first subplot shows legend entries
fig.update_layout(
height=900,
hovermode="x unified",
margin=dict(l=50, r=20, t=90, b=40),
legend=dict(orientation="h", yanchor="bottom", y=1.06, xanchor="right", x=1),
)
# Default styling for every x-axis (grid + tick format), then move the
# tick labels off the bottom subplot and onto the top one. That way the
# reader sees what day/time each column represents on the first chart
# (Outdoor temperature) without having to scroll all the way to pressure.
fig.update_xaxes(
tickformat="%b %-d\n%-I %p",
ticklabelmode="instant",
showgrid=True,
showticklabels=False,
)
fig.update_xaxes(
row=1, col=1,
side="top",
showticklabels=True,
)
return fig