diff --git a/drb-c2-core/app/internal/outages/__init__.py b/drb-c2-core/app/internal/outages/__init__.py new file mode 100644 index 0000000..948fe44 --- /dev/null +++ b/drb-c2-core/app/internal/outages/__init__.py @@ -0,0 +1,8 @@ +"""Pluggable power-outage providers (map layer, server-26#184). + +Same shape as ``app.internal.cameras``: every source is one adapter carrying +its catalogued licence status, every provider defaults OFF, and a licence-gated +one serves only after an admin acknowledgement recorded in audit_log +(``registry``). Scraped utility storm-center JSON is never shipped -- those +sources are stubs until a data-sharing agreement exists. +""" diff --git a/drb-c2-core/app/internal/outages/base.py b/drb-c2-core/app/internal/outages/base.py new file mode 100644 index 0000000..072fa57 --- /dev/null +++ b/drb-c2-core/app/internal/outages/base.py @@ -0,0 +1,201 @@ +"""Outage provider interface, normalised record and the per-provider feed cache. + +The licence model (``Licence``, ``LicenceInfo``, the GATED set) is the camera +layer's, imported -- one catalogue vocabulary for every licence-gated source. +Only the acknowledgement wording differs (outage data, not imagery), so +``OutageLicenceInfo`` overrides ``warning()`` and nothing else. + +Outage feeds are public and identical for every org, so -- like the camera +catalogs -- they are cached in memory, fetched lazily on request, and never +written to Firestore. A failed refresh keeps serving the last good feed and +reports the error and its age: a dead feed must show as an error, never as an +empty layer (the AI-silent-failures lesson). +""" +import asyncio +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from app.internal.cameras.base import ( # noqa: F401 (re-exported for adapters) + BBox, Licence, LicenceInfo, bbox_intersects, finite, get_json, require_parsed, safe_error, +) +from app.internal.logger import logger + +RETRY_AFTER_FAILURE_S = 60 +DEFAULT_FEED_TTL_S = 5 * 60 # ODIN asks utilities for updates every 5-15 min +USER_AGENT = "DRB-c2-core/1.0 (+public power-outage data; contact via odin.ornl.gov)" + +OutageArea = Dict[str, Any] + + +@dataclass(frozen=True) +class OutageLicenceInfo(LicenceInfo): + """``LicenceInfo`` with the acknowledgement worded for outage data.""" + + def warning(self) -> Optional[str]: + if self.status == Licence.PROHIBITED: + return f"{self.label}. This source's terms forbid this use; it cannot be enabled." + if not self.gated: + return None + return (f"{self.label}. {self.terms} Enabling it means DRB shows this source's outage data " + f"to every signed-in user. Only confirm if DRB actually holds the right to do so.") + + +@dataclass(frozen=True) +class OutageProviderMeta: + id: str + name: str + region: str + coverage: BBox # rough bbox; a query outside it never triggers a fetch + licence: LicenceInfo + granularity: str # "county", "point/polygon", ... + implemented: bool + notes: str # what is verified vs unverified about this adapter + homepage: Optional[str] = None + ttl_s: int = DEFAULT_FEED_TTL_S + + +class OutageProvider: + """Base adapter. Subclasses set ``meta`` and implement ``fetch``.""" + meta: OutageProviderMeta + + async def fetch(self) -> List[OutageArea]: + raise NotImplementedError(f"{self.meta.id}: adapter is a stub ({self.meta.notes})") + + def coverage_note(self) -> Optional[str]: + """What this source can and cannot see, shown next to its data.""" + return None + + +class StubProvider(OutageProvider): + """A catalogued source with no adapter -- listed so Admin shows its licence + status, but it can never be enabled.""" + + def __init__(self, meta: OutageProviderMeta): + assert not meta.implemented + self.meta = meta + + +class Feed: + """In-memory cache of one provider's normalised outage areas.""" + + def __init__(self, provider: OutageProvider): + self.provider = provider + self.items: List[OutageArea] = [] + self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch + self.last_attempt_at: Optional[float] = None + self.error: Optional[str] = None + self._next_attempt = 0.0 + self._lock = asyncio.Lock() + + async def get(self) -> "Feed": + if time.time() < self._next_attempt: + return self + async with self._lock: + if time.time() < self._next_attempt: + return self + ttl = self.provider.meta.ttl_s + self.last_attempt_at = time.time() + try: + items = await self.provider.fetch() + self.items = items + self.fetched_at = time.time() + self.error = None + self._next_attempt = time.time() + ttl + except Exception as e: + self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S) + self.error = safe_error(e) + logger.warning(f"outages/{self.provider.meta.id}: refresh failed, serving {len(self.items)} cached: {self.error}") + return self + + def status(self) -> dict: + return {"fetched_at": self.fetched_at, "last_attempt_at": self.last_attempt_at, + "error": self.error, "count": len(self.items)} + + +# ── helpers shared by adapters ─────────────────────────────────────────────── + +def make_area(provider_id: str, fips: str, *, county: Optional[str], state: Optional[str], customers_out: int, + customers_served: Optional[int] = None, etr: Optional[str] = None, since: Optional[str] = None, + updated_at: Optional[str] = None, lat: Optional[float] = None, lon: Optional[float] = None, + bbox: Optional[BBox] = None, utilities: Optional[List[dict]] = None, + stale_customers_out: int = 0) -> OutageArea: + """One normalised county outage record. ``id`` is provider-prefixed. + + ``customers_out`` counts only live events. ``stale_customers_out`` counts + events the source still lists whose own restoration estimate passed long + ago (ODIN carries April rows in October) -- kept visible, never summed in. + ``stale`` is True when every event in the county is stale.""" + return { + "id": f"{provider_id}:{fips}", + "provider": provider_id, + "fips": fips, "county": county, "state": state, + "customers_out": customers_out, + "stale_customers_out": stale_customers_out, + "stale": customers_out == 0 and stale_customers_out > 0, + "customers_served": customers_served, + "etr": etr, # latest estimated restoration across the county's outages, if any + "since": since, # earliest reported start across the county's outages + "updated_at": updated_at, # newest time a reporting utility in this county last sent data + # Approximate: the centre of the county's bounding box, for placing a label. + "lat": lat, "lon": lon, + "bbox": list(bbox) if bbox else None, # south, west, north, east + "utilities": utilities or [], + } + + +def area_in_bbox(area: OutageArea, bbox: BBox) -> bool: + """County bbox intersects the query; falls back to the label point.""" + b = area.get("bbox") + if b and len(b) == 4: + return bbox_intersects((b[0], b[1], b[2], b[3]), bbox) + lat, lon = area.get("lat"), area.get("lon") + if lat is None or lon is None: + return False + s, w, n, e = bbox + return s <= lat <= n and w <= lon <= e + + +def parse_iso(v: Any) -> Optional[datetime]: + """ISO-8601 (with Z or offset) -> aware UTC datetime; None when unparseable.""" + if not isinstance(v, str) or not v: + return None + try: + d = datetime.fromisoformat(v.replace("Z", "+00:00")) + except ValueError: + return None + return (d if d.tzinfo else d.replace(tzinfo=timezone.utc)).astimezone(timezone.utc) + + +def iso(d: Optional[datetime]) -> Optional[str]: + return d.isoformat().replace("+00:00", "Z") if d else None + + +def geometry_bbox(geom: Any) -> Optional[BBox]: + """Bounding box (south, west, north, east) of a GeoJSON Polygon/MultiPolygon.""" + if not isinstance(geom, dict): + return None + lats: List[float] = [] + lons: List[float] = [] + + def walk(c: Any) -> None: + if isinstance(c, list) and len(c) >= 2 and not isinstance(c[0], list): + lon, lat = finite(c[0]), finite(c[1]) + if lon is not None and lat is not None and -180 <= lon <= 180 and -90 <= lat <= 90: + lons.append(lon) + lats.append(lat) + elif isinstance(c, list): + for x in c: + walk(x) + + walk(geom.get("coordinates")) + if not lats: + return None + return (min(lats), min(lons), max(lats), max(lons)) + + +def bbox_centre(b: Optional[BBox]) -> Tuple[Optional[float], Optional[float]]: + if not b: + return None, None + return (round((b[0] + b[2]) / 2, 5), round((b[1] + b[3]) / 2, 5)) diff --git a/drb-c2-core/app/internal/outages/providers.py b/drb-c2-core/app/internal/outages/providers.py new file mode 100644 index 0000000..520a2c9 --- /dev/null +++ b/drb-c2-core/app/internal/outages/providers.py @@ -0,0 +1,273 @@ +"""Outage sources -- server-26#184 (source-terms table in its 2026-10-05 comment). + +Only ODIN has an adapter. Every other source is listed so Admin shows its +licence status, and is a stub because DRB holds no right to use it: the utility +storm-center maps are undocumented JSON with no reuse licence (the panel ruling +stands -- do not ship scraped utility data commercially), PowerOutage.us +licenses its API for personal/non-commercial use only, and EAGLE-I's live feed +is a DOE partner system. Do not "fix" a stub by scraping. + +ODIN, verified first-hand 2026-10-05 against https://odin.ornl.gov/v3/api-docs +(the OpenAPI document the ODIN developer guide points at) and live responses: + + GET /odi?format=JSON public, no token. ``{"outage": [CIM Outage, ...]}``, + one row per outage event, every row aggregated to a + county: ``communityDescriptor`` = 5-digit county FIPS, + ``metersAffected``, ``reportedStartTime``, + ``estimatedRestorationTime.ert``, ``names`` carrying + ``UtilityName`` / ``UtilityId`` (EIA id). + GET /odi/map?scale=COUNTY public. One row per county with active outages: + ``id`` (FIPS), ``countyName``, ``stateName``, + ``metersOut`` (= sum of /odi metersAffected, checked), + ``feature`` (county Polygon/MultiPolygon). + GET /odi/status public. One row per reporting utility: ``eiaId``, + ``name``, ``receivedDate``, ``hasError``. + +``metersServed`` on /odi/map is the utility's system-wide total (National Grid +NY carries 1,686,884 on every county), so it is NOT a county denominator and +``customers_served`` stays None. +""" +import asyncio +import re +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional, Tuple + +from app.internal.outages.base import ( + Licence, OutageArea, OutageLicenceInfo, OutageProvider, OutageProviderMeta, StubProvider, USER_AGENT, + bbox_centre, finite, geometry_bbox, get_json, iso, make_area, parse_iso, require_parsed, +) + +US_BBOX = (17.5, -180.0, 72.0, -64.0) # CONUS + AK + HI + PR, rough +NY_BBOX = (40.4, -80.0, 45.1, -71.7) + +ODIN_BASE = "https://odin.ornl.gov" +ODIN_OPENAPI = "https://odin.ornl.gov/v3/api-docs" +_FIPS = re.compile(r"^\d{5}$") +# An event whose own estimated restoration passed this long ago is stale: ODIN +# still listed Avangrid rows from April 2026 (ETR April/May) on 2026-10-05. +# Those are not live outages and must not be summed into one or set "since". +STALE_AFTER_ETR = timedelta(hours=24) + + +class OdinProvider(OutageProvider): + meta = OutageProviderMeta( + id="odin", + name="ODIN (Oak Ridge National Laboratory / DOE)", + region="United States -- participating utilities only (NY: National Grid, Central Hudson, Avangrid reporting 2026-10-05)", + coverage=US_BBOX, + licence=OutageLicenceInfo( + status=Licence.UNVERIFIED, + label="Licence unverified -- public county-level API, but no terms of use or reuse licence found", + terms=("ODIN documents GET /odi, /odi/map and /odi/status as public, non-authenticated endpoints " + "returning current outages aggregated to county FIPS (OpenAPI read first-hand 2026-10-05). " + "That document carries no licence or terms-of-service field, and no terms-of-use page was " + "found on odin.ornl.gov. ORNL is operated for DOE by a contractor and the data is " + "utility-reported, so federal public-domain status cannot be assumed. Confirm reuse terms " + "with ODIN (odin.ornl.gov/pages/contact.html) before relying on it commercially."), + terms_url=ODIN_OPENAPI, + attribution="Outage data: ODIN, Oak Ridge National Laboratory (utility-reported)", + attribution_url="https://odin.ornl.gov", + disclaimer=("County-level counts of meters reported out by utilities that participate in ODIN. " + "A utility that does not report to ODIN shows no outages here, even during one. " + "Restoration times are the utility's estimate, when given."), + issue="server-26#184", + ), + granularity="county (FIPS)", + implemented=True, + notes=("Public API, verified first-hand 2026-10-05: /odi?format=JSON (counts, start, ETR, utility EIA id), " + "/odi/map?scale=COUNTY (county name, state, county polygon -> bbox and label point) and /odi/status " + "(utility last-received time). Counts are meters, used as customers. Con Edison / O&R are on ODIN's " + "participant list but were NOT reporting on 2026-10-05, so NYC/Westchester show nothing from ODIN " + "until they do. Schema canary: rows upstream but none parseable is an error, never an empty layer."), + homepage="https://odin.ornl.gov", + ) + + def __init__(self) -> None: + self.reporting_utilities: Optional[int] = None + self.utilities_with_error: Optional[int] = None + + async def fetch(self) -> List[OutageArea]: + h = {"User-Agent": USER_AGENT} + odi, county_map, status = await asyncio.gather( + get_json(f"{ODIN_BASE}/odi", params={"format": "JSON"}, headers=h), + get_json(f"{ODIN_BASE}/odi/map", params={"scale": "COUNTY"}, headers=h), + get_json(f"{ODIN_BASE}/odi/status", headers=h), + ) + areas, info = parse_odin(odi, county_map, status) + self.reporting_utilities = info["reporting_utilities"] + self.utilities_with_error = info["utilities_with_error"] + return areas + + def coverage_note(self) -> Optional[str]: + n = self.reporting_utilities + base = "Only utilities that report to ODIN appear; one outside ODIN shows no outages here even during one." + if n is None: + return base + err = f", {self.utilities_with_error} with a feed error" if self.utilities_with_error else "" + return f"{base} {n} utilities reporting nationwide{err}." + + +def _utility(names: object) -> Tuple[Optional[str], Optional[str]]: + name = eia = None + for n in names if isinstance(names, list) else []: + if not isinstance(n, dict): + continue + if n.get("nameType") == "UtilityName" and isinstance(n.get("name"), str): + name = n["name"] + elif n.get("nameType") == "UtilityId" and n.get("name") is not None: + eia = str(n["name"]) + return name, eia + + +def parse_odin(odi: object, county_map: object, status: object, + now: Optional[datetime] = None) -> Tuple[List[OutageArea], dict]: + """The three public ODIN responses -> one normalised record per county. + + Events whose estimated restoration is more than ``STALE_AFTER_ETR`` before + ``now`` are counted separately (``stale_customers_out``), not as live. + + Raises ValueError when a payload has the wrong top-level shape or when rows + came back but none could be read -- an upstream schema change must surface + as a feed error, not as an empty map.""" + if not isinstance(odi, dict) or not isinstance(odi.get("outage"), list): + raise ValueError("ODIN /odi: expected {'outage': [...]} -- upstream schema change?") + if not isinstance(county_map, list): + raise ValueError("ODIN /odi/map: expected a list -- upstream schema change?") + if not isinstance(status, list): + raise ValueError("ODIN /odi/status: expected a list -- upstream schema change?") + + # Utility freshness, by EIA id. + received: Dict[str, str] = {} + errors = 0 + for u in status: + if not isinstance(u, dict): + continue + errors += 1 if u.get("hasError") is True else 0 + eia, d = u.get("eiaId"), parse_iso(u.get("receivedDate")) + if eia is not None and d is not None: + received[str(eia)] = iso(d) + require_parsed(len(status), list(received), "ODIN /odi/status") + + # County geography. + geo: Dict[str, dict] = {} + for row in county_map: + if not isinstance(row, dict): + continue + fips = str(row.get("id") or "") + if not _FIPS.match(fips): + continue + b = geometry_bbox(row.get("feature")) + geo[fips] = {"county": row.get("countyName") if isinstance(row.get("countyName"), str) else None, + "state": row.get("stateName") if isinstance(row.get("stateName"), str) else None, + "bbox": b} + require_parsed(len(county_map), list(geo), "ODIN /odi/map county") + + # Outage events, aggregated per county. + stale_before = (now or datetime.now(timezone.utc)) - STALE_AFTER_ETR + agg: Dict[str, dict] = {} + parsed = stale_events = 0 + for o in odi["outage"]: + if not isinstance(o, dict): + continue + fips = str(o.get("communityDescriptor") or "") + meters = finite(o.get("metersAffected")) + if not _FIPS.match(fips) or meters is None or meters < 0: + continue + parsed += 1 + area = o.get("outageArea") if isinstance(o.get("outageArea"), dict) else {} + start = parse_iso(o.get("reportedStartTime")) or parse_iso(area.get("earliestReportedTime")) + ert_obj = o.get("estimatedRestorationTime") if isinstance(o.get("estimatedRestorationTime"), dict) else {} + ert = parse_iso(ert_obj.get("ert")) + if ert is not None and ert.year < 2000: # Puget Sound Energy sends 0001-01-01T00:00:00Z for "no ETR" + ert = None + stale = ert is not None and ert < stale_before + stale_events += stale + a = agg.setdefault(fips, {"out": 0, "stale_out": 0, "utilities": {}, + "live": {"since": None, "etr": None}, "stale": {"since": None, "etr": None}}) + a["stale_out" if stale else "out"] += int(meters) + t = a["stale" if stale else "live"] # since/etr come from live events whenever there are any + if start and (t["since"] is None or start < t["since"]): + t["since"] = start + if ert and (t["etr"] is None or ert > t["etr"]): + t["etr"] = ert + name, eia = _utility(o.get("names")) + key = eia or name or "unknown" + u = a["utilities"].setdefault(key, {"name": name, "eia_id": eia, "customers_out": 0, + "stale_customers_out": 0}) + u["stale_customers_out" if stale else "customers_out"] += int(meters) + require_parsed(len(odi["outage"]), [None] * parsed, "ODIN /odi outage") + + areas: List[OutageArea] = [] + for fips, a in agg.items(): + g = geo.get(fips, {}) + utilities = sorted(a["utilities"].values(), key=lambda u: (-u["customers_out"], -u["stale_customers_out"])) + stamps = [received[u["eia_id"]] for u in utilities if u["eia_id"] in received] + lat, lon = bbox_centre(g.get("bbox")) + t = a["live"] if a["out"] else a["stale"] + areas.append(make_area( + "odin", fips, county=g.get("county"), state=g.get("state"), customers_out=a["out"], + stale_customers_out=a["stale_out"], customers_served=None, etr=iso(t["etr"]), since=iso(t["since"]), + updated_at=max(stamps) if stamps else None, lat=lat, lon=lon, bbox=g.get("bbox"), + utilities=utilities, + )) + areas.sort(key=lambda r: (-r["customers_out"], -r["stale_customers_out"])) + return areas, {"reporting_utilities": len(status), "utilities_with_error": errors, "stale_events": stale_events} + + +# ── stubs: catalogued, never enable-able ───────────────────────────────────── + +_STORM_CENTER_TERMS = ("The utility's public outage map is backed by undocumented JSON (vendor storm-center " + "endpoints) with no published licence for automated reuse; the endpoints can change " + "without notice. Panel ruling (server-26#184): do not ship scraped utility data " + "commercially. A direct data-sharing agreement is the licensed path (also a sales lead).") + + +def _stub(id: str, name: str, region: str, coverage, status: Licence, label: str, terms: str, + terms_url: Optional[str], granularity: str, notes: str, homepage: Optional[str]) -> StubProvider: + return StubProvider(OutageProviderMeta( + id=id, name=name, region=region, coverage=coverage, + licence=OutageLicenceInfo(status=status, label=label, terms=terms, terms_url=terms_url, + issue="server-26#184"), + granularity=granularity, implemented=False, notes=notes, homepage=homepage, + )) + + +PROVIDERS: List[OutageProvider] = [ + OdinProvider(), + _stub("poweroutage_us", "PowerOutage.us (FE Bluefire LLC)", "United States", US_BBOX, Licence.PAID, + "Paid API -- licence is personal/non-commercial (news media excepted); commercial use needs an agreement DRB does not hold", + ("API & Content Terms: the licence is for \"personal and noncommercial use (except for News Media " + "Organizations)\"; commercial use needs a separate agreement; pricing by contact. Quoted from a " + "search summary -- the terms page returned 403 to a direct fetch, so re-read it first-hand before " + "any acknowledgement."), + "https://poweroutage.us/legal/apitermsofuse", "county / utility", + "No adapter: needs a commercial contract and its API documentation.", "https://poweroutage.us"), + _stub("coned_oru_stormcenter", "Con Edison / Orange & Rockland outage map", "NYC, Westchester, Orange & Rockland", + NY_BBOX, Licence.AGREEMENT_REQUIRED, + "Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence", + _STORM_CENTER_TERMS, "https://www.coned.com/en/services-and-outages/outage-map-faq", "point / polygon + ETR", + ("No adapter by design. Con Edison and O&R are on ODIN's participant list but were not reporting to " + "ODIN on 2026-10-05; if they start, the ODIN adapter covers them at county level."), + "https://outagemap.coned.com"), + _stub("nyseg_rge_stormcenter", "NYSEG / RG&E outage map (Avangrid)", "Upstate NY", NY_BBOX, + Licence.AGREEMENT_REQUIRED, + "Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence", + _STORM_CENTER_TERMS, None, "point / polygon + ETR", + ("No adapter by design. Avangrid (NYSEG/RG&E) reports to ODIN, so county-level data arrives through " + "the ODIN adapter."), + "https://www.nyseg.com"), + _stub("central_hudson_stormcenter", "Central Hudson outage map", "Hudson Valley, NY", NY_BBOX, + Licence.AGREEMENT_REQUIRED, + "Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence", + _STORM_CENTER_TERMS, None, "point / polygon + ETR", + ("No adapter by design. Central Hudson reports to ODIN, so county-level data arrives through the " + "ODIN adapter."), + "https://www.cenhud.com"), + _stub("eagle_i_live", "EAGLE-I live feed (ORNL / DOE)", "United States", US_BBOX, Licence.AGREEMENT_REQUIRED, + "Live feed is a DOE / government-partner system -- DRB has no access agreement", + ("EAGLE-I's live 15-minute county outage feed is restricted to DOE and government partners. The public " + "ORNL Constellation dataset is historic only (no terms stated on the dataset) and is no use for a " + "live map."), + "https://doi.ccs.ornl.gov/dataset/d26451af-7ea4-577c-8285-78b72cd8a2dc", "county", + "No adapter: needs a DOE partner agreement.", "https://eagle-i.doe.gov"), +] diff --git a/drb-c2-core/app/internal/outages/registry.py b/drb-c2-core/app/internal/outages/registry.py new file mode 100644 index 0000000..0d3d8ce --- /dev/null +++ b/drb-c2-core/app/internal/outages/registry.py @@ -0,0 +1,216 @@ +"""Outage provider registry: settings, licence gate, and bbox queries. + +Mirrors ``app.internal.cameras.registry`` rule for rule. Settings live in +Firestore ``config/outage_providers``:: + + {"providers": {"": {"enabled": bool, "updated_at": iso, "updated_by": {...}, + "acknowledgement": {"by_uid", "by_email", "at", "licence_status", "warning"} | None}}} + +Rules (owner decision #247 on server-26#184): + * every provider defaults OFF -- a missing doc/entry means off, and an + unreadable settings doc means everything is off (fail closed); + * a stub (no adapter) or a PROHIBITED source can never be enabled; + * a GATED licence (agreement required / non-commercial / unverified / paid) + can only be enabled with an explicit acknowledgement, recorded with who and + when in the settings doc AND audit_log. The audit write is a precondition + for a gated enable: no record, no enable; + * an acknowledgement is bound to the licence status it was given for. If a + provider's catalogued status changes in code, the old acknowledgement no + longer counts and the provider stops serving until re-acknowledged. +""" +import asyncio +import time +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple + +from app.internal import firestore as fstore +from app.internal.logger import logger +from app.internal.outages import providers as sources +from app.internal.outages.base import BBox, Feed, Licence, OutageArea, OutageProvider, area_in_bbox, bbox_intersects + +COLLECTION, DOC_ID = "config", "outage_providers" +_TTL_S = 30.0 +MAX_AREAS = 3000 # per response; ODIN's whole country is ~100-300 counties on a normal day + +PROVIDERS: Dict[str, OutageProvider] = {p.meta.id: p for p in sources.PROVIDERS} +FEEDS: Dict[str, Feed] = {pid: Feed(p) for pid, p in PROVIDERS.items() if p.meta.implemented} + +NO_SOURCE_MESSAGE = "No licensed outage source covers this area." + +_cache: Dict[str, dict] = {} +_cache_ts = 0.0 + + +class ProviderError(Exception): + """A refused settings change. ``code`` is the HTTP status the router returns.""" + + def __init__(self, code: int, message: str, **extra): + super().__init__(message) + self.code, self.message, self.extra = code, message, extra + + +def invalidate_cache() -> None: + global _cache_ts + _cache_ts = 0.0 + + +async def get_settings() -> Dict[str, dict]: + """Per-provider settings. Unreadable Firestore means everything is OFF (fail closed).""" + global _cache, _cache_ts + if _cache_ts and time.monotonic() - _cache_ts < _TTL_S: + return _cache + try: + doc = await fstore.doc_get(COLLECTION, DOC_ID) or {} + provs = doc.get("providers") if isinstance(doc.get("providers"), dict) else {} + _cache = {k: v for k, v in provs.items() if isinstance(v, dict)} + except Exception as e: + logger.warning(f"outage providers: settings unreadable ({e}); treating every provider as OFF") + _cache = {} + _cache_ts = time.monotonic() + return _cache + + +def effective(provider: OutageProvider, entry: Optional[dict]) -> Tuple[bool, Optional[str]]: + """(serving?, reason-if-not) for a provider given its settings entry.""" + meta = provider.meta + if not meta.implemented: + return False, "adapter is a stub" + if meta.licence.status == Licence.PROHIBITED: + return False, "source terms forbid this use" + if not entry or not entry.get("enabled"): + return False, "disabled (default)" + if meta.licence.gated: + ack = entry.get("acknowledgement") or {} + if ack.get("licence_status") != meta.licence.status.value: + return False, "licence acknowledgement missing or out of date" + return True, None + + +async def is_serving(provider_id: str) -> bool: + p = PROVIDERS.get(provider_id) + if p is None: + return False + return effective(p, (await get_settings()).get(provider_id))[0] + + +async def set_enabled(provider_id: str, enabled: bool, actor: Tuple[str, str], + acknowledge_licence: bool = False) -> dict: + p = PROVIDERS.get(provider_id) + if p is None: + raise ProviderError(404, f"unknown outage provider '{provider_id}'") + meta, lic = p.meta, p.meta.licence + uid, email = actor + now = datetime.now(timezone.utc).isoformat() + entry: dict = {"enabled": bool(enabled), "updated_at": now, "updated_by": {"uid": uid, "email": email}} + + if enabled: + if not meta.implemented: + raise ProviderError(409, f"{meta.name}: no working adapter -- {meta.notes}") + if lic.status == Licence.PROHIBITED: + raise ProviderError(409, lic.warning() or "prohibited") + if lic.gated: + if not acknowledge_licence: + raise ProviderError(409, f"{meta.name}: licence acknowledgement required", + requires_acknowledgement=True, warning=lic.warning(), + licence=lic.as_dict()) + entry["acknowledgement"] = {"by_uid": uid, "by_email": email, "at": now, + "licence_status": lic.status.value, "warning": lic.warning()} + # Precondition, not a side effect: a gated enable with no audit + # record must not happen. A failure here aborts before the write. + try: + await _audit(actor, "outage_provider.enable_acknowledged", provider_id, + {"licence_status": lic.status.value, "licence_label": lic.label, + "warning": lic.warning(), "acknowledged_at": now}) + except Exception as e: + logger.error(f"outage providers: audit write failed, refusing gated enable of {provider_id} ({e})") + raise ProviderError(503, "audit log unavailable -- a licence-gated provider cannot be enabled " + "without an audit record; try again") + else: + entry["acknowledgement"] = None # re-enabling a gated source needs a fresh acknowledgement + + before = (await get_settings()).get(provider_id) or {} + await fstore.doc_set(COLLECTION, DOC_ID, {"providers": {provider_id: entry}}, merge=True) + invalidate_cache() + if not (enabled and lic.gated): + try: + await _audit(actor, "outage_provider.enable" if enabled else "outage_provider.disable", provider_id, + {"from": bool(before.get("enabled")), "to": bool(enabled), + "licence_status": lic.status.value}) + except Exception as e: # the toggle already happened; never 500 a completed write + logger.error(f"outage providers: audit write failed for {provider_id} ({e})") + logger.info(f"outage provider {provider_id} -> enabled={enabled} by {email or uid}") + return await describe(provider_id) + + +async def _audit(actor: Tuple[str, str], action: str, provider_id: str, details: dict) -> None: + from app.internal import audit + await audit.write_audit(actor_uid=actor[0], actor_email=actor[1], action=action, + details={"provider_id": provider_id, **details}) + + +async def describe(provider_id: str) -> dict: + p = PROVIDERS[provider_id] + entry = (await get_settings()).get(provider_id) or {} + serving, reason = effective(p, entry) + m = p.meta + feed = FEEDS.get(provider_id) + return { + "id": m.id, "name": m.name, "region": m.region, "granularity": m.granularity, + "implemented": m.implemented, "notes": m.notes, "homepage": m.homepage, + "licence": m.licence.as_dict(), + "enabled": bool(entry.get("enabled")), + "serving": serving, "not_serving_reason": reason, + "acknowledgement": entry.get("acknowledgement"), + "updated_at": entry.get("updated_at"), "updated_by": entry.get("updated_by"), + "coverage_note": p.coverage_note(), + "health": feed.status() if feed else None, + } + + +async def list_providers() -> List[dict]: + await get_settings() + return [await describe(pid) for pid in PROVIDERS] + + +def credit(provider_id: str) -> dict: + lic = PROVIDERS[provider_id].meta.licence + return {"attribution": lic.attribution, "attribution_url": lic.attribution_url, "disclaimer": lic.disclaimer} + + +async def outages_in_bbox(bbox: BBox) -> dict: + """Outage areas from every SERVING provider whose coverage meets ``bbox``. + Providers that are off are never fetched. + + ``status`` says why the list is what it is, so an empty layer is never + ambiguous: ``none_enabled`` (no provider is on), ``no_source`` (none of the + serving providers covers this area), ``error`` (every covering provider + failed and has nothing cached), else ``ok`` -- which may legitimately + carry zero areas when nothing is out.""" + conf = await get_settings() + serving = [pid for pid in FEEDS if effective(PROVIDERS[pid], conf.get(pid))[0]] + live = [pid for pid in serving if bbox_intersects(PROVIDERS[pid].meta.coverage, bbox)] + await asyncio.gather(*(FEEDS[pid].get() for pid in live)) + areas: List[OutageArea] = [] + providers = [] + for pid in serving: + feed = FEEDS[pid] + hits = [a for a in feed.items if area_in_bbox(a, bbox)] if pid in live else [] + room = max(0, MAX_AREAS - len(areas)) + areas.extend(hits[:room]) + providers.append({"id": pid, "name": PROVIDERS[pid].meta.name, **credit(pid), + "granularity": PROVIDERS[pid].meta.granularity, + "coverage_note": PROVIDERS[pid].coverage_note(), + "covers_bbox": pid in live, + "fetched_at": feed.fetched_at, "error": feed.error, + "total_in_bbox": len(hits), "truncated": len(hits) > room}) + + if not serving: + status, message = "none_enabled", "No outage source is enabled. An admin can enable one under Admin -> Outage providers." + elif not live: + status, message = "no_source", NO_SOURCE_MESSAGE + elif all(FEEDS[pid].error and FEEDS[pid].fetched_at is None for pid in live): + status, message = "error", "Every outage source covering this area failed to load; see each provider's error." + else: + status, message = "ok", None + return {"areas": areas, "providers": providers, "serving_count": len(serving), + "status": status, "message": message} diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index 35c631a..a89a34c 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -19,6 +19,7 @@ from app.internal.auth import ( ) from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras +from app.routers import outages from app.routers import discord as discord_router from app.routers import discord_link as discord_link_router from app.internal import dynsec @@ -146,6 +147,8 @@ app.include_router(places.router, dependencies=[Depends(require_service_or_fi app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit) +app.include_router(outages.router, dependencies=[Depends(require_service_or_firebase_token)]) +app.include_router(outages.admin_router) # auth: admin only, per endpoint (needs the principal for audit) app.include_router(upload.router) # auth is per-node, handled inline app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run) diff --git a/drb-c2-core/app/routers/outages.py b/drb-c2-core/app/routers/outages.py new file mode 100644 index 0000000..73b07fa --- /dev/null +++ b/drb-c2-core/app/routers/outages.py @@ -0,0 +1,52 @@ +"""Power-outage endpoints (map layer, server-26#184). + +``GET /outages`` -- any signed-in user (auth applied at include time in main.py): +outage areas inside a bbox from every provider an admin has enabled, each with +the provider's attribution, disclaimer, coverage note and feed freshness, plus +a ``status`` that says why an empty list is empty. + +``/admin/outage-providers`` -- platform admin only: list every catalogued +provider with its licence status and health, and toggle it. Gated licences +need ``acknowledge_licence: true``; that acknowledgement is stored with who and +when and written to audit_log (see app/internal/outages/registry.py). +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from app.internal.auth import describe_actor, require_admin_token +from app.internal.outages import registry + +router = APIRouter(prefix="/outages", tags=["outages"]) +admin_router = APIRouter(prefix="/admin/outage-providers", tags=["admin"]) + + +@router.get("") +async def get_outages( + south: float = Query(..., ge=-90, le=90), + west: float = Query(..., ge=-180, le=180), + north: float = Query(..., ge=-90, le=90), + east: float = Query(..., ge=-180, le=180), +): + if south > north or west > east: + raise HTTPException(400, "bbox must satisfy south<=north and west<=east") + return await registry.outages_in_bbox((south, west, north, east)) + + +class ProviderToggle(BaseModel): + enabled: bool + acknowledge_licence: bool = False + + +@admin_router.get("") +async def list_outage_providers(_=Depends(require_admin_token)): + return {"providers": await registry.list_providers()} + + +@admin_router.put("/{provider_id}") +async def set_outage_provider(provider_id: str, body: ProviderToggle, principal: dict = Depends(require_admin_token)): + try: + return await registry.set_enabled(provider_id, body.enabled, describe_actor(principal), + acknowledge_licence=body.acknowledge_licence) + except registry.ProviderError as e: + return JSONResponse(status_code=e.code, content={"detail": e.message, **e.extra}) diff --git a/drb-c2-core/tests/fixtures/odin_public_2026-10-05.json b/drb-c2-core/tests/fixtures/odin_public_2026-10-05.json new file mode 100644 index 0000000..4f91304 --- /dev/null +++ b/drb-c2-core/tests/fixtures/odin_public_2026-10-05.json @@ -0,0 +1,3384 @@ +{ + "_recorded": "2026-10-05 from https://odin.ornl.gov public endpoints (OpenAPI: https://odin.ornl.gov/v3/api-docs). Rows filtered to four counties; values verbatim.", + "odi_map_scale_county": [ + { + "countyName": "King", + "feature": { + "coordinates": [ + [ + [ + -122.475914, + 47.525282 + ], + [ + -122.458321, + 47.532735 + ], + [ + -122.447017, + 47.540044 + ], + [ + -122.444854, + 47.543333 + ], + [ + -122.444297, + 47.555565 + ], + [ + -122.454766, + 47.573685 + ], + [ + -122.453582, + 47.581237 + ], + [ + -122.447569, + 47.593313 + ], + [ + -122.446063, + 47.607998 + ], + [ + -122.44867, + 47.61651 + ], + [ + -122.454908, + 47.626561 + ], + [ + -122.452812, + 47.643798 + ], + [ + -122.459375, + 47.669161 + ], + [ + -122.4597, + 47.675788 + ], + [ + -122.457614, + 47.685171 + ], + [ + -122.446925, + 47.704478 + ], + [ + -122.43746, + 47.713048 + ], + [ + -122.433464, + 47.728908 + ], + [ + -122.42992, + 47.73528 + ], + [ + -122.427149, + 47.745364 + ], + [ + -122.42943, + 47.762425 + ], + [ + -122.438093, + 47.777814 + ], + [ + -121.991775, + 47.775349 + ], + [ + -121.884438, + 47.777217 + ], + [ + -121.497024, + 47.777832 + ], + [ + -121.455545, + 47.778426 + ], + [ + -121.455535, + 47.780576 + ], + [ + -121.119179, + 47.779933 + ], + [ + -121.119483, + 47.775839 + ], + [ + -121.107703, + 47.766683 + ], + [ + -121.110505, + 47.761597 + ], + [ + -121.110506, + 47.757827 + ], + [ + -121.10646, + 47.756806 + ], + [ + -121.101278, + 47.75714 + ], + [ + -121.0988, + 47.759105 + ], + [ + -121.094018, + 47.758184 + ], + [ + -121.089914, + 47.746321 + ], + [ + -121.082126, + 47.743025 + ], + [ + -121.078995, + 47.73919 + ], + [ + -121.081524, + 47.734468 + ], + [ + -121.084945, + 47.732297 + ], + [ + -121.08478, + 47.72991 + ], + [ + -121.087347, + 47.728629 + ], + [ + -121.083949, + 47.723068 + ], + [ + -121.069965, + 47.717829 + ], + [ + -121.066005, + 47.713603 + ], + [ + -121.067117, + 47.706272 + ], + [ + -121.079371, + 47.701964 + ], + [ + -121.090288, + 47.702834 + ], + [ + -121.090887, + 47.699067 + ], + [ + -121.094609, + 47.695034 + ], + [ + -121.105513, + 47.688815 + ], + [ + -121.121584, + 47.685213 + ], + [ + -121.123679, + 47.680208 + ], + [ + -121.128824, + 47.676181 + ], + [ + -121.128914, + 47.672155 + ], + [ + -121.123179, + 47.663326 + ], + [ + -121.125635, + 47.657159 + ], + [ + -121.131884, + 47.655664 + ], + [ + -121.131009, + 47.653122 + ], + [ + -121.133697, + 47.645605 + ], + [ + -121.125971, + 47.637403 + ], + [ + -121.127868, + 47.632101 + ], + [ + -121.125266, + 47.628128 + ], + [ + -121.118762, + 47.626971 + ], + [ + -121.113902, + 47.621945 + ], + [ + -121.113099, + 47.618986 + ], + [ + -121.120066, + 47.615675 + ], + [ + -121.120284, + 47.612823 + ], + [ + -121.117909, + 47.607208 + ], + [ + -121.111887, + 47.602515 + ], + [ + -121.111115, + 47.599715 + ], + [ + -121.113569, + 47.597288 + ], + [ + -121.11792, + 47.596758 + ], + [ + -121.11946, + 47.591507 + ], + [ + -121.137806, + 47.595749 + ], + [ + -121.142693, + 47.591993 + ], + [ + -121.152149, + 47.595578 + ], + [ + -121.172192, + 47.590233 + ], + [ + -121.170755, + 47.586006 + ], + [ + -121.172985, + 47.583384 + ], + [ + -121.169846, + 47.578494 + ], + [ + -121.171781, + 47.574323 + ], + [ + -121.177019, + 47.569621 + ], + [ + -121.176718, + 47.566687 + ], + [ + -121.179941, + 47.564956 + ], + [ + -121.188306, + 47.568731 + ], + [ + -121.194701, + 47.566125 + ], + [ + -121.208538, + 47.565957 + ], + [ + -121.216852, + 47.569773 + ], + [ + -121.2289, + 47.565603 + ], + [ + -121.233025, + 47.560616 + ], + [ + -121.232033, + 47.557954 + ], + [ + -121.234056, + 47.555944 + ], + [ + -121.233362, + 47.550567 + ], + [ + -121.235695, + 47.548474 + ], + [ + -121.234583, + 47.545637 + ], + [ + -121.243467, + 47.542963 + ], + [ + -121.241475, + 47.53894 + ], + [ + -121.243053, + 47.53831 + ], + [ + -121.242636, + 47.535995 + ], + [ + -121.2516, + 47.532045 + ], + [ + -121.255616, + 47.533476 + ], + [ + -121.256961, + 47.529199 + ], + [ + -121.262284, + 47.523602 + ], + [ + -121.278999, + 47.522941 + ], + [ + -121.285534, + 47.517034 + ], + [ + -121.283391, + 47.514869 + ], + [ + -121.28651, + 47.513201 + ], + [ + -121.29195, + 47.513551 + ], + [ + -121.288473, + 47.50933 + ], + [ + -121.29222, + 47.50209 + ], + [ + -121.300347, + 47.495842 + ], + [ + -121.302698, + 47.486311 + ], + [ + -121.311166, + 47.482935 + ], + [ + -121.312037, + 47.479167 + ], + [ + -121.314676, + 47.477879 + ], + [ + -121.316223, + 47.479838 + ], + [ + -121.324552, + 47.479422 + ], + [ + -121.331991, + 47.473689 + ], + [ + -121.338673, + 47.476388 + ], + [ + -121.341599, + 47.474609 + ], + [ + -121.348117, + 47.465239 + ], + [ + -121.363923, + 47.464833 + ], + [ + -121.379259, + 47.454305 + ], + [ + -121.379105, + 47.450464 + ], + [ + -121.385275, + 47.444982 + ], + [ + -121.381455, + 47.438448 + ], + [ + -121.385247, + 47.434389 + ], + [ + -121.403087, + 47.431246 + ], + [ + -121.414065, + 47.422017 + ], + [ + -121.422616, + 47.422315 + ], + [ + -121.427252, + 47.420846 + ], + [ + -121.428378, + 47.416226 + ], + [ + -121.424199, + 47.415762 + ], + [ + -121.422305, + 47.412974 + ], + [ + -121.42282, + 47.405995 + ], + [ + -121.427476, + 47.402566 + ], + [ + -121.430356, + 47.403171 + ], + [ + -121.433816, + 47.399107 + ], + [ + -121.427357, + 47.395742 + ], + [ + -121.426104, + 47.389854 + ], + [ + -121.430013, + 47.386978 + ], + [ + -121.440509, + 47.385771 + ], + [ + -121.441484, + 47.380585 + ], + [ + -121.43777, + 47.371847 + ], + [ + -121.443939, + 47.373815 + ], + [ + -121.453003, + 47.371799 + ], + [ + -121.458978, + 47.373116 + ], + [ + -121.465895, + 47.368106 + ], + [ + -121.461257, + 47.36134 + ], + [ + -121.464064, + 47.353818 + ], + [ + -121.449748, + 47.347925 + ], + [ + -121.443834, + 47.343392 + ], + [ + -121.438423, + 47.343726 + ], + [ + -121.430511, + 47.334487 + ], + [ + -121.431574, + 47.329828 + ], + [ + -121.43608, + 47.328594 + ], + [ + -121.439333, + 47.323548 + ], + [ + -121.438455, + 47.317538 + ], + [ + -121.445331, + 47.309816 + ], + [ + -121.443606, + 47.307898 + ], + [ + -121.432682, + 47.304955 + ], + [ + -121.430667, + 47.296441 + ], + [ + -121.432846, + 47.293829 + ], + [ + -121.427492, + 47.289075 + ], + [ + -121.419419, + 47.286726 + ], + [ + -121.419133, + 47.290837 + ], + [ + -121.4163, + 47.293499 + ], + [ + -121.409781, + 47.289217 + ], + [ + -121.394195, + 47.288767 + ], + [ + -121.390562, + 47.286038 + ], + [ + -121.372603, + 47.284972 + ], + [ + -121.369903, + 47.285589 + ], + [ + -121.370774, + 47.288325 + ], + [ + -121.366989, + 47.290932 + ], + [ + -121.361778, + 47.291935 + ], + [ + -121.359676, + 47.285526 + ], + [ + -121.341568, + 47.281658 + ], + [ + -121.341316, + 47.277982 + ], + [ + -121.33835, + 47.275969 + ], + [ + -121.338746, + 47.268322 + ], + [ + -121.335417, + 47.263539 + ], + [ + -121.335885, + 47.260629 + ], + [ + -121.331941, + 47.258774 + ], + [ + -121.331979, + 47.257418 + ], + [ + -121.334029, + 47.25421 + ], + [ + -121.33755, + 47.2529 + ], + [ + -121.339833, + 47.247146 + ], + [ + -121.346545, + 47.246426 + ], + [ + -121.352504, + 47.24229 + ], + [ + -121.360747, + 47.240526 + ], + [ + -121.358489, + 47.237573 + ], + [ + -121.358368, + 47.234419 + ], + [ + -121.365569, + 47.226984 + ], + [ + -121.365109, + 47.224431 + ], + [ + -121.346241, + 47.218686 + ], + [ + -121.335226, + 47.219179 + ], + [ + -121.325183, + 47.216086 + ], + [ + -121.311197, + 47.207658 + ], + [ + -121.311631, + 47.205571 + ], + [ + -121.309207, + 47.20339 + ], + [ + -121.311763, + 47.199675 + ], + [ + -121.306466, + 47.192758 + ], + [ + -121.306138, + 47.187954 + ], + [ + -121.309955, + 47.185255 + ], + [ + -121.311846, + 47.179541 + ], + [ + -121.317823, + 47.173387 + ], + [ + -121.311901, + 47.164037 + ], + [ + -121.306013, + 47.162 + ], + [ + -121.29755, + 47.148673 + ], + [ + -121.301243, + 47.145187 + ], + [ + -121.303413, + 47.13889 + ], + [ + -121.315534, + 47.133844 + ], + [ + -121.320757, + 47.135292 + ], + [ + -121.335095, + 47.132683 + ], + [ + -121.339903, + 47.135697 + ], + [ + -121.349742, + 47.1376 + ], + [ + -121.353898, + 47.14054 + ], + [ + -121.364677, + 47.141759 + ], + [ + -121.368215, + 47.13959 + ], + [ + -121.381317, + 47.137255 + ], + [ + -121.388581, + 47.133538 + ], + [ + -121.393968, + 47.132762 + ], + [ + -121.394872, + 47.128672 + ], + [ + -121.399148, + 47.126574 + ], + [ + -121.401344, + 47.122939 + ], + [ + -121.406237, + 47.120721 + ], + [ + -121.408081, + 47.1181 + ], + [ + -121.404323, + 47.108639 + ], + [ + -121.399058, + 47.104598 + ], + [ + -121.402526, + 47.098013 + ], + [ + -121.392999, + 47.092023 + ], + [ + -121.382766, + 47.094769 + ], + [ + -121.38046, + 47.090996 + ], + [ + -121.376893, + 47.089875 + ], + [ + -121.379961, + 47.087248 + ], + [ + -121.394004, + 47.088981 + ], + [ + -121.406655, + 47.088588 + ], + [ + -121.411971, + 47.090699 + ], + [ + -121.418027, + 47.088223 + ], + [ + -121.441756, + 47.084402 + ], + [ + -121.454736, + 47.089353 + ], + [ + -121.471879, + 47.099739 + ], + [ + -121.480682, + 47.110636 + ], + [ + -121.485711, + 47.111966 + ], + [ + -121.489776, + 47.116367 + ], + [ + -121.505257, + 47.117975 + ], + [ + -121.50975, + 47.122503 + ], + [ + -121.51372, + 47.122973 + ], + [ + -121.51593, + 47.121557 + ], + [ + -121.531511, + 47.124579 + ], + [ + -121.535883, + 47.122546 + ], + [ + -121.551232, + 47.122925 + ], + [ + -121.560361, + 47.119537 + ], + [ + -121.573636, + 47.120267 + ], + [ + -121.581964, + 47.118658 + ], + [ + -121.594461, + 47.122228 + ], + [ + -121.595198, + 47.125255 + ], + [ + -121.605793, + 47.134902 + ], + [ + -121.606243, + 47.137416 + ], + [ + -121.60965, + 47.138382 + ], + [ + -121.611469, + 47.142991 + ], + [ + -121.617609, + 47.14468 + ], + [ + -121.617233, + 47.146485 + ], + [ + -121.621046, + 47.147812 + ], + [ + -121.620794, + 47.149399 + ], + [ + -121.629533, + 47.153428 + ], + [ + -121.637553, + 47.153308 + ], + [ + -121.643824, + 47.155594 + ], + [ + -121.648522, + 47.154934 + ], + [ + -121.662198, + 47.160044 + ], + [ + -121.668653, + 47.15587 + ], + [ + -121.680793, + 47.15257 + ], + [ + -121.684173, + 47.149785 + ], + [ + -121.705071, + 47.151997 + ], + [ + -121.719136, + 47.151116 + ], + [ + -121.728016, + 47.157632 + ], + [ + -121.738016, + 47.160371 + ], + [ + -121.743086, + 47.164538 + ], + [ + -121.749486, + 47.162072 + ], + [ + -121.762463, + 47.167509 + ], + [ + -121.769297, + 47.165417 + ], + [ + -121.773347, + 47.166492 + ], + [ + -121.777687, + 47.170456 + ], + [ + -121.777564, + 47.173383 + ], + [ + -121.783363, + 47.172222 + ], + [ + -121.796464, + 47.173059 + ], + [ + -121.799426, + 47.169827 + ], + [ + -121.803328, + 47.169248 + ], + [ + -121.805296, + 47.165831 + ], + [ + -121.810399, + 47.163867 + ], + [ + -121.813312, + 47.159501 + ], + [ + -121.819297, + 47.158802 + ], + [ + -121.827069, + 47.152678 + ], + [ + -121.831296, + 47.151931 + ], + [ + -121.831689, + 47.148483 + ], + [ + -121.839617, + 47.143362 + ], + [ + -121.846125, + 47.14439 + ], + [ + -121.849453, + 47.147261 + ], + [ + -121.860413, + 47.146597 + ], + [ + -121.860849, + 47.150651 + ], + [ + -121.865477, + 47.152603 + ], + [ + -121.872936, + 47.150534 + ], + [ + -121.878466, + 47.15394 + ], + [ + -121.886187, + 47.152947 + ], + [ + -121.901538, + 47.155742 + ], + [ + -121.909422, + 47.150544 + ], + [ + -121.920888, + 47.149956 + ], + [ + -121.927892, + 47.147705 + ], + [ + -121.927858, + 47.144816 + ], + [ + -121.930913, + 47.142702 + ], + [ + -121.931907, + 47.139282 + ], + [ + -121.937864, + 47.138721 + ], + [ + -121.943816, + 47.14005 + ], + [ + -121.950487, + 47.14497 + ], + [ + -121.949937, + 47.150953 + ], + [ + -121.954188, + 47.155668 + ], + [ + -121.958674, + 47.157237 + ], + [ + -121.969515, + 47.156281 + ], + [ + -121.973171, + 47.160709 + ], + [ + -121.980581, + 47.164563 + ], + [ + -121.991446, + 47.163237 + ], + [ + -121.993932, + 47.17257 + ], + [ + -122.004533, + 47.170195 + ], + [ + -122.006774, + 47.170964 + ], + [ + -122.006695, + 47.172877 + ], + [ + -122.009758, + 47.175403 + ], + [ + -122.012724, + 47.17605 + ], + [ + -122.022306, + 47.173974 + ], + [ + -122.031934, + 47.175007 + ], + [ + -122.036433, + 47.172794 + ], + [ + -122.047485, + 47.17123 + ], + [ + -122.053683, + 47.17572 + ], + [ + -122.054297, + 47.178601 + ], + [ + -122.058429, + 47.179912 + ], + [ + -122.063385, + 47.185879 + ], + [ + -122.081336, + 47.190224 + ], + [ + -122.086339, + 47.193729 + ], + [ + -122.094764, + 47.194977 + ], + [ + -122.094831, + 47.198199 + ], + [ + -122.100713, + 47.200071 + ], + [ + -122.110303, + 47.209943 + ], + [ + -122.110916, + 47.212528 + ], + [ + -122.115764, + 47.215977 + ], + [ + -122.113242, + 47.217785 + ], + [ + -122.115686, + 47.220689 + ], + [ + -122.113036, + 47.223631 + ], + [ + -122.117514, + 47.225865 + ], + [ + -122.114864, + 47.22897 + ], + [ + -122.115747, + 47.231344 + ], + [ + -122.125459, + 47.232316 + ], + [ + -122.125873, + 47.234026 + ], + [ + -122.122511, + 47.237241 + ], + [ + -122.122968, + 47.239134 + ], + [ + -122.137585, + 47.246821 + ], + [ + -122.138334, + 47.250645 + ], + [ + -122.136755, + 47.254373 + ], + [ + -122.143976, + 47.257526 + ], + [ + -122.334914, + 47.257366 + ], + [ + -122.33483, + 47.26311 + ], + [ + -122.437252, + 47.333717 + ], + [ + -122.454485, + 47.324179 + ], + [ + -122.471551, + 47.31862 + ], + [ + -122.500513, + 47.317765 + ], + [ + -122.520417, + 47.322775 + ], + [ + -122.528059, + 47.327014 + ], + [ + -122.534781, + 47.33344 + ], + [ + -122.540942, + 47.344818 + ], + [ + -122.541661, + 47.349193 + ], + [ + -122.540063, + 47.355742 + ], + [ + -122.529072, + 47.372959 + ], + [ + -122.528362, + 47.377036 + ], + [ + -122.531115, + 47.383161 + ], + [ + -122.539269, + 47.392461 + ], + [ + -122.539761, + 47.396552 + ], + [ + -122.5364, + 47.404653 + ], + [ + -122.527917, + 47.416136 + ], + [ + -122.524499, + 47.423871 + ], + [ + -122.522743, + 47.449725 + ], + [ + -122.517672, + 47.464867 + ], + [ + -122.487908, + 47.50191 + ], + [ + -122.482815, + 47.518322 + ], + [ + -122.475914, + 47.525282 + ] + ] + ], + "type": "Polygon", + "properties": { + "outage_type": "COUNTY", + "metersOut": "3", + "county": "King", + "utility_1": "CITY OF SEATTLE - (WA)", + "source_type": "MIXED", + "state": "Washington", + "utility_0": "Puget Sound Energy Inc", + "precision_label": "County-level outage summary" + } + }, + "generalized": null, + "h3Resolution": null, + "id": "53033", + "metersOut": 3, + "outageType": "COUNTY", + "outages": [ + { + "areaId": null, + "cellIds": null, + "county": "53033", + "dataGeometry": null, + "envelope": { + "maxX": -122.034387741, + "maxY": 47.69667735, + "minX": -122.222013044, + "minY": 47.478707168 + }, + "metersOut": 2, + "metersServed": null, + "outageBbox": "{\"type\":\"Polygon\",\"coordinates\":[[[-122.222013044,47.478707168],[-122.222013044,47.478797097],[-122.221880394,47.478797097],[-122.221880394,47.478707168],[-122.222013044,47.478707168]]]}", + "outageIndex": 2, + "outageKind": "POLYGON", + "reported": 2, + "seq": null, + "state": "53", + "stateName": "Washington", + "tract": "53033026003,53033032333", + "utilityId": "4002", + "utilityName": "Puget Sound Energy Inc", + "zip": null + }, + { + "areaId": null, + "cellIds": null, + "county": "53033", + "dataGeometry": null, + "envelope": { + "maxX": -122.35167, + "maxY": 47.74612, + "minX": -122.35167, + "minY": 47.74612 + }, + "metersOut": 1, + "metersServed": 470380, + "outageBbox": "{\"type\":\"Point\",\"coordinates\":[-122.35167,47.74612]}", + "outageIndex": 0, + "outageKind": "POINT", + "reported": 1, + "seq": null, + "state": "53", + "stateName": "Washington", + "tract": "53033020900", + "utilityId": "2375", + "utilityName": "CITY OF SEATTLE - (WA)", + "zip": null + } + ], + "precisionLabel": "County-level outage summary", + "sourceType": "MIXED", + "stateName": "Washington", + "tract": null, + "zipCode": null + }, + { + "countyName": "Albany", + "feature": { + "coordinates": [ + [ + [ + -74.180274, + 42.729979 + ], + [ + -73.809603, + 42.775016 + ], + [ + -73.809369, + 42.778869 + ], + [ + -73.797965, + 42.779477 + ], + [ + -73.787065, + 42.789677 + ], + [ + -73.782464, + 42.791777 + ], + [ + -73.777364, + 42.791777 + ], + [ + -73.769564, + 42.786277 + ], + [ + -73.766664, + 42.786077 + ], + [ + -73.758466, + 42.795276 + ], + [ + -73.750165, + 42.801376 + ], + [ + -73.737663, + 42.818877 + ], + [ + -73.726663, + 42.822577 + ], + [ + -73.722663, + 42.820677 + ], + [ + -73.721363, + 42.813077 + ], + [ + -73.716663, + 42.808177 + ], + [ + -73.720363, + 42.804177 + ], + [ + -73.719863, + 42.801277 + ], + [ + -73.711363, + 42.795177 + ], + [ + -73.708963, + 42.787677 + ], + [ + -73.703163, + 42.782477 + ], + [ + -73.689162, + 42.775377 + ], + [ + -73.683457, + 42.779112 + ], + [ + -73.681162, + 42.782977 + ], + [ + -73.676762, + 42.783277 + ], + [ + -73.683762, + 42.766677 + ], + [ + -73.688962, + 42.738278 + ], + [ + -73.690162, + 42.735277 + ], + [ + -73.697244, + 42.728818 + ], + [ + -73.703563, + 42.709478 + ], + [ + -73.702463, + 42.699978 + ], + [ + -73.729127, + 42.665052 + ], + [ + -73.741764, + 42.654379 + ], + [ + -73.751165, + 42.63608 + ], + [ + -73.752965, + 42.628679 + ], + [ + -73.761265, + 42.610379 + ], + [ + -73.761265, + 42.600779 + ], + [ + -73.752365, + 42.57668 + ], + [ + -73.752165, + 42.56658 + ], + [ + -73.758313, + 42.534215 + ], + [ + -73.761332, + 42.525832 + ], + [ + -73.777085, + 42.503999 + ], + [ + -73.785548, + 42.487209 + ], + [ + -73.786078, + 42.475249 + ], + [ + -73.783721, + 42.464231 + ], + [ + -73.836757, + 42.458592 + ], + [ + -73.93326, + 42.444814 + ], + [ + -74.263469, + 42.407127 + ], + [ + -74.264863, + 42.419863 + ], + [ + -74.252424, + 42.445359 + ], + [ + -74.244457, + 42.470265 + ], + [ + -74.241629, + 42.472036 + ], + [ + -74.236447, + 42.485459 + ], + [ + -74.228501, + 42.494381 + ], + [ + -74.226447, + 42.499233 + ], + [ + -74.224985, + 42.50855 + ], + [ + -74.225639, + 42.526403 + ], + [ + -74.229065, + 42.52903 + ], + [ + -74.241572, + 42.550802 + ], + [ + -74.236959, + 42.56012 + ], + [ + -74.237621, + 42.563124 + ], + [ + -74.233995, + 42.575688 + ], + [ + -74.220274, + 42.585412 + ], + [ + -74.219639, + 42.591386 + ], + [ + -74.215166, + 42.597435 + ], + [ + -74.193495, + 42.618999 + ], + [ + -74.192569, + 42.627069 + ], + [ + -74.179324, + 42.646384 + ], + [ + -74.169725, + 42.667426 + ], + [ + -74.168525, + 42.67431 + ], + [ + -74.169525, + 42.691365 + ], + [ + -74.164678, + 42.717207 + ], + [ + -74.180274, + 42.729979 + ] + ] + ], + "type": "Polygon", + "properties": { + "outage_type": "COUNTY", + "metersOut": "3", + "county": "Albany", + "source_type": "COUNTY", + "state": "New York", + "utility_0": "CENTRAL HUDSON GAS & ELEC CORP", + "precision_label": "County-level outage summary" + } + }, + "generalized": null, + "h3Resolution": null, + "id": "36001", + "metersOut": 3, + "outageType": "COUNTY", + "outages": [ + { + "areaId": null, + "cellIds": null, + "county": "36001", + "dataGeometry": null, + "envelope": { + "maxX": -73.676762, + "maxY": 42.822577, + "minX": -74.264863, + "minY": 42.407127 + }, + "metersOut": 3, + "metersServed": 309263, + "outageBbox": "{\"type\":\"Polygon\",\"coordinates\":[[[-74.264863,42.407127],[-74.264863,42.822577],[-73.676762,42.822577],[-73.676762,42.407127],[-74.264863,42.407127]]]}", + "outageIndex": 0, + "outageKind": "COUNTY", + "reported": 1, + "seq": null, + "state": "36", + "stateName": "New York", + "tract": null, + "utilityId": "554", + "utilityName": "CENTRAL HUDSON GAS & ELEC CORP", + "zip": null + } + ], + "precisionLabel": "County-level outage summary", + "sourceType": "COUNTY", + "stateName": "New York", + "tract": null, + "zipCode": null + }, + { + "countyName": "Onondaga", + "feature": { + "coordinates": [ + [ + [ + -76.471774, + 43.104983 + ], + [ + -76.476125, + 43.125066 + ], + [ + -76.486303, + 43.143223 + ], + [ + -76.481227, + 43.145886 + ], + [ + -76.473066, + 43.146017 + ], + [ + -76.479224, + 43.227519 + ], + [ + -76.344632, + 43.23267 + ], + [ + -76.344582, + 43.231525 + ], + [ + -76.324213, + 43.232308 + ], + [ + -76.316117, + 43.229428 + ], + [ + -76.307541, + 43.230788 + ], + [ + -76.289012, + 43.2185 + ], + [ + -76.287572, + 43.211652 + ], + [ + -76.282915, + 43.206756 + ], + [ + -76.281027, + 43.201091 + ], + [ + -76.271351, + 43.203968 + ], + [ + -76.262051, + 43.218468 + ], + [ + -76.262151, + 43.223568 + ], + [ + -76.263551, + 43.226168 + ], + [ + -76.266851, + 43.227468 + ], + [ + -76.267751, + 43.232768 + ], + [ + -76.272151, + 43.234868 + ], + [ + -76.275551, + 43.238768 + ], + [ + -76.272851, + 43.240168 + ], + [ + -76.267551, + 43.239668 + ], + [ + -76.263751, + 43.236368 + ], + [ + -76.25745, + 43.236868 + ], + [ + -76.25775, + 43.234068 + ], + [ + -76.250001, + 43.225191 + ], + [ + -76.24465, + 43.214568 + ], + [ + -76.233849, + 43.212368 + ], + [ + -76.230049, + 43.207868 + ], + [ + -76.224449, + 43.204968 + ], + [ + -76.217873, + 43.204772 + ], + [ + -76.208336, + 43.208036 + ], + [ + -76.210649, + 43.210568 + ], + [ + -76.206176, + 43.216612 + ], + [ + -76.198748, + 43.212568 + ], + [ + -76.194248, + 43.217068 + ], + [ + -76.202548, + 43.222268 + ], + [ + -76.20472, + 43.22898 + ], + [ + -76.205008, + 43.237412 + ], + [ + -76.213393, + 43.24002 + ], + [ + -76.210609, + 43.244004 + ], + [ + -76.212977, + 43.256672 + ], + [ + -76.208825, + 43.263648 + ], + [ + -76.2119, + 43.267132 + ], + [ + -76.202944, + 43.271605 + ], + [ + -76.182104, + 43.257621 + ], + [ + -76.179626, + 43.253593 + ], + [ + -76.173533, + 43.251 + ], + [ + -76.169047, + 43.246668 + ], + [ + -76.148526, + 43.239636 + ], + [ + -76.139838, + 43.240356 + ], + [ + -76.130494, + 43.234527 + ], + [ + -76.115967, + 43.233561 + ], + [ + -76.106968, + 43.227476 + ], + [ + -76.10638, + 43.224312 + ], + [ + -76.099254, + 43.224937 + ], + [ + -76.084514, + 43.216769 + ], + [ + -76.080303, + 43.215808 + ], + [ + -76.079984, + 43.218052 + ], + [ + -76.079064, + 43.218593 + ], + [ + -76.078737, + 43.215461 + ], + [ + -76.069361, + 43.214667 + ], + [ + -76.068071, + 43.212558 + ], + [ + -76.081266, + 43.214319 + ], + [ + -76.085182, + 43.213081 + ], + [ + -76.084899, + 43.211083 + ], + [ + -76.08023, + 43.20997 + ], + [ + -76.079673, + 43.209132 + ], + [ + -76.0784, + 43.209364 + ], + [ + -76.076897, + 43.208449 + ], + [ + -76.08076, + 43.208616 + ], + [ + -76.082457, + 43.20685 + ], + [ + -76.081865, + 43.202257 + ], + [ + -76.078902, + 43.19948 + ], + [ + -76.057905, + 43.200724 + ], + [ + -76.055386, + 43.199168 + ], + [ + -76.053414, + 43.201426 + ], + [ + -76.051991, + 43.200737 + ], + [ + -76.053414, + 43.201826 + ], + [ + -76.052619, + 43.202857 + ], + [ + -76.047668, + 43.202612 + ], + [ + -76.034954, + 43.195502 + ], + [ + -76.032031, + 43.190919 + ], + [ + -76.023653, + 43.189947 + ], + [ + -76.011817, + 43.18558 + ], + [ + -76.007646, + 43.180467 + ], + [ + -76.002787, + 43.179469 + ], + [ + -76.003633, + 43.177295 + ], + [ + -76.005744, + 43.177774 + ], + [ + -76.003941, + 43.176757 + ], + [ + -76.002285, + 43.178139 + ], + [ + -76.002541, + 43.179621 + ], + [ + -75.991437, + 43.18325 + ], + [ + -75.989609, + 43.179225 + ], + [ + -75.992635, + 43.173849 + ], + [ + -75.990028, + 43.171527 + ], + [ + -75.988447, + 43.172616 + ], + [ + -75.986032, + 43.171554 + ], + [ + -75.983084, + 43.173706 + ], + [ + -75.98151, + 43.172779 + ], + [ + -75.979478, + 43.170698 + ], + [ + -75.981008, + 43.167704 + ], + [ + -75.978099, + 43.167606 + ], + [ + -75.978661, + 43.165095 + ], + [ + -75.97438, + 43.163175 + ], + [ + -75.977439, + 43.1594 + ], + [ + -75.977068, + 43.157646 + ], + [ + -75.971235, + 43.154639 + ], + [ + -75.973374, + 43.148996 + ], + [ + -75.971783, + 43.143847 + ], + [ + -75.977267, + 43.14047 + ], + [ + -75.977284, + 43.136195 + ], + [ + -75.973837, + 43.137342 + ], + [ + -75.975299, + 43.13912 + ], + [ + -75.972191, + 43.140469 + ], + [ + -75.96752, + 43.134947 + ], + [ + -75.964495, + 43.135168 + ], + [ + -75.963814, + 43.133632 + ], + [ + -75.957405, + 43.133672 + ], + [ + -75.95758, + 43.13629 + ], + [ + -75.955015, + 43.133927 + ], + [ + -75.953405, + 43.135459 + ], + [ + -75.950057, + 43.134853 + ], + [ + -75.953277, + 43.133363 + ], + [ + -75.950149, + 43.132061 + ], + [ + -75.954592, + 43.127986 + ], + [ + -75.956763, + 43.126804 + ], + [ + -75.958677, + 43.128328 + ], + [ + -75.958628, + 43.125396 + ], + [ + -75.960567, + 43.12327 + ], + [ + -75.958339, + 43.123728 + ], + [ + -75.95701, + 43.121973 + ], + [ + -75.95932, + 43.119597 + ], + [ + -75.956135, + 43.120266 + ], + [ + -75.957585, + 43.117695 + ], + [ + -75.955843, + 43.118314 + ], + [ + -75.9544, + 43.116857 + ], + [ + -75.956023, + 43.115148 + ], + [ + -75.960135, + 43.115395 + ], + [ + -75.963517, + 43.117563 + ], + [ + -75.963719, + 43.119571 + ], + [ + -75.965463, + 43.119651 + ], + [ + -75.965928, + 43.1154 + ], + [ + -75.971793, + 43.109029 + ], + [ + -75.970349, + 43.107567 + ], + [ + -75.97297, + 43.106634 + ], + [ + -75.971524, + 43.104914 + ], + [ + -75.976396, + 43.103038 + ], + [ + -75.976344, + 43.098694 + ], + [ + -75.974569, + 43.096509 + ], + [ + -75.975404, + 43.090921 + ], + [ + -75.971121, + 43.092468 + ], + [ + -75.971584, + 43.088903 + ], + [ + -75.969547, + 43.089549 + ], + [ + -75.96753, + 43.087625 + ], + [ + -75.968792, + 43.089529 + ], + [ + -75.967382, + 43.08992 + ], + [ + -75.965272, + 43.088863 + ], + [ + -75.966673, + 43.087531 + ], + [ + -75.963973, + 43.086697 + ], + [ + -75.960814, + 43.089011 + ], + [ + -75.961825, + 43.085505 + ], + [ + -75.954475, + 43.087464 + ], + [ + -75.954115, + 43.088823 + ], + [ + -75.95126, + 43.087315 + ], + [ + -75.950431, + 43.089629 + ], + [ + -75.948699, + 43.086817 + ], + [ + -75.947087, + 43.08895 + ], + [ + -75.939488, + 43.086663 + ], + [ + -75.938057, + 43.089109 + ], + [ + -75.93445, + 43.087102 + ], + [ + -75.931135, + 43.090221 + ], + [ + -75.928666, + 43.087214 + ], + [ + -75.92892, + 43.088986 + ], + [ + -75.926757, + 43.090565 + ], + [ + -75.919894, + 43.089876 + ], + [ + -75.918818, + 43.086115 + ], + [ + -75.917215, + 43.08591 + ], + [ + -75.896079, + 42.790964 + ], + [ + -76.08017, + 42.781599 + ], + [ + -76.080213, + 42.780014 + ], + [ + -76.274673, + 42.771257 + ], + [ + -76.275348, + 42.78047 + ], + [ + -76.29877, + 42.80746 + ], + [ + -76.356974, + 42.84945 + ], + [ + -76.450738, + 42.84576 + ], + [ + -76.462999, + 43.006316 + ], + [ + -76.491941, + 43.004964 + ], + [ + -76.499219, + 43.099837 + ], + [ + -76.497829, + 43.102657 + ], + [ + -76.491232, + 43.106295 + ], + [ + -76.473594, + 43.104029 + ], + [ + -76.471774, + 43.104983 + ] + ] + ], + "type": "Polygon", + "properties": { + "outage_type": "COUNTY", + "metersOut": "11", + "county": "Onondaga", + "source_type": "COUNTY", + "state": "New York", + "utility_0": "NATIONAL GRID", + "precision_label": "County-level outage summary" + } + }, + "generalized": null, + "h3Resolution": null, + "id": "36067", + "metersOut": 11, + "outageType": "COUNTY", + "outages": [ + { + "areaId": null, + "cellIds": null, + "county": "36067", + "dataGeometry": null, + "envelope": { + "maxX": -75.896079, + "maxY": 43.271605, + "minX": -76.499324, + "minY": 42.771257 + }, + "metersOut": 11, + "metersServed": 1686884, + "outageBbox": "{\"type\":\"Polygon\",\"coordinates\":[[[-76.499324,42.771257],[-76.499324,43.271605],[-75.896079,43.271605],[-75.896079,42.771257],[-76.499324,42.771257]]]}", + "outageIndex": 4, + "outageKind": "COUNTY", + "reported": 1, + "seq": null, + "state": "36", + "stateName": "New York", + "tract": null, + "utilityId": "1932", + "utilityName": "NATIONAL GRID", + "zip": null + } + ], + "precisionLabel": "County-level outage summary", + "sourceType": "COUNTY", + "stateName": "New York", + "tract": null, + "zipCode": null + }, + { + "countyName": "Monroe", + "feature": { + "coordinates": [ + [ + [ + -77.99729, + 43.132981 + ], + [ + -77.994777, + 43.375058 + ], + [ + -77.993982, + 43.631234 + ], + [ + -77.387995, + 43.631543 + ], + [ + -77.376215, + 43.345163 + ], + [ + -77.374582, + 43.160451 + ], + [ + -77.371478, + 43.034696 + ], + [ + -77.485418, + 43.034564 + ], + [ + -77.482517, + 42.943164 + ], + [ + -77.635022, + 42.944464 + ], + [ + -77.637522, + 42.939964 + ], + [ + -77.759052, + 42.940462 + ], + [ + -77.760736, + 42.942998 + ], + [ + -77.759418, + 42.944491 + ], + [ + -77.752545, + 42.94564 + ], + [ + -77.746371, + 42.944072 + ], + [ + -77.737554, + 42.947869 + ], + [ + -77.733124, + 42.945186 + ], + [ + -77.731181, + 42.946901 + ], + [ + -77.734469, + 42.954771 + ], + [ + -77.732568, + 42.955931 + ], + [ + -77.728407, + 42.953033 + ], + [ + -77.725032, + 42.959223 + ], + [ + -77.726199, + 42.965968 + ], + [ + -77.724586, + 42.971293 + ], + [ + -77.718837, + 42.971441 + ], + [ + -77.717828, + 42.973365 + ], + [ + -77.720748, + 42.979193 + ], + [ + -77.732457, + 42.981341 + ], + [ + -77.730957, + 42.988372 + ], + [ + -77.909832, + 42.987762 + ], + [ + -77.909414, + 43.00252 + ], + [ + -77.90754, + 43.002859 + ], + [ + -77.904121, + 43.037056 + ], + [ + -77.951044, + 43.039544 + ], + [ + -77.905934, + 43.133561 + ], + [ + -77.99729, + 43.132981 + ] + ] + ], + "type": "Polygon", + "properties": { + "outage_type": "COUNTY", + "metersOut": "18", + "county": "Monroe", + "source_type": "ZIP", + "state": "New York", + "utility_0": "AVANGRID", + "precision_label": "County-level outage summary" + } + }, + "generalized": null, + "h3Resolution": null, + "id": "36055", + "metersOut": 18, + "outageType": "COUNTY", + "outages": [ + { + "areaId": null, + "cellIds": null, + "county": "36055", + "dataGeometry": null, + "envelope": { + "maxX": -77.671293, + "maxY": 43.25294, + "minX": -77.759955, + "minY": 43.173908 + }, + "metersOut": 18, + "metersServed": null, + "outageBbox": "{\"type\":\"Polygon\",\"coordinates\":[[[-77.759955,43.173908],[-77.759955,43.25294],[-77.671293,43.25294],[-77.671293,43.173908],[-77.759955,43.173908]]]}", + "outageIndex": 6, + "outageKind": "ZIP", + "reported": 1, + "seq": null, + "state": "36", + "stateName": "New York", + "tract": null, + "utilityId": "10000", + "utilityName": "AVANGRID", + "zip": "14626" + } + ], + "precisionLabel": "County-level outage summary", + "sourceType": "ZIP", + "stateName": "New York", + "tract": null, + "zipCode": null + } + ], + "odi_format_json": { + "outage": [ + { + "MRID": "1165478", + "actualPeriod": null, + "cause": null, + "causeKind": null, + "communityDescriptor": "36001", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "2026-10-05T08:30:00Z", + "ertSource": null + }, + "incident": { + "cause": null, + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 3, + "mrid": "1165478", + "names": [ + { + "name": "CENTRAL HUDSON GAS & ELEC CORP", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "3249", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-04T22:14:30Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-04T22:14:30Z", + "statusKind": "assigned", + "utilityDisclaimer": "Based on reports from customers and field personnel, we predict the outages listed" + }, + { + "MRID": "951411", + "actualPeriod": null, + "cause": "Unknown", + "causeKind": "", + "communityDescriptor": "36067", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "2026-10-05T08:00:00Z", + "ertSource": null + }, + "incident": { + "cause": "Unknown", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 11, + "mrid": "951411", + "names": [ + { + "name": "NATIONAL GRID", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "13573", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-04T18:35:40Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-04T18:35:40Z", + "statusKind": "", + "utilityDisclaimer": "" + }, + { + "MRID": null, + "actualPeriod": null, + "cause": "Investigating", + "causeKind": null, + "communityDescriptor": "53033", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": null, + "incident": { + "cause": "Investigating", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 1, + "mrid": null, + "names": [ + { + "name": "CITY OF SEATTLE - (WA)", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "16868", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-05T06:04:00Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-05T06:04:00Z", + "statusKind": "Dispatched", + "utilityDisclaimer": null + }, + { + "MRID": "62bf6b17-1acc-485b-bc53-42dfca9fcfc8", + "actualPeriod": null, + "cause": "Scheduled Outage", + "causeKind": "lineDown", + "communityDescriptor": "53033", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "0001-01-01T00:00:00Z", + "ertSource": null + }, + "incident": { + "cause": "Scheduled Outage", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 1, + "mrid": "62bf6b17-1acc-485b-bc53-42dfca9fcfc8", + "names": [ + { + "name": "Puget Sound Energy Inc", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "pugetsoundenergy", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-03T15:29:43Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-03T15:29:43Z", + "statusKind": "awaitingCrewAssignment", + "utilityDisclaimer": "None" + }, + { + "MRID": "a6fd7d77-f084-4d6f-9dac-08d274e0099f", + "actualPeriod": null, + "cause": "Under investigation", + "causeKind": "lineDown", + "communityDescriptor": "53033", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "0001-01-01T00:00:00Z", + "ertSource": null + }, + "incident": { + "cause": "Under investigation", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 1, + "mrid": "a6fd7d77-f084-4d6f-9dac-08d274e0099f", + "names": [ + { + "name": "Puget Sound Energy Inc", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "pugetsoundenergy", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-10-05T03:23:27Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-10-05T03:23:27Z", + "statusKind": "awaitingCrewAssignment", + "utilityDisclaimer": "None" + }, + { + "MRID": "1650672", + "actualPeriod": null, + "cause": "N/A", + "causeKind": null, + "communityDescriptor": "36055", + "customersRestored": null, + "estimatedPeriod": null, + "estimatedRestorationTime": { + "confidenceKind": null, + "ert": "2026-04-10T22:15:00Z", + "ertSource": null + }, + "incident": { + "cause": "N/A", + "location": { + "coordinateSystem": { + "crsUrn": "EPSG:4326" + }, + "direction": null, + "geoInfoReference": null, + "kind": "ELECTRICAL_NETWORK", + "positionPoints": [], + "type": null + } + }, + "metersAffected": 18, + "mrid": "1650672", + "names": [ + { + "name": "AVANGRID", + "nameType": "UtilityName", + "nameTypeAuthority": "EIA" + }, + { + "name": "14349", + "nameType": "UtilityId", + "nameTypeAuthority": "EIA" + } + ], + "originalCustomersServed": null, + "originalMetersAffected": null, + "outageArea": { + "earliestReportedTime": "2026-04-10T19:13:29Z", + "metersServed": null, + "outageAreaKind": "COUNTY" + }, + "outageKind": null, + "reportedStartTime": "2026-04-10T19:13:29Z", + "statusKind": "assigned", + "utilityDisclaimer": null + } + ] + }, + "odi_status": [ + { + "dataId": "45ea5762-cf5c-46b3-b0fa-bb34c03925e4", + "dataResolution": "COUNTY", + "eiaId": "3249", + "hasError": false, + "name": "CENTRAL HUDSON GAS & ELEC CORP", + "outageFeed": null, + "receivedDate": "2026-10-05T06:21:16.964Z", + "totalOutages": 3, + "vendor": null + }, + { + "dataId": "d18693f9-59f9-4c45-9dfb-07fcc6046758", + "dataResolution": "COUNTY", + "eiaId": "13573", + "hasError": false, + "name": "NATIONAL GRID", + "outageFeed": null, + "receivedDate": "2026-10-05T06:20:47.233Z", + "totalOutages": 15, + "vendor": null + }, + { + "dataId": "7bc8b495-4fea-45ff-aae5-bce2b8ab48a5", + "dataResolution": "ZIP", + "eiaId": "14349", + "hasError": false, + "name": "AVANGRID", + "outageFeed": null, + "receivedDate": "2026-10-05T06:20:12.410Z", + "totalOutages": 21, + "vendor": null + }, + { + "dataId": "81288660-5502-469f-96b9-c2a1a1cf482f", + "dataResolution": "POINT", + "eiaId": "16868", + "hasError": false, + "name": "CITY OF SEATTLE - (WA)", + "outageFeed": null, + "receivedDate": "2026-10-05T06:20:00.171Z", + "totalOutages": 1, + "vendor": "datacapable" + }, + { + "dataId": "579e922c-44e2-4540-8ef3-f338bc15c7c9", + "dataResolution": "COUNTY", + "eiaId": "17609", + "hasError": true, + "name": "SOUTHERN CALIFORNIA EDISON CO", + "outageFeed": null, + "receivedDate": "2026-10-05T06:15:02.910Z", + "totalOutages": 6332, + "vendor": null + } + ] +} \ No newline at end of file diff --git a/drb-c2-core/tests/test_outage_providers.py b/drb-c2-core/tests/test_outage_providers.py new file mode 100644 index 0000000..13d8c8a --- /dev/null +++ b/drb-c2-core/tests/test_outage_providers.py @@ -0,0 +1,329 @@ +"""Power-outage providers (server-26#184): registry, licence gate, admin routes, ODIN adapter. + +What is held here (mirrors tests/test_camera_providers.py): + * every provider defaults OFF, and an off provider is never fetched; + * stubs can never be enabled (409); + * a gated licence needs ``acknowledge_licence``, which is stored with who/when + and audited -- and an audit failure refuses the enable (fail closed); + * an acknowledgement given for a different licence status no longer counts; + * the admin routes are admin-only; + * the ODIN parser reads the recorded public payloads, and an all-unparseable + payload is an error, not an empty layer; + * an area no serving provider covers says so instead of returning a bare []. +""" +import asyncio +import json +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.internal.auth import require_admin_token, require_service_or_firebase_token +from app.internal.outages import base, providers, registry +from app.internal.outages.base import Licence +from app.main import app + +client = TestClient(app) +ADMIN = {"uid": "admin-1", "email": "admin@example.com", "role": "admin"} +FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "odin_public_2026-10-05.json").read_text()) +STUBS = ("poweroutage_us", "coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live") +NY_BBOX = (42.5, -78.5, 43.5, -75.5) # Rochester .. Syracuse +RECORDED_AT = datetime(2026, 10, 5, 6, 45, tzinfo=timezone.utc) # when the fixture was captured + + +class FakeStore: + def __init__(self): + self.docs = {} + self.audit = [] + + async def doc_get(self, col, doc): + return self.docs.get((col, doc)) + + async def doc_set(self, col, doc, data, merge=True): + cur = self.docs.setdefault((col, doc), {}) + if col == "audit_log": + self.audit.append(data) + return + for k, v in data.items(): # one-level deep merge is all registry needs + if merge and isinstance(v, dict) and isinstance(cur.get(k), dict): + cur[k] = {**cur[k], **v} + else: + cur[k] = v + + +@pytest.fixture(autouse=True) +def store(): + s = FakeStore() + registry.invalidate_cache() + for feed in registry.FEEDS.values(): + feed.items, feed.fetched_at, feed.error, feed._next_attempt = [], None, None, 0.0 + with patch.object(registry.fstore, "doc_get", s.doc_get), patch.object(registry.fstore, "doc_set", s.doc_set), \ + patch("app.internal.audit.fstore.doc_set", s.doc_set): + yield s + registry.invalidate_cache() + app.dependency_overrides.clear() + + +def run(coro): + return asyncio.run(coro) + + +def _odin_areas(): + return providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"], + now=RECORDED_AT)[0] + + +# ── registry / gate ────────────────────────────────────────────────────────── + +def test_every_catalogued_source_is_registered_and_defaults_off(): + listed = run(registry.list_providers()) + assert {p["id"] for p in listed} == {"odin", *STUBS} + assert all(p["enabled"] is False and p["serving"] is False for p in listed) + assert all(p["licence"]["label"] for p in listed) + odin = next(p for p in listed if p["id"] == "odin") + assert odin["licence"]["status"] == "unverified" and odin["licence"]["gated"] is True + assert odin["licence"]["terms_url"] == "https://odin.ornl.gov/v3/api-docs" + assert odin["not_serving_reason"] == "disabled (default)" + + +def test_catalogued_licence_statuses_match_the_source_terms(): + lic = {pid: p.meta.licence.status for pid, p in registry.PROVIDERS.items()} + assert lic["poweroutage_us"] == Licence.PAID + for pid in ("coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live"): + assert lic[pid] == Licence.AGREEMENT_REQUIRED, pid + assert all(not registry.PROVIDERS[pid].meta.implemented for pid in STUBS) + + +def test_warning_is_worded_for_outage_data(): + w = registry.PROVIDERS["odin"].meta.licence.warning() + assert "outage data" in w and "imagery" not in w + + +def test_off_provider_is_never_fetched(): + fetch = AsyncMock(return_value=[]) + with patch.object(registry.PROVIDERS["odin"], "fetch", fetch): + out = run(registry.outages_in_bbox(NY_BBOX)) + assert out["areas"] == [] and out["providers"] == [] and fetch.await_count == 0 + assert out["serving_count"] == 0 and out["status"] == "none_enabled" + + +def test_stubs_cannot_be_enabled(): + for pid in STUBS: + with pytest.raises(registry.ProviderError) as e: + run(registry.set_enabled(pid, True, ("u", "e"), acknowledge_licence=True)) + assert e.value.code == 409, pid + + +def test_gated_licence_requires_acknowledgement(store): + with pytest.raises(registry.ProviderError) as e: + run(registry.set_enabled("odin", True, ("u1", "a@x"))) + assert e.value.code == 409 and e.value.extra["requires_acknowledgement"] is True + assert "Licence unverified" in e.value.extra["warning"] + assert not store.audit and not run(registry.is_serving("odin")) + + out = run(registry.set_enabled("odin", True, ("u1", "a@x"), acknowledge_licence=True)) + assert out["serving"] is True + ack = out["acknowledgement"] + assert ack["by_uid"] == "u1" and ack["by_email"] == "a@x" and ack["at"] + assert ack["licence_status"] == Licence.UNVERIFIED.value + assert store.audit[-1]["action"] == "outage_provider.enable_acknowledged" + assert store.audit[-1]["details"]["provider_id"] == "odin" + assert store.docs[("config", "outage_providers")]["providers"]["odin"]["enabled"] is True + + +def test_gated_enable_refused_when_audit_cannot_be_written(store): + with patch.object(registry, "_audit", AsyncMock(side_effect=RuntimeError("firestore down"))): + with pytest.raises(registry.ProviderError) as e: + run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True)) + assert e.value.code == 503 + assert not run(registry.is_serving("odin")) + assert ("config", "outage_providers") not in store.docs + + +def test_disable_clears_acknowledgement_and_is_audited(store): + run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True)) + out = run(registry.set_enabled("odin", False, ("u", "e"))) + assert out["serving"] is False and out["acknowledgement"] is None + assert store.audit[-1]["action"] == "outage_provider.disable" + + +def test_stale_acknowledgement_stops_serving(): + p = registry.PROVIDERS["odin"] + entry = {"enabled": True, "acknowledgement": {"licence_status": "paid"}} + assert registry.effective(p, entry) == (False, "licence acknowledgement missing or out of date") + entry["acknowledgement"]["licence_status"] = p.meta.licence.status.value + assert registry.effective(p, entry) == (True, None) + + +def test_unreadable_settings_fail_closed(): + with patch.object(registry.fstore, "doc_get", AsyncMock(side_effect=RuntimeError("boom"))): + registry.invalidate_cache() + assert run(registry.get_settings()) == {} + assert not run(registry.is_serving("odin")) + + +# ── bbox query ─────────────────────────────────────────────────────────────── + +def test_query_returns_areas_in_bbox_with_credit(store): + run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True)) + with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(return_value=_odin_areas())): + out = run(registry.outages_in_bbox(NY_BBOX)) + assert out["status"] == "ok" and out["serving_count"] == 1 + assert {a["fips"] for a in out["areas"]} == {"36055", "36067"} # not Albany, not King County WA + prov = out["providers"][0] + assert prov["attribution"].startswith("Outage data: ODIN") and prov["disclaimer"] + assert prov["total_in_bbox"] == 2 and prov["fetched_at"] and prov["error"] is None + assert "Only utilities that report to ODIN appear" in prov["coverage_note"] + + +def test_area_no_serving_provider_covers_says_so(store): + run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True)) + fetch = AsyncMock(return_value=_odin_areas()) + with patch.object(registry.PROVIDERS["odin"], "fetch", fetch): + out = run(registry.outages_in_bbox((51.4, -0.2, 51.6, 0.1))) # London + assert fetch.await_count == 0 # outside ODIN coverage -> never fetched + assert out["areas"] == [] and out["status"] == "no_source" + assert out["message"] == "No licensed outage source covers this area." + + +def test_dead_feed_is_an_error_not_an_empty_layer(store): + run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True)) + with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(side_effect=ValueError("schema change?"))): + out = run(registry.outages_in_bbox(NY_BBOX)) + assert out["status"] == "error" and "schema change" in out["providers"][0]["error"] + + +def test_failed_refresh_keeps_last_good_feed(): + feed = registry.FEEDS["odin"] + with patch.object(feed.provider, "fetch", AsyncMock(return_value=_odin_areas())): + run(feed.get()) + feed._next_attempt = 0.0 + with patch.object(feed.provider, "fetch", AsyncMock(side_effect=RuntimeError("down"))): + run(feed.get()) + assert len(feed.items) == 4 and "down" in feed.error and feed.fetched_at + + +# ── admin routes ───────────────────────────────────────────────────────────── + +def test_admin_routes_require_admin(): + def deny(): + raise HTTPException(403, "Admin access required") + app.dependency_overrides[require_admin_token] = deny + assert client.get("/admin/outage-providers").status_code == 403 + assert client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True}).status_code == 403 + + +def test_admin_route_toggle_and_gate(store): + app.dependency_overrides[require_admin_token] = lambda: ADMIN + r = client.put("/admin/outage-providers/odin", json={"enabled": True}) + assert r.status_code == 409 and r.json()["requires_acknowledgement"] is True + assert r.json()["licence"]["status"] == "unverified" + r = client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True}) + assert r.status_code == 200 and r.json()["acknowledgement"]["by_email"] == "admin@example.com" + assert client.put("/admin/outage-providers/poweroutage_us", + json={"enabled": True, "acknowledge_licence": True}).status_code == 409 + assert client.put("/admin/outage-providers/nope", json={"enabled": True}).status_code == 404 + listed = client.get("/admin/outage-providers").json()["providers"] + assert next(p for p in listed if p["id"] == "odin")["serving"] is True + + +def test_outages_route_rejects_inverted_bbox_and_reports_status(): + app.dependency_overrides[require_service_or_firebase_token] = lambda: {"uid": "v"} + assert client.get("/outages", params={"south": 2, "west": 0, "north": 1, "east": 1}).status_code == 400 + r = client.get("/outages", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4}) + assert r.status_code == 200 and r.json()["areas"] == [] and r.json()["status"] == "none_enabled" + + +def test_outages_route_requires_sign_in(): + def deny(): + raise HTTPException(401, "Not signed in") + app.dependency_overrides[require_service_or_firebase_token] = deny + assert client.get("/outages", params={"south": 40, "west": -74, "north": 41, "east": -73}).status_code == 401 + + +# ── ODIN adapter (recorded public payloads, 2026-10-05) ────────────────────── + +def test_odin_parser_reads_recorded_payload(): + areas, info = providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"], + now=RECORDED_AT) + by = {a["fips"]: a for a in areas} + assert set(by) == {"36001", "36067", "36055", "53033"} + onondaga = by["36067"] + assert onondaga["id"] == "odin:36067" and onondaga["provider"] == "odin" + assert onondaga["county"] == "Onondaga" and onondaga["state"] == "New York" + assert onondaga["customers_out"] == 11 and onondaga["customers_served"] is None + assert onondaga["etr"] == "2026-10-05T08:00:00Z" and onondaga["since"] == "2026-10-04T18:35:40Z" + assert onondaga["updated_at"].startswith("2026-10-05T06:20:47") + assert onondaga["utilities"] == [{"name": "NATIONAL GRID", "eia_id": "13573", "customers_out": 11, + "stale_customers_out": 0}] + assert onondaga["stale"] is False and onondaga["stale_customers_out"] == 0 + s, w, n, e = onondaga["bbox"] + assert s < onondaga["lat"] < n and w < onondaga["lon"] < e + assert 42.7 < onondaga["lat"] < 43.3 and -76.6 < onondaga["lon"] < -75.8 + # King County WA: several outage events from two utilities, summed per county + king = by["53033"] + assert king["customers_out"] == sum(r["metersOut"] for r in FIXTURE["odi_map_scale_county"] if r["id"] == "53033") + assert len(king["utilities"]) == 2 + # Puget Sound Energy's "0001-01-01T00:00:00Z" means no ETR -- not a restoration time, not stale. + assert king["etr"] is None and king["stale_customers_out"] == 0 + assert info["reporting_utilities"] == len(FIXTURE["odi_status"]) and info["utilities_with_error"] == 1 + + +def test_odin_events_long_past_their_etr_are_stale_not_live(): + # ODIN still listed Avangrid's Monroe County row from April 2026 (ETR + # 2026-04-10) on 2026-10-05. It must not read as 18 customers out right now. + by = {a["fips"]: a for a in _odin_areas()} + monroe = by["36055"] + assert monroe["customers_out"] == 0 and monroe["stale_customers_out"] == 18 and monroe["stale"] is True + assert monroe["since"].startswith("2026-04-10") # still shown, so the stale row is explainable + # Same payload, captured the day the outage was live: not stale. + live = {a["fips"]: a for a in providers.parse_odin( + FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"], + now=datetime(2026, 4, 10, 20, 0, tzinfo=timezone.utc))[0]} + assert live["36055"]["customers_out"] == 18 and live["36055"]["stale"] is False + # Live counties sort ahead of stale ones. + assert [a["stale"] for a in _odin_areas()] == sorted(a["stale"] for a in _odin_areas()) + + +def test_odin_fetch_uses_the_three_public_endpoints(): + calls = [] + + async def fake_get_json(url, params=None, headers=None): + calls.append((url, params)) + return {"https://odin.ornl.gov/odi": FIXTURE["odi_format_json"], + "https://odin.ornl.gov/odi/map": FIXTURE["odi_map_scale_county"], + "https://odin.ornl.gov/odi/status": FIXTURE["odi_status"]}[url] + + p = providers.OdinProvider() + with patch.object(providers, "get_json", fake_get_json): + areas = run(p.fetch()) + assert len(areas) == 4 + assert ("https://odin.ornl.gov/odi", {"format": "JSON"}) in calls + assert ("https://odin.ornl.gov/odi/map", {"scale": "COUNTY"}) in calls + assert p.reporting_utilities == len(FIXTURE["odi_status"]) + assert "utilities reporting nationwide" in p.coverage_note() + + +def test_odin_schema_canary_trips_on_unparseable_payload(): + odi, cmap, status = FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"] + renamed_odi = {"outage": [{"county": "36067", "count": 11}]} # fields renamed upstream + with pytest.raises(ValueError, match="schema"): + providers.parse_odin(renamed_odi, cmap, status) + with pytest.raises(ValueError, match="schema"): + providers.parse_odin(odi, [{"fips": "36067"}], status) + with pytest.raises(ValueError, match="schema"): + providers.parse_odin(odi, cmap, [{"utility": "x"}]) + with pytest.raises(ValueError, match="schema"): + providers.parse_odin([], cmap, status) # wrapper changed + # A genuinely quiet day is NOT an error: nothing upstream, nothing parsed. + assert providers.parse_odin({"outage": []}, [], status)[0] == [] + + +def test_area_in_bbox_uses_county_extent(): + a = base.make_area("odin", "36067", county="Onondaga", state="New York", customers_out=1, + lat=43.0, lon=-76.2, bbox=(42.77, -76.5, 43.27, -75.9)) + assert base.area_in_bbox(a, (43.2, -76.0, 43.5, -75.5)) # overlaps the county's corner only + assert not base.area_in_bbox(a, (44.0, -76.0, 44.5, -75.5)) diff --git a/drb-frontend/app/admin/page.tsx b/drb-frontend/app/admin/page.tsx index 6a744c9..6b83723 100644 --- a/drb-frontend/app/admin/page.tsx +++ b/drb-frontend/app/admin/page.tsx @@ -4,6 +4,7 @@ import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; import { ReplayTab } from "@/components/admin/ReplayTab"; import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab"; +import { OutageProvidersTab } from "@/components/admin/OutageProvidersTab"; import { useEffect, useState, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types"; @@ -1268,11 +1269,12 @@ function SttEvalTab() { // Main admin page // --------------------------------------------------------------------------- -type AdminTab = "features" | "cameras" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval"; +type AdminTab = "features" | "cameras" | "outages" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval"; const TAB_LABELS: { key: AdminTab; label: string }[] = [ { key: "features", label: "AI Features" }, { key: "cameras", label: "Camera providers" }, + { key: "outages", label: "Outage providers" }, { key: "correlation", label: "Correlation Debug" }, { key: "replay", label: "Replay" }, { key: "calls", label: "Calls" }, @@ -1285,7 +1287,7 @@ export default function AdminPage() { const { user, isAdmin, loading: authLoading } = useAuth(); const router = useRouter(); const [tab, setTab] = useState("features"); - // Deep link to a tab (/admin#cameras), e.g. from the live wall's empty state. + // Deep link to a tab (/admin#cameras, /admin#outages), e.g. from a map layer's empty state. useEffect(() => { const h = window.location.hash.slice(1); if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab); @@ -1304,7 +1306,7 @@ export default function AdminPage() { if (!isAdmin) return null; // Users/Audit tabs benefit from full width; everything else is narrow - const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras"; + const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages"; return (
@@ -1326,6 +1328,7 @@ export default function AdminPage() { {tab === "features" && } {tab === "cameras" && } + {tab === "outages" && } {tab === "correlation" && } {tab === "replay" && } {tab === "calls" && } diff --git a/drb-frontend/components/admin/OutageProvidersTab.tsx b/drb-frontend/components/admin/OutageProvidersTab.tsx new file mode 100644 index 0000000..492a7e6 --- /dev/null +++ b/drb-frontend/components/admin/OutageProvidersTab.tsx @@ -0,0 +1,156 @@ +"use client"; + +/** + * Admin → Outage providers (server-26#184). Same licence gate as Camera + * providers: every source is listed with its licence status shown first, all + * default OFF. A licence-gated source (unverified / agreement required / paid) + * shows a blocking warning and needs an explicit confirmation, which c2-core + * records with who/when in config/outage_providers and audit_log. Stubs — + * utility storm-center maps, PowerOutage.us, EAGLE-I live — cannot be switched + * on at all: DRB holds no right to that data. + */ +import { useEffect, useState } from "react"; +import { c2api } from "@/lib/c2api"; +import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types"; + +const LICENCE_STYLE: Record = { + commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" }, + attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" }, + agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" }, + non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" }, + unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" }, + paid: { label: "Paid licence — no contract", cls: "bg-amber-950 text-amber-300 border-amber-700" }, + prohibited: { label: "Terms forbid use", cls: "bg-red-950 text-red-300 border-red-700" }, +}; + +function fmtEpoch(s: number | null | undefined): string { + return s ? new Date(s * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—"; +} + +export function OutageProvidersTab() { + const [providers, setProviders] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [confirming, setConfirming] = useState(null); + const [ackChecked, setAckChecked] = useState(false); + + const load = () => + c2api.listOutageProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e))); + useEffect(() => { load(); }, []); + + async function toggle(p: OutageProviderAdmin, enabled: boolean, acknowledge = false) { + if (enabled && p.licence.gated && !acknowledge) { + setError(null); + setAckChecked(false); + setConfirming(p); + return; + } + setBusy(p.id); + setError(null); + try { + const updated = await c2api.setOutageProvider(p.id, enabled, acknowledge); + setProviders((cur) => cur?.map((x) => (x.id === p.id ? updated : x)) ?? null); + setConfirming(null); + } catch (e) { + setError(String(e)); + } finally { + setBusy(null); + } + } + + return ( +
+

+ Power-outage sources for the map. Every provider is off until enabled here. Licence status comes from the + source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right + to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing + agreement exists. +

+ {error &&
{error}
} + {!providers ? ( +

Loading…

+ ) : ( +
+ {providers.map((p) => { + const lic = LICENCE_STYLE[p.licence.status]; + const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null; + return ( +
+
+
+ {lic.label} +

{p.name}

+

{p.licence.label}

+

{p.region} · {p.granularity}

+
+
+ +

+ {p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"} +

+
+
+ {p.implemented && ( +
+ counties out: {p.health ? p.health.count : "—"} + last fetch: {fmtEpoch(p.health?.fetched_at)} + health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"} +
+ )} + {p.health?.error &&

{p.health.error}

} + {p.coverage_note &&

{p.coverage_note}

} + {p.licence.attribution &&

Attribution shown with the data: “{p.licence.attribution}”

} + {p.acknowledgement && ( +

+ Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()} +

+ )} +
+ Terms & adapter notes +

{p.licence.terms}

+

{p.notes}

+

+ {p.licence.terms_url && terms} + {p.homepage && source} + {p.licence.issue && tracking: {p.licence.issue}} +

+
+
+ ); + })} +
+ )} + + {confirming && ( +
+
+

Enable {confirming.name}?

+

{LICENCE_STYLE[confirming.licence.status].label}

+

{confirming.licence.warning}

+ {error &&

{error}

} + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 0cf554b..30b9128 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -1,5 +1,7 @@ import { auth } from "@/lib/firebase"; -import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types"; +import type { + AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending, +} from "@/lib/types"; import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord"; const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000"; @@ -498,6 +500,22 @@ export const c2api = { body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }), }), + // Power outages from every admin-enabled provider inside a bbox (server-26#184). + // `status` explains an empty list: none_enabled / no_source / error / ok. + getOutages: (bbox: { south: number; west: number; north: number; east: number }) => + request(`/outages?${new URLSearchParams({ + south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), + })}`), + + // Admin → Outage providers. Same licence gate as cameras: a gated provider + // answers 409 with `requires_acknowledgement` until `acknowledge_licence` is sent. + listOutageProviders: () => request<{ providers: OutageProviderAdmin[] }>("/admin/outage-providers"), + setOutageProvider: (id: string, enabled: boolean, acknowledgeLicence = false) => + request(`/admin/outage-providers/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }), + }), + // Public waitlist — no auth, see routers/waitlist.py joinWaitlist: (body: { email: string; org_name?: string; note?: string }) => request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }), diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 17052b0..279f1fe 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -217,6 +217,74 @@ export interface CameraProviderAdmin { health: { fetched_at: number | null; last_attempt_at: number | null; error: string | null; count: number } | null; } +// Power outages (server-26#184) — GET /outages, cached server-side, not Firestore. +/** + * One county with active outages from an enabled provider. `id` is + * provider-prefixed ("odin:36067"). Counts are utility-reported meters, used + * as customers. `lat`/`lon` is the centre of the county's bounding box — a + * label point, not an outage location. Times are ISO-8601 UTC. + */ +export interface OutageArea { + id: string; + provider: string; + fips: string; + county: string | null; + state: string | null; + customers_out: number; // live events only + /** Meters on events the source still lists whose own ETR passed >24h ago (ODIN keeps months-old rows). */ + stale_customers_out: number; + stale: boolean; // every event in the county is stale: dim or hide, never show as live + customers_served: number | null; // null: no county-level denominator from the source + etr: string | null; // latest estimated restoration across the county's outages + since: string | null; // earliest reported start + updated_at: string | null; // newest time a reporting utility here last sent data + lat: number | null; + lon: number | null; + bbox: [number, number, number, number] | null; // south, west, north, east + utilities: { name: string | null; eia_id: string | null; customers_out: number; stale_customers_out: number }[]; +} + +export interface OutageProviderFeed extends CameraCredit { + id: string; + name: string; + granularity: string; + coverage_note: string | null; + covers_bbox: boolean; + fetched_at: number | null; // epoch s of the provider's last successful fetch + error: string | null; + total_in_bbox: number; + truncated: boolean; +} + +export interface OutagesResponse { + areas: OutageArea[]; + providers: OutageProviderFeed[]; + serving_count: number; + /** Why the list is what it is — an empty layer is never ambiguous. */ + status: "ok" | "none_enabled" | "no_source" | "error"; + message: string | null; +} + +/** Admin → Outage providers (GET /admin/outage-providers). */ +export interface OutageProviderAdmin { + id: string; + name: string; + region: string; + granularity: string; + implemented: boolean; + notes: string; + homepage: string | null; + licence: CameraProviderAdmin["licence"]; + enabled: boolean; + serving: boolean; + not_serving_reason: string | null; + acknowledgement: CameraProviderAdmin["acknowledgement"]; + updated_at: string | null; + updated_by: { uid: string; email: string } | null; + coverage_note: string | null; + health: CameraProviderAdmin["health"]; +} + export interface Ny511Event { id: string; lat: number;