fix(storm-intel): correct IEM SBW endpoint to use sts/ets timestamps + handle MultiPolygon

Use /geojson/sbw.geojson?sts=...&ets=... instead of year param (which was
being ignored). Handle MultiPolygon geometry type from IEM response. Use
correct 'issue' date field. Filter phenomena server-side (SV/TO/FF only).
Use hailtag field for accurate severity on SVR warnings.
This commit is contained in:
Satyam-Rastogi
2026-05-18 23:58:39 +05:30
parent fd353ab732
commit 5cd0d456f4
+79 -80
View File
@@ -1,94 +1,108 @@
/** /**
* Fetches historical NWS storm-based warnings (SVR + TOR) from the Iowa * Fetches historical NWS storm-based warnings (SVR + TOR) from the Iowa
* Environmental Mesonet (IEM) archive for WFO FWD (Fort Worth), which covers * Environmental Mesonet (IEM) for WFO FWD (Fort Worth), which covers
* Collin County and the Plano TX metro area. * Collin County and the Plano TX metro area.
* *
* IEM SBW archive — free, no auth, GeoJSON polygons derived from NEXRAD. * Correct endpoint: /geojson/sbw.geojson with sts/ets timestamp params.
* https://mesonet.agron.iastate.edu/api/1/nws/sbw.geojson * Geometry is MultiPolygon; date field is "issue"; phenomena filtered server-side.
* *
* Response: array of storm events matching MOCK_STORM_EVENTS schema. * No API key required — IEM is a free public service (Iowa State University).
* Cached 12 hours on Vercel edge — historical data rarely changes. * Cached 12 hours on Vercel edge.
*/ */
const IEM_BASE = 'https://mesonet.agron.iastate.edu/api/1/nws/sbw.geojson'; const IEM_BASE = 'https://mesonet.agron.iastate.edu/geojson/sbw.geojson';
const WFO = 'FWD'; // Fort Worth NWS office — covers Collin County / Plano TX const WFO = 'FWD'; // Fort Worth NWS office — covers Collin County / Plano TX
// Generous bounding box covering Plano, Allen, McKinney, Frisco, Richardson // Generous bounding box: Plano, Allen, McKinney, Frisco, Richardson
const BBOX = { minLat: 32.85, maxLat: 33.40, minLon: -97.10, maxLon: -96.35 }; const BBOX = { minLat: 32.85, maxLat: 33.45, minLon: -97.15, maxLon: -96.35 };
const IEM_HEADERS = { const IEM_HEADERS = {
'User-Agent': 'LynkedUpPro-CRM/1.0 (admin@lynkedup.com)', 'User-Agent': 'LynkedUpPro-CRM/1.0 (admin@lynkedup.com)',
Accept: 'application/geo+json', Accept: 'application/geo+json',
}; };
function polygonIntersectsBBox(rawCoords) { // Only keep storm-relevant phenomena
const KEEP_PHENOMENA = new Set(['SV', 'TO', 'FF']);
function polygonInBBox(rawCoords) {
return rawCoords.some(([lon, lat]) => return rawCoords.some(([lon, lat]) =>
lat >= BBOX.minLat && lat <= BBOX.maxLat && lat >= BBOX.minLat && lat <= BBOX.maxLat &&
lon >= BBOX.minLon && lon <= BBOX.maxLon lon >= BBOX.minLon && lon <= BBOX.maxLon
); );
} }
function derivedAreaName(rawCoords, phenomena) { function extractRing(geom) {
const n = rawCoords.length; if (!geom) return null;
if (geom.type === 'Polygon' && geom.coordinates?.[0]?.length >= 3)
return geom.coordinates[0];
if (geom.type === 'MultiPolygon' && geom.coordinates?.[0]?.[0]?.length >= 3)
return geom.coordinates[0][0]; // first ring of first polygon
return null;
}
function derivedAreaName(rawCoords, ph) {
const n = rawCoords.length;
const centerLat = rawCoords.reduce((s, [, lat]) => s + lat, 0) / n; const centerLat = rawCoords.reduce((s, [, lat]) => s + lat, 0) / n;
const centerLon = rawCoords.reduce((s, [lon]) => s + lon, 0) / n; const centerLon = rawCoords.reduce((s, [lon]) => s + lon, 0) / n;
let city; let city;
if (centerLat > 33.22) city = 'McKinney'; if (centerLat > 33.22) city = 'McKinney';
else if (centerLat > 33.16 && centerLon < -96.79) city = 'Frisco'; else if (centerLat > 33.16 && centerLon < -96.79) city = 'Frisco';
else if (centerLat > 33.16) city = 'N. Plano / Allen'; else if (centerLat > 33.16) city = 'N. Plano / Allen';
else if (centerLat > 33.10) city = 'Allen'; else if (centerLat > 33.10) city = 'Allen';
else if (centerLon < -96.84) city = 'Legacy Corridor'; else if (centerLon < -96.84) city = 'Legacy Corridor';
else if (centerLat > 33.04) city = 'Central Plano'; else if (centerLat > 33.04) city = 'Central Plano';
else city = 'S. Plano'; else city = 'S. Plano';
const evtLabel = phenomena === 'TO' ? 'Tornado Warning' const label = ph === 'TO' ? 'Tornado Warning'
: phenomena === 'FF' ? 'Flash Flood Warning' : ph === 'FF' ? 'Flash Flood Warning'
: 'Severe Thunderstorm'; : 'Severe Thunderstorm';
return `${city}${evtLabel}`; return `${city}${label}`;
} }
function toStormEvent(feature, idx) { function toStormEvent(feature, idx) {
const p = feature.properties || {}; const p = feature.properties || {};
const geom = feature.geometry; const ph = p.phenomena || '';
if (!geom?.coordinates?.[0]) return null;
const rawCoords = geom.coordinates[0]; // GeoJSON: [lon, lat] if (!KEEP_PHENOMENA.has(ph)) return null;
if (rawCoords.length < 3 || !polygonIntersectsBBox(rawCoords)) return null;
// Swap to Leaflet [lat, lon] order const rawCoords = extractRing(feature.geometry);
if (!rawCoords || !polygonInBBox(rawCoords)) return null;
// GeoJSON coords are [lon, lat]; Leaflet needs [lat, lon]
const polygon = rawCoords.map(([lon, lat]) => [lat, lon]); const polygon = rawCoords.map(([lon, lat]) => [lat, lon]);
const phenomena = p.phenomena || 'SV'; const type = ph === 'TO' ? 'tornado' : ph === 'FF' ? 'flood' : 'hail';
const type = phenomena === 'TO' ? 'tornado'
: phenomena === 'FF' ? 'flood'
: 'hail';
// area2163 = area in km² (EPSG:2163 equal-area projection). Use as severity proxy. // hailtag stores hail size in inches (e.g. "1.75") — present on SVR warnings
const areaSqKm = parseFloat(p.area2163) || 0; const hailIn = p.hailtag ? parseFloat(p.hailtag) : null;
const severity = phenomena === 'TO' ? 'severe'
: areaSqKm > 900 ? 'severe'
: areaSqKm > 400 ? 'significant'
: areaSqKm > 120 ? 'moderate'
: 'trace';
// Rough homes estimate: ~1200 homes/sq mile * 0.386 sq miles/km² ≈ 463 homes/km² let severity;
const estimatedHomes = areaSqKm > 0 if (ph === 'TO') {
? Math.min(Math.round((areaSqKm * 463) / 50) * 50, 50000) severity = 'severe';
: null; } else if (hailIn !== null) {
severity = hailIn >= 2.0 ? 'severe'
: hailIn >= 1.5 ? 'significant'
: hailIn >= 1.0 ? 'moderate'
: 'trace';
} else if (ph === 'FF') {
severity = 'moderate';
} else {
severity = 'significant'; // SVR without hail tag
}
const issued = p.issued || p.expire || new Date().toISOString(); const date = p.issue || p.polygon_begin || new Date().toISOString();
return { return {
id: `IEM-${WFO}-${phenomena}-${p.eventid ?? idx}`, id: `IEM-${WFO}-${ph}-${p.year ?? ''}-${p.eventid ?? idx}`,
date: issued, date,
type, type,
severity, severity,
maxHailSize: null, maxHailSize: hailIn,
areaName: derivedAreaName(rawCoords, phenomena), areaName: derivedAreaName(rawCoords, ph),
county: 'Collin', county: 'Collin',
estimatedHomes, estimatedHomes: null,
description: `${type === 'tornado' ? 'Tornado Warning' : type === 'flood' ? 'Flash Flood Warning' : 'Severe Thunderstorm Warning'} issued by NWS ${p.wfo || WFO}`, description: `${type === 'tornado' ? 'Tornado Warning' : type === 'flood' ? 'Flash Flood Warning' : 'Severe Thunderstorm Warning'} NWS ${WFO}`,
source: 'IEM/NWS', source: 'IEM/NWS',
polygon, polygon,
}; };
@@ -101,45 +115,30 @@ export default async function handler(req, res) {
if (req.method === 'OPTIONS') return res.status(200).end(); if (req.method === 'OPTIONS') return res.status(200).end();
if (req.method !== 'GET') return res.status(405).json({ error: 'Method not allowed' }); if (req.method !== 'GET') return res.status(405).json({ error: 'Method not allowed' });
const months = Math.min(parseInt(req.query.months ?? '36', 10), 36); const months = Math.min(parseInt(req.query.months ?? '36', 10), 36);
const now = new Date(); const now = new Date();
const cutoff = new Date(now.getTime() - months * 30.44 * 86400000); const cutoff = new Date(now.getTime() - months * 30.44 * 86400000);
// Collect every calendar year between cutoff and now const sts = cutoff.toISOString();
const years = new Set(); const ets = now.toISOString();
for (let y = cutoff.getFullYear(); y <= now.getFullYear(); y++) years.add(y); const url = `${IEM_BASE}?sts=${encodeURIComponent(sts)}&ets=${encodeURIComponent(ets)}&wfo=${WFO}`;
// Fetch SV (Severe Thunderstorm) + TO (Tornado) per year in parallel
const phenomena = ['SV', 'TO'];
const fetches = [];
for (const year of years) {
for (const ph of phenomena) {
const url = `${IEM_BASE}?wfo=${WFO}&year=${year}&phenomena=${ph}&significance=W`;
fetches.push(
fetch(url, { headers: IEM_HEADERS })
.then(r => r.ok ? r.json() : { features: [] })
.catch(() => ({ features: [] }))
);
}
}
try { try {
const results = await Promise.all(fetches); const response = await fetch(url, { headers: IEM_HEADERS });
const features = results.flatMap(r => Array.isArray(r.features) ? r.features : []); if (!response.ok) throw new Error(`IEM ${response.status}`);
const geojson = await response.json();
const features = Array.isArray(geojson.features) ? geojson.features : [];
const events = features const events = features
.map((f, i) => toStormEvent(f, i)) .map((f, i) => toStormEvent(f, i))
.filter(Boolean) .filter(Boolean)
.filter(e => {
const d = new Date(e.date);
return d >= cutoff && d <= now;
})
.sort((a, b) => new Date(b.date) - new Date(a.date)) .sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 150); // cap at 150 events .slice(0, 150);
return res.status(200).json(events); return res.status(200).json(events);
} catch (err) { } catch (err) {
console.error('[storm-polygons] fetch error:', err.message); console.error('[storm-polygons] error:', err.message);
return res.status(200).json([]); return res.status(200).json([]);
} }
} }