From 4fe750f10caae004c4127dd67e4fb04e1c2ee793 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Wed, 22 Jul 2026 12:08:24 -0700 Subject: [PATCH] Port e77460e Plain-language weather insight sentences (backend half) from monorepo (drift reconciliation) The frontend/ half of this commit (calendar.js, calendar.html, app.js, style.css) is out of scope for this repo; a separate agent handles the split frontend repo. Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z --- api/payloads.py | 3 ++ data/climate.py | 30 ++++++++++++---- data/grading.py | 62 ++++++++++++++++++++++++++++++++ tests/data/test_climate.py | 18 +++++++++- tests/data/test_climate_store.py | 2 +- tests/data/test_grading.py | 22 ++++++++++++ 6 files changed, 128 insertions(+), 9 deletions(-) diff --git a/api/payloads.py b/api/payloads.py index 3e90e3d..420ab50 100644 --- a/api/payloads.py +++ b/api/payloads.py @@ -241,6 +241,9 @@ def build_calendar(cell, history, start_ts, end_ts, months, place, run=None) -> "range": {"start": start_ts.isoformat(), "end": end_ts.isoformat()}, "months": months, "days": days, + # Per-month climatology over the full history — drives the plain-language + # insight strip and cross-month rain comparisons in the calendar UI. + "months_climate": grading.month_climate(history), } diff --git a/data/climate.py b/data/climate.py index 4964f41..f88fc43 100644 --- a/data/climate.py +++ b/data/climate.py @@ -68,20 +68,23 @@ DAILY_VARS = ( "temperature_2m_max,temperature_2m_min,precipitation_sum," "wind_speed_10m_max,wind_gusts_10m_max," "apparent_temperature_max,apparent_temperature_min," - "relative_humidity_2m_mean" + "relative_humidity_2m_mean," + "sunshine_duration,daylight_duration" ) # Optional metric columns beyond the required date/tmax/tmin/precip schema. A # source that lacks one gets an all-null column in _finalize_frame, so every # finalized frame has the same schema without each builder stubbing missing -# columns by hand. -OPTIONAL_COLS = ("wind", "gust", "humid", "fmax", "fmin") +# columns by hand. `sun` (the sunshine fraction) only comes from Open-Meteo, so +# the NASA/MET fallbacks pick up its null column here too. +OPTIONAL_COLS = ("wind", "gust", "humid", "fmax", "fmin", "sun") # Columns added after the original tmax/tmin/precip schema. A cached parquet # missing any of these predates the wind/feels/humidity features (or the split -# apparent high/low) and is refetched so the new metrics show up immediately -# instead of after the 30-day cache TTL. `feels` is derived in _finalize_frame; -# the rest are the optional raw inputs, so this stays in lockstep with them. +# apparent high/low, or the sunshine fraction) and is refetched so the new +# metrics show up immediately instead of after the 30-day cache TTL. `feels` is +# derived in _finalize_frame; the rest are the optional raw inputs, so this stays +# in lockstep with them. NEW_COLS = (*OPTIONAL_COLS, "feels") # Thermoneutral baseline (°F). The combined "feels like" metric reports whichever @@ -433,13 +436,26 @@ def _to_frame(daily: dict) -> pl.DataFrame: # from the fixed comfort baseline), which the weekly/day views still use. "fmax": col("apparent_temperature_max"), "fmin": col("apparent_temperature_min"), + # Raw seconds of sunshine and of daylight; combined below into `sun`. + "sunsec": col("sunshine_duration"), + "daysec": col("daylight_duration"), } ) df = df.with_columns( pl.col("date").str.to_date(), *[pl.col(c).cast(pl.Float64, strict=False) - for c in ("tmax", "tmin", "precip", "wind", "gust", "fmax", "fmin")], + for c in ("tmax", "tmin", "precip", "wind", "gust", "fmax", "fmin", + "sunsec", "daysec")], ) + # `sun` = fraction of the day's daylight that was sunshine, clamped to [0, 1] + # (sunshine_duration can slightly exceed daylight near the poles). Null when + # either series is missing or daylight is zero (polar night). + df = df.with_columns( + pl.when((pl.col("daysec") > 0) & pl.col("sunsec").is_not_null()) + .then((pl.col("sunsec") / pl.col("daysec")).clip(0.0, 1.0)) + .otherwise(None) + .alias("sun") + ).drop("sunsec", "daysec") return _finalize_frame(df) diff --git a/data/grading.py b/data/grading.py index 6f8f350..dfd34df 100644 --- a/data/grading.py +++ b/data/grading.py @@ -15,6 +15,7 @@ import polars as pl HALF_WINDOW = 7 # +/- 7 days -> a 15-day seasonal window RAIN_THRESHOLD = 0.01 # inches; a day with < this much precip counts as "dry" +SUN_THRESHOLD = 0.5 # `sun` fraction >= this counts as a "sunny" day (>=50% of daylight) DSR_LOOKBACK_MIN_DAYS = 14 # min history required before a window to seed dry streaks # Metrics graded on the diverging temperature scale (percentile -> TEMP_BANDS): @@ -192,6 +193,67 @@ def climatology(df: pl.DataFrame, target_doy: int) -> dict: "max": round(float(np.max(v)), 2), "mean": round(float(np.mean(v)), 1), } + # Plain-language "insight" frequencies over the same window: the share of days + # that see measurable rain, and the share that are sunny. Powers the friendly + # summary sentences on the home page. + out["rain_freq"] = _rain_frac(sub) + out["sun_freq"] = _sun_frac(sub) + return out + + +def _rain_frac(df: pl.DataFrame) -> float | None: + """Fraction of days with measurable rain (>= RAIN_THRESHOLD), or None if no + usable precip data.""" + if "precip" not in df.columns: + return None + v = _finite(df["precip"]) + if v.size == 0: + return None + return round(float(np.mean(v >= RAIN_THRESHOLD)), 3) + + +def _sun_frac(df: pl.DataFrame) -> float | None: + """Fraction of days that are sunny (`sun` fraction >= SUN_THRESHOLD), or None + if no usable sunshine data (e.g. NASA/MET-sourced cells).""" + if "sun" not in df.columns: + return None + v = _finite(df["sun"]) + if v.size == 0: + return None + return round(float(np.mean(v >= SUN_THRESHOLD)), 3) + + +# Days per calendar month (non-leap); used to turn a rain fraction into an +# average count of rainy days for the month. +_DAYS_IN_MONTH = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + + +def month_climate(df: pl.DataFrame) -> dict: + """Per calendar month (1..12), a compact climatological summary across the full + history: typical high/low (median), the share of days that see rain / are + sunny, and the implied average count of rainy days. Feeds the calendar view's + plain-language insight strip and cross-month ("Nx more likely") comparisons.""" + if "date" not in df.columns: + return {} + months = df["date"].dt.month().to_numpy() + out: dict = {} + for m in range(1, 13): + sub = df.filter(pl.Series(months == m)) + if sub.is_empty(): + continue + tmax = _finite(sub["tmax"]) if "tmax" in sub.columns else np.array([]) + tmin = _finite(sub["tmin"]) if "tmin" in sub.columns else np.array([]) + rain_freq = _rain_frac(sub) + entry = { + "n_samples": int(len(sub)), + "tmax_med": round(float(np.median(tmax)), 1) if tmax.size else None, + "tmin_med": round(float(np.median(tmin)), 1) if tmin.size else None, + "rain_freq": rain_freq, + "sun_freq": _sun_frac(sub), + "rainy_days": (round(rain_freq * _DAYS_IN_MONTH[m - 1], 1) + if rain_freq is not None else None), + } + out[str(m)] = entry return out diff --git a/tests/data/test_climate.py b/tests/data/test_climate.py index 74533cc..e8e5782 100644 --- a/tests/data/test_climate.py +++ b/tests/data/test_climate.py @@ -20,6 +20,9 @@ def _om_daily(n=3): "apparent_temperature_max": [82.0, 95.0, 70.0], "apparent_temperature_min": [58.0, 68.0, 50.0], "relative_humidity_2m_mean": [50.0, 60.0, 70.0], + # Sunshine vs daylight seconds -> `sun` fraction (full sun, half, quarter). + "sunshine_duration": [43200.0, 21600.0, 10800.0], + "daylight_duration": [43200.0, 43200.0, 43200.0], } @@ -29,7 +32,20 @@ def test_to_frame_schema_and_day_filter(): assert len(df) == 2 assert df["doy"].dtype == pl.Int16 assert set(df.columns) == {"date", "tmax", "tmin", "precip", "wind", "gust", - "humid", "fmax", "fmin", "feels", "doy"} + "humid", "fmax", "fmin", "feels", "sun", "doy"} + + +def test_to_frame_derives_sun_fraction(): + df = climate._to_frame(_om_daily()) + # sun = sunshine_duration / daylight_duration, clamped to [0, 1]. + assert df["sun"].to_list() == [1.0, 0.5] + + +def test_to_frame_sun_null_without_sunshine(): + daily = _om_daily() + del daily["sunshine_duration"], daily["daylight_duration"] + df = climate._to_frame(daily) + assert "sun" in df.columns and df["sun"].is_null().all() def test_to_frame_tolerates_missing_series(): diff --git a/tests/data/test_climate_store.py b/tests/data/test_climate_store.py index d3edcc1..b31bf35 100644 --- a/tests/data/test_climate_store.py +++ b/tests/data/test_climate_store.py @@ -25,7 +25,7 @@ def _hist_frame(n=5, tmax0=70.0): """A raw daily frame with the persisted columns (no doy — the write path drops it).""" d0 = datetime.date(1995, 1, 1) cols = {"date": [d0 + datetime.timedelta(days=i) for i in range(n)]} - for c in ("tmax", "tmin", "precip", "wind", "gust", "humid", "fmax", "fmin", "feels"): + for c in ("tmax", "tmin", "precip", "wind", "gust", "humid", "fmax", "fmin", "feels", "sun"): cols[c] = [float(tmax0 + i) for i in range(n)] return pl.DataFrame(cols) diff --git a/tests/data/test_grading.py b/tests/data/test_grading.py index 8fb7b88..cfe7d28 100644 --- a/tests/data/test_grading.py +++ b/tests/data/test_grading.py @@ -164,3 +164,25 @@ def test_climatology_summary(history): assert climo["n_samples"] >= 15 * 19 assert climo["tmax"]["p40"] <= climo["tmax"]["p50"] <= climo["tmax"]["p60"] assert climo["feels"] is None # column absent from this record + + +def test_climatology_frequencies(history): + climo = grading.climatology(history, 180) + # ~30% of days carry rain in the fixture; sunshine is absent from this record. + assert 0.0 < climo["rain_freq"] < 1.0 + assert climo["sun_freq"] is None + + +def test_month_climate(history): + mc = grading.month_climate(history) + assert sorted(int(k) for k in mc) == list(range(1, 13)) + for k, e in mc.items(): + assert 0.0 <= e["rain_freq"] <= 1.0 + assert e["sun_freq"] is None # no sunshine column in the fixture + assert e["tmax_med"] is not None + # rainy_days is the rain fraction scaled to the month's length, stored + # rounded to one decimal (so allow for that 0.1 rounding). + assert e["rainy_days"] == pytest.approx( + e["rain_freq"] * grading._DAYS_IN_MONTH[int(k) - 1], abs=0.06) + # Summer (Jul) is warmer than winter (Jan) given the seasonal fixture. + assert mc["7"]["tmax_med"] > mc["1"]["tmax_med"]