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,23 +1,24 @@
|
|||
// 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.
|
||||
// Lookup des normales saisonnières TN/TX par dept × jour (day-of-year 1..366),
|
||||
// calculées sur 1991-2020 avec lissage 7 jours. 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;
|
||||
let normalesData: Record<string, DailyNormale[] | null> | null = null;
|
||||
|
||||
async function loadNormales(): Promise<Record<string, MonthNormale[] | null>> {
|
||||
async function loadNormales(): Promise<Record<string, DailyNormale[] | null>> {
|
||||
if (normalesData) return normalesData;
|
||||
try {
|
||||
const m = await import('../data/normales.json');
|
||||
normalesData = m.default as Record<string, MonthNormale[] | null>;
|
||||
normalesData = m.default as Record<string, DailyNormale[] | null>;
|
||||
} catch {
|
||||
normalesData = {};
|
||||
}
|
||||
return normalesData;
|
||||
}
|
||||
|
||||
export interface MonthNormale {
|
||||
month: number; // 1..12
|
||||
export interface DailyNormale {
|
||||
doy: number; // 1..366
|
||||
tn: number | null;
|
||||
tx: number | null;
|
||||
tnStd: number;
|
||||
|
|
@ -25,32 +26,58 @@ export interface MonthNormale {
|
|||
n: number;
|
||||
}
|
||||
|
||||
export async function normaleForMonth(dept: string, month: number): Promise<MonthNormale | null> {
|
||||
// Day-of-year en convention "leap calendar" (1..366), aligné sur le calendrier bissextile.
|
||||
// Les années non-bissextiles : on bascule simplement le 1er mars au doy 60 si pas 29 fév.
|
||||
// Mais pour matcher l'indexation du JSON (1..366), on utilise une convention stable.
|
||||
export function dayOfYear(date: Date): number {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = date.getUTCMonth() + 1;
|
||||
const d = date.getUTCDate();
|
||||
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 = (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
|
||||
return (isLeap ? cumulLeap : cumulNonLeap)[m - 1] + d;
|
||||
}
|
||||
|
||||
export async function normaleForDay(dept: string, doy: number): Promise<DailyNormale | null> {
|
||||
const data = await loadNormales();
|
||||
const arr = data[dept];
|
||||
if (!arr) return null;
|
||||
return arr[month - 1] ?? null;
|
||||
return arr[doy - 1] ?? null;
|
||||
}
|
||||
|
||||
export async function normaleForDate(dept: string, date: Date): Promise<DailyNormale | null> {
|
||||
return normaleForDay(dept, dayOfYear(date));
|
||||
}
|
||||
|
||||
// Série continue de normales pour une plage de dates : [{date, doy, tn, tx, ...}]
|
||||
export async function normalesForRange(dept: string, dates: string[]): Promise<Array<{ date: string; tn: number | null; tx: number | null; tnStd: number; txStd: number } | null>> {
|
||||
const data = await loadNormales();
|
||||
const arr = data[dept];
|
||||
if (!arr) return dates.map(() => null);
|
||||
return dates.map((iso) => {
|
||||
const doy = dayOfYear(new Date(iso));
|
||||
const n = arr[doy - 1];
|
||||
if (!n) return null;
|
||||
return { date: iso, tn: n.tn, tx: n.tx, tnStd: n.tnStd, txStd: n.txStd };
|
||||
});
|
||||
}
|
||||
|
||||
export type AnomalyCategory =
|
||||
| 'normal'
|
||||
| 'warm'
|
||||
| 'cool'
|
||||
| 'anomaly_warm'
|
||||
| 'anomaly_cool'
|
||||
| 'extreme_warm'
|
||||
| 'extreme_cool'
|
||||
| 'normal' | 'warm' | 'cool'
|
||||
| 'anomaly_warm' | 'anomaly_cool'
|
||||
| 'extreme_warm' | 'extreme_cool'
|
||||
| 'unknown';
|
||||
|
||||
export interface Anomaly {
|
||||
windowDays: number; // fenêtre dominante retenue (3 ou 7)
|
||||
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
|
||||
sigmaTx: number | null;
|
||||
sigmaTn: number | null;
|
||||
txCategory: AnomalyCategory;
|
||||
}
|
||||
|
|
@ -64,42 +91,84 @@ function categorize(sigma: number | null): AnomalyCategory {
|
|||
return 'normal';
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
function buildWindow(days: DayObservation[], windowSize: number, nrm: MonthNormale): Omit<Anomaly, 'txCategory'> & { txCategory: AnomalyCategory } {
|
||||
async function buildWindow(
|
||||
dept: string,
|
||||
days: DayObservation[],
|
||||
windowSize: number,
|
||||
): Promise<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;
|
||||
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;
|
||||
// Comparer chaque jour observé à SA normale du jour, puis moyenner les écarts.
|
||||
// Plus juste que de moyenner les T° et comparer à une normale moyennée.
|
||||
const data = await loadNormales();
|
||||
const arr = data[dept];
|
||||
|
||||
let txN = 0, txSumDiff = 0, txSumStd2 = 0, txSumTx = 0;
|
||||
let tnN = 0, tnSumDiff = 0, tnSumStd2 = 0, tnSumTn = 0;
|
||||
let normaleTxSum = 0, normaleTxN = 0, normaleTnSum = 0, normaleTnN = 0;
|
||||
|
||||
for (const d of recent) {
|
||||
if (!arr) break;
|
||||
const doy = dayOfYear(new Date(d.date));
|
||||
const n = arr[doy - 1];
|
||||
if (!n) continue;
|
||||
if (d.tx !== null && n.tx !== null) {
|
||||
txN++;
|
||||
txSumDiff += d.tx - n.tx;
|
||||
txSumStd2 += n.txStd * n.txStd;
|
||||
txSumTx += d.tx;
|
||||
normaleTxSum += n.tx; normaleTxN++;
|
||||
}
|
||||
if (d.tn !== null && n.tn !== null) {
|
||||
tnN++;
|
||||
tnSumDiff += d.tn - n.tn;
|
||||
tnSumStd2 += n.tnStd * n.tnStd;
|
||||
tnSumTn += d.tn;
|
||||
normaleTnSum += n.tn; normaleTnN++;
|
||||
}
|
||||
}
|
||||
|
||||
const meanTx = txN > 0 ? +(txSumTx / txN).toFixed(1) : null;
|
||||
const meanTn = tnN > 0 ? +(tnSumTn / tnN).toFixed(1) : null;
|
||||
const normaleTx = normaleTxN > 0 ? +(normaleTxSum / normaleTxN).toFixed(1) : null;
|
||||
const normaleTn = normaleTnN > 0 ? +(normaleTnSum / normaleTnN).toFixed(1) : null;
|
||||
const diffTx = txN > 0 ? +(txSumDiff / txN).toFixed(1) : null;
|
||||
const diffTn = tnN > 0 ? +(tnSumDiff / tnN).toFixed(1) : null;
|
||||
// σ effectif = racine de la moyenne des variances (combinaison de jours différents)
|
||||
const stdTx = txN > 0 ? Math.sqrt(txSumStd2 / txN) : 0;
|
||||
const stdTn = tnN > 0 ? Math.sqrt(tnSumStd2 / tnN) : 0;
|
||||
const sigmaTx = diffTx !== null && stdTx > 0 ? +(diffTx / stdTx).toFixed(2) : null;
|
||||
const sigmaTn = diffTn !== null && stdTn > 0 ? +(diffTn / stdTn).toFixed(2) : null;
|
||||
|
||||
return {
|
||||
windowDays: recent.length, meanTx, meanTn,
|
||||
normaleTx: nrm.tx, normaleTn: nrm.tn,
|
||||
diffTx, diffTn, sigmaTx, sigmaTn,
|
||||
normaleTx, normaleTn, 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 w3 = await buildWindow(dept, days, 3);
|
||||
const w7 = await buildWindow(dept, days, 7);
|
||||
const worst = (SEVERITY[w3.txCategory] ?? -1) >= (SEVERITY[w7.txCategory] ?? -1) ? w3 : w7;
|
||||
return worst;
|
||||
}
|
||||
|
||||
// Compat : ancien helper utilisé ailleurs si présent
|
||||
export async function normaleForMonth(dept: string, month: number): Promise<{ tx: number | null; tn: number | null; txStd: number; tnStd: number } | null> {
|
||||
const data = await loadNormales();
|
||||
const arr = data[dept];
|
||||
if (!arr) return null;
|
||||
// Moyenne les normales du mois pour rétro-compat
|
||||
const mid = new Date(Date.UTC(2024, month - 1, 15));
|
||||
const doy = dayOfYear(mid);
|
||||
const n = arr[doy - 1];
|
||||
if (!n) return null;
|
||||
return { tx: n.tx, tn: n.tn, txStd: n.txStd, tnStd: n.tnStd };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue