feat: normales saisonnières 1991-2020 + AnomalyBadge
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
- scripts/build-normales.mjs : agrégation TN/TX mensuelles par dept sur la période WMO 1991-2020 depuis les fichiers Q_<DEPT>_previous-1950-2024. Output src/data/normales.json (78 KB, committé). Run annuel max. - Corse : Météo France utilise le code historique "20" (avant split 2A/2B en 1976), donc 2A et 2B partagent la même normale issue de Q_20_*. - src/lib/normales.ts : computeAnomaly() qui moyenne TX/TN des 7 derniers jours, compare à la normale du mois, calcule l'écart en °C et en σ, catégorise (normal / warm / cool / anomaly_warm / anomaly_cool / extreme_warm / extreme_cool / unknown). - src/components/AnomalyBadge.astro : badge coloré (vert/jaune/orange/rouge) visible sur /departement/[code] juste au-dessus du graphe T°. Différencie "il fait chaud" de "il fait anormalement chaud pour ce mois". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dc01c46c76
commit
c2b489f9b9
5 changed files with 308 additions and 0 deletions
98
src/lib/normales.ts
Normal file
98
src/lib/normales.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Lookup des normales saisonnières TN/TX par dept × mois, calculées sur 1991-2020.
|
||||
// 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, MonthNormale[] | null> | null = null;
|
||||
|
||||
async function loadNormales(): Promise<Record<string, MonthNormale[] | null>> {
|
||||
if (normalesData) return normalesData;
|
||||
try {
|
||||
const m = await import('../data/normales.json');
|
||||
normalesData = m.default as Record<string, MonthNormale[] | null>;
|
||||
} catch {
|
||||
normalesData = {};
|
||||
}
|
||||
return normalesData;
|
||||
}
|
||||
|
||||
export interface MonthNormale {
|
||||
month: number; // 1..12
|
||||
tn: number | null;
|
||||
tx: number | null;
|
||||
tnStd: number;
|
||||
txStd: number;
|
||||
n: number;
|
||||
}
|
||||
|
||||
export async function normaleForMonth(dept: string, month: number): Promise<MonthNormale | null> {
|
||||
const data = await loadNormales();
|
||||
const arr = data[dept];
|
||||
if (!arr) return null;
|
||||
return arr[month - 1] ?? null;
|
||||
}
|
||||
|
||||
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; // diffTx / txStd
|
||||
sigmaTn: number | null;
|
||||
/**
|
||||
* - 'normal' : |sigma| <= 1
|
||||
* - 'warm' / 'cool' : 1 < |sigma| <= 2
|
||||
* - 'anomaly_warm' / 'anomaly_cool' : |sigma| > 2 (déviation significative)
|
||||
* - 'extreme_warm' / 'extreme_cool' : |sigma| > 3
|
||||
* - 'unknown' : pas de normale
|
||||
*/
|
||||
txCategory: 'normal' | 'warm' | 'cool' | 'anomaly_warm' | 'anomaly_cool' | 'extreme_warm' | 'extreme_cool' | 'unknown';
|
||||
}
|
||||
|
||||
function categorize(sigma: number | null): Anomaly['txCategory'] {
|
||||
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';
|
||||
}
|
||||
|
||||
export async function computeAnomaly(dept: string, days: DayObservation[]): Promise<Anomaly | null> {
|
||||
if (days.length === 0) return null;
|
||||
const recent = days.slice(-7);
|
||||
if (recent.length === 0) return null;
|
||||
|
||||
// Représentatif : moyenne des 7 derniers jours
|
||||
const txs = recent.map((d) => d.tx).filter((v): v is number => v !== null);
|
||||
const tns = recent.map((d) => d.tn).filter((v): v is number => v !== null);
|
||||
const meanTx = txs.length ? +(txs.reduce((s, v) => s + v, 0) / txs.length).toFixed(1) : null;
|
||||
const meanTn = tns.length ? +(tns.reduce((s, v) => s + v, 0) / tns.length).toFixed(1) : null;
|
||||
|
||||
// Mois représentatif : médian des 7 jours
|
||||
const middleDate = recent[Math.floor(recent.length / 2)].date;
|
||||
const month = parseInt(middleDate.slice(5, 7), 10);
|
||||
const nrm = await normaleForMonth(dept, month);
|
||||
if (!nrm) return null;
|
||||
|
||||
const diffTx = meanTx !== null && nrm.tx !== null ? +(meanTx - nrm.tx).toFixed(1) : null;
|
||||
const diffTn = meanTn !== null && nrm.tn !== null ? +(meanTn - nrm.tn).toFixed(1) : null;
|
||||
const sigmaTx = diffTx !== null && nrm.txStd > 0 ? +(diffTx / nrm.txStd).toFixed(2) : null;
|
||||
const sigmaTn = diffTn !== null && nrm.tnStd > 0 ? +(diffTn / nrm.tnStd).toFixed(2) : null;
|
||||
|
||||
return {
|
||||
windowDays: recent.length,
|
||||
meanTx,
|
||||
meanTn,
|
||||
normaleTx: nrm.tx,
|
||||
normaleTn: nrm.tn,
|
||||
diffTx,
|
||||
diffTn,
|
||||
sigmaTx,
|
||||
sigmaTn,
|
||||
txCategory: categorize(sigmaTx),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue