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
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:
commit
669e52a0d9
7 changed files with 63 additions and 30 deletions
|
|
@ -279,8 +279,10 @@ def all_time_records(df: pl.DataFrame) -> dict:
|
|||
|
||||
|
||||
def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]:
|
||||
"""Longest run of consecutive days without measurable rain, and the ISO date the
|
||||
streak began. A dry day is precip < RAIN_THRESHOLD; null precip counts as dry."""
|
||||
"""Longest run of consecutive days without any rain, and the ISO date the streak
|
||||
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:
|
||||
return (0, None)
|
||||
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
|
||||
cur_len, cur_start = 0, None
|
||||
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:
|
||||
cur_len, cur_start = 0, None
|
||||
else:
|
||||
|
|
@ -305,11 +307,12 @@ def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]:
|
|||
def _band_stats(samples: np.ndarray) -> dict | None:
|
||||
if samples.size == 0:
|
||||
return None
|
||||
# Percentiles for the chart's nested "normal" fan, matching the 9 tier bounds:
|
||||
# p40-p60 is the Normal band; p25/p75, p10/p90 and p1/p99 mark the successive
|
||||
# Below/Above Normal, Low/High, Very Low/High and Near-Record edges.
|
||||
p1, p10, p25, p40, p50, p60, p75, p90, p99 = np.percentile(
|
||||
samples, [1, 10, 25, 40, 50, 60, 75, 90, 99]
|
||||
# Percentiles for the chart's nested "normal" fan. p40-p60 is the Normal band;
|
||||
# p25/p75, p10/p90 and p1/p99 mark the successive Below/Above Normal, Low/High,
|
||||
# Very Low/High and Near-Record edges. p95 additionally splits the rain fan's top
|
||||
# region into Very Heavy (90-95) and Severe (95-99); unused by the temperature fan.
|
||||
p1, p10, p25, p40, p50, p60, p75, p90, p95, p99 = np.percentile(
|
||||
samples, [1, 10, 25, 40, 50, 60, 75, 90, 95, 99]
|
||||
)
|
||||
return {
|
||||
"p1": round(float(p1), 1),
|
||||
|
|
@ -320,6 +323,7 @@ def _band_stats(samples: np.ndarray) -> dict | None:
|
|||
"p60": round(float(p60), 1),
|
||||
"p75": round(float(p75), 1),
|
||||
"p90": round(float(p90), 1),
|
||||
"p95": round(float(p95), 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]:
|
||||
"""Map each date (ISO string) to days since the last measurable rain, walking a
|
||||
chronological precip series. Missing precip counts as a dry day. `dates` is an
|
||||
iterable of ``datetime.date`` (a polars Date column's ``.to_list()``)."""
|
||||
"""Map each date (ISO string) to days since the last day with any rain, walking a
|
||||
chronological precip series. Any measurable rain (precip > 0) resets the count, so
|
||||
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] = {}
|
||||
streak = 0
|
||||
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
|
||||
out[_as_date(d).isoformat()] = streak
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ def test_dry_streaks_walk():
|
|||
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]
|
||||
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 --------------------------------
|
||||
|
|
@ -141,7 +143,7 @@ def test_grade_day_normals_and_departure(history):
|
|||
result = grading.grade_day(history, target, {"tmax": row["tmax"], "tmin": row["tmin"],
|
||||
"precip": row["precip"]})
|
||||
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))
|
||||
assert result["departure"] == round(expected, 1)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
|
|||
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
|
||||
import { track } from "./digest.js";
|
||||
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}
|
||||
|
||||
|
|
@ -323,11 +323,14 @@ function precipCell(d, isFc, isToday) {
|
|||
const dd = ` data-date="${d.date}"`;
|
||||
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
|
||||
if (!g) return `<span class="${cls}"${dd}>—</span>`;
|
||||
if (g.value === 0 && d.dsr != null && d.dsr > 0) {
|
||||
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since measurable rain">${d.dsr}d</span>`;
|
||||
// Only a genuinely dry day (no rain at all) labels the streak; any rain day —
|
||||
// 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] || "";
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { uv } from "./account.js"; // header sign-in entry + notification bell
|
|||
import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
|
||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||
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,
|
||||
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
|
||||
applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths,
|
||||
|
|
@ -42,7 +42,7 @@ const SKY_WORDS = [
|
|||
// 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").
|
||||
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 data = null; // last /api/v2/calendar response
|
||||
|
|
@ -704,7 +704,7 @@ function attachHover(byDate) {
|
|||
["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))],
|
||||
["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))],
|
||||
["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) {
|
||||
const rc = metric === "dsr" ? " tt-r-active" : "";
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const PCT_FALLBACK = {
|
|||
p1: ["p1", "p10", "min", "p50"], p10: ["p10", "p50"],
|
||||
p25: ["p25", "p30", "p50"], p40: ["p40", "p30", "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) => {
|
||||
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"],
|
||||
["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 = [
|
||||
["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"],
|
||||
];
|
||||
|
||||
|
|
@ -212,7 +216,7 @@ export function precipChart(days, n, xFor, C) {
|
|||
getPct: (d) => (d.precip ? d.precip.percentile : null),
|
||||
// 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.
|
||||
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),
|
||||
fmtLabel: (v) => fmtPrecip(v, false),
|
||||
labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline
|
||||
|
|
|
|||
|
|
@ -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 { getJSON, TTL, prefetchViews } from "./cache.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";
|
||||
|
||||
// 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).
|
||||
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 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 = `
|
||||
<h2>${nice}</h2>
|
||||
${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 "";
|
||||
const obs = m.obs;
|
||||
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;
|
||||
if (obs) {
|
||||
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>`;
|
||||
} else {
|
||||
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 val = fmtRange(fmt, t.lo, t.hi);
|
||||
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}">
|
||||
<span class="ladder-sw" style="background:${tierColor(t.c)}"></span>
|
||||
<span class="ladder-label" style="--cat:${tierColor(t.c)}">${t.label}</span>
|
||||
|
|
|
|||
|
|
@ -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"/>`),
|
||||
};
|
||||
|
||||
// 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
|
||||
// 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:
|
||||
// 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).
|
||||
export function weatherType(t, p, dsr) {
|
||||
const wet = p != null && p >= 0.01;
|
||||
// `precipCls` is the precip grade class when known: a rain tier means it rained
|
||||
// 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 temp = t == null ? "" :
|
||||
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
|
||||
|
|
|
|||
Loading…
Reference in a new issue