From 9cfd4f8385ab6a84c3ccf5981200cd45b34abfa8 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 26 May 2026 00:07:18 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20anomalie=20fen=C3=AAtres=203j+7j=20(wors?= =?UTF-8?q?t-case)=20+=20carte=20plus=20large=20PC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- Données_d’observation_swagger.json | 460 ++++++++++++++++++ ..._d’observation_swagger.json:Zone.Identifier | Bin 0 -> 25 bytes scripts/build-stations-synop.mjs | 91 ++++ src/components/FranceMap.astro | 2 +- src/data/stations-synop.json | 1 + src/lib/normales.ts | 71 +-- 6 files changed, 592 insertions(+), 33 deletions(-) create mode 100644 Données_d’observation_swagger.json create mode 100644 Données_d’observation_swagger.json:Zone.Identifier create mode 100644 scripts/build-stations-synop.mjs create mode 100644 src/data/stations-synop.json diff --git a/Données_d’observation_swagger.json b/Données_d’observation_swagger.json new file mode 100644 index 0000000..18dbff1 --- /dev/null +++ b/Données_d’observation_swagger.json @@ -0,0 +1,460 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "DonneesPubliquesObservation", + "version": "1" + }, + "servers": [ + { + "url": "https://public-api.meteofrance.fr/public/DPObs/v1" + } + ], + "security": [ + { + "default": [] + } + ], + "tags": [ + { + "name": "Produits Obs", + "description": "Services de téléchargement des données d'observation à la date demandée." + }, + { + "name": "Produits Synop", + "description": "Services de téléchargement des messages internationaux d’observation en surface, SYNOP, à la date demandée." + }, + { + "name": "Produits Bouees", + "description": "ProduitBouees - Service de téléchargement des données d'observations météorologiques effectuées par les bouées Météo-France, à la date demandée" + } + ], + "paths": { + "/station/infrahoraire-6m": { + "get": { + "tags": [ + "Produits Obs" + ], + "summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données d'observation pour tous les paramètres disponibles, à la fréquence 6 minutes, pour une station, à la date la plus proche de la date demandée.", + "description": "Renvoie tous les paramètres disponibles pour la station demandée et pour :\n- la date/heure la plus proche de la date demandée selon les données disponibles.", + "parameters": [ + { + "name": "id_station", + "in": "query", + "description": "Identifiant de la station (nomenclature : 8 chiffres selon DDCCCNNN = insee de la commune (DD département, CCC n° de la commune dans le département et NNN n° de la station dans la commune)", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "description": "Date demandée (au format ISO 8601 avec TZ UTC : AAAA-MM-JJThh:mm:ssZ).\n\nPar défaut = date courante", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "format", + "in": "query", + "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "enum": [ + "json", + "csv", + "geojson" + ] + } + } + ], + "responses": { + "200": { + "description": "Si une liste vide est renvoyée : station absente ou inexistante\n \nSinon : OK" + }, + "400": { + "description": "Contrôle de paramètres en erreur" + }, + "404": { + "description": "Jeu de données inexistant" + } + }, + "security": [ + { + "default": [] + } + ], + "x-auth-type": "Application & Application User", + "x-throttling-tier": "Unlimited" + } + }, + "/station/horaire": { + "get": { + "tags": [ + "Produits Obs" + ], + "summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données d’observation pour tous les paramètres disponibles, à la fréquence horaire, pour une station, à la date la plus proche de la date demandée.", + "description": "Renvoie tous les paramètres disponibles pour la station demandée et pour :\n- la date/heure la plus proche de la date demandée selon les données disponibles.", + "parameters": [ + { + "name": "id_station", + "in": "query", + "description": "Identifiant de la station (nomenclature : 8 chiffres selon DDCCCNNN = insee de la commune (DD département, CCC n° de la commune dans le département et NNN n° de la station dans la commune)", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "description": "Date demandée (au format ISO 8601 avec TZ UTC : AAAA-MM-JJThh:mm:ssZ).\n\nPar défaut = date courante", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "format", + "in": "query", + "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "enum": [ + "json", + "csv", + "geojson" + ] + } + } + ], + "responses": { + "200": { + "description": "Si une liste vide est renvoyée : station absente ou inexistante\n \nSinon : OK" + }, + "400": { + "description": "Contrôle de paramètres en erreur" + }, + "404": { + "description": "Jeu de données inexistant" + } + }, + "security": [ + { + "default": [] + } + ], + "x-auth-type": "Application & Application User", + "x-throttling-tier": "Unlimited" + } + }, + "/liste-stations": { + "get": { + "tags": [ + "Produits Obs" + ], + "summary": "Télécharger le fichier TEXTE (CSV) de la liste des stations d'observation.", + "description": "Renvoie la liste des stations.", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "La liste est indisponible" + } + }, + "security": [ + { + "default": [] + } + ], + "x-auth-type": "Application & Application User", + "x-throttling-tier": "Unlimited" + } + }, + "/liste-stations-synop": { + "get": { + "tags": [ + "Produits Synop" + ], + "summary": "Télécharger le fichier TEXTE de la liste des stations d'observation.", + "description": "Renvoie la liste des stations.", + "parameters": [ + { + "name": "format", + "in": "query", + "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "enum": [ + "json", + "csv", + "geojson" + ] + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "La liste est indisponible" + } + }, + "security": [ + { + "default": [] + } + ], + "x-auth-type": "Application & Application User", + "x-throttling-tier": "Unlimited" + } + }, + "/synop": { + "get": { + "tags": [ + "Produits Synop" + ], + "summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données SYNOP pour une station, à la date demandée.", + "description": "Renvoie tous les paramètres disponibles pour la station et pour la date/heure demandées.", + "parameters": [ + { + "name": "format", + "in": "query", + "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "enum": [ + "json", + "csv", + "geojson" + ] + } + }, + { + "name": "id_station", + "in": "query", + "description": "Identifiant(s) de la station (séparés par des , par exemple id_station=07002,07003)", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "date_debut", + "in": "query", + "description": "Date de début d'observation au format AAAA-MM-JJTHH:mm:00Z.", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "date_fin", + "in": "query", + "description": "Date de fin d'observation au format AAAA-MM-JJTHH:mm:00Z.", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK (liste vide si pas de donnée correspondant à la requête)" + }, + "400": { + "description": "Contrôle de paramètres en erreur" + }, + "401": { + "description": "Non autorisé - informations d'identification non valides" + }, + "403": { + "description": "Accès interdit" + }, + "404": { + "description": "Jeu de données inexistant et/ou jeu de données indisponible" + }, + "500": { + "description": "Erreur interne au serveur" + }, + "502": { + "description": "Erreur de passerelle" + }, + "503": { + "description": "Service indisponible" + }, + "504": { + "description": "Temps trop long" + } + }, + "security": [ + { + "default": [] + } + ], + "x-auth-type": "Application & Application User", + "x-throttling-tier": "Unlimited" + } + }, + "/liste-bouees": { + "get": { + "tags": [ + "Produits Bouees" + ], + "summary": "Télécharger le fichier TEXTE de la liste des stations d'observation.", + "description": "Renvoie la liste des stations et bouées.", + "parameters": [ + { + "name": "format", + "in": "query", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "enum": [ + "json", + "csv", + "geojson" + ] + } + } + ], + "responses": { + "200": { + "description": "ok" + }, + "404": { + "description": "La liste est indisponible" + } + }, + "security": [ + { + "default": [] + } + ], + "x-auth-type": "Application & Application User", + "x-throttling-tier": "Unlimited" + } + }, + "/bouees": { + "get": { + "tags": [ + "Produits Bouees" + ], + "summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données d'observations pour une station, à la date demandée.", + "description": "Renvoie tous les paramètres disponibles pour la station à la date heure demandée.", + "parameters": [ + { + "name": "format", + "in": "query", + "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "enum": [ + "json", + "csv", + "geojson" + ] + } + }, + { + "name": "date_debut", + "in": "query", + "description": "Date de début d'observation au format AAAA-MM-JJTHH:mm:00Z", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "date_fin", + "in": "query", + "description": "Date de fin d'observation au format AAAA-MM-JJTHH:mm:00Z", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "id_bouees", + "in": "query", + "description": "Identifiant(s) de la station (séparés par des , par exemple id_station=6100001,6100002)", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ok (liste vide si aucune donnée ne correspondent à la requête)" + } + }, + "security": [ + { + "default": [] + } + ], + "x-auth-type": "Application & Application User", + "x-throttling-tier": "Unlimited" + } + } + }, + "components": { + "securitySchemes": { + "default": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://public-api.meteofrance.fr/authorize", + "scopes": {} + } + } + } + } + } +} \ No newline at end of file diff --git a/Données_d’observation_swagger.json:Zone.Identifier b/Données_d’observation_swagger.json:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} + +// Parse CSV stations +const rows = readFileSync(STATIONS_CSV, 'utf-8').trim().split('\n'); +const header = rows[0].split(';'); +const idx = { + lat: header.indexOf('lat'), + lon: header.indexOf('lon'), + wmo: header.indexOf('geo_id_wmo'), + name: header.indexOf('name'), +}; +const stations = rows.slice(1).map((line) => { + const c = line.split(';'); + return { + id: c[idx.wmo], + name: c[idx.name], + lat: parseFloat(c[idx.lat]), + lon: parseFloat(c[idx.lon]), + }; +}).filter((s) => Number.isFinite(s.lat) && Number.isFinite(s.lon) && s.id); + +console.log(`Loaded ${stations.length} SYNOP stations`); + +// Centroïde par dept depuis le GeoJSON (moyenne simple des coords du contour externe). +const geojson = JSON.parse(readFileSync(GEOJSON, 'utf-8')); +const centroids = {}; +for (const f of geojson.features) { + const code = String(f.properties.code); + const polys = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates; + let sumLat = 0, sumLon = 0, n = 0; + for (const poly of polys) { + const ring = poly[0]; // contour externe + for (const [lng, lat] of ring) { sumLat += lat; sumLon += lng; n++; } + } + if (n) centroids[code] = { lat: sumLat / n, lon: sumLon / n }; +} +console.log(`Computed ${Object.keys(centroids).length} dept centroids`); + +// Plus proche station par dept. +const mapping = {}; +for (const [code, c] of Object.entries(centroids)) { + let best = null; + for (const s of stations) { + const d = distKm(c.lat, c.lon, s.lat, s.lon); + if (!best || d < best.distKm) best = { stationId: s.id, name: s.name, distKm: +d.toFixed(1) }; + } + mapping[code] = best; +} + +mkdirSync(dirname(OUT), { recursive: true }); +writeFileSync(OUT, JSON.stringify(mapping, null, 0)); + +// Stats : moyenne, médiane, max des distances. +const dists = Object.values(mapping).map((m) => m.distKm).sort((a, b) => a - b); +const med = dists[Math.floor(dists.length / 2)]; +const max = dists[dists.length - 1]; +const farDepts = Object.entries(mapping).filter(([, m]) => m.distKm > 50).map(([k, m]) => `${k}=${m.distKm}km`); +console.log(`distKm: median=${med}, max=${max}, depts >50km away from station: ${farDepts.join(', ')}`); +console.log(`Wrote ${OUT}`); diff --git a/src/components/FranceMap.astro b/src/components/FranceMap.astro index 7d07e95..c11476b 100644 --- a/src/components/FranceMap.astro +++ b/src/components/FranceMap.astro @@ -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" > Carte Vigilance Météo France diff --git a/src/data/stations-synop.json b/src/data/stations-synop.json new file mode 100644 index 0000000..61894cb --- /dev/null +++ b/src/data/stations-synop.json @@ -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}} \ No newline at end of file diff --git a/src/lib/normales.ts b/src/lib/normales.ts index dcfd468..555f694 100644 --- a/src/lib/normales.ts +++ b/src/lib/normales.ts @@ -32,8 +32,18 @@ export async function normaleForMonth(dept: string, month: number): Promise 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 { - 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 = { + 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 & { 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 { + 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; +}