File size: 11,227 Bytes
c4b7a94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a17d1c
614270d
 
 
c4b7a94
614270d
 
0a17d1c
 
 
 
 
 
 
 
 
 
 
 
c4b7a94
0a17d1c
6fd1ae5
c4b7a94
 
 
e3bb26c
614270d
 
 
c71ac1b
 
 
614270d
 
 
 
 
 
 
 
 
 
 
 
 
 
26b0dfb
614270d
e3bb26c
f0a2503
6fd1ae5
 
614270d
c71ac1b
 
f0a2503
6fd1ae5
c4b7a94
 
e3bb26c
 
 
 
986b42c
e3bb26c
986b42c
 
e3bb26c
 
986b42c
 
e3bb26c
f0a2503
e3bb26c
 
 
 
 
 
 
986b42c
 
 
e3bb26c
f0a2503
e3bb26c
 
f0a2503
 
986b42c
f0a2503
986b42c
e3bb26c
 
 
 
 
 
 
 
986b42c
e3bb26c
 
 
c4b7a94
 
 
 
 
 
 
 
 
 
 
 
ba5aabf
 
4b323a5
ba5aabf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4b7a94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3bb26c
c4b7a94
 
 
 
 
 
 
 
 
 
 
e3bb26c
 
 
c4b7a94
 
 
 
 
 
 
e3bb26c
 
c4b7a94
 
 
 
 
 
 
 
 
 
e3bb26c
 
c4b7a94
 
 
 
 
 
 
 
 
 
bb2faab
c4b7a94
3361f38
 
c4b7a94
3361f38
 
 
 
071f1ee
 
 
 
3361f38
 
 
 
 
 
071f1ee
c4b7a94
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
"""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