// 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 | null = null; async function loadNormales(): Promise> { if (normalesData) return normalesData; try { const m = await import('../data/normales.json'); normalesData = m.default as Record; } 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 { 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 { 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> { 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 = { 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 & { 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 { 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 }; }