feat: normales saisonnières 1991-2020 + AnomalyBadge
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
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:
parent
dc01c46c76
commit
c2b489f9b9
5 changed files with 308 additions and 0 deletions
117
scripts/build-normales.mjs
Normal file
117
scripts/build-normales.mjs
Normal 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`);
|
||||||
80
src/components/AnomalyBadge.astro
Normal file
80
src/components/AnomalyBadge.astro
Normal 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
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
98
src/lib/normales.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue