feat: normales saisonnières 1991-2020 + AnomalyBadge
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:
Florian 2026-05-25 21:34:04 +02:00
parent dc01c46c76
commit c2b489f9b9
5 changed files with 308 additions and 0 deletions

View 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

File diff suppressed because one or more lines are too long

98
src/lib/normales.ts Normal file
View 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),
};
}

View file

@ -7,7 +7,9 @@ import { getDepartement, isDrom } from '../../lib/departements';
import { PHENOMENA, COLOR_LABEL } from '../../lib/phenomena';
import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice';
import { getClimatoForDepartement } from '../../lib/climato';
import { computeAnomaly } from '../../lib/normales';
import ClimatoChart from '../../components/ClimatoChart.astro';
import AnomalyBadge from '../../components/AnomalyBadge.astro';
export const prerender = false;
@ -31,9 +33,13 @@ if (!drom) {
}
let climato = null;
let anomaly = null;
if (!drom) {
try {
climato = await getClimatoForDepartement(dept.code);
if (climato?.days?.length) {
anomaly = await computeAnomaly(dept.code, climato.days);
}
} catch (e) {
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">
<div class="container-tight py-8">
<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} />
<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
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>
</div>
</section>