feat: normales saisonnières 1991-2020 + AnomalyBadge
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:
Florian 2026-05-25 21:34:04 +02:00
parent dc01c46c76
commit c2b489f9b9
5 changed files with 308 additions and 0 deletions

117
scripts/build-normales.mjs Normal file
View file

@ -0,0 +1,117 @@
#!/usr/bin/env node
// Calcule les normales mensuelles TN / TX par département sur la période WMO 1991-2020
// à partir des données quotidiennes Météo France (Q_<DEPT>_previous-1950-2024_RR-T-Vent.csv.gz).
//
// Output : src/data/normales.json
// { "01": [{ month:1, tn:-1.5, tx:5.2, tnStd:3.1, txStd:4.2, n:1234 }, ..., 12], "02": [...], ... }
//
// Lancé manuellement (très long : 96 × ~4 MB download + parse).
// À relancer une fois par an, ou jamais — les normales 1991-2020 restent la référence WMO standard.
import { gunzipSync } from 'node:zlib';
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(__dirname, '../src/data/normales.json');
const BASE = 'https://object.files.data.gouv.fr/meteofrance/data/synchro_ftp/BASE/QUOT';
const PERIOD_START = 1991;
const PERIOD_END = 2020;
const DEPTS = [
'01','02','03','04','05','06','07','08','09','10',
'11','12','13','14','15','16','17','18','19','21',
'22','23','24','25','26','27','28','29','2A','2B',
'30','31','32','33','34','35','36','37','38','39',
'40','41','42','43','44','45','46','47','48','49',
'50','51','52','53','54','55','56','57','58','59',
'60','61','62','63','64','65','66','67','68','69',
'70','71','72','73','74','75','76','77','78','79',
'80','81','82','83','84','85','86','87','88','89',
'90','91','92','93','94','95',
];
async function fetchAndParse(dept) {
// Météo France utilise "20" pour la Corse historique (avant split 2A/2B en 1976).
// On affecte la même normale à 2A et 2B (climat sensiblement homogène à l'échelle mensuelle).
const fileCode = (dept === '2A' || dept === '2B') ? '20' : dept;
const url = `${BASE}/Q_${fileCode}_previous-1950-2024_RR-T-Vent.csv.gz`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const text = gunzipSync(buf).toString('utf-8');
const lines = text.split('\n');
if (lines.length < 2) return null;
const header = lines[0].split(';');
const idxDate = header.indexOf('AAAAMMJJ');
const idxTN = header.indexOf('TN');
const idxTX = header.indexOf('TX');
if (idxDate === -1) return null;
const acc = Array.from({ length: 12 }, () => ({
tnSum: 0, tnSquares: 0, tnN: 0,
txSum: 0, txSquares: 0, txN: 0,
}));
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const cols = line.split(';');
const raw = cols[idxDate];
if (!raw || raw.length !== 8) continue;
const year = parseInt(raw.slice(0, 4), 10);
if (year < PERIOD_START || year > PERIOD_END) continue;
const m = parseInt(raw.slice(4, 6), 10) - 1;
if (m < 0 || m > 11) continue;
if (idxTN !== -1 && cols[idxTN]) {
const v = parseFloat(cols[idxTN].replace(',', '.'));
if (Number.isFinite(v)) {
acc[m].tnSum += v; acc[m].tnSquares += v * v; acc[m].tnN++;
}
}
if (idxTX !== -1 && cols[idxTX]) {
const v = parseFloat(cols[idxTX].replace(',', '.'));
if (Number.isFinite(v)) {
acc[m].txSum += v; acc[m].txSquares += v * v; acc[m].txN++;
}
}
}
return acc.map((a, m) => {
const tnMean = a.tnN > 0 ? a.tnSum / a.tnN : null;
const txMean = a.txN > 0 ? a.txSum / a.txN : null;
const tnVar = a.tnN > 1 && tnMean !== null ? (a.tnSquares - a.tnN * tnMean * tnMean) / (a.tnN - 1) : 0;
const txVar = a.txN > 1 && txMean !== null ? (a.txSquares - a.txN * txMean * txMean) / (a.txN - 1) : 0;
return {
month: m + 1,
tn: tnMean !== null ? +tnMean.toFixed(2) : null,
tx: txMean !== null ? +txMean.toFixed(2) : null,
tnStd: +Math.sqrt(Math.max(0, tnVar)).toFixed(2),
txStd: +Math.sqrt(Math.max(0, txVar)).toFixed(2),
n: Math.min(a.tnN, a.txN),
};
});
}
const start = Date.now();
const all = {};
let done = 0;
for (const dept of DEPTS) {
const t0 = Date.now();
process.stdout.write(`[${++done}/${DEPTS.length}] ${dept}... `);
try {
const r = await fetchAndParse(dept);
all[dept] = r;
console.log(`ok ${(Date.now() - t0) / 1000 | 0}s`);
} catch (e) {
console.log(`FAIL ${e.message}`);
all[dept] = null;
}
}
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify(all));
const dur = ((Date.now() - start) / 1000 / 60).toFixed(1);
const size = (JSON.stringify(all).length / 1024).toFixed(1);
console.log(`\nWrote ${OUT} (${size} KB) in ${dur} min`);

View file

@ -0,0 +1,80 @@
---
import type { Anomaly } from '../lib/normales';
interface Props {
anomaly: Anomaly;
}
const { anomaly } = Astro.props;
const CATEGORY_LABEL: Record<Anomaly['txCategory'], { label: string; cls: string; icon: string }> = {
normal: {
label: 'Températures dans la normale saisonnière',
cls: 'border-slate-200 bg-slate-50 text-slate-700',
icon: '🌡️',
},
warm: {
label: 'Légèrement au-dessus de la normale',
cls: 'border-yellow-200 bg-yellow-50 text-yellow-900',
icon: '↗',
},
cool: {
label: 'Légèrement en dessous de la normale',
cls: 'border-blue-200 bg-blue-50 text-blue-900',
icon: '↘',
},
anomaly_warm: {
label: 'Anormalement chaud',
cls: 'border-orange-300 bg-orange-50 text-orange-900',
icon: '🔥',
},
anomaly_cool: {
label: 'Anormalement frais',
cls: 'border-blue-300 bg-blue-100 text-blue-900',
icon: '❄',
},
extreme_warm: {
label: 'Extrêmement chaud (déviation extrême)',
cls: 'border-red-300 bg-red-50 text-red-900 font-semibold',
icon: '🚨',
},
extreme_cool: {
label: 'Extrêmement frais (déviation extrême)',
cls: 'border-blue-400 bg-blue-200 text-blue-900 font-semibold',
icon: '🚨',
},
unknown: {
label: 'Normale non disponible pour ce mois',
cls: 'border-slate-200 bg-slate-50 text-slate-500',
icon: '?',
},
};
const cat = CATEGORY_LABEL[anomaly.txCategory];
const signDiff = (anomaly.diffTx ?? 0) > 0 ? '+' : '';
---
<div class:list={['rounded-lg border p-4', cat.cls]}>
<div class="flex items-center gap-2 text-sm font-semibold">
<span aria-hidden="true">{cat.icon}</span>
<span>{cat.label}</span>
</div>
{anomaly.diffTx !== null && anomaly.normaleTx !== null && (
<p class="mt-2 text-sm">
<strong>T° max moyenne {anomaly.windowDays} derniers jours :</strong>{' '}
<span class="font-mono">{anomaly.meanTx}°C</span>
<span class="text-slate-500"> · normale du mois (1991-2020) : <span class="font-mono">{anomaly.normaleTx}°C</span></span>
</p>
<p class="mt-1 text-sm">
<strong>Écart :</strong>{' '}
<span class="font-mono">{signDiff}{anomaly.diffTx}°C</span>
{anomaly.sigmaTx !== null && (
<span class="text-xs text-slate-500"> ({anomaly.sigmaTx > 0 ? '+' : ''}{anomaly.sigmaTx}σ)</span>
)}
</p>
)}
<p class="mt-2 text-xs text-slate-500">
Comparaison sur la période de référence WMO 1991-2020. σ = écart-type, mesure de l'amplitude
historique du mois ; au-delà de 2σ l'événement est statistiquement rare.
</p>
</div>

1
src/data/normales.json Normal file

File diff suppressed because one or more lines are too long

98
src/lib/normales.ts Normal file
View 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),
};
}

View file

@ -7,7 +7,9 @@ import { getDepartement, isDrom } from '../../lib/departements';
import { PHENOMENA, COLOR_LABEL } from '../../lib/phenomena'; import { PHENOMENA, COLOR_LABEL } from '../../lib/phenomena';
import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice'; import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice';
import { getClimatoForDepartement } from '../../lib/climato'; import { getClimatoForDepartement } from '../../lib/climato';
import { computeAnomaly } from '../../lib/normales';
import ClimatoChart from '../../components/ClimatoChart.astro'; import ClimatoChart from '../../components/ClimatoChart.astro';
import AnomalyBadge from '../../components/AnomalyBadge.astro';
export const prerender = false; export const prerender = false;
@ -31,9 +33,13 @@ if (!drom) {
} }
let climato = null; let climato = null;
let anomaly = null;
if (!drom) { if (!drom) {
try { try {
climato = await getClimatoForDepartement(dept.code); climato = await getClimatoForDepartement(dept.code);
if (climato?.days?.length) {
anomaly = await computeAnomaly(dept.code, climato.days);
}
} catch (e) { } catch (e) {
console.warn('climato fetch failed for', dept.code, (e as Error).message); console.warn('climato fetch failed for', dept.code, (e as Error).message);
} }
@ -147,10 +153,16 @@ const adviceFor = highest && ADVICE[highest.phenomenonId];
<section class="border-t border-slate-200 bg-slate-50"> <section class="border-t border-slate-200 bg-slate-50">
<div class="container-tight py-8"> <div class="container-tight py-8">
<h2 class="mb-4 text-xl font-semibold">Températures récentes</h2> <h2 class="mb-4 text-xl font-semibold">Températures récentes</h2>
{anomaly && (
<div class="mb-4">
<AnomalyBadge anomaly={anomaly} />
</div>
)}
<ClimatoChart days={climato.days} /> <ClimatoChart days={climato.days} />
<p class="mt-2 text-xs text-slate-500"> <p class="mt-2 text-xs text-slate-500">
Source : Météo France — données climatologiques de base quotidiennes, agrégées par moyenne Source : Météo France — données climatologiques de base quotidiennes, agrégées par moyenne
sur toutes les stations du département. Donnée brute, contrôle qualité Météo France. sur toutes les stations du département. Donnée brute, contrôle qualité Météo France.
Normales calculées sur 1991-2020 (référence WMO).
</p> </p>
</div> </div>
</section> </section>