fix(climato): fiabiliser les jours récents à couverture station partielle
Some checks failed
Deploy info-canicule / deploy (push) Failing after 4s
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:
parent
e247005d34
commit
9db5d4c204
3 changed files with 45 additions and 15 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, Agg>): 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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue