/** * Incident ↔ power-outage cross-match (server-26#184). Pure — no React, no * fetch — so it can be unit-tested once a JS runner exists. * * Two granularities, two claims: * - point/polygon data: an outage within OUTAGE_POINT_KM of the incident is * "outage ~0.3 km away". * - county data (ODIN): the incident sits inside an affected county's * bounding box, which only supports " has N meters out" — never * "outage here". A county bbox is not the county shape, so a point near a * border can fall in two; both are listed and the wording says "in or near". * * Stale rows (the source still lists them but their own ETR passed >24 h ago) * are never presented as live outages. */ import type { IncidentRecord, OutageArea, OutageProviderFeed } from "@/lib/types"; import { km, type LatLon } from "@/lib/liveWall"; /** Point-level outages closer than this count as "at" the incident. */ export const OUTAGE_POINT_KM = 0.5; // Wires-down / transformer / manhole / pole / tree-on-wires: the calls an // outage cluster should line up with. Matched against type, tags, title, summary. const UTILITY_HAZARD_RE = new RegExp( [ String.raw`wires?[\s_-]*down`, String.raw`downed[\s_-]+(?:power[\s_-]+|electric(?:al)?[\s_-]+)?(?:wires?|lines?)`, String.raw`(?:power|electric(?:al)?|utility|primary|live)[\s_-]*lines?[\s_-]*down`, String.raw`transformer`, String.raw`manhole`, String.raw`(?:utility|telephone|electric|power)[\s_-]*pole`, String.raw`pole[\s_-]*(?:fire|down|damage|struck|hit)`, String.raw`tree[\s_-]+(?:on|in|into|across|against|through)[\s_-]+(?:the[\s_-]+)?(?:wires?|lines?)`, String.raw`arcing`, ].join("|"), "i", ); export function isUtilityHazard(inc: Pick): boolean { const text = [inc.type, ...(inc.tags ?? []), inc.title, inc.summary].filter(Boolean).join(" \n "); return UTILITY_HAZARD_RE.test(text); } export function isCountyLevel(feed: OutageProviderFeed | undefined, area: OutageArea): boolean { if (feed) return feed.granularity.toLowerCase().startsWith("county"); return !!area.fips; // unknown provider: assume the weaker (county) claim } export function countyName(a: OutageArea): string { const c = a.county ? (/\b(county|parish|borough)\b/i.test(a.county) ? a.county : `${a.county} County`) : `FIPS ${a.fips}`; return a.state ? `${c}, ${a.state}` : c; } function inBbox(p: LatLon, b: [number, number, number, number]): boolean { const [s, w, n, e] = b; return p[0] >= s && p[0] <= n && p[1] >= w && p[1] <= e; } export interface OutageMatch { area: OutageArea; feed?: OutageProviderFeed; kind: "county" | "point"; /** Distance to the nearest incident point (point data only). */ km: number | null; /** More than one county bbox holds the incident — the county is uncertain. */ ambiguous: boolean; /** The incident reads as a wires-down/transformer/manhole/pole/tree-on-wires call. */ hazard: boolean; /** Honest one-line wording for the panel. */ text: string; } const n = (v: number) => v.toLocaleString(); /** The cross-match for one incident. Live (non-stale) outages only, nearest/biggest first. */ export function matchOutages( inc: Pick, points: LatLon[], areas: OutageArea[], providers: OutageProviderFeed[], ): OutageMatch[] { if (!points.length) return []; const feeds = new Map(providers.map((p) => [p.id, p])); const hazard = isUtilityHazard(inc); const county: { area: OutageArea; feed?: OutageProviderFeed }[] = []; const point: OutageMatch[] = []; for (const area of areas) { if (area.stale || area.customers_out <= 0) continue; const feed = feeds.get(area.provider); if (isCountyLevel(feed, area)) { const hit = area.bbox ? points.some((p) => inBbox(p, area.bbox!)) : false; // no county extent → no containment claim if (hit) county.push({ area, feed }); } else if (area.lat != null && area.lon != null) { const d = Math.min(...points.map((p) => km(p, [area.lat!, area.lon!]))); if (d <= OUTAGE_POINT_KM) { point.push({ area, feed, kind: "point", km: d, ambiguous: false, hazard, text: `${n(area.customers_out)} meters/customers out ~${d < 0.1 ? "<0.1" : d.toFixed(1)} km away (utility-reported)`, }); } } } const ambiguous = county.length > 1; const countyMatches: OutageMatch[] = county .sort((a, b) => b.area.customers_out - a.area.customers_out) .map(({ area, feed }) => ({ area, feed, kind: "county", km: null, ambiguous, hazard, text: `${ambiguous ? "In or near " : ""}${countyName(area)}: county has ${n(area.customers_out)} meters/customers out (utility-reported, county-level — not a location)`, })); return [...point.sort((a, b) => (a.km ?? 0) - (b.km ?? 0)), ...countyMatches]; } /** Stale-only counties the incident sits in — said out loud so they aren't mistaken for clear. */ export function staleCountiesAt(points: LatLon[], areas: OutageArea[]): OutageArea[] { return areas.filter((a) => a.stale && a.bbox && points.some((p) => inBbox(p, a.bbox!))); }