Review: ODIN stale events and 0001-01-01 no-ETR sentinel (Refs #184)
ODIN still lists Avangrid rows from April 2026 (ETR April/May) as active on 2026-10-05; parse_odin now counts events whose own ETR passed >24h ago as stale_customers_out, not customers_out, and since/etr come from live events. Puget Sound Energy sends 0001-01-01T00:00:00Z for no ETR; treat as None. Ack dialog now shows a refused enable's error instead of hiding it behind the overlay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ebeabe342e
commit
0efcd676c3
@@ -119,13 +119,21 @@ class Feed:
|
||||
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) -> OutageArea:
|
||||
"""One normalised county outage record. ``id`` is provider-prefixed."""
|
||||
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
|
||||
|
||||
@@ -29,6 +29,7 @@ NY carries 1,686,884 on every county), so it is NOT a county denominator and
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from app.internal.outages.base import (
|
||||
@@ -42,6 +43,10 @@ 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):
|
||||
@@ -114,9 +119,13 @@ def _utility(names: object) -> Tuple[Optional[str], Optional[str]]:
|
||||
return name, eia
|
||||
|
||||
|
||||
def parse_odin(odi: object, county_map: object, status: object) -> Tuple[List[OutageArea], dict]:
|
||||
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."""
|
||||
@@ -154,8 +163,9 @@ def parse_odin(odi: object, county_map: object, status: object) -> Tuple[List[Ou
|
||||
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 = 0
|
||||
parsed = stale_events = 0
|
||||
for o in odi["outage"]:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
@@ -164,36 +174,44 @@ def parse_odin(odi: object, county_map: object, status: object) -> Tuple[List[Ou
|
||||
if not _FIPS.match(fips) or meters is None or meters < 0:
|
||||
continue
|
||||
parsed += 1
|
||||
a = agg.setdefault(fips, {"out": 0, "since": None, "etr": None, "utilities": {}})
|
||||
a["out"] += int(meters)
|
||||
area = o.get("outageArea") if isinstance(o.get("outageArea"), dict) else {}
|
||||
start = parse_iso(o.get("reportedStartTime")) or parse_iso(area.get("earliestReportedTime"))
|
||||
if start and (a["since"] is None or start < a["since"]):
|
||||
a["since"] = start
|
||||
ert_obj = o.get("estimatedRestorationTime") if isinstance(o.get("estimatedRestorationTime"), dict) else {}
|
||||
ert = parse_iso(ert_obj.get("ert"))
|
||||
if ert and (a["etr"] is None or ert > a["etr"]):
|
||||
a["etr"] = 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})
|
||||
u["customers_out"] += int(meters)
|
||||
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"])
|
||||
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"],
|
||||
customers_served=None, etr=iso(a["etr"]), since=iso(a["since"]),
|
||||
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"])
|
||||
return areas, {"reporting_utilities": len(status), "utilities_with_error": errors}
|
||||
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 ─────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user