From 9db5d4c2041f6810e1e50b76441aa2d434f85f85 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 30 May 2026 16:04:34 +0200 Subject: [PATCH] =?UTF-8?q?fix(climato):=20fiabiliser=20les=20jours=20r?= =?UTF-8?q?=C3=A9cents=20=C3=A0=20couverture=20station=20partielle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 1 + src/lib/climato.ts | 6 +++- src/pages/departement/[code].astro | 53 ++++++++++++++++++++++-------- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index df8950e..d187103 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ Home et pages dept renvoient `Cache-Control: ... must-revalidate` (cf. commit `e - **SYNOP renvoie les températures en Kelvin**. `observations.ts` les convertit via `K2C()`. Piège classique si on ajoute un nouveau champ température. - **DROM** : pas (encore) inclus dans le mapping `departements.ts` côté front. Vigilance Métropole only pour le MVP. Pour ajouter Outre-mer : étendre `DEPARTEMENTS` + tester si `domain_id` correspond aux codes 971-978. - **Conseils par phénomène** (`advice.ts`) : texte curated depuis sante.gouv.fr et meteofrance.fr. À relire / actualiser périodiquement (au moins 1× par an). +- **Climato — couverture station des derniers jours très partielle** : dans `latest-2025-2026`, les 1-2 jours les plus récents ont souvent 0-1 station publiée (parfois TN sans TX) → l'agrégat département est nul ou non représentatif. La page dept (`[code].astro`, `_mergedDay`) ne fait confiance à l'agrégat climato d'un jour que si sa couverture ≥ 50 % de la médiane station du dept ; sinon elle bascule sur le SYNOP (garde ≥ 3 obs/jour pour éviter `tx==tn`), puis sur la valeur fine, puis `null`. Le badge anomalie utilise les 7 derniers jours **complets** (finissant J-1, today exclu car partiel). Ne pas réintroduire de lecture directe de `climato.days` brut pour le calcul d'anomalie. - **Normales 1991-2020** : fichier `src/data/normales.json` committé. Régénération via `scripts/build-normales.mjs` quand la décennie de référence Météo France change (~tous les 10 ans, prochain ~2031). - **Cache miss au boot** : si Valkey est down, `cacheOrFetch` log un warning mais re-fetch à chaque requête — pas de fallback persistant. Acceptable pour un service stateless, mais surveiller la latence Opendatasoft. diff --git a/src/lib/climato.ts b/src/lib/climato.ts index 2ec957e..068d0fb 100644 --- a/src/lib/climato.ts +++ b/src/lib/climato.ts @@ -15,7 +15,9 @@ export interface DayObservation { tx: number | null; tm: number | null; rr: number | null; - stations: number; + stations: number; // nombre de stations ayant une ligne pour ce jour + txN?: number; // nombre de stations ayant contribué à TX (couverture réelle de TX) + tnN?: number; // idem pour TN (optionnels : absents des objets fusionnés climato/SYNOP côté page) } export interface ClimatoSeries { @@ -93,6 +95,8 @@ function aggregateDays(byDate: Map): DayObservation[] { tm: agg.tmN > 0 ? +(agg.tmSum / agg.tmN).toFixed(1) : null, rr: agg.rrN > 0 ? +(agg.rrSum / agg.rrN).toFixed(1) : null, stations: agg.stations, + txN: agg.txN, + tnN: agg.tnN, })); } diff --git a/src/pages/departement/[code].astro b/src/pages/departement/[code].astro index bb35b6a..1d8fecf 100644 --- a/src/pages/departement/[code].astro +++ b/src/pages/departement/[code].astro @@ -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(); +// 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(); if (hourly) { const _byDate = new Map(); 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)),