Simplify the score module and page: dedup builders, consolidate metric metadata (#209)
Backend (scoring.py): - Fold the parallel WEIGHTS/METRIC_LABELS/DIRECTION_WORDS/TEMP_DIR_METRICS maps into one METRICS descriptor so a scored metric is defined in one place. - Share one _build_entry between the seasonal and annual paths. - Add _freq_for so the annual entry reads the wet-day / heat-stress-day share directly instead of re-running the full precip_divergence just for it. - Drop unconsumed payload: per-q v6/pct and the overall bias. - Drop _slice's dead 'annual' branch; derive SLICES from SEASONS. Frontend (score.js): - One scoreCell() and signedPts() helper for the four score-cell sites and the two signed-points chips; reuse .section-title / .table-wrap instead of cloning. Behavior-preserving (identical scores); bumps the score cache version.
This commit is contained in:
parent
41e84060ab
commit
3bd41a41ff
2 changed files with 100 additions and 123 deletions
221
scoring.py
221
scoring.py
|
|
@ -6,9 +6,9 @@ at that percentile and ask where it lands in the baseline distribution. The gap
|
||||||
between where it lands and ``q`` — in percentile points, so it is unit-free and
|
between where it lands and ``q`` — in percentile points, so it is unit-free and
|
||||||
comparable across metrics — is the divergence. A metric's score is the mean
|
comparable across metrics — is the divergence. A metric's score is the mean
|
||||||
absolute divergence over the categories, mapped to 0-100; its sign (bias) says
|
absolute divergence over the categories, mapped to 0-100; its sign (bias) says
|
||||||
which way it drifted. Metrics are computed per meteorological season and pooled
|
which way it drifted. Metrics are computed per meteorological season and the
|
||||||
into an annual view, then weighted (temps / humidity / feels-like heaviest) into
|
seasonal differentials are averaged into an annual view, then weighted (temps /
|
||||||
one overall score.
|
humidity / feels-like heaviest) into one overall score.
|
||||||
|
|
||||||
Baseline is the ENTIRE record, including the recent years — the same all-years
|
Baseline is the ENTIRE record, including the recent years — the same all-years
|
||||||
climatology every other view grades against. The recent window's ~13% overlap
|
climatology every other view grades against. The recent window's ~13% overlap
|
||||||
|
|
@ -24,20 +24,25 @@ RECENT_YEARS = 6
|
||||||
QS = (10, 25, 50, 75, 90) # the scored percentile categories
|
QS = (10, 25, 50, 75, 90) # the scored percentile categories
|
||||||
SEASONS = {"djf": (12, 1, 2), "mam": (3, 4, 5),
|
SEASONS = {"djf": (12, 1, 2), "mam": (3, 4, 5),
|
||||||
"jja": (6, 7, 8), "son": (9, 10, 11)}
|
"jja": (6, 7, 8), "son": (9, 10, 11)}
|
||||||
SLICES = ("annual", "djf", "mam", "jja", "son")
|
SLICES = ("annual", *SEASONS) # payload slice keys
|
||||||
|
|
||||||
# All scored metrics. wetbulb is derived at the read boundary (climate.py); the
|
# Per-metric descriptor — display label, overall-score weight, and the
|
||||||
# rest are raw daily columns. Order here is the canonical compute order; the
|
# (positive-drift, negative-drift) direction words — in ONE table so a scored
|
||||||
# frontend renders in its own display order (Precip first).
|
# metric is defined in a single place. Temps / humidity / feels-like dominate the
|
||||||
SCORE_METRICS = ("tmax", "tmin", "feels", "humid", "wetbulb", "wind", "gust", "precip")
|
# weighting; wet bulb medium; wind + precip lightest. wetbulb is derived at the
|
||||||
|
# read boundary (climate.py); the rest are raw daily columns. Insertion order is
|
||||||
# Temps / humidity / feels-like dominate; wet bulb medium; wind + precip lightest.
|
# the canonical compute order (the frontend renders in its own display order).
|
||||||
WEIGHTS = {"feels": 2.0, "humid": 2.0, "tmax": 1.5, "tmin": 1.5,
|
METRICS = {
|
||||||
"wetbulb": 1.25, "precip": 0.75, "wind": 0.5, "gust": 0.5}
|
"tmax": {"label": "High temp", "weight": 1.5, "dir": ("warmer", "cooler")},
|
||||||
|
"tmin": {"label": "Low temp", "weight": 1.5, "dir": ("warmer", "cooler")},
|
||||||
# Metrics whose signed drift feeds the headline "warmer / cooler" (so wind and
|
"feels": {"label": "Feels like", "weight": 2.0, "dir": ("warmer", "cooler")},
|
||||||
# precip signs never cancel the temperature story in the overall bias).
|
"humid": {"label": "Humidity", "weight": 2.0, "dir": ("muggier", "drier")},
|
||||||
TEMP_DIR_METRICS = ("tmax", "tmin", "feels", "wetbulb")
|
"wetbulb": {"label": "Wet bulb", "weight": 1.25, "dir": ("warmer", "cooler")},
|
||||||
|
"wind": {"label": "Wind", "weight": 0.5, "dir": ("windier", "calmer")},
|
||||||
|
"gust": {"label": "Gusts", "weight": 0.5, "dir": ("gustier", "calmer")},
|
||||||
|
"precip": {"label": "Precip", "weight": 0.75, "dir": ("wetter", "drier")},
|
||||||
|
}
|
||||||
|
SCORE_METRICS = tuple(METRICS)
|
||||||
|
|
||||||
MIN_BASELINE_YEARS = 25 # below this, no scoring at all (payload carries a reason)
|
MIN_BASELINE_YEARS = 25 # below this, no scoring at all (payload carries a reason)
|
||||||
MIN_SLICE_SAMPLES = 300 # recent-slice floor (~540 expected per 6-yr season)
|
MIN_SLICE_SAMPLES = 300 # recent-slice floor (~540 expected per 6-yr season)
|
||||||
|
|
@ -49,20 +54,6 @@ SATURATION = 15.0 # mean |divergence| (pct points) that maps to score 10
|
||||||
# sustained exertion. Reported as a recent-vs-baseline share alongside the score.
|
# sustained exertion. Reported as a recent-vs-baseline share alongside the score.
|
||||||
WETBULB_STRESS_F = 78.8 # 26 °C
|
WETBULB_STRESS_F = 78.8 # 26 °C
|
||||||
|
|
||||||
METRIC_LABELS = {
|
|
||||||
"tmax": "High temp", "tmin": "Low temp", "feels": "Feels like",
|
|
||||||
"humid": "Humidity", "wetbulb": "Wet bulb", "wind": "Wind",
|
|
||||||
"gust": "Gusts", "precip": "Precip",
|
|
||||||
}
|
|
||||||
|
|
||||||
# (word for a positive drift, word for a negative drift).
|
|
||||||
DIRECTION_WORDS = {
|
|
||||||
"tmax": ("warmer", "cooler"), "tmin": ("warmer", "cooler"),
|
|
||||||
"feels": ("warmer", "cooler"), "wetbulb": ("warmer", "cooler"),
|
|
||||||
"humid": ("muggier", "drier"), "wind": ("windier", "calmer"),
|
|
||||||
"gust": ("gustier", "calmer"), "precip": ("wetter", "drier"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# score threshold -> (label, css for positive bias, css for negative bias). The
|
# score threshold -> (label, css for positive bias, css for negative bias). The
|
||||||
# css classes are the existing 9-step temperature scale, so no new color tokens
|
# css classes are the existing 9-step temperature scale, so no new color tokens
|
||||||
# are needed — the frontend's TIER_COLORS resolves them for free. Lower-bound
|
# are needed — the frontend's TIER_COLORS resolves them for free. Lower-bound
|
||||||
|
|
@ -92,19 +83,16 @@ def recent_baseline_split(df: pl.DataFrame):
|
||||||
return df.filter(pl.col("date") > cutoff), df
|
return df.filter(pl.col("date") > cutoff), df
|
||||||
|
|
||||||
|
|
||||||
def _slice(df: pl.DataFrame, key: str) -> pl.DataFrame:
|
def _slice(df: pl.DataFrame, season: str) -> pl.DataFrame:
|
||||||
"""A slice of the record: the whole thing for ``"annual"``, else the pooled
|
"""The pooled days of one meteorological season (DJF wraps the year end, but the
|
||||||
days of one meteorological season (DJF wraps the year end, but the samples are
|
samples are pooled across all years so the boundary is irrelevant)."""
|
||||||
pooled across all years so the boundary is irrelevant)."""
|
return df.filter(pl.col("date").dt.month().is_in(list(SEASONS[season])))
|
||||||
if key == "annual":
|
|
||||||
return df
|
|
||||||
return df.filter(pl.col("date").dt.month().is_in(list(SEASONS[key])))
|
|
||||||
|
|
||||||
|
|
||||||
def divergence(base: np.ndarray, rec: np.ndarray) -> dict | None:
|
def divergence(base: np.ndarray, rec: np.ndarray) -> dict | None:
|
||||||
"""Core math for one metric within one slice. For each category ``q`` in
|
"""Core math for one metric within one slice. For each category ``q`` in
|
||||||
``QS``: the recent value at that percentile, where it lands in the baseline,
|
``QS``: the recent value at that percentile, where it lands in the baseline,
|
||||||
and the signed gap ``d = landed − q``. Returns per-category detail plus the
|
and the signed gap ``d = landed − q``. Returns per-category ``d`` plus the
|
||||||
mean-absolute-divergence (``mad``, drives the score) and mean signed
|
mean-absolute-divergence (``mad``, drives the score) and mean signed
|
||||||
divergence (``bias``, drives the direction). ``None`` when the recent slice is
|
divergence (``bias``, drives the direction). ``None`` when the recent slice is
|
||||||
too thin to trust."""
|
too thin to trust."""
|
||||||
|
|
@ -112,11 +100,10 @@ def divergence(base: np.ndarray, rec: np.ndarray) -> dict | None:
|
||||||
return None
|
return None
|
||||||
per_q, deltas = [], []
|
per_q, deltas = [], []
|
||||||
for q in QS:
|
for q in QS:
|
||||||
v6 = float(np.percentile(rec, q))
|
pct = grading.empirical_percentile(base, float(np.percentile(rec, q)))
|
||||||
pct = grading.empirical_percentile(base, v6)
|
|
||||||
if pct is None:
|
if pct is None:
|
||||||
continue
|
continue
|
||||||
per_q.append({"q": q, "v6": round(v6, 2), "pct": pct, "d": round(pct - q, 1)})
|
per_q.append({"q": q, "d": round(pct - q, 1)})
|
||||||
deltas.append(pct - q)
|
deltas.append(pct - q)
|
||||||
if not per_q:
|
if not per_q:
|
||||||
return None
|
return None
|
||||||
|
|
@ -131,23 +118,38 @@ def precip_divergence(base: np.ndarray, rec: np.ndarray) -> dict | None:
|
||||||
*amounts* shifted (percentile divergence over rain days only) and how the
|
*amounts* shifted (percentile divergence over rain days only) and how the
|
||||||
wet-day *frequency* shifted (points of the wet-day share). They average into
|
wet-day *frequency* shifted (points of the wet-day share). They average into
|
||||||
one mad/bias; the frequency alone carries it when rain days are too few for a
|
one mad/bias; the frequency alone carries it when rain days are too few for a
|
||||||
stable amount distribution."""
|
stable amount distribution. (The wet-day share for the card's freq read-out is
|
||||||
|
computed separately by ``_freq_for``.)"""
|
||||||
if rec.size < MIN_SLICE_SAMPLES or base.size == 0:
|
if rec.size < MIN_SLICE_SAMPLES or base.size == 0:
|
||||||
return None
|
return None
|
||||||
thr = grading.RAIN_THRESHOLD
|
thr = grading.RAIN_THRESHOLD
|
||||||
f6 = float(np.mean(rec >= thr)) * 100.0
|
freq_d = (float(np.mean(rec >= thr)) - float(np.mean(base >= thr))) * 100.0
|
||||||
f45 = float(np.mean(base >= thr)) * 100.0
|
|
||||||
freq_d = f6 - f45
|
|
||||||
wet_rec = rec[rec >= thr]
|
wet_rec = rec[rec >= thr]
|
||||||
amount = divergence(base[base >= thr], wet_rec) if wet_rec.size >= MIN_WET_DAYS else None
|
amount = divergence(base[base >= thr], wet_rec) if wet_rec.size >= MIN_WET_DAYS else None
|
||||||
if amount is not None:
|
if amount is not None:
|
||||||
mad = 0.5 * amount["mad"] + 0.5 * abs(freq_d)
|
return {"per_q": amount["per_q"],
|
||||||
bias = 0.5 * amount["bias"] + 0.5 * freq_d
|
"mad": round(0.5 * amount["mad"] + 0.5 * abs(freq_d), 1),
|
||||||
else:
|
"bias": round(0.5 * amount["bias"] + 0.5 * freq_d, 1)}
|
||||||
mad, bias = abs(freq_d), freq_d
|
return {"per_q": [], "mad": round(abs(freq_d), 1), "bias": round(freq_d, 1)}
|
||||||
return {"per_q": amount["per_q"] if amount else [],
|
|
||||||
"mad": round(mad, 1), "bias": round(bias, 1),
|
|
||||||
"freq": {"f6": round(f6, 1), "f45": round(f45, 1), "d": round(freq_d, 1)}}
|
def _freq_for(metric: str, base: np.ndarray, rec: np.ndarray) -> dict | None:
|
||||||
|
"""The day-count read-out a metric card carries, or ``None``. Precip: the
|
||||||
|
wet-day share; wet bulb: the heat-stress-day share. Both recent-vs-baseline in
|
||||||
|
percentage points, over the whole slice — counts, not distribution shifts."""
|
||||||
|
if metric == "precip":
|
||||||
|
return _day_share(base, rec, grading.RAIN_THRESHOLD)
|
||||||
|
if metric == "wetbulb":
|
||||||
|
return _day_share(base, rec, WETBULB_STRESS_F, {"threshold_f": WETBULB_STRESS_F})
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _day_share(base: np.ndarray, rec: np.ndarray, thr: float, extra: dict | None = None) -> dict:
|
||||||
|
"""Share of days at or above ``thr`` — recent (``f6``) vs baseline (``f45``) and
|
||||||
|
the gap (``d``), in percentage points."""
|
||||||
|
f6 = float(np.mean(rec >= thr)) * 100.0
|
||||||
|
f45 = float(np.mean(base >= thr)) * 100.0
|
||||||
|
return {"f6": round(f6, 1), "f45": round(f45, 1), "d": round(f6 - f45, 1), **(extra or {})}
|
||||||
|
|
||||||
|
|
||||||
def score_of(mad: float) -> int:
|
def score_of(mad: float) -> int:
|
||||||
|
|
@ -166,68 +168,59 @@ def tier_of(score: int, bias: float):
|
||||||
|
|
||||||
|
|
||||||
def _direction(metric: str, bias: float) -> str:
|
def _direction(metric: str, bias: float) -> str:
|
||||||
up, down = DIRECTION_WORDS[metric]
|
up, down = METRICS[metric]["dir"]
|
||||||
return up if bias >= 0 else down
|
return up if bias >= 0 else down
|
||||||
|
|
||||||
|
|
||||||
def _null_entry(metric: str, reason: str) -> dict:
|
def _null_entry(metric: str, reason: str) -> dict:
|
||||||
return {"key": metric, "label": METRIC_LABELS[metric], "score": None,
|
return {"key": metric, "label": METRICS[metric]["label"], "score": None,
|
||||||
"weight": WEIGHTS[metric], "reason": reason}
|
"weight": METRICS[metric]["weight"], "reason": reason}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_entry(metric: str, mad: float, bias: float, per_q: list,
|
||||||
|
n_recent: int, n_base: int, freq: dict | None) -> dict:
|
||||||
|
"""Assemble a scored metric entry from its divergence magnitude/direction — the
|
||||||
|
one place the entry shape is defined, shared by the seasonal and annual paths."""
|
||||||
|
score = score_of(mad)
|
||||||
|
tier, css = tier_of(score, bias)
|
||||||
|
direction = _direction(metric, bias)
|
||||||
|
entry = {
|
||||||
|
"key": metric, "label": METRICS[metric]["label"],
|
||||||
|
"score": score, "mad": round(mad, 1), "bias": round(bias, 1),
|
||||||
|
"tier": tier, "class": css, "direction": direction,
|
||||||
|
"grade": tier if score < 15 else f"{tier}, {direction}",
|
||||||
|
"weight": METRICS[metric]["weight"], "per_q": per_q,
|
||||||
|
"n_recent": n_recent, "n_base": n_base,
|
||||||
|
}
|
||||||
|
if freq is not None:
|
||||||
|
entry["freq"] = freq
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def _metric_entry(metric: str, base: np.ndarray, rec: np.ndarray) -> dict:
|
def _metric_entry(metric: str, base: np.ndarray, rec: np.ndarray) -> dict:
|
||||||
div = precip_divergence(base, rec) if metric == "precip" else divergence(base, rec)
|
div = precip_divergence(base, rec) if metric == "precip" else divergence(base, rec)
|
||||||
if div is None:
|
if div is None:
|
||||||
return _null_entry(metric, "not enough recent data")
|
return _null_entry(metric, "not enough recent data")
|
||||||
score = score_of(div["mad"])
|
return _build_entry(metric, div["mad"], div["bias"], div["per_q"],
|
||||||
tier, css = tier_of(score, div["bias"])
|
int(rec.size), int(base.size), _freq_for(metric, base, rec))
|
||||||
direction = _direction(metric, div["bias"])
|
|
||||||
entry = {
|
|
||||||
"key": metric, "label": METRIC_LABELS[metric],
|
|
||||||
"score": score, "mad": div["mad"], "bias": div["bias"],
|
|
||||||
"tier": tier, "class": css, "direction": direction,
|
|
||||||
"grade": tier if score < 15 else f"{tier}, {direction}",
|
|
||||||
"weight": WEIGHTS[metric], "per_q": div["per_q"],
|
|
||||||
"n_recent": int(rec.size), "n_base": int(base.size),
|
|
||||||
}
|
|
||||||
if "freq" in div:
|
|
||||||
entry["freq"] = div["freq"]
|
|
||||||
if metric == "wetbulb":
|
|
||||||
# How often the peak wet bulb reaches heat-stress levels — a "wet-bulb day"
|
|
||||||
# vs a normal one — recent share vs the full record.
|
|
||||||
entry["freq"] = _stress_freq(base, rec, WETBULB_STRESS_F)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def _stress_freq(base: np.ndarray, rec: np.ndarray, thr: float) -> dict:
|
|
||||||
"""Share of days at or above ``thr`` — recent vs baseline, in percentage
|
|
||||||
points. The rest of the days are 'normal'."""
|
|
||||||
f6 = float(np.mean(rec >= thr)) * 100.0
|
|
||||||
f45 = float(np.mean(base >= thr)) * 100.0
|
|
||||||
return {"f6": round(f6, 1), "f45": round(f45, 1),
|
|
||||||
"d": round(f6 - f45, 1), "threshold_f": thr}
|
|
||||||
|
|
||||||
|
|
||||||
def _overall(entries: dict) -> dict | None:
|
def _overall(entries: dict) -> dict | None:
|
||||||
"""Weighted roll-up of the present metrics in one slice. Weights renormalize
|
"""Weighted roll-up of the present metrics in one slice. Weights renormalize
|
||||||
over whatever is present (a metric missing for the source drops out cleanly).
|
over whatever is present (a metric missing for the source drops out cleanly).
|
||||||
|
|
||||||
The total reads as a direction-agnostic NET CHANGE: it is colored by magnitude
|
The total reads as a direction-agnostic NET CHANGE: colored by magnitude on the
|
||||||
on the intensity ramp and labeled by tier alone, not 'warmer/cooler' — the
|
intensity ramp and labeled by tier alone, not 'warmer/cooler' — the per-metric
|
||||||
per-metric cards still carry direction. A signed ``bias`` (from the
|
cards carry direction."""
|
||||||
temperature-direction metrics) stays in the payload for reference."""
|
|
||||||
present = [e for e in entries.values() if e.get("score") is not None]
|
present = [e for e in entries.values() if e.get("score") is not None]
|
||||||
if not present:
|
if not present:
|
||||||
return None
|
return None
|
||||||
wsum = sum(e["weight"] for e in present)
|
wsum = sum(e["weight"] for e in present)
|
||||||
mad = sum(e["weight"] * e["mad"] for e in present) / wsum
|
mad = sum(e["weight"] * e["mad"] for e in present) / wsum
|
||||||
dirs = [e for e in present if e["key"] in TEMP_DIR_METRICS]
|
|
||||||
bias = (sum(e["weight"] * e["bias"] for e in dirs) / sum(e["weight"] for e in dirs)
|
|
||||||
if dirs else 0.0)
|
|
||||||
score = score_of(mad)
|
score = score_of(mad)
|
||||||
tier, css = tier_of(score, 1.0) # magnitude only — net change, not a direction
|
tier, css = tier_of(score, 1.0) # magnitude only — net change, not a direction
|
||||||
return {"score": score, "mad": round(mad, 1), "bias": round(bias, 1),
|
return {"score": score, "mad": round(mad, 1), "tier": tier, "class": css,
|
||||||
"tier": tier, "class": css, "grade": tier, "descriptor": "net change"}
|
"grade": tier, "descriptor": "net change"}
|
||||||
|
|
||||||
|
|
||||||
def _slice_entry(metric: str, history: pl.DataFrame, base_s: pl.DataFrame,
|
def _slice_entry(metric: str, history: pl.DataFrame, base_s: pl.DataFrame,
|
||||||
|
|
@ -243,9 +236,9 @@ def _annual_entry(metric: str, seasonal: list, history: pl.DataFrame,
|
||||||
differentials rather than from an annually-pooled distribution. Pooling all
|
differentials rather than from an annually-pooled distribution. Pooling all
|
||||||
days together widens the reference spread and hides a shift confined to one
|
days together widens the reference spread and hides a shift confined to one
|
||||||
season (a hot-summer trend barely moves the all-year percentiles); averaging
|
season (a hot-summer trend barely moves the all-year percentiles); averaging
|
||||||
the four seasons' divergences keeps it visible. Frequency read-outs
|
the four seasons' divergences keeps it visible. Frequency read-outs (precip wet
|
||||||
(precip wet days, wet-bulb heat-stress days) are day counts, so those stay
|
days, wet-bulb heat-stress days) are day counts, so those stay pooled over the
|
||||||
pooled over the whole year."""
|
whole year."""
|
||||||
if metric not in history.columns:
|
if metric not in history.columns:
|
||||||
return _null_entry(metric, "not available for this location")
|
return _null_entry(metric, "not available for this location")
|
||||||
present = [e for e in seasonal if e.get("score") is not None]
|
present = [e for e in seasonal if e.get("score") is not None]
|
||||||
|
|
@ -253,31 +246,17 @@ def _annual_entry(metric: str, seasonal: list, history: pl.DataFrame,
|
||||||
return _null_entry(metric, "not enough recent data")
|
return _null_entry(metric, "not enough recent data")
|
||||||
mad = sum(e["mad"] for e in present) / len(present)
|
mad = sum(e["mad"] for e in present) / len(present)
|
||||||
bias = sum(e["bias"] for e in present) / len(present)
|
bias = sum(e["bias"] for e in present) / len(present)
|
||||||
score = score_of(mad)
|
|
||||||
tier, css = tier_of(score, bias)
|
|
||||||
direction = _direction(metric, bias)
|
|
||||||
per_q = []
|
per_q = []
|
||||||
for q in QS:
|
for q in QS:
|
||||||
ds = [pq["d"] for e in present for pq in e.get("per_q", []) if pq["q"] == q]
|
ds = [pq["d"] for e in present for pq in e["per_q"] if pq["q"] == q]
|
||||||
if ds:
|
if ds:
|
||||||
per_q.append({"q": q, "d": round(sum(ds) / len(ds), 1)})
|
per_q.append({"q": q, "d": round(sum(ds) / len(ds), 1)})
|
||||||
entry = {
|
freq = None
|
||||||
"key": metric, "label": METRIC_LABELS[metric],
|
if metric in ("precip", "wetbulb"):
|
||||||
"score": score, "mad": round(mad, 1), "bias": round(bias, 1),
|
freq = _freq_for(metric, grading._finite(baseline[metric]), grading._finite(recent[metric]))
|
||||||
"tier": tier, "class": css, "direction": direction,
|
return _build_entry(metric, mad, bias, per_q,
|
||||||
"grade": tier if score < 15 else f"{tier}, {direction}",
|
sum(e["n_recent"] for e in present),
|
||||||
"weight": WEIGHTS[metric], "per_q": per_q,
|
sum(e["n_base"] for e in present), freq)
|
||||||
"n_recent": sum(e["n_recent"] for e in present),
|
|
||||||
"n_base": sum(e["n_base"] for e in present),
|
|
||||||
}
|
|
||||||
if metric == "precip":
|
|
||||||
div = precip_divergence(grading._finite(baseline["precip"]), grading._finite(recent["precip"]))
|
|
||||||
if div:
|
|
||||||
entry["freq"] = div["freq"]
|
|
||||||
elif metric == "wetbulb":
|
|
||||||
entry["freq"] = _stress_freq(grading._finite(baseline["wetbulb"]),
|
|
||||||
grading._finite(recent["wetbulb"]), WETBULB_STRESS_F)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def build_scores(history: pl.DataFrame) -> dict:
|
def build_scores(history: pl.DataFrame) -> dict:
|
||||||
|
|
@ -293,16 +272,14 @@ def build_scores(history: pl.DataFrame) -> dict:
|
||||||
|
|
||||||
# Each season scored on its own distribution first…
|
# Each season scored on its own distribution first…
|
||||||
slices = {}
|
slices = {}
|
||||||
for key in SEASONS:
|
for season in SEASONS:
|
||||||
base_s, rec_s = _slice(baseline, key), _slice(recent, key)
|
base_s, rec_s = _slice(baseline, season), _slice(recent, season)
|
||||||
entries = {m: _slice_entry(m, history, base_s, rec_s) for m in SCORE_METRICS}
|
entries = {m: _slice_entry(m, history, base_s, rec_s) for m in SCORE_METRICS}
|
||||||
slices[key] = {"overall": _overall(entries), "metrics": entries}
|
slices[season] = {"overall": _overall(entries), "metrics": entries}
|
||||||
|
|
||||||
# …then the annual headline is the average of those seasonal differentials.
|
# …then the annual headline is the average of those seasonal differentials.
|
||||||
annual = {}
|
annual = {m: _annual_entry(m, [slices[s]["metrics"][m] for s in SEASONS],
|
||||||
for m in SCORE_METRICS:
|
history, baseline, recent) for m in SCORE_METRICS}
|
||||||
seasonal = [slices[k]["metrics"][m] for k in SEASONS]
|
|
||||||
annual[m] = _annual_entry(m, seasonal, history, baseline, recent)
|
|
||||||
slices = {"annual": {"overall": _overall(annual), "metrics": annual}, **slices}
|
slices = {"annual": {"overall": _overall(annual), "metrics": annual}, **slices}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
2
views.py
2
views.py
|
|
@ -108,7 +108,7 @@ def forecast_key(today, days: int) -> str:
|
||||||
# is the plain `history_token` (expires when the archive tail advances). The key
|
# is the plain `history_token` (expires when the archive tail advances). The key
|
||||||
# just carries the scoring-math version, so a math change invalidates only score
|
# just carries the scoring-math version, so a math change invalidates only score
|
||||||
# rows without disturbing any other kind.
|
# rows without disturbing any other kind.
|
||||||
SCORE_VER = "s3"
|
SCORE_VER = "s4"
|
||||||
|
|
||||||
|
|
||||||
def score_key() -> str:
|
def score_key() -> str:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue