fix(climato): fiabiliser les jours récents à couverture station partielle
Some checks failed
Deploy info-canicule / deploy (push) Failing after 4s

Les 1-2 derniers jours du flux climato `latest` ont souvent 0-1 station
publiée (parfois TN sans TX), produisant des agrégats département nuls ou
non représentatifs. Symptômes côté page dept : TX/TN vides ou tx==tn à
J-1/J-2 sur le graphe, et moyenne 3j/7j figée (computeAnomaly lisait
climato.days brut et retombait sur les mêmes vieux jours complets).

- climato.ts : exposer la couverture réelle par champ (txN/tnN, optionnels,
  rétro-compatibles avec le cache 24h).
- [code].astro : `_mergedDay` choisit par champ la source fiable —
  climato si couverture >= 50% de la médiane station du dept, sinon SYNOP
  (garde >= 3 obs/jour pour éviter tx==tn), sinon valeur fine, sinon null.
  Le badge anomalie utilise désormais les 7 derniers jours complets
  (finissant J-1) au lieu de climato.days brut, recollant graphe et badge.

Vérifié par simulation sur données réelles (depts 31/75/13) : jours pleins
inchangés (zéro régression), jours récents partiels comblés proprement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-30 16:04:34 +02:00
parent e247005d34
commit 9db5d4c204
3 changed files with 45 additions and 15 deletions

View file

@ -54,14 +54,25 @@ let normales30: Array<{ tx: number | null; tn: number | null }> = [];
let normales365: Array<{ tx: number | null; tn: number | null }> = [];
let normaleHourly: { tx: number | null; tn: number | null } | null = null;
// Build calendar-based windows ending at today (Paris time), filling missing days with nulls.
// Source data has a ~1-4 day publication lag; the chart must still end at today.
// Build calendar-based windows ending at today (Paris time).
// Source data has a ~1-4 day publication lag AND the most recent published day(s) often have
// drastically thin station coverage (0-1 station, sometimes TX missing) — so a per-day dept
// aggregate is only trustworthy when enough stations reported. Below that threshold we prefer
// the SYNOP nearest-station window, which is consistent day-to-day. See plan/commit message.
const _todayParis = new Intl.DateTimeFormat('sv', { timeZone: 'Europe/Paris' }).format(new Date());
const _climaByDate = new Map((climato?.days ?? []).map((d) => [d.date, d]));
// Derive daily TX/TN from the 48h SYNOP window to fill the climato publication gap (typically 1-2 days).
// TX = max hourly t, TN = min hourly t — approximation acceptable for gap-filling.
const _synopDaily = new Map<string, { tx: number | null; tn: number | null }>();
// Minimum coverage for a climato day to be trusted: half the dept's typical station count
// (median over days that reported anything), floored at 2.
const _statCounts = (climato?.days ?? []).map((d) => d.stations).filter((n) => n > 0).sort((a, b) => a - b);
const _typicalStations = _statCounts.length ? _statCounts[Math.floor(_statCounts.length / 2)] : 0;
const _minCoverage = Math.max(2, Math.ceil(_typicalStations * 0.5));
// Derive daily TX/TN from the 48h SYNOP window to fill the climato gap (typically 1-2 days).
// TX = max hourly t, TN = min hourly t. Require >= MIN_SYNOP_OBS observations: a day with a single
// observation would yield tx == tn (a degenerate, misleading point).
const MIN_SYNOP_OBS = 3;
const _synopDaily = new Map<string, { tx: number; tn: number }>();
if (hourly) {
const _byDate = new Map<string, number[]>();
for (const obs of hourly.observations) {
@ -71,22 +82,34 @@ if (hourly) {
_byDate.get(d)!.push(obs.t);
}
for (const [date, temps] of _byDate) {
if (temps.length < MIN_SYNOP_OBS) continue;
_synopDaily.set(date, { tx: +Math.max(...temps).toFixed(1), tn: +Math.min(...temps).toFixed(1) });
}
}
function _buildRange(n: number) {
// Per-field preference for a given calendar date:
// 1. reliable climato (enough stations + non-null) → dept aggregate (best);
// 2. else SYNOP-derived value (consistent nearby station);
// 3. else thin climato value (better than nothing when no SYNOP);
// 4. else null (honest gap).
function _mergedDay(dateStr: string) {
const c = _climaByDate.get(dateStr);
const s = _synopDaily.get(dateStr);
const txReliable = !!c && c.tx !== null && (c.txN ?? c.stations ?? 0) >= _minCoverage;
const tnReliable = !!c && c.tn !== null && (c.tnN ?? c.stations ?? 0) >= _minCoverage;
const tx = txReliable ? c!.tx : (s?.tx ?? c?.tx ?? null);
const tn = tnReliable ? c!.tn : (s?.tn ?? c?.tn ?? null);
return { date: dateStr, tx, tn, tm: c?.tm ?? null, rr: c?.rr ?? null, stations: c?.stations ?? 0 };
}
// n days ending today (endOffset 0) or ending yesterday (endOffset 1, for complete-day windows).
function _buildRange(n: number, endOffset = 0) {
const result = [];
for (let i = n - 1; i >= 0; i--) {
for (let i = n - 1 + endOffset; i >= endOffset; i--) {
const d = new Date(_todayParis + 'T12:00:00Z'); // noon UTC to avoid DST drift
d.setUTCDate(d.getUTCDate() - i);
const dateStr = new Intl.DateTimeFormat('sv', { timeZone: 'Europe/Paris' }).format(d);
if (_climaByDate.has(dateStr)) {
result.push(_climaByDate.get(dateStr)!);
} else {
const synop = _synopDaily.get(dateStr);
result.push({ date: dateStr, tn: synop?.tn ?? null, tx: synop?.tx ?? null, tm: null, rr: null, stations: 0 });
}
result.push(_mergedDay(dateStr));
}
return result;
}
@ -96,7 +119,9 @@ const last365 = climato ? _buildRange(365) : [];
if (!drom) {
if (climato?.days?.length) {
anomaly = await computeAnomaly(dept.code, climato.days);
// Anomaly over the same calendar windows as the chart, ending YESTERDAY (today is incomplete).
// computeAnomaly re-slices internally for the 3-day / 7-day sub-windows.
anomaly = await computeAnomaly(dept.code, _buildRange(7, 1));
}
const [s7, s30, s365] = await Promise.all([
normalesForRange(dept.code, last7.map((d) => d.date)),