info-canicule/src/lib/normales.ts
Florian 290f9be7b9 fix+perf: corrections de revue (currentEcheance, dayOfYear, SWR, last-good, doc apikey)
- vigilance: currentEcheance basée sur productDatetime (jour calme renvoyait J1 à tort)
- normales: dayOfYear extrait en Europe/Paris pour 'now' (UTC mélangeait les jours après minuit)
- meteofrance-auth + CLAUDE.md: header `apikey:` documenté correctement (pas Authorization Bearer)
- cache: SWR — envelope {v, fu}, hard TTL = ttl*6, refresh background avec lock anti-stampede
- vigilance: snapshot last-good (TTL 30j) écrit à chaque fetch, fallback final si MF+ODS KO
- vigilance: nettoyage variable url morte dans fetchOpendatasoft

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:58:03 +02:00

181 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Lookup des normales saisonnières TN/TX par dept × jour (day-of-year 1..366),
// calculées sur 1991-2020 avec lissage 7 jours. Données générées par
// scripts/build-normales.mjs, committées en JSON statique.
import type { DayObservation } from './climato';
let normalesData: Record<string, DailyNormale[] | null> | null = null;
async function loadNormales(): Promise<Record<string, DailyNormale[] | null>> {
if (normalesData) return normalesData;
try {
const m = await import('../data/normales.json');
normalesData = m.default as Record<string, DailyNormale[] | null>;
} catch {
normalesData = {};
}
return normalesData;
}
export interface DailyNormale {
doy: number; // 1..366
tn: number | null;
tx: number | null;
tnStd: number;
txStd: number;
n: number;
}
// Day-of-year en convention "leap calendar" (1..366), aligné sur le calendrier bissextile.
// Extraction Y/M/D en Europe/Paris pour rester cohérent avec :
// - les pages SSR qui formatent en Europe/Paris (todayLabel, productDate),
// - les obs climato YYYY-MM-DD (parsées en UTC midnight, donc Paris == J ou J-1
// selon offset DST, mais ça représente toujours la "journée Paris" du jour
// suivant — assez proche pour le lookup normale).
// - `new Date()` (now) : en UTC, getUTC* renvoie hier entre 00h et 01h/02h Paris.
// Toujours utiliser le jour Paris-local.
export function dayOfYear(date: Date): number {
// sv-SE → format "YYYY-MM-DD"
const iso = date.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });
const y = parseInt(iso.slice(0, 4), 10);
const m = parseInt(iso.slice(5, 7), 10);
const d = parseInt(iso.slice(8, 10), 10);
const cumulNonLeap = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
const cumulLeap = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
const isLeap = (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
return (isLeap ? cumulLeap : cumulNonLeap)[m - 1] + d;
}
export async function normaleForDay(dept: string, doy: number): Promise<DailyNormale | null> {
const data = await loadNormales();
const arr = data[dept];
if (!arr) return null;
return arr[doy - 1] ?? null;
}
export async function normaleForDate(dept: string, date: Date): Promise<DailyNormale | null> {
return normaleForDay(dept, dayOfYear(date));
}
// Série continue de normales pour une plage de dates : [{date, doy, tn, tx, ...}]
export async function normalesForRange(dept: string, dates: string[]): Promise<Array<{ date: string; tn: number | null; tx: number | null; tnStd: number; txStd: number } | null>> {
const data = await loadNormales();
const arr = data[dept];
if (!arr) return dates.map(() => null);
return dates.map((iso) => {
const doy = dayOfYear(new Date(iso));
const n = arr[doy - 1];
if (!n) return null;
return { date: iso, tn: n.tn, tx: n.tx, tnStd: n.tnStd, txStd: n.txStd };
});
}
export type AnomalyCategory =
| 'normal' | 'warm' | 'cool'
| 'anomaly_warm' | 'anomaly_cool'
| 'extreme_warm' | 'extreme_cool'
| 'unknown';
export interface Anomaly {
windowDays: number;
meanTx: number | null;
meanTn: number | null;
normaleTx: number | null;
normaleTn: number | null;
diffTx: number | null;
diffTn: number | null;
sigmaTx: number | null;
sigmaTn: number | null;
txCategory: AnomalyCategory;
}
function categorize(sigma: number | null): AnomalyCategory {
if (sigma === null || !Number.isFinite(sigma)) return 'unknown';
const abs = Math.abs(sigma);
if (abs > 3) return sigma > 0 ? 'extreme_warm' : 'extreme_cool';
if (abs > 2) return sigma > 0 ? 'anomaly_warm' : 'anomaly_cool';
if (abs > 1) return sigma > 0 ? 'warm' : 'cool';
return 'normal';
}
const SEVERITY: Record<AnomalyCategory, number> = {
unknown: -1, normal: 0, cool: 1, warm: 1,
anomaly_cool: 2, anomaly_warm: 2,
extreme_cool: 3, extreme_warm: 3,
};
async function buildWindow(
dept: string,
days: DayObservation[],
windowSize: number,
): Promise<Omit<Anomaly, 'txCategory'> & { txCategory: AnomalyCategory }> {
const recent = days.slice(-windowSize);
// Comparer chaque jour observé à SA normale du jour, puis moyenner les écarts.
// Plus juste que de moyenner les T° et comparer à une normale moyennée.
const data = await loadNormales();
const arr = data[dept];
let txN = 0, txSumDiff = 0, txSumStd2 = 0, txSumTx = 0;
let tnN = 0, tnSumDiff = 0, tnSumStd2 = 0, tnSumTn = 0;
let normaleTxSum = 0, normaleTxN = 0, normaleTnSum = 0, normaleTnN = 0;
for (const d of recent) {
if (!arr) break;
const doy = dayOfYear(new Date(d.date));
const n = arr[doy - 1];
if (!n) continue;
if (d.tx !== null && n.tx !== null) {
txN++;
txSumDiff += d.tx - n.tx;
txSumStd2 += n.txStd * n.txStd;
txSumTx += d.tx;
normaleTxSum += n.tx; normaleTxN++;
}
if (d.tn !== null && n.tn !== null) {
tnN++;
tnSumDiff += d.tn - n.tn;
tnSumStd2 += n.tnStd * n.tnStd;
tnSumTn += d.tn;
normaleTnSum += n.tn; normaleTnN++;
}
}
const meanTx = txN > 0 ? +(txSumTx / txN).toFixed(1) : null;
const meanTn = tnN > 0 ? +(tnSumTn / tnN).toFixed(1) : null;
const normaleTx = normaleTxN > 0 ? +(normaleTxSum / normaleTxN).toFixed(1) : null;
const normaleTn = normaleTnN > 0 ? +(normaleTnSum / normaleTnN).toFixed(1) : null;
const diffTx = txN > 0 ? +(txSumDiff / txN).toFixed(1) : null;
const diffTn = tnN > 0 ? +(tnSumDiff / tnN).toFixed(1) : null;
// σ effectif = racine de la moyenne des variances (combinaison de jours différents)
const stdTx = txN > 0 ? Math.sqrt(txSumStd2 / txN) : 0;
const stdTn = tnN > 0 ? Math.sqrt(tnSumStd2 / tnN) : 0;
const sigmaTx = diffTx !== null && stdTx > 0 ? +(diffTx / stdTx).toFixed(2) : null;
const sigmaTn = diffTn !== null && stdTn > 0 ? +(diffTn / stdTn).toFixed(2) : null;
return {
windowDays: recent.length, meanTx, meanTn,
normaleTx, normaleTn, diffTx, diffTn, sigmaTx, sigmaTn,
txCategory: categorize(sigmaTx),
};
}
export async function computeAnomaly(dept: string, days: DayObservation[]): Promise<Anomaly | null> {
if (days.length === 0) return null;
const w3 = await buildWindow(dept, days, 3);
const w7 = await buildWindow(dept, days, 7);
const worst = (SEVERITY[w3.txCategory] ?? -1) >= (SEVERITY[w7.txCategory] ?? -1) ? w3 : w7;
return worst;
}
// Compat : ancien helper utilisé ailleurs si présent
export async function normaleForMonth(dept: string, month: number): Promise<{ tx: number | null; tn: number | null; txStd: number; tnStd: number } | null> {
const data = await loadNormales();
const arr = data[dept];
if (!arr) return null;
// Moyenne les normales du mois pour rétro-compat
const mid = new Date(Date.UTC(2024, month - 1, 15));
const doy = dayOfYear(mid);
const n = arr[doy - 1];
if (!n) return null;
return { tx: n.tx, tn: n.tn, txStd: n.txStd, tnStd: n.tnStd };
}