fix: anomalie fenêtres 3j+7j (worst-case) + carte plus large PC
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
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:
parent
c2b489f9b9
commit
9cfd4f8385
6 changed files with 592 additions and 33 deletions
|
|
@ -48,7 +48,7 @@ for (const [code] of entries) {
|
|||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Carte des départements français colorée selon le niveau Vigilance"
|
||||
class="h-auto w-full max-w-3xl mx-auto"
|
||||
class="h-auto w-full max-w-5xl mx-auto"
|
||||
id="france-map"
|
||||
>
|
||||
<title>Carte Vigilance Météo France</title>
|
||||
|
|
|
|||
1
src/data/stations-synop.json
Normal file
1
src/data/stations-synop.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"10":{"stationId":"07168","distKm":4.8},"11":{"stationId":"07635","distKm":11.9},"12":{"stationId":"07552","distKm":19.5},"13":{"stationId":"07648","distKm":10.1},"14":{"stationId":"07027","distKm":13.4},"15":{"stationId":"07549","distKm":27.2},"16":{"stationId":"07412","distKm":40.8},"17":{"stationId":"07412","distKm":35.9},"18":{"stationId":"07255","distKm":2.8},"19":{"stationId":"07438","distKm":39.8},"21":{"stationId":"07280","distKm":31.8},"22":{"stationId":"07120","distKm":10.5},"23":{"stationId":"07361","distKm":12},"24":{"stationId":"07530","distKm":37.9},"25":{"stationId":"07288","distKm":30.2},"26":{"stationId":"07577","distKm":40.4},"27":{"stationId":"07038","distKm":19.8},"28":{"stationId":"07143","distKm":12.3},"29":{"stationId":"07109","distKm":21.4},"30":{"stationId":"07645","distKm":32.5},"31":{"stationId":"07630","distKm":35.4},"32":{"stationId":"07622","distKm":12.6},"33":{"stationId":"07510","distKm":17},"34":{"stationId":"07638","distKm":31.1},"35":{"stationId":"07130","distKm":12.6},"36":{"stationId":"07354","distKm":15.2},"37":{"stationId":"07240","distKm":20.8},"38":{"stationId":"07486","distKm":21.6},"39":{"stationId":"07386","distKm":34.4},"40":{"stationId":"07607","distKm":7.7},"41":{"stationId":"07245","distKm":16.5},"42":{"stationId":"07475","distKm":24.3},"43":{"stationId":"07471","distKm":8.3},"44":{"stationId":"07222","distKm":20.2},"45":{"stationId":"07249","distKm":45.2},"46":{"stationId":"07535","distKm":21.8},"47":{"stationId":"07524","distKm":25.4},"48":{"stationId":"07554","distKm":3.4},"49":{"stationId":"07230","distKm":7.7},"50":{"stationId":"07027","distKm":64.7},"51":{"stationId":"07166","distKm":18.2},"52":{"stationId":"07283","distKm":32.9},"53":{"stationId":"07134","distKm":15.1},"54":{"stationId":"07093","distKm":14.2},"55":{"stationId":"07169","distKm":57.1},"56":{"stationId":"07205","distKm":42.5},"57":{"stationId":"07093","distKm":38.2},"58":{"stationId":"07260","distKm":36.6},"59":{"stationId":"07015","distKm":13.8},"60":{"stationId":"07055","distKm":22.5},"61":{"stationId":"07139","distKm":18.2},"62":{"stationId":"07015","distKm":50.2},"63":{"stationId":"07460","distKm":5.7},"64":{"stationId":"07610","distKm":33.3},"65":{"stationId":"07621","distKm":13.5},"66":{"stationId":"07747","distKm":37.4},"67":{"stationId":"07190","distKm":22.3},"68":{"stationId":"07197","distKm":14.2},"69":{"stationId":"07480","distKm":27.6},"70":{"stationId":"07292","distKm":27.9},"71":{"stationId":"07385","distKm":43.3},"72":{"stationId":"07235","distKm":4.3},"73":{"stationId":"07497","distKm":32.5},"74":{"stationId":"07490","distKm":29.9},"75":{"stationId":"07156","distKm":3.6},"76":{"stationId":"07037","distKm":24.4},"77":{"stationId":"07153","distKm":19.6},"78":{"stationId":"07145","distKm":12.3},"79":{"stationId":"07330","distKm":25.8},"80":{"stationId":"07059","distKm":28.9},"81":{"stationId":"07632","distKm":12.9},"82":{"stationId":"07540","distKm":9.7},"83":{"stationId":"07675","distKm":11.5},"84":{"stationId":"07586","distKm":6.6},"85":{"stationId":"07306","distKm":1.9},"86":{"stationId":"07335","distKm":7.6},"87":{"stationId":"07434","distKm":4.2},"88":{"stationId":"07181","distKm":45.8},"89":{"stationId":"07266","distKm":4.8},"90":{"stationId":"07296","distKm":7.5},"91":{"stationId":"07149","distKm":24.2},"92":{"stationId":"07156","distKm":6.5},"93":{"stationId":"07150","distKm":4.9},"94":{"stationId":"07149","distKm":7.2},"95":{"stationId":"07053","distKm":5.6},"01":{"stationId":"07482","distKm":15.7},"02":{"stationId":"07061","distKm":45.1},"03":{"stationId":"07374","distKm":28.3},"04":{"stationId":"07588","distKm":23.3},"05":{"stationId":"07591","distKm":25.9},"06":{"stationId":"07690","distKm":32},"07":{"stationId":"07570","distKm":26.8},"08":{"stationId":"07075","distKm":15.2},"09":{"stationId":"07627","distKm":33.4},"2A":{"stationId":"07761","distKm":12.1},"2B":{"stationId":"07753","distKm":31.9}}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue