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>
117 lines
4.5 KiB
JavaScript
117 lines
4.5 KiB
JavaScript
#!/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`);
|