fix: anomalie fenêtres 3j+7j (worst-case) + carte plus large PC
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run

- normales.ts : computeAnomaly évalue 2 fenêtres (3j pour détecter un pic
  récent, 7j pour la tendance lissée), retient la pire catégorie. Évite
  de noyer un +15°C sur 3 jours dans 4 jours précédents normaux.
- FranceMap : max-w-3xl → max-w-5xl (1024px sur PC, plus lisible).

Mapping dept → station SYNOP (script build-stations-synop.mjs + json statique)
pour préparer l'intégration hourly via API Météo France officielle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-26 00:07:18 +02:00
parent c2b489f9b9
commit 9cfd4f8385
6 changed files with 592 additions and 33 deletions

View file

@ -32,8 +32,18 @@ export async function normaleForMonth(dept: string, month: number): Promise<Mont
return arr[month - 1] ?? null;
}
export type AnomalyCategory =
| 'normal'
| 'warm'
| 'cool'
| 'anomaly_warm'
| 'anomaly_cool'
| 'extreme_warm'
| 'extreme_cool'
| 'unknown';
export interface Anomaly {
windowDays: number;
windowDays: number; // fenêtre dominante retenue (3 ou 7)
meanTx: number | null;
meanTn: number | null;
normaleTx: number | null;
@ -42,17 +52,10 @@ export interface Anomaly {
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';
txCategory: AnomalyCategory;
}
function categorize(sigma: number | null): Anomaly['txCategory'] {
function categorize(sigma: number | null): AnomalyCategory {
if (sigma === null || !Number.isFinite(sigma)) return 'unknown';
const abs = Math.abs(sigma);
if (abs > 3) return sigma > 0 ? 'extreme_warm' : 'extreme_cool';
@ -61,38 +64,42 @@ function categorize(sigma: number | null): Anomaly['txCategory'] {
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;
// Sévérité ordonnée pour comparer 2 fenêtres et retenir la pire.
const SEVERITY: Record<AnomalyCategory, number> = {
unknown: -1, normal: 0, cool: 1, warm: 1,
anomaly_cool: 2, anomaly_warm: 2,
extreme_cool: 3, extreme_warm: 3,
};
// Représentatif : moyenne des 7 derniers jours
function buildWindow(days: DayObservation[], windowSize: number, nrm: MonthNormale): Omit<Anomaly, 'txCategory'> & { txCategory: AnomalyCategory } {
const recent = days.slice(-windowSize);
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,
windowDays: recent.length, meanTx, meanTn,
normaleTx: nrm.tx, normaleTn: nrm.tn,
diffTx, diffTn, sigmaTx, sigmaTn,
txCategory: categorize(sigmaTx),
};
}
export async function computeAnomaly(dept: string, days: DayObservation[]): Promise<Anomaly | null> {
if (days.length === 0) return null;
const middleDate = days.slice(-7)[Math.floor(Math.min(7, days.length) / 2)].date;
const month = parseInt(middleDate.slice(5, 7), 10);
const nrm = await normaleForMonth(dept, month);
if (!nrm) return null;
// Évalue 2 fenêtres en parallèle : 3 jours (détecte les pics récents) et 7 jours (tendance lissée).
// Retient la pire catégorie pour éviter de noyer un événement aigu dans un lissage trop long.
const w3 = buildWindow(days, 3, nrm);
const w7 = buildWindow(days, 7, nrm);
const worst = (SEVERITY[w3.txCategory] ?? -1) >= (SEVERITY[w7.txCategory] ?? -1) ? w3 : w7;
return worst;
}