Deduplicate the data-layer plumbing in climate.py and grading.py (#46)

climate.py repeated the same four mechanical patterns:
- the Open-Meteo daily params dict (3x) -> _om_daily_params(cell, **window)
- the doy attach line (6x) -> _with_doy
- makedirs + drop-doy + zstd to_parquet (3x) -> _write_cache
- the identical _to_frame/_nasa_to_frame tail (feels-like, valid-day
  filter, doy) -> _finalize_frame

grading.py encoded the tier tables twice — TEMP_BANDS/RAIN_BANDS plus the
hand-aligned _TEMP_LADDER/_RAIN_LADDER ('kept aligned' by comment). The
ladders are now derived from the bands (_ladder_from; verified
byte-identical to the old tables before landing), so tier boundaries have
exactly one definition. grade_range's inline dry-streak walk is replaced
with the existing dry_streaks(); its per-(doy,var) sample cache now also
memoizes the window mask per doy instead of recomputing it once per
metric (9x per day-of-year).

New tests pin the refactor: _to_frame schema/day-filter/missing-series
tolerance, the combined feels-like side selection, NASA unit conversions
and fill-sentinel handling, _om_daily_params windows, and _write_cache
stripping the derived doy column.
This commit is contained in:
Emi Griffith 2026-07-11 13:05:57 -07:00 committed by GitHub
parent b702e019d5
commit 7aaad17603
3 changed files with 163 additions and 90 deletions

View file

@ -180,6 +180,45 @@ def _derive_humidity(df: pd.DataFrame) -> pd.DataFrame:
return df return df
def _with_doy(df: pd.DataFrame) -> pd.DataFrame:
"""(Re)attach the int16 day-of-year column the grading windows key on."""
df["doy"] = df["date"].dt.dayofyear.astype("int16")
return df
def _write_cache(df: pd.DataFrame, path: str) -> None:
"""Persist a daily record to the parquet cache — the raw record only, never
the derived doy column (it's recomputed at read time)."""
os.makedirs(CACHE_DIR, exist_ok=True)
df.drop(columns=["doy"], errors="ignore").to_parquet(
path, compression="zstd", index=False)
def _om_daily_params(cell: dict, **window) -> dict:
"""The Open-Meteo daily-request params every fetch shares — location, the
variable set, imperial units. The date/window selectors (start_date/end_date
or past_days/forecast_days) come in as kwargs."""
return {
"latitude": cell["center_lat"],
"longitude": cell["center_lon"],
"daily": DAILY_VARS,
"timezone": "auto",
"temperature_unit": "fahrenheit",
"precipitation_unit": "inch",
"wind_speed_unit": "mph",
**window,
}
def _finalize_frame(df: pd.DataFrame) -> pd.DataFrame:
"""Shared tail of every source→frame mapping: the combined feels-like, the
valid-day filter, and the day-of-year column. A usable climate day needs a
real high/low; other columns may be NaN and simply grade as None."""
df["feels"] = _combined_feels(df["fmax"], df["fmin"])
df = df.dropna(subset=["tmax", "tmin"]).reset_index(drop=True)
return _with_doy(df)
def _combined_feels(amax: pd.Series, amin: pd.Series) -> pd.Series: def _combined_feels(amax: pd.Series, amin: pd.Series) -> pd.Series:
"""One daily "feels like" value: the apparent-temperature extreme furthest from """One daily "feels like" value: the apparent-temperature extreme furthest from
the comfort baseline the heat-index high on warm days, the wind-chill low on the comfort baseline the heat-index high on warm days, the wind-chill low on
@ -212,31 +251,14 @@ def _to_frame(daily: dict) -> pd.DataFrame:
# The apparent (felt) high and low are kept as their own columns — graded # The apparent (felt) high and low are kept as their own columns — graded
# independently — alongside the combined `feels` (whichever side is further # independently — alongside the combined `feels` (whichever side is further
# from the fixed comfort baseline), which the weekly/day views still use. # from the fixed comfort baseline), which the weekly/day views still use.
amax = pd.to_numeric(pd.Series(col("apparent_temperature_max")), errors="coerce") df["fmax"] = pd.to_numeric(pd.Series(col("apparent_temperature_max")), errors="coerce")
amin = pd.to_numeric(pd.Series(col("apparent_temperature_min")), errors="coerce") df["fmin"] = pd.to_numeric(pd.Series(col("apparent_temperature_min")), errors="coerce")
df["fmax"] = amax return _finalize_frame(df)
df["fmin"] = amin
df["feels"] = _combined_feels(amax, amin)
# Need a valid high/low to be a usable climate day; the rest may be NaN and are
# simply ungraded for that day (the percentile helpers return None).
df = df.dropna(subset=["tmax", "tmin"]).reset_index(drop=True)
df["doy"] = df["date"].dt.dayofyear.astype("int16")
return df
def _fetch_history(cell: dict) -> pd.DataFrame: def _fetch_history(cell: dict) -> pd.DataFrame:
end = (datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)).isoformat() end = (datetime.date.today() - datetime.timedelta(days=ARCHIVE_LATENCY_DAYS)).isoformat()
params = { params = _om_daily_params(cell, start_date=START_DATE, end_date=end)
"latitude": cell["center_lat"],
"longitude": cell["center_lon"],
"start_date": START_DATE,
"end_date": end,
"daily": DAILY_VARS,
"timezone": "auto",
"temperature_unit": "fahrenheit",
"precipitation_unit": "inch",
"wind_speed_unit": "mph",
}
r = _request(ARCHIVE_URL, params, 180, phase="history_fetch") r = _request(ARCHIVE_URL, params, 180, phase="history_fetch")
return _to_frame(r.json()["daily"]) return _to_frame(r.json()["daily"])
@ -284,10 +306,7 @@ def _nasa_to_frame(param: dict) -> pd.DataFrame:
# temperature outside its regime), mirroring the Open-Meteo columns. # temperature outside its regime), mirroring the Open-Meteo columns.
df["fmax"] = pd.Series(_heat_index(df["tmax"], df["humid"]), index=df.index) df["fmax"] = pd.Series(_heat_index(df["tmax"], df["humid"]), index=df.index)
df["fmin"] = pd.Series(_wind_chill(df["tmin"], df["wind"]), index=df.index) df["fmin"] = pd.Series(_wind_chill(df["tmin"], df["wind"]), index=df.index)
df["feels"] = _combined_feels(df["fmax"], df["fmin"]) return _finalize_frame(df)
df = df.dropna(subset=["tmax", "tmin"]).reset_index(drop=True)
df["doy"] = df["date"].dt.dayofyear.astype("int16")
return df
def _fetch_history_nasa(cell: dict) -> pd.DataFrame: def _fetch_history_nasa(cell: dict) -> pd.DataFrame:
@ -308,17 +327,7 @@ def _fetch_history_nasa(cell: dict) -> pd.DataFrame:
def _fetch_history_range(cell: dict, start_date: str, end_date: str) -> pd.DataFrame: def _fetch_history_range(cell: dict, start_date: str, end_date: str) -> pd.DataFrame:
"""Fetch just a date range of archive history (used to top up the recent tail).""" """Fetch just a date range of archive history (used to top up the recent tail)."""
params = { params = _om_daily_params(cell, start_date=start_date, end_date=end_date)
"latitude": cell["center_lat"],
"longitude": cell["center_lon"],
"start_date": start_date,
"end_date": end_date,
"daily": DAILY_VARS,
"timezone": "auto",
"temperature_unit": "fahrenheit",
"precipitation_unit": "inch",
"wind_speed_unit": "mph",
}
r = _request(ARCHIVE_URL, params, 60, phase="history_topup") r = _request(ARCHIVE_URL, params, 60, phase="history_topup")
return _to_frame(r.json()["daily"]) return _to_frame(r.json()["daily"])
@ -364,7 +373,7 @@ def _topup_tail(cell: dict, df: pd.DataFrame, path: str) -> pd.DataFrame:
merged = (pd.concat([df, recent.drop(columns=["doy"], errors="ignore")]) merged = (pd.concat([df, recent.drop(columns=["doy"], errors="ignore")])
.drop_duplicates(subset="date", keep="last") .drop_duplicates(subset="date", keep="last")
.sort_values("date").reset_index(drop=True)) .sort_values("date").reset_index(drop=True))
merged.to_parquet(path, compression="zstd", index=False) _write_cache(merged, path)
return merged return merged
@ -384,8 +393,7 @@ def load_cached_history(cell: dict) -> pd.DataFrame | None:
hit = _read_history_cache(_cache_path(cell["id"])) hit = _read_history_cache(_cache_path(cell["id"]))
if hit is None: if hit is None:
return None return None
df = hit[0].copy() df = _with_doy(hit[0].copy())
df["doy"] = df["date"].dt.dayofyear.astype("int16")
_derive_humidity(df) _derive_humidity(df)
return df return df
@ -403,9 +411,7 @@ def _load_history(cell: dict) -> tuple[pd.DataFrame, dict]:
df, age_s = hit df, age_s = hit
if age_s > HISTORY_TOPUP_INTERVAL: if age_s > HISTORY_TOPUP_INTERVAL:
df = _topup_tail(cell, df, path) # refresh just the recent days df = _topup_tail(cell, df, path) # refresh just the recent days
df = df.copy() return _with_doy(df.copy()), {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
df["doy"] = df["date"].dt.dayofyear.astype("int16")
return df, {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
global _archive_cooldown_until global _archive_cooldown_until
@ -413,14 +419,12 @@ def _load_history(cell: dict) -> tuple[pd.DataFrame, dict]:
hit = _read_history_cache(path) # another thread may have populated it while we waited hit = _read_history_cache(path) # another thread may have populated it while we waited
if hit is not None: if hit is not None:
df, age_s = hit df, age_s = hit
df["doy"] = df["date"].dt.dayofyear.astype("int16") return _with_doy(df), {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
return df, {"cached": True, "cache_age_days": round(age_s / 86400.0, 1)}
stale = pd.read_parquet(path) if os.path.exists(path) else None stale = pd.read_parquet(path) if os.path.exists(path) else None
def _serve_stale(): def _serve_stale():
stale["doy"] = stale["date"].dt.dayofyear.astype("int16") return _with_doy(stale), {"cached": True, "stale": True, "cache_age_days": None}
return stale, {"cached": True, "stale": True, "cache_age_days": None}
# Fetch. Open-Meteo is primary (richer: gusts + apparent temp); NASA POWER is # Fetch. Open-Meteo is primary (richer: gusts + apparent temp); NASA POWER is
# the backup when Open-Meteo is unavailable (network error or its daily limit). # the backup when Open-Meteo is unavailable (network error or its daily limit).
@ -446,9 +450,8 @@ def _load_history(cell: dict) -> tuple[pd.DataFrame, dict]:
raise WeatherUnavailable(limit_message(_archive_limit_daily), raise WeatherUnavailable(limit_message(_archive_limit_daily),
daily=_archive_limit_daily) daily=_archive_limit_daily)
os.makedirs(CACHE_DIR, exist_ok=True)
# Store the raw record; percentiles are derived at request time. # Store the raw record; percentiles are derived at request time.
df.drop(columns=["doy"]).to_parquet(path, compression="zstd", index=False) _write_cache(df, path)
return df, {"cached": False, "cache_age_days": 0, "source": source} return df, {"cached": False, "cache_age_days": 0, "source": source}
@ -489,9 +492,7 @@ def _load_recent_forecast(cell: dict) -> pd.DataFrame:
if os.path.exists(path): if os.path.exists(path):
age_h = (time.time() - os.path.getmtime(path)) / 3600.0 age_h = (time.time() - os.path.getmtime(path)) / 3600.0
if age_h < FORECAST_TTL_HOURS: if age_h < FORECAST_TTL_HOURS:
df = pd.read_parquet(path) return _with_doy(pd.read_parquet(path))
df["doy"] = df["date"].dt.dayofyear.astype("int16")
return df
params = { params = {
"latitude": cell["center_lat"], "latitude": cell["center_lat"],
@ -514,8 +515,7 @@ def _load_recent_forecast(cell: dict) -> pd.DataFrame:
raise WeatherUnavailable(limit_message(daily), daily=daily) from e raise WeatherUnavailable(limit_message(daily), daily=daily) from e
raise raise
df = _to_frame(r.json()["daily"]) df = _to_frame(r.json()["daily"])
os.makedirs(CACHE_DIR, exist_ok=True) _write_cache(df, path)
df.drop(columns=["doy"]).to_parquet(path, compression="zstd", index=False)
return df return df

View file

@ -5,6 +5,8 @@ historical day whose day-of-year is within +/- `HALF_WINDOW` days of it (wrappin
around the year end). An observed value is then placed on that distribution as an around the year end). An observed value is then placed on that distribution as an
empirical percentile and mapped to a human-readable grade. empirical percentile and mapped to a human-readable grade.
""" """
import warnings
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@ -58,34 +60,25 @@ RAIN_BANDS = [
] ]
# Tier ladders for the single-day detail view: each tier and the two percentile def _ladder_from(bands, bottom_lo=None):
# marks that bound it. Kept aligned with TEMP_BANDS / RAIN_BANDS (same labels and """Derive the single-day detail view's tier ladder from a band table, so the
# css classes) so the UI names and colors stay consistent across every view. two can never drift apart: each tier as (class, label, printable percentile
# (class, label, percentile-range label, lower-bound pct, upper-bound pct) range, lower-bound pct, upper-bound pct). The top tier is open-ended (>99);
_TEMP_LADDER = [ the bottom runs down from the 1st percentile ``bottom_lo`` marks its lower
("rec-hot", "Near Record", ">99%", 99, None), bound (None for temperature; 0 for rain, whose lightest tier bottoms out at
("very-hot", "Very High", "9099%", 90, 99), the smallest measured rain day)."""
("hot", "High", "7590%", 75, 90), top_thr, top_label, top_css = bands[0]
("warm", "Above Normal", "6075%", 60, 75), out = [(top_css, top_label, f">{top_thr}%", top_thr, None)]
("normal", "Normal", "4060%", 40, 60), for (thr, label, css), (prev_thr, _, _) in zip(bands[1:-1], bands[:-2]):
("cool", "Below Normal", "2540%", 25, 40), out.append((css, label, f"{thr}{prev_thr}%", thr, prev_thr))
("cold", "Low", "1025%", 10, 25), edge = bands[-2][0]
("very-cold", "Very Low", "110%", 1, 10), out.append((bands[-1][2], bands[-1][1], f"<{edge}%", bottom_lo, edge))
("rec-cold", "Near Record", "<1%", None, 1), return out
]
# Rain tiers are on the rain-day-only percentile scale (see _grade_precip). The
# lightest tier's lower bound is the smallest measured rain day (marked with 0). _TEMP_LADDER = _ladder_from(TEMP_BANDS)
_RAIN_LADDER = [ # Rain tiers are on the rain-day-only percentile scale (see _grade_precip).
("wet-9", "Extreme", ">99%", 99, None), _RAIN_LADDER = _ladder_from(RAIN_BANDS, bottom_lo=0)
("wet-8", "Very Heavy", "9099%", 90, 99),
("wet-7", "Heavy", "7590%", 75, 90),
("wet-6", "ModHeavy", "6075%", 60, 75),
("wet-5", "Moderate", "4060%", 40, 60),
("wet-4", "LightMod", "2540%", 25, 40),
("wet-3", "Light", "1025%", 10, 25),
("wet-2", "Very Light", "110%", 1, 10),
("wet-1", "Trace", "<1%", 0, 1),
]
def _band(pct: float, bands) -> tuple[str, str]: def _band(pct: float, bands) -> tuple[str, str]:
@ -222,12 +215,16 @@ def grade_range(df: pd.DataFrame, start, end) -> list[dict]:
full = df.sort_values("date") full = df.sort_values("date")
doys_all = full["doy"].values doys_all = full["doy"].values
cols = {v: full[v].values for v in CLIMO_METRICS if v in full.columns} cols = {v: full[v].values for v in CLIMO_METRICS if v in full.columns}
masks: dict[int, np.ndarray] = {}
cache: dict[tuple[int, str], np.ndarray] = {} cache: dict[tuple[int, str], np.ndarray] = {}
def samples(doy: int, var: str) -> np.ndarray: def samples(doy: int, var: str) -> np.ndarray:
key = (doy, var) key = (doy, var)
if key not in cache: if key not in cache:
arr = cols[var][window_mask(doys_all, doy)] mask = masks.get(doy)
if mask is None:
mask = masks[doy] = window_mask(doys_all, doy)
arr = cols[var][mask]
cache[key] = arr[~np.isnan(arr)] cache[key] = arr[~np.isnan(arr)]
return cache[key] return cache[key]
@ -236,18 +233,12 @@ def grade_range(df: pd.DataFrame, start, end) -> list[dict]:
# the first shown day is exact even when a dry spell straddles the window start. # the first shown day is exact even when a dry spell straddles the window start.
# A fixed 14-day lookback would be the bare minimum but still undercounts longer # A fixed 14-day lookback would be the bare minimum but still undercounts longer
# droughts (the record has 25-day dry streaks); using the full history (already # droughts (the record has 25-day dry streaks); using the full history (already
# cached per cell) is both correct for any streak length and free. NaN precip # cached per cell) is both correct for any streak length and free.
# compares False against the threshold, so it counts as a dry day.
if full["date"].min() > start - pd.Timedelta(days=DSR_LOOKBACK_MIN_DAYS): if full["date"].min() > start - pd.Timedelta(days=DSR_LOOKBACK_MIN_DAYS):
# Should never happen (history starts in 1980); guards against a future # Should never happen (history starts in 1980); guards against a future
# change that trims history and would silently truncate streaks. # change that trims history and would silently truncate streaks.
import warnings
warnings.warn("dry-streak lookback shorter than the 14-day minimum", stacklevel=2) warnings.warn("dry-streak lookback shorter than the 14-day minimum", stacklevel=2)
dsr_map: dict[str, int] = {} dsr_map = dry_streaks(full["date"].values, cols["precip"])
streak = 0
for d, p in zip(full["date"].values, cols["precip"]):
streak = 0 if p >= RAIN_THRESHOLD else streak + 1
dsr_map[pd.Timestamp(d).date().isoformat()] = streak
sub = full[(full["date"] >= start) & (full["date"] <= end)] sub = full[(full["date"] >= start) & (full["date"] <= end)]
out = [] out = []

82
tests/test_climate.py Normal file
View file

@ -0,0 +1,82 @@
"""The source→frame mappings and cache-write path (pure parts of climate.py —
no network)."""
import numpy as np
import pandas as pd
import climate
def _om_daily(n=3):
"""A minimal Open-Meteo daily response."""
return {
"time": [f"2026-06-{d:02d}" for d in range(1, n + 1)],
"temperature_2m_max": [80.0, 90.0, None],
"temperature_2m_min": [60.0, 70.0, 55.0],
"precipitation_sum": [0.0, 0.25, 0.1],
"wind_speed_10m_max": [10.0, 20.0, 5.0],
"wind_gusts_10m_max": [15.0, 30.0, 8.0],
"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],
}
def test_to_frame_schema_and_day_filter():
df = climate._to_frame(_om_daily())
# The None tmax day is dropped: a usable climate day needs a real high/low.
assert len(df) == 2
assert df["doy"].dtype == np.int16
assert set(df.columns) == {"date", "tmax", "tmin", "precip", "wind", "gust",
"humid", "fmax", "fmin", "feels", "doy"}
def test_to_frame_tolerates_missing_series():
daily = _om_daily()
del daily["wind_gusts_10m_max"], daily["relative_humidity_2m_mean"]
df = climate._to_frame(daily)
assert df["gust"].isna().all() and df["humid"].isna().all()
assert df["tmax"].notna().all() # required series unaffected
def test_combined_feels_picks_the_extreme_side():
df = climate._to_frame(_om_daily())
# Day 2: fmax 95 is 30 past the 65°F comfort point vs fmin 68 only 3 below —
# the hot side wins; both days here are hot-side days.
assert df.loc[df["date"] == "2026-06-02", "feels"].item() == 95.0
def test_nasa_to_frame_converts_units_and_fills():
param = {
"T2M_MAX": {"20260601": 30.0, "20260602": -999.0}, # °C; -999 = missing
"T2M_MIN": {"20260601": 20.0, "20260602": 15.0},
"PRECTOTCORR": {"20260601": 25.4, "20260602": 0.0}, # mm
"WS10M_MAX": {"20260601": 10.0, "20260602": 5.0}, # m/s
"RH2M": {"20260601": 50.0, "20260602": 60.0},
}
df = climate._nasa_to_frame(param)
assert len(df) == 1 # the missing-tmax day dropped
row = df.iloc[0]
assert row["tmax"] == 86.0 # 30°C
assert row["precip"] == 1.0 # 25.4mm = 1in
assert round(row["wind"], 1) == 22.4 # 10 m/s in mph
assert np.isnan(row["gust"]) # POWER has no gusts
assert row["fmax"] > row["tmax"] # ≥80°F: the NWS heat index applies
def test_om_daily_params_carries_the_window():
cell = {"center_lat": 47.6, "center_lon": -122.3}
p = climate._om_daily_params(cell, start_date="2026-01-01", end_date="2026-02-01")
assert p["latitude"] == 47.6 and p["daily"] == climate.DAILY_VARS
assert p["temperature_unit"] == "fahrenheit"
assert p["start_date"] == "2026-01-01" and p["end_date"] == "2026-02-01"
p2 = climate._om_daily_params(cell, past_days=25, forecast_days=8)
assert p2["past_days"] == 25 and "start_date" not in p2
def test_write_cache_strips_the_derived_doy(tmp_path):
df = climate._to_frame(_om_daily())
path = str(tmp_path / "cell.parquet")
climate._write_cache(df, path)
stored = pd.read_parquet(path)
assert "doy" not in stored.columns
assert len(stored) == len(df)