Merge remote-tracking branch 'forgejo/qa/dry-trace' into qa/frontend-qa-batch
All checks were successful
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 58s
PR build (required check) / build-backend (pull_request) Successful in 1m16s
PR build (required check) / gate (pull_request) Successful in 2s

# Conflicts:
#	frontend/static/day.js
This commit is contained in:
Emi Griffith 2026-07-24 16:10:39 -07:00
commit 669e52a0d9
7 changed files with 63 additions and 30 deletions

View file

@ -279,8 +279,10 @@ def all_time_records(df: pl.DataFrame) -> dict:
def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]: def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]:
"""Longest run of consecutive days without measurable rain, and the ISO date the """Longest run of consecutive days without any rain, and the ISO date the streak
streak began. A dry day is precip < RAIN_THRESHOLD; null precip counts as dry.""" began. A dry day has no rain at all (precip <= 0); null precip counts as dry. The
threshold matches _grade_precip's dry/rain split — any measurable rain, however
slight, is a rain day that breaks the streak (not the 0.01" rain-frequency line)."""
if "precip" not in df.columns: if "precip" not in df.columns:
return (0, None) return (0, None)
d = df.select(["date", "precip"]).sort("date") d = df.select(["date", "precip"]).sort("date")
@ -288,7 +290,7 @@ def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]:
best_len, best_start = 0, None best_len, best_start = 0, None
cur_len, cur_start = 0, None cur_len, cur_start = 0, None
for dt, p in zip(dates, precips): for dt, p in zip(dates, precips):
wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p >= RAIN_THRESHOLD wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p > 0
if wet: if wet:
cur_len, cur_start = 0, None cur_len, cur_start = 0, None
else: else:
@ -305,11 +307,12 @@ def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]:
def _band_stats(samples: np.ndarray) -> dict | None: def _band_stats(samples: np.ndarray) -> dict | None:
if samples.size == 0: if samples.size == 0:
return None return None
# Percentiles for the chart's nested "normal" fan, matching the 9 tier bounds: # Percentiles for the chart's nested "normal" fan. p40-p60 is the Normal band;
# p40-p60 is the Normal band; p25/p75, p10/p90 and p1/p99 mark the successive # p25/p75, p10/p90 and p1/p99 mark the successive Below/Above Normal, Low/High,
# Below/Above Normal, Low/High, Very Low/High and Near-Record edges. # Very Low/High and Near-Record edges. p95 additionally splits the rain fan's top
p1, p10, p25, p40, p50, p60, p75, p90, p99 = np.percentile( # region into Very Heavy (90-95) and Severe (95-99); unused by the temperature fan.
samples, [1, 10, 25, 40, 50, 60, 75, 90, 99] p1, p10, p25, p40, p50, p60, p75, p90, p95, p99 = np.percentile(
samples, [1, 10, 25, 40, 50, 60, 75, 90, 95, 99]
) )
return { return {
"p1": round(float(p1), 1), "p1": round(float(p1), 1),
@ -320,6 +323,7 @@ def _band_stats(samples: np.ndarray) -> dict | None:
"p60": round(float(p60), 1), "p60": round(float(p60), 1),
"p75": round(float(p75), 1), "p75": round(float(p75), 1),
"p90": round(float(p90), 1), "p90": round(float(p90), 1),
"p95": round(float(p95), 1),
"p99": round(float(p99), 1), "p99": round(float(p99), 1),
} }
@ -352,13 +356,14 @@ def _grade_precip(samples: np.ndarray, value) -> dict | None:
def dry_streaks(dates, precips) -> dict[str, int]: def dry_streaks(dates, precips) -> dict[str, int]:
"""Map each date (ISO string) to days since the last measurable rain, walking a """Map each date (ISO string) to days since the last day with any rain, walking a
chronological precip series. Missing precip counts as a dry day. `dates` is an chronological precip series. Any measurable rain (precip > 0) resets the count, so
iterable of ``datetime.date`` (a polars Date column's ``.to_list()``).""" this matches _grade_precip's dry/rain split; missing precip counts as a dry day.
`dates` is an iterable of ``datetime.date`` (a polars Date column's ``.to_list()``)."""
out: dict[str, int] = {} out: dict[str, int] = {}
streak = 0 streak = 0
for d, p in zip(dates, precips): for d, p in zip(dates, precips):
wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p >= RAIN_THRESHOLD wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p > 0
streak = 0 if wet else streak + 1 streak = 0 if wet else streak + 1
out[_as_date(d).isoformat()] = streak out[_as_date(d).isoformat()] = streak
return out return out

View file

@ -113,7 +113,9 @@ def test_dry_streaks_walk():
dates = [datetime.date(2024, 1, 1) + datetime.timedelta(days=i) for i in range(5)] dates = [datetime.date(2024, 1, 1) + datetime.timedelta(days=i) for i in range(5)]
precips = [0.5, 0.0, float("nan"), 0.02, 0.005] precips = [0.5, 0.0, float("nan"), 0.02, 0.005]
out = grading.dry_streaks(dates, precips) out = grading.dry_streaks(dates, precips)
assert list(out.values()) == [0, 1, 2, 0, 1] # NaN counts as dry # Any rain > 0 breaks the streak (matching the dry/rain grading split), so the
# 0.005" trace day resets to 0; only exact 0.0 and NaN count as dry.
assert list(out.values()) == [0, 1, 2, 0, 0]
# ---- range + day grading over a synthetic record -------------------------------- # ---- range + day grading over a synthetic record --------------------------------
@ -141,7 +143,7 @@ def test_grade_day_normals_and_departure(history):
result = grading.grade_day(history, target, {"tmax": row["tmax"], "tmin": row["tmin"], result = grading.grade_day(history, target, {"tmax": row["tmax"], "tmin": row["tmin"],
"precip": row["precip"]}) "precip": row["precip"]})
assert set(result["normals"]["tmax"]) == {"p1", "p10", "p25", "p40", "p50", "p60", assert set(result["normals"]["tmax"]) == {"p1", "p10", "p25", "p40", "p50", "p60",
"p75", "p90", "p99"} "p75", "p90", "p95", "p99"}
expected = max(abs(result["tmax"]["percentile"] - 50), abs(result["tmin"]["percentile"] - 50)) expected = max(abs(result["tmax"]["percentile"] - 50), abs(result["tmin"]["percentile"] - 50))
assert result["departure"] == round(expected, 1) assert result["departure"] == round(expected, 1)

View file

@ -8,7 +8,7 @@ import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js"; tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
import { track } from "./digest.js"; import { track } from "./digest.js";
import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel, import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel,
tierKeySegs, GUIDE_LINK, fmtPrecip, fmtWind, fmtHumid } from "./shared.js"; tierKeySegs, GUIDE_LINK, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid } from "./shared.js";
let selected = null; // {lat, lon} let selected = null; // {lat, lon}
@ -323,11 +323,14 @@ function precipCell(d, isFc, isToday) {
const dd = ` data-date="${d.date}"`; const dd = ` data-date="${d.date}"`;
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : ""); const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
if (!g) return `<span class="${cls}"${dd}>—</span>`; if (!g) return `<span class="${cls}"${dd}>—</span>`;
if (g.value === 0 && d.dsr != null && d.dsr > 0) { // Only a genuinely dry day (no rain at all) labels the streak; any rain day —
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since measurable rain">${d.dsr}d</span>`; // down to sub-0.01" trace that rounds to 0 — shows its depth, tinted by tier.
if (g.class === "dry" && d.dsr != null && d.dsr > 0) {
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since rain">${d.dsr}d</span>`;
} }
const col = TIER_COLORS[g.class] || ""; const col = TIER_COLORS[g.class] || "";
return `<span class="${cls}"${dd} style="--c:${col}">${cRain(g.value)}</span>`; const amt = g.class && g.class !== "dry" ? fmtPrecipTier(g.value, g.class, false) : cRain(g.value);
return `<span class="${cls}"${dd} style="--c:${col}">${amt}</span>`;
} }
// Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first). // Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first).

View file

@ -7,7 +7,7 @@ import { uv } from "./account.js"; // header sign-in entry + notification bell
import { TTL, chunkedFetch, prefetchViews } from "./cache.js"; import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js"; import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd, import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd,
placeLabel, fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd, placeLabel, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip, CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks, seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths, applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths,
@ -42,7 +42,7 @@ const SKY_WORDS = [
// The shared weather summary, adapted to the calendar's compact day records // The shared weather summary, adapted to the calendar's compact day records
// (dsr included, so a long-dry no-rain day reads "dry" rather than "clear"). // (dsr included, so a long-dry no-rain day reads "dry" rather than "clear").
const weatherType = (rec) => const weatherType = (rec) =>
wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr); wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr, rec.precip && rec.precip.c);
let selected = null; // {lat, lon} let selected = null; // {lat, lon}
let data = null; // last /api/v2/calendar response let data = null; // last /api/v2/calendar response
@ -704,7 +704,7 @@ function attachHover(byDate) {
["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))], ["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))],
["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))], ["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))],
["gust", line("gust", "Gust", rec.gust, fmtWind, tempColor(rec.gust))], ["gust", line("gust", "Gust", rec.gust, fmtWind, tempColor(rec.gust))],
["precip", line("precip", "Precip", rec.precip, fmtPrecip, precipColor)], ["precip", line("precip", "Precip", rec.precip, (v) => fmtPrecipTier(v, rec.precip && rec.precip.c), precipColor)],
]; ];
if (dsrStr) { if (dsrStr) {
const rc = metric === "dsr" ? " tt-r-active" : ""; const rc = metric === "dsr" ? " tt-r-active" : "";

View file

@ -64,7 +64,7 @@ const PCT_FALLBACK = {
p1: ["p1", "p10", "min", "p50"], p10: ["p10", "p50"], p1: ["p1", "p10", "min", "p50"], p10: ["p10", "p50"],
p25: ["p25", "p30", "p50"], p40: ["p40", "p30", "p50"], p25: ["p25", "p30", "p50"], p40: ["p40", "p30", "p50"],
p60: ["p60", "p70", "p50"], p75: ["p75", "p70", "p50"], p60: ["p60", "p70", "p50"], p75: ["p75", "p70", "p50"],
p90: ["p90", "p50"], p99: ["p99", "p90", "max", "p50"], p90: ["p90", "p50"], p95: ["p95", "p90", "p50"], p99: ["p99", "p90", "max", "p50"],
}; };
const pget = (o, k) => { const pget = (o, k) => {
for (const kk of (PCT_FALLBACK[k] || [k])) if (o && o[kk] != null) return o[kk]; for (const kk of (PCT_FALLBACK[k] || [k])) if (o && o[kk] != null) return o[kk];
@ -93,8 +93,12 @@ const TEMP_FAN = [
["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"], ["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"],
["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"], ["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"],
]; ];
// Eight rain-intensity tiers (post-split): Trace / Light / Brisk / Typical / Heavy
// (60-90) / Very Heavy (90-95) / Severe (95-99) / Extreme. Each band maps a rain-day
// percentile range to its calendar tier colour; the p75 vertex inside the single
// Heavy tier keeps the fan following the p75 contour.
const RAIN_FAN = [ const RAIN_FAN = [
["p90", "p99", "wet-8"], ["p75", "p90", "wet-7"], ["p60", "p75", "wet-6"], ["p95", "p99", "wet-8"], ["p90", "p95", "wet-7"], ["p75", "p90", "wet-6"], ["p60", "p75", "wet-6"],
["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"], ["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"],
]; ];
@ -212,7 +216,7 @@ export function precipChart(days, n, xFor, C) {
getPct: (d) => (d.precip ? d.precip.percentile : null), getPct: (d) => (d.precip ? d.precip.percentile : null),
// Rain days take their intensity-tier color; no-rain days warm with the dry // Rain days take their intensity-tier color; no-rain days warm with the dry
// streak (tan→red) so a dry spell reads as dry, matching the Dry chart. // streak (tan→red) so a dry spell reads as dry, matching the Dry chart.
dotColor: (d) => (d.precip && d.precip.value > 0 ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)), dotColor: (d) => (d.precip && d.precip.class && d.precip.class !== "dry" ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)),
fmtAxis: (v) => fmtPrecip(v, false), fmtAxis: (v) => fmtPrecip(v, false),
fmtLabel: (v) => fmtPrecip(v, false), fmtLabel: (v) => fmtPrecip(v, false),
labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline

View file

@ -6,7 +6,7 @@ import { fmtTemp, onUnitChange } from "./units.js";
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
import { getJSON, TTL, prefetchViews } from "./cache.js"; import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js"; import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtWind, fmtHumid, import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid,
todayISO, isoOfDate, weatherType, placeLabel } from "./shared.js"; todayISO, isoOfDate, weatherType, placeLabel } from "./shared.js";
// Color a tier the same way the calendar cell would be colored for it. // Color a tier the same way the calendar cell would be colored for it.
@ -117,7 +117,8 @@ function render(data) {
// Weather summary from the observed high + precip (omitted for days with no obs yet). // Weather summary from the observed high + precip (omitted for days with no obs yet).
const th = d.metrics.tmax.obs ? d.metrics.tmax.obs.value : null; const th = d.metrics.tmax.obs ? d.metrics.tmax.obs.value : null;
const pr = d.metrics.precip.obs ? d.metrics.precip.obs.value : null; const pr = d.metrics.precip.obs ? d.metrics.precip.obs.value : null;
const wt = th != null || pr != null ? weatherType(th, pr) : null; const prCls = d.metrics.precip.obs ? d.metrics.precip.obs.class : null;
const wt = th != null || pr != null ? weatherType(th, pr, undefined, prCls) : null;
dayHead.innerHTML = ` dayHead.innerHTML = `
<h2>${nice}</h2> <h2>${nice}</h2>
${wt ? `<p class="day-weather">${wt.icon} ${wt.text}</p>` : ""} ${wt ? `<p class="day-weather">${wt.icon} ${wt.text}</p>` : ""}
@ -157,11 +158,14 @@ function ladderCard(title, m, fmt, kind) {
if (!m || !m.ladder) return ""; if (!m || !m.ladder) return "";
const obs = m.obs; const obs = m.obs;
const activeClass = obs ? obs.class : null; const activeClass = obs ? obs.class : null;
// The observed precip depth reads "trace" for a sub-0.01" rain day (rounds to 0
// but is graded a rain tier); ladder tier ranges keep the plain numeric formatter.
const obsFmt = kind === "precip" ? ((v) => fmtPrecipTier(v, activeClass)) : fmt;
let summary; let summary;
if (obs) { if (obs) {
const pct = obs.percentile == null ? "" : ` · ${pctOrd(obs.percentile)} pct`; const pct = obs.percentile == null ? "" : ` · ${pctOrd(obs.percentile)} pct`;
summary = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${fmt(obs.value)}</span> summary = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${obsFmt(obs.value)}</span>
<span class="day-obs-meta">${obs.grade}${pct}</span>`; <span class="day-obs-meta">${obs.grade}${pct}</span>`;
} else { } else {
summary = `<span class="day-obs-meta">No observation for this day yet</span>`; summary = `<span class="day-obs-meta">No observation for this day yet</span>`;
@ -184,7 +188,7 @@ function ladderCard(title, m, fmt, kind) {
else if (t.lo == null) val = `< ${fmt(t.hi)}`; // bottom tier: strictly below p1 else if (t.lo == null) val = `< ${fmt(t.hi)}`; // bottom tier: strictly below p1
else val = fmtRange(fmt, t.lo, t.hi); else val = fmtRange(fmt, t.lo, t.hi);
const marker = t.c === activeClass && obs const marker = t.c === activeClass && obs
? `<span class="ladder-mark">◀ ${bareVal(fmt, obs.value)}</span>` : ""; ? `<span class="ladder-mark">◀ ${bareVal(obsFmt, obs.value)}</span>` : "";
return `<div class="ladder-row${active}"> return `<div class="ladder-row${active}">
<span class="ladder-sw" style="background:${tierColor(t.c)}"></span> <span class="ladder-sw" style="background:${tierColor(t.c)}"></span>
<span class="ladder-label" style="--cat:${tierColor(t.c)}">${t.label}</span> <span class="ladder-label" style="--cat:${tierColor(t.c)}">${t.label}</span>

View file

@ -467,13 +467,28 @@ export const WX_ICONS = {
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`), snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
}; };
// A graded precip depth for display, given its grade class. Reanalysis "trace"
// rain — any measurable precip below 0.01" — is graded as a rain tier but rounds to
// 0.00" / 0 mm, so its depth prints as "trace" rather than a bone-dry "0.00" that
// would contradict the rain grade (and the now-reset dry streak). Genuinely dry
// days (class "dry") and real, roundable depths format as usual.
export function fmtPrecipTier(v, cls, withUnit = true) {
if (cls && cls !== "dry" && v != null && parseFloat(fmtPrecip(v, false)) === 0)
return "trace";
return fmtPrecip(v, withUnit);
}
// Plain-language "what was the day like" descriptor from the raw values: a // Plain-language "what was the day like" descriptor from the raw values: a
// temperature word (from the daily high, °F) crossed with a sky/precip word // temperature word (from the daily high, °F) crossed with a sky/precip word
// (precip in inches — falling as snow at/below freezing). `dsr` is optional: // (precip in inches — falling as snow at/below freezing). `dsr` is optional:
// when a long dry streak is known, a no-rain day reads "dry" instead of // when a long dry streak is known, a no-rain day reads "dry" instead of
// "clear" (the calendar passes it; the day page doesn't track streaks). // "clear" (the calendar passes it; the day page doesn't track streaks).
export function weatherType(t, p, dsr) { // `precipCls` is the precip grade class when known: a rain tier means it rained
const wet = p != null && p >= 0.01; // even when the rounded depth reads 0 (trace), so it decides wet/dry ahead of the
// depth — matching the dry/rain grading split (precip > 0). Without it, any
// positive depth counts as wet.
export function weatherType(t, p, dsr, precipCls) {
const wet = precipCls != null ? precipCls !== "dry" : (p != null && p > 0);
const freezing = t != null && t <= 34; const freezing = t != null && t <= 34;
const temp = t == null ? "" : const temp = t == null ? "" :
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" : t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :