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
This commit is contained in:
Emi Griffith 2026-07-22 12:08:24 -07:00
parent cc0b1e298f
commit 4fe750f10c
6 changed files with 128 additions and 9 deletions

View file

@ -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),
}

View file

@ -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)

View file

@ -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

View file

@ -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():

View file

@ -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)

View file

@ -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"]