info-canicule/scripts/build-normales.mjs
Florian c2b489f9b9
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
feat: normales saisonnières 1991-2020 + AnomalyBadge
- 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>
2026-05-25 21:34:04 +02:00

117 lines
4.5 KiB
JavaScript
Raw Permalink 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.

#!/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`);