info-canicule/scripts/build-normales.mjs
Florian cb8d111a12
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
feat: normales journalières + dedupe SYNOP + carte plus large
3 fixes en un :

1. lib/observations.ts : dedupe par validity_time sur le parse hourly.
   L API MF SYNOP retourne chaque obs en doublon exact sur les ranges
   multi-step (bug gateway WSO2). Constaté 7/8 paires identiques sur 24h.

2. Normales 1991-2020 passées de mensuelles à journalières (lissées 7j).
   - scripts/build-normales.mjs : agrégation par day-of-year (1..366)
     avec moving average ±3j pour stabiliser le bruit jour-à-jour.
   - src/data/normales.json : 2.28 MB (vs 78 KB), 96 × 366 entrées.
   - lib/normales.ts : normaleForDay/Date + normalesForRange, computeAnomaly
     compare maintenant chaque jour observé à SA normale (pas à la moyenne
     du mois) → bien plus précis sur les jours de transition mensuelle.
   - TemperatureChartInteractive : overlay normales en COURBE qui suit
     la saison (7j/30j) au lieu d une ligne horizontale unique.
     24h reste ligne horizontale (normale du jour courant).
   - Tooltip 7j/30j ajoute "↳ normale TX X°C (+Y)" pour montrer l écart
     par point.

3. Carte sur la home libérée du container-tight (max-w-5xl = 1024px) :
   wrapper dédié max-w-[1400px] → carte ~37% plus grande sur PC large.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:43:10 +02:00

160 lines
6.2 KiB
JavaScript
Raw 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 JOURNALIÈRES 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
// { "75": [{ doy:1, tn:-2.1, tx:5.2, tnStd:3.4, txStd:4.5, n:152 }, ..., 366], ... }
// doy = day-of-year (1..366), tn/tx en °C, tnStd/txStd écart-type.
//
// Taille output : ~1.8 MB (vs ~78 KB pour mensuelles). Acceptable pour bundle.
// Lancé manuellement, à relancer ~1× / an (les normales 1991-2020 restent référence WMO jusqu'à 2031).
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',
];
// Day-of-year 1..366. On utilise une convention "leap" (29 fév = 60),
// les années non-bissextiles n'ont pas de 60 → on garde le bucket même vide
// puis on l'interpole en post-process.
function dayOfYear(year, month, day) {
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 = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
return (isLeap ? cumulLeap : cumulNonLeap)[month - 1] + day;
}
async function fetchAndParse(dept) {
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;
// 367 buckets pour doy 1..366 (index 0 inutilisé).
const acc = Array.from({ length: 367 }, () => ({
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);
const d = parseInt(raw.slice(6, 8), 10);
if (m < 1 || m > 12 || d < 1 || d > 31) continue;
const doy = dayOfYear(year, m, d);
if (doy < 1 || doy > 366) continue;
if (idxTN !== -1 && cols[idxTN]) {
const v = parseFloat(cols[idxTN].replace(',', '.'));
if (Number.isFinite(v)) {
acc[doy].tnSum += v; acc[doy].tnSquares += v * v; acc[doy].tnN++;
}
}
if (idxTX !== -1 && cols[idxTX]) {
const v = parseFloat(cols[idxTX].replace(',', '.'));
if (Number.isFinite(v)) {
acc[doy].txSum += v; acc[doy].txSquares += v * v; acc[doy].txN++;
}
}
}
const out = [];
for (let doy = 1; doy <= 366; doy++) {
const a = acc[doy];
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;
out.push({
doy,
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),
});
}
// Lissage léger sur fenêtre 7 jours pour réduire le bruit jour-à-jour (la T° d'un 12 juin
// n'est pas significativement différente d'un 13 juin sur 30 ans). Mode wrap-around pour les bordures.
const smoothed = out.map((_, i) => {
let txSum = 0, txN = 0, tnSum = 0, tnN = 0;
let stdTxSum = 0, stdTnSum = 0, stdN = 0;
for (let k = -3; k <= 3; k++) {
const j = (i + k + 366) % 366; // wrap
const w = out[j];
if (w.tx !== null) { txSum += w.tx; txN++; stdTxSum += w.txStd; }
if (w.tn !== null) { tnSum += w.tn; tnN++; stdTnSum += w.tnStd; }
stdN++;
}
return {
doy: out[i].doy,
tn: tnN > 0 ? +(tnSum / tnN).toFixed(2) : null,
tx: txN > 0 ? +(txSum / txN).toFixed(2) : null,
tnStd: stdN > 0 ? +(stdTnSum / stdN).toFixed(2) : 0,
txStd: stdN > 0 ? +(stdTxSum / stdN).toFixed(2) : 0,
n: out[i].n,
};
});
return smoothed;
}
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;
}
}
// 2A et 2B partagent les données code "20"
if (all['2A']) all['2B'] = all['2A'];
else if (all['2B']) all['2A'] = all['2B'];
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`);