feat: normales journalières + dedupe SYNOP + carte plus large
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
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>
This commit is contained in:
parent
84e8bd200f
commit
cb8d111a12
8 changed files with 283 additions and 118 deletions
|
|
@ -1,12 +1,13 @@
|
|||
#!/usr/bin/env node
|
||||
// Calcule les normales mensuelles TN / TX par département sur la période WMO 1991-2020
|
||||
// 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
|
||||
// { "01": [{ month:1, tn:-1.5, tx:5.2, tnStd:3.1, txStd:4.2, n:1234 }, ..., 12], "02": [...], ... }
|
||||
// { "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.
|
||||
//
|
||||
// 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.
|
||||
// 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';
|
||||
|
|
@ -32,9 +33,17 @@ const DEPTS = [
|
|||
'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) {
|
||||
// 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);
|
||||
|
|
@ -49,7 +58,8 @@ async function fetchAndParse(dept) {
|
|||
const idxTX = header.indexOf('TX');
|
||||
if (idxDate === -1) return null;
|
||||
|
||||
const acc = Array.from({ length: 12 }, () => ({
|
||||
// 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,
|
||||
}));
|
||||
|
|
@ -62,36 +72,65 @@ async function fetchAndParse(dept) {
|
|||
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;
|
||||
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[m].tnSum += v; acc[m].tnSquares += v * v; acc[m].tnN++;
|
||||
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[m].txSum += v; acc[m].txSquares += v * v; acc[m].txN++;
|
||||
acc[doy].txSum += v; acc[doy].txSquares += v * v; acc[doy].txN++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acc.map((a, m) => {
|
||||
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;
|
||||
return {
|
||||
month: m + 1,
|
||||
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();
|
||||
|
|
@ -110,6 +149,10 @@ for (const dept of DEPTS) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue