Outage providers: licence-gated registry, ODIN adapter, /outages API, admin tab (Refs #184) #255

Merged
logan merged 2 commits from feat/184-outage-providers into main 2026-10-05 02:47:16 -04:00
Owner

Refs #184 — part 1 of 2 (map layer + incident cross-match are batch 3).

What

  • app/internal/outages/ — registry mirrors cameras/registry.py rule for rule: settings doc config/outage_providers, every provider OFF by default, unreadable settings fail closed, stubs never enable, gated licence needs acknowledge_licence stored with who/when and an audit_log write as a precondition (audit failure → 503, no enable), ack bound to the licence status (stale ack stops serving). Reuses cameras.base Licence/LicenceInfo/get_json/safe_error/require_parsed; cameras/* untouched.
  • ODIN adapter (Licence.UNVERIFIED, gated). Public county-level API verified first-hand against https://odin.ornl.gov/v3/api-docs and live responses 2026-10-05: /odi?format=JSON (counts, start, ETR, EIA id), /odi/map?scale=COUNTY (county name, bbox → label point), /odi/status (utility last-received). Schema canary on all three (rows upstream, 0 parsed → error). metersServed is utility-wide (National Grid NY = 1,686,884 on every county), so customers_served stays null. No terms of use or licence found anywhere on ODIN — hence unverified.
  • Stubs (409, never enable): poweroutage_us (paid), coned_oru_stormcenter / nyseg_rge_stormcenter / central_hudson_stormcenter (agreement required), eagle_i_live (agreement required).
  • GET /outages?south&west&north&east (signed-in) → {areas, providers[{id,name,attribution,disclaimer,coverage_note,fetched_at,error,total_in_bbox,…}], serving_count, status, message}; status ∈ ok / none_enabled / no_source ("No licensed outage source covers this area.") / error. Off providers are never fetched.
  • /admin/outage-providers GET + PUT /{id} — require_admin_token + describe_actor, 409 requires_acknowledgement like cameras.
  • Frontend: c2api.getOutages / listOutageProviders / setOutageProvider, types, Admin → Outage providers tab (/admin#outages).

Coverage finding (live, 2026-10-05): ODIN carries National Grid, Central Hudson and Avangrid (NYSEG/RG&E) in NY. Con Edison / O&R are on ODIN's participant list but are not reporting — so NYC/Westchester show nothing from ODIN today. Avangrid has NY rows with start/ETR dates in April 2026 (stale upstream); since exposes it.

Tests: tests/test_outage_providers.py (22) mirrors test_camera_providers.py + ODIN parser test from a recorded fixture (tests/fixtures/odin_public_2026-10-05.json). Full c2-core: 779 passed. Frontend tsc --noEmit: exit 0.

🤖 Generated with Claude Code

Refs #184 — part 1 of 2 (map layer + incident cross-match are batch 3). **What** - `app/internal/outages/` — registry mirrors `cameras/registry.py` rule for rule: settings doc `config/outage_providers`, every provider OFF by default, unreadable settings fail closed, stubs never enable, gated licence needs `acknowledge_licence` stored with who/when **and** an `audit_log` write as a precondition (audit failure → 503, no enable), ack bound to the licence status (stale ack stops serving). Reuses `cameras.base` Licence/LicenceInfo/get_json/safe_error/require_parsed; `cameras/*` untouched. - **ODIN adapter** (`Licence.UNVERIFIED`, gated). Public county-level API verified first-hand against https://odin.ornl.gov/v3/api-docs and live responses 2026-10-05: `/odi?format=JSON` (counts, start, ETR, EIA id), `/odi/map?scale=COUNTY` (county name, bbox → label point), `/odi/status` (utility last-received). Schema canary on all three (rows upstream, 0 parsed → error). `metersServed` is utility-wide (National Grid NY = 1,686,884 on every county), so `customers_served` stays null. No terms of use or licence found anywhere on ODIN — hence unverified. - **Stubs** (409, never enable): `poweroutage_us` (paid), `coned_oru_stormcenter` / `nyseg_rge_stormcenter` / `central_hudson_stormcenter` (agreement required), `eagle_i_live` (agreement required). - `GET /outages?south&west&north&east` (signed-in) → `{areas, providers[{id,name,attribution,disclaimer,coverage_note,fetched_at,error,total_in_bbox,…}], serving_count, status, message}`; `status` ∈ ok / none_enabled / no_source ("No licensed outage source covers this area.") / error. Off providers are never fetched. - `/admin/outage-providers` GET + PUT `/{id}` — `require_admin_token` + `describe_actor`, 409 `requires_acknowledgement` like cameras. - Frontend: `c2api.getOutages / listOutageProviders / setOutageProvider`, types, Admin → **Outage providers** tab (`/admin#outages`). **Coverage finding (live, 2026-10-05):** ODIN carries National Grid, Central Hudson and Avangrid (NYSEG/RG&E) in NY. Con Edison / O&R are on ODIN's participant list but are **not reporting** — so NYC/Westchester show nothing from ODIN today. Avangrid has NY rows with start/ETR dates in April 2026 (stale upstream); `since` exposes it. **Tests**: `tests/test_outage_providers.py` (22) mirrors `test_camera_providers.py` + ODIN parser test from a recorded fixture (`tests/fixtures/odin_public_2026-10-05.json`). Full c2-core: 779 passed. Frontend `tsc --noEmit`: exit 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 02:39:43 -04:00
Part 1 of 2 for server-26#184 (map layer + incident cross-match follow in batch 3).

- app/internal/outages/: same gate as the camera registry -- settings doc
  config/outage_providers, every provider OFF by default, unreadable settings
  fail closed, stubs never enable, gated licences need acknowledge_licence
  stored with who/when plus an audit_log write as a precondition, and the
  acknowledgement is bound to the licence status it was given for.
  Reuses cameras.base Licence/LicenceInfo/get_json/safe_error/require_parsed.
- ODIN adapter (Licence.UNVERIFIED, gated): public county-level API verified
  first-hand against https://odin.ornl.gov/v3/api-docs -- /odi?format=JSON
  (counts, start, ETR), /odi/map?scale=COUNTY (county name, bbox, label
  point), /odi/status (utility freshness). Schema canary on all three.
  metersServed is utility-wide, so customers_served stays None.
- Stubs: poweroutage_us (paid), coned_oru / nyseg_rge / central_hudson
  storm centers and eagle_i_live (agreement required).
- GET /outages (signed-in) returns areas + per-provider credit/health and a
  status (ok / none_enabled / no_source / error) so an empty layer is never
  ambiguous. /admin/outage-providers GET + PUT, admin only, 409 with
  requires_acknowledgement like cameras.
- Frontend: c2api getOutages/listOutageProviders/setOutageProvider, types,
  Admin -> Outage providers tab (deep link /admin#outages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan added 1 commit 2026-10-05 02:45:46 -04:00
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>
Author
Owner

Review (0efcd67): two parser bugs found against live ODIN data and fixed on this branch.

  1. Stale events read as live. ODIN still lists Avangrid rows with April/May 2026 ETRs (Monroe 18, Chenango/Broome/Wayne 1 each) on 2026-10-05. They were summed into customers_out and set since to April. Now an event whose own ETR passed >24h ago goes to stale_customers_out; the area carries stale: true when every event is stale; since/etr come from live events. Batch 3 map layer: dim or hide stale areas.
  2. 0001-01-01T00:00:00Z ETR sentinel (Puget Sound Energy, "no ETR") would have surfaced as the county ETR. Now None.
  3. Ack dialog now shows a refused enable's error (it was hidden behind the overlay).

Tests: pytest 780 passed (23 outage tests, fixture parsed at a pinned now so tests don't go stale); tsc exit 0 in a fresh scratch copy. Merges clean onto current origin/main.

Review (0efcd67): two parser bugs found against live ODIN data and fixed on this branch. 1. **Stale events read as live.** ODIN still lists Avangrid rows with April/May 2026 ETRs (Monroe 18, Chenango/Broome/Wayne 1 each) on 2026-10-05. They were summed into customers_out and set since to April. Now an event whose own ETR passed >24h ago goes to stale_customers_out; the area carries stale: true when every event is stale; since/etr come from live events. Batch 3 map layer: dim or hide stale areas. 2. **0001-01-01T00:00:00Z ETR sentinel** (Puget Sound Energy, "no ETR") would have surfaced as the county ETR. Now None. 3. Ack dialog now shows a refused enable's error (it was hidden behind the overlay). Tests: pytest 780 passed (23 outage tests, fixture parsed at a pinned now so tests don't go stale); tsc exit 0 in a fresh scratch copy. Merges clean onto current origin/main.
logan merged commit 7bee87b147 into main 2026-10-05 02:47:16 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#255