refactor(nws): migrate from states to bbox region filtering

- Add RegionConfig pydantic model with validators
- NWSAdapter now uses bbox for client-side alert filtering
- Implement apply_config for hot-reload of region changes
- Remove states-based filtering logic

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-16 18:49:46 +00:00
commit dfcc0c3a5c
2 changed files with 572 additions and 488 deletions

View file

@ -1,449 +1,502 @@
"""NWS (National Weather Service) alert adapter.""" """NWS (National Weather Service) alert adapter."""
import asyncio import asyncio
import logging import logging
import re import re
import sqlite3 import sqlite3
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import aiohttp import aiohttp
from tenacity import ( from tenacity import (
retry, retry,
stop_after_attempt, stop_after_attempt,
wait_exponential_jitter, wait_exponential_jitter,
retry_if_exception_type, retry_if_exception_type,
) )
from central import __version__ from central import __version__
from central.adapter import SourceAdapter from central.adapter import SourceAdapter
from central.config import NWSAdapterConfig from central.config_models import AdapterConfig, RegionConfig
from central.models import Event, Geo from central.models import Event, Geo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# FIPS state codes to postal abbreviations # FIPS state codes to postal abbreviations
FIPS_TO_STATE: dict[str, str] = { FIPS_TO_STATE: dict[str, str] = {
"01": "AL", "02": "AK", "04": "AZ", "05": "AR", "06": "CA", "01": "AL", "02": "AK", "04": "AZ", "05": "AR", "06": "CA",
"08": "CO", "09": "CT", "10": "DE", "11": "DC", "12": "FL", "08": "CO", "09": "CT", "10": "DE", "11": "DC", "12": "FL",
"13": "GA", "15": "HI", "16": "ID", "17": "IL", "18": "IN", "13": "GA", "15": "HI", "16": "ID", "17": "IL", "18": "IN",
"19": "IA", "20": "KS", "21": "KY", "22": "LA", "23": "ME", "19": "IA", "20": "KS", "21": "KY", "22": "LA", "23": "ME",
"24": "MD", "25": "MA", "26": "MI", "27": "MN", "28": "MS", "24": "MD", "25": "MA", "26": "MI", "27": "MN", "28": "MS",
"29": "MO", "30": "MT", "31": "NE", "32": "NV", "33": "NH", "29": "MO", "30": "MT", "31": "NE", "32": "NV", "33": "NH",
"34": "NJ", "35": "NM", "36": "NY", "37": "NC", "38": "ND", "34": "NJ", "35": "NM", "36": "NY", "37": "NC", "38": "ND",
"39": "OH", "40": "OK", "41": "OR", "42": "PA", "44": "RI", "39": "OH", "40": "OK", "41": "OR", "42": "PA", "44": "RI",
"45": "SC", "46": "SD", "47": "TN", "48": "TX", "49": "UT", "45": "SC", "46": "SD", "47": "TN", "48": "TX", "49": "UT",
"50": "VT", "51": "VA", "53": "WA", "54": "WV", "55": "WI", "50": "VT", "51": "VA", "53": "WA", "54": "WV", "55": "WI",
"56": "WY", "60": "AS", "66": "GU", "69": "MP", "72": "PR", "56": "WY", "60": "AS", "66": "GU", "69": "MP", "72": "PR",
"78": "VI", "78": "VI",
} }
SEVERITY_MAP: dict[str, int | None] = { SEVERITY_MAP: dict[str, int | None] = {
"Extreme": 4, "Extreme": 4,
"Severe": 3, "Severe": 3,
"Moderate": 2, "Moderate": 2,
"Minor": 1, "Minor": 1,
"Unknown": None, "Unknown": None,
} }
NWS_API_URL = "https://api.weather.gov/alerts/active" NWS_API_URL = "https://api.weather.gov/alerts/active"
def _snake_case(s: str) -> str: def _snake_case(s: str) -> str:
"""Convert a string to snake_case.""" """Convert a string to snake_case."""
s = re.sub(r"[^a-zA-Z0-9\s]", "", s) s = re.sub(r"[^a-zA-Z0-9\s]", "", s)
s = re.sub(r"\s+", "_", s.strip()) s = re.sub(r"\s+", "_", s.strip())
return s.lower() return s.lower()
def _parse_datetime(s: str | None) -> datetime | None: def _parse_datetime(s: str | None) -> datetime | None:
"""Parse an ISO datetime string to UTC datetime.""" """Parse an ISO datetime string to UTC datetime."""
if not s: if not s:
return None return None
try: try:
dt = datetime.fromisoformat(s.replace("Z", "+00:00")) dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
return dt.astimezone(timezone.utc) return dt.astimezone(timezone.utc)
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
def _compute_centroid(geometry: dict[str, Any] | None) -> tuple[float, float] | None: def _compute_centroid(geometry: dict[str, Any] | None) -> tuple[float, float] | None:
"""Compute centroid from GeoJSON geometry using arithmetic mean of vertices.""" """Compute centroid from GeoJSON geometry using arithmetic mean of vertices."""
if not geometry: if not geometry:
return None return None
geom_type = geometry.get("type") geom_type = geometry.get("type")
coords = geometry.get("coordinates") coords = geometry.get("coordinates")
if not coords: if not coords:
return None return None
all_points: list[tuple[float, float]] = [] all_points: list[tuple[float, float]] = []
if geom_type == "Point": if geom_type == "Point":
return (coords[0], coords[1]) return (coords[0], coords[1])
elif geom_type == "Polygon": elif geom_type == "Polygon":
for ring in coords: for ring in coords:
for point in ring: for point in ring:
all_points.append((point[0], point[1])) all_points.append((point[0], point[1]))
elif geom_type == "MultiPolygon": elif geom_type == "MultiPolygon":
for polygon in coords: for polygon in coords:
for ring in polygon: for ring in polygon:
for point in ring: for point in ring:
all_points.append((point[0], point[1])) all_points.append((point[0], point[1]))
else: else:
return None return None
if not all_points: if not all_points:
return None return None
avg_lon = sum(p[0] for p in all_points) / len(all_points) avg_lon = sum(p[0] for p in all_points) / len(all_points)
avg_lat = sum(p[1] for p in all_points) / len(all_points) avg_lat = sum(p[1] for p in all_points) / len(all_points)
return (avg_lon, avg_lat) return (avg_lon, avg_lat)
def _compute_bbox( def _compute_bbox(
geometry: dict[str, Any] | None geometry: dict[str, Any] | None
) -> tuple[float, float, float, float] | None: ) -> tuple[float, float, float, float] | None:
"""Compute bounding box from GeoJSON geometry.""" """Compute bounding box from GeoJSON geometry."""
if not geometry: if not geometry:
return None return None
geom_type = geometry.get("type") geom_type = geometry.get("type")
coords = geometry.get("coordinates") coords = geometry.get("coordinates")
if not coords: if not coords:
return None return None
all_points: list[tuple[float, float]] = [] all_points: list[tuple[float, float]] = []
if geom_type == "Point": if geom_type == "Point":
return (coords[0], coords[1], coords[0], coords[1]) return (coords[0], coords[1], coords[0], coords[1])
elif geom_type == "Polygon": elif geom_type == "Polygon":
for ring in coords: for ring in coords:
for point in ring: for point in ring:
all_points.append((point[0], point[1])) all_points.append((point[0], point[1]))
elif geom_type == "MultiPolygon": elif geom_type == "MultiPolygon":
for polygon in coords: for polygon in coords:
for ring in polygon: for ring in polygon:
for point in ring: for point in ring:
all_points.append((point[0], point[1])) all_points.append((point[0], point[1]))
else: else:
return None return None
if not all_points: if not all_points:
return None return None
min_lon = min(p[0] for p in all_points) min_lon = min(p[0] for p in all_points)
max_lon = max(p[0] for p in all_points) max_lon = max(p[0] for p in all_points)
min_lat = min(p[1] for p in all_points) min_lat = min(p[1] for p in all_points)
max_lat = max(p[1] for p in all_points) max_lat = max(p[1] for p in all_points)
return (min_lon, min_lat, max_lon, max_lat) return (min_lon, min_lat, max_lon, max_lat)
def _extract_states_from_codes( def _extract_states_from_codes(
same_codes: list[str], ugc_codes: list[str] same_codes: list[str], ugc_codes: list[str]
) -> set[str]: ) -> set[str]:
"""Extract state abbreviations from SAME and UGC codes.""" """Extract state abbreviations from SAME and UGC codes."""
states: set[str] = set() states: set[str] = set()
for code in same_codes: for code in same_codes:
if len(code) >= 2: if len(code) >= 2:
fips_state = code[:2] fips_state = code[:2]
if fips_state in FIPS_TO_STATE: if fips_state in FIPS_TO_STATE:
states.add(FIPS_TO_STATE[fips_state]) states.add(FIPS_TO_STATE[fips_state])
for code in ugc_codes: for code in ugc_codes:
if len(code) >= 2 and code[:2].isalpha(): if len(code) >= 2 and code[:2].isalpha():
states.add(code[:2].upper()) states.add(code[:2].upper())
return states return states
def _build_regions(same_codes: list[str], ugc_codes: list[str]) -> list[str]: def _build_regions(same_codes: list[str], ugc_codes: list[str]) -> list[str]:
"""Build sorted list of region strings from geocodes.""" """Build sorted list of region strings from geocodes."""
regions: set[str] = set() regions: set[str] = set()
for code in same_codes: for code in same_codes:
if len(code) >= 2: if len(code) >= 2:
fips_state = code[:2] fips_state = code[:2]
if fips_state in FIPS_TO_STATE: if fips_state in FIPS_TO_STATE:
state = FIPS_TO_STATE[fips_state] state = FIPS_TO_STATE[fips_state]
regions.add(f"US-{state}-FIPS{code}") regions.add(f"US-{state}-FIPS{code}")
for code in ugc_codes: for code in ugc_codes:
if len(code) >= 3 and code[:2].isalpha(): if len(code) >= 3 and code[:2].isalpha():
state = code[:2].upper() state = code[:2].upper()
rest = code[2:] rest = code[2:]
if rest.startswith("C"): if rest.startswith("C"):
regions.add(f"US-{state}-C{rest[1:]}") regions.add(f"US-{state}-C{rest[1:]}")
elif rest.startswith("Z"): elif rest.startswith("Z"):
regions.add(f"US-{state}-Z{rest[1:]}") regions.add(f"US-{state}-Z{rest[1:]}")
else: else:
regions.add(f"US-{state}-{rest}") regions.add(f"US-{state}-{rest}")
return sorted(regions) return sorted(regions)
class NWSAdapter(SourceAdapter): class NWSAdapter(SourceAdapter):
"""National Weather Service alerts adapter.""" """National Weather Service alerts adapter."""
name = "nws" name = "nws"
def __init__( def __init__(
self, self,
config: NWSAdapterConfig, config: AdapterConfig,
cursor_db_path: Path, cursor_db_path: Path,
) -> None: ) -> None:
self.config = config self.cursor_db_path = cursor_db_path
self.states = set(s.upper() for s in config.states) self._session: aiohttp.ClientSession | None = None
self.cursor_db_path = cursor_db_path self._db: sqlite3.Connection | None = None
self._session: aiohttp.ClientSession | None = None
self._db: sqlite3.Connection | None = None # Extract settings from unified config
self.contact_email: str = config.settings.get("contact_email", "")
async def startup(self) -> None:
"""Initialize HTTP session and cursor database.""" # Parse region from settings
user_agent = f"Central/{__version__} ({self.config.contact_email})" region_dict = config.settings.get("region")
self._session = aiohttp.ClientSession( if region_dict:
headers={"User-Agent": user_agent}, self.region: RegionConfig | None = RegionConfig(**region_dict)
timeout=aiohttp.ClientTimeout(total=30), else:
) self.region = None
self._db = sqlite3.connect(str(self.cursor_db_path)) async def apply_config(self, new_config: AdapterConfig) -> None:
self._db.execute(""" """Apply new configuration from hot-reload."""
CREATE TABLE IF NOT EXISTS adapter_cursors ( # Update contact email
adapter TEXT PRIMARY KEY, self.contact_email = new_config.settings.get("contact_email", "")
cursor_data TEXT NOT NULL,
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP # Update region
) region_dict = new_config.settings.get("region")
""") if region_dict:
self._db.execute(""" self.region = RegionConfig(**region_dict)
CREATE TABLE IF NOT EXISTS published_ids ( else:
adapter TEXT NOT NULL, self.region = None
event_id TEXT NOT NULL,
first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, logger.info(
last_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "NWS config applied",
PRIMARY KEY (adapter, event_id) extra={
) "region": region_dict,
""") "contact_email": self.contact_email,
self._db.execute(""" },
CREATE INDEX IF NOT EXISTS published_ids_last_seen )
ON published_ids (last_seen)
""") def _point_in_region(self, centroid: tuple[float, float] | None) -> bool:
self._db.commit() """Check if centroid is within configured region bbox."""
if self.region is None:
logger.info("NWS adapter started", extra={"states": list(self.states)}) # No region configured = accept all
return True
async def shutdown(self) -> None: if centroid is None:
"""Close HTTP session and database.""" return False
if self._session: lon, lat = centroid
await self._session.close() return (
self._session = None self.region.west <= lon <= self.region.east
if self._db: and self.region.south <= lat <= self.region.north
self._db.close() )
self._db = None
logger.info("NWS adapter shut down") async def startup(self) -> None:
"""Initialize HTTP session and cursor database."""
def _get_cursor(self) -> str | None: user_agent = f"Central/{__version__} ({self.contact_email})"
"""Get the stored If-Modified-Since cursor.""" self._session = aiohttp.ClientSession(
if not self._db: headers={"User-Agent": user_agent},
return None timeout=aiohttp.ClientTimeout(total=30),
cur = self._db.execute( )
"SELECT cursor_data FROM adapter_cursors WHERE adapter = ?",
(self.name,) self._db = sqlite3.connect(str(self.cursor_db_path))
) self._db.execute("""
row = cur.fetchone() CREATE TABLE IF NOT EXISTS adapter_cursors (
return row[0] if row else None adapter TEXT PRIMARY KEY,
cursor_data TEXT NOT NULL,
def _set_cursor(self, last_modified: str) -> None: updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
"""Store the Last-Modified header for next request.""" )
if not self._db: """)
return self._db.execute("""
self._db.execute( CREATE TABLE IF NOT EXISTS published_ids (
""" adapter TEXT NOT NULL,
INSERT INTO adapter_cursors (adapter, cursor_data, updated) event_id TEXT NOT NULL,
VALUES (?, ?, CURRENT_TIMESTAMP) first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
ON CONFLICT (adapter) DO UPDATE SET last_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
cursor_data = excluded.cursor_data, PRIMARY KEY (adapter, event_id)
updated = CURRENT_TIMESTAMP )
""", """)
(self.name, last_modified) self._db.execute("""
) CREATE INDEX IF NOT EXISTS published_ids_last_seen
self._db.commit() ON published_ids (last_seen)
""")
def is_published(self, event_id: str) -> bool: self._db.commit()
"""Check if an event has already been published."""
if not self._db: logger.info(
return False "NWS adapter started",
cur = self._db.execute( extra={
"SELECT 1 FROM published_ids WHERE adapter = ? AND event_id = ?", "region": {
(self.name, event_id) "north": self.region.north,
) "south": self.region.south,
return cur.fetchone() is not None "east": self.region.east,
"west": self.region.west,
def mark_published(self, event_id: str) -> None: } if self.region else None,
"""Mark an event as published.""" },
if not self._db: )
return
self._db.execute( async def shutdown(self) -> None:
""" """Close HTTP session and database."""
INSERT INTO published_ids (adapter, event_id, first_seen, last_seen) if self._session:
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) await self._session.close()
ON CONFLICT (adapter, event_id) DO UPDATE SET self._session = None
last_seen = CURRENT_TIMESTAMP if self._db:
""", self._db.close()
(self.name, event_id) self._db = None
) logger.info("NWS adapter shut down")
self._db.commit()
def _get_cursor(self) -> str | None:
def bump_last_seen(self, event_id: str) -> None: """Get the stored If-Modified-Since cursor."""
"""Bump the last_seen timestamp for an event.""" if not self._db:
if not self._db: return None
return cur = self._db.execute(
self._db.execute( "SELECT cursor_data FROM adapter_cursors WHERE adapter = ?",
"UPDATE published_ids SET last_seen = CURRENT_TIMESTAMP WHERE adapter = ? AND event_id = ?", (self.name,)
(self.name, event_id) )
) row = cur.fetchone()
self._db.commit() return row[0] if row else None
def sweep_old_ids(self) -> int: def _set_cursor(self, last_modified: str) -> None:
"""Remove published_ids older than 8 days. Returns count deleted.""" """Store the Last-Modified header for next request."""
if not self._db: if not self._db:
return 0 return
cur = self._db.execute( self._db.execute(
"DELETE FROM published_ids WHERE last_seen < datetime('now', '-8 days')" """
) INSERT INTO adapter_cursors (adapter, cursor_data, updated)
self._db.commit() VALUES (?, ?, CURRENT_TIMESTAMP)
return cur.rowcount ON CONFLICT (adapter) DO UPDATE SET
cursor_data = excluded.cursor_data,
@retry( updated = CURRENT_TIMESTAMP
stop=stop_after_attempt(5), """,
wait=wait_exponential_jitter(initial=1, max=60), (self.name, last_modified)
retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)), )
reraise=True, self._db.commit()
)
async def _fetch_alerts(self) -> tuple[int, dict[str, Any] | None, str | None]: def is_published(self, event_id: str) -> bool:
"""Fetch alerts from NWS API with conditional request.""" """Check if an event has already been published."""
if not self._session: if not self._db:
raise RuntimeError("Session not initialized") return False
cur = self._db.execute(
headers: dict[str, str] = {} "SELECT 1 FROM published_ids WHERE adapter = ? AND event_id = ?",
cursor = self._get_cursor() (self.name, event_id)
if cursor: )
headers["If-Modified-Since"] = cursor return cur.fetchone() is not None
async with self._session.get(NWS_API_URL, headers=headers) as resp: def mark_published(self, event_id: str) -> None:
if resp.status in (429, 403): """Mark an event as published."""
retry_after = resp.headers.get("Retry-After", "60") if not self._db:
try: return
wait_time = int(retry_after) self._db.execute(
except ValueError: """
wait_time = 60 INSERT INTO published_ids (adapter, event_id, first_seen, last_seen)
logger.warning( VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"Rate limited by NWS", ON CONFLICT (adapter, event_id) DO UPDATE SET
extra={"status": resp.status, "retry_after": wait_time} last_seen = CURRENT_TIMESTAMP
) """,
await asyncio.sleep(wait_time) (self.name, event_id)
raise aiohttp.ClientError(f"Rate limited: {resp.status}") )
self._db.commit()
if resp.status == 304:
return (304, None, None) def bump_last_seen(self, event_id: str) -> None:
"""Bump the last_seen timestamp for an event."""
resp.raise_for_status() if not self._db:
return
data = await resp.json() self._db.execute(
last_modified = resp.headers.get("Last-Modified") "UPDATE published_ids SET last_seen = CURRENT_TIMESTAMP WHERE adapter = ? AND event_id = ?",
(self.name, event_id)
return (resp.status, data, last_modified) )
self._db.commit()
def _normalize_feature(self, feature: dict[str, Any]) -> Event | None:
"""Normalize a GeoJSON feature to an Event.""" def sweep_old_ids(self) -> int:
props = feature.get("properties", {}) """Remove published_ids older than 8 days. Returns count deleted."""
geocode = props.get("geocode", {}) if not self._db:
return 0
same_codes = geocode.get("SAME", []) cur = self._db.execute(
ugc_codes = geocode.get("UGC", []) "DELETE FROM published_ids WHERE last_seen < datetime('now', '-8 days')"
)
feature_states = _extract_states_from_codes(same_codes, ugc_codes) self._db.commit()
if not feature_states.intersection(self.states): return cur.rowcount
return None
@retry(
event_id = feature.get("id") stop=stop_after_attempt(5),
if not event_id: wait=wait_exponential_jitter(initial=1, max=60),
logger.warning("Feature missing id", extra={"properties": props}) retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)),
return None reraise=True,
)
event_type = props.get("event", "Unknown") async def _fetch_alerts(self) -> tuple[int, dict[str, Any] | None, str | None]:
category = f"wx.alert.{_snake_case(event_type)}" """Fetch alerts from NWS API with conditional request."""
if not self._session:
time = _parse_datetime(props.get("sent")) raise RuntimeError("Session not initialized")
if not time:
logger.warning("Feature missing sent time", extra={"id": event_id}) headers: dict[str, str] = {}
return None cursor = self._get_cursor()
if cursor:
expires = _parse_datetime(props.get("expires")) headers["If-Modified-Since"] = cursor
severity_str = props.get("severity", "Unknown") async with self._session.get(NWS_API_URL, headers=headers) as resp:
severity = SEVERITY_MAP.get(severity_str) if resp.status in (429, 403):
retry_after = resp.headers.get("Retry-After", "60")
geometry = feature.get("geometry") try:
centroid = _compute_centroid(geometry) wait_time = int(retry_after)
bbox = _compute_bbox(geometry) except ValueError:
regions = _build_regions(same_codes, ugc_codes) wait_time = 60
primary_region = regions[0] if regions else None logger.warning(
"Rate limited by NWS",
geo = Geo( extra={"status": resp.status, "retry_after": wait_time}
centroid=centroid, )
bbox=bbox, await asyncio.sleep(wait_time)
regions=regions, raise aiohttp.ClientError(f"Rate limited: {resp.status}")
primary_region=primary_region,
) if resp.status == 304:
return (304, None, None)
return Event(
id=event_id, resp.raise_for_status()
source="central/adapters/nws",
category=category, data = await resp.json()
time=time, last_modified = resp.headers.get("Last-Modified")
expires=expires,
severity=severity, return (resp.status, data, last_modified)
geo=geo,
data=props, def _normalize_feature(self, feature: dict[str, Any]) -> Event | None:
) """Normalize a GeoJSON feature to an Event."""
props = feature.get("properties", {})
async def poll(self) -> AsyncIterator[Event]: geocode = props.get("geocode", {})
"""Poll NWS API for active alerts."""
try: same_codes = geocode.get("SAME", [])
status, data, last_modified = await self._fetch_alerts() ugc_codes = geocode.get("UGC", [])
except Exception as e:
logger.error("Failed to fetch NWS alerts", extra={"error": str(e)}) # Compute geometry data first
raise geometry = feature.get("geometry")
centroid = _compute_centroid(geometry)
if status == 304: bbox = _compute_bbox(geometry)
logger.info("NWS returned 304 Not Modified")
return # Filter by region bbox (client-side filtering)
if not self._point_in_region(centroid):
if last_modified: return None
self._set_cursor(last_modified)
event_id = feature.get("id")
features = data.get("features", []) if data else [] if not event_id:
logger.info( logger.warning("Feature missing id", extra={"properties": props})
"NWS poll completed", return None
extra={"status": status, "feature_count": len(features)}
) event_type = props.get("event", "Unknown")
category = f"wx.alert.{_snake_case(event_type)}"
yielded = 0
for feature in features: time = _parse_datetime(props.get("sent"))
try: if not time:
event = self._normalize_feature(feature) logger.warning("Feature missing sent time", extra={"id": event_id})
if event: return None
yield event
yielded += 1 expires = _parse_datetime(props.get("expires"))
except Exception as e:
logger.warning( severity_str = props.get("severity", "Unknown")
"Failed to normalize feature", severity = SEVERITY_MAP.get(severity_str)
extra={"error": str(e), "feature_id": feature.get("id")}
) regions = _build_regions(same_codes, ugc_codes)
primary_region = regions[0] if regions else None
logger.info("NWS yielded events", extra={"count": yielded})
geo = Geo(
centroid=centroid,
bbox=bbox,
regions=regions,
primary_region=primary_region,
)
return Event(
id=event_id,
source="central/adapters/nws",
category=category,
time=time,
expires=expires,
severity=severity,
geo=geo,
data=props,
)
async def poll(self) -> AsyncIterator[Event]:
"""Poll NWS API for active alerts."""
try:
status, data, last_modified = await self._fetch_alerts()
except Exception as e:
logger.error("Failed to fetch NWS alerts", extra={"error": str(e)})
raise
if status == 304:
logger.info("NWS returned 304 Not Modified")
return
if last_modified:
self._set_cursor(last_modified)
features = data.get("features", []) if data else []
logger.info(
"NWS poll completed",
extra={"status": status, "feature_count": len(features)}
)
yielded = 0
for feature in features:
try:
event = self._normalize_feature(feature)
if event:
yield event
yielded += 1
except Exception as e:
logger.warning(
"Failed to normalize feature",
extra={"error": str(e), "feature_id": feature.get("id")}
)
logger.info("NWS yielded events", extra={"count": yielded})

View file

@ -1,39 +1,70 @@
"""Pydantic models for database-backed configuration.""" """Pydantic models for database-backed configuration."""
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
class AdapterConfig(BaseModel): class RegionConfig(BaseModel):
"""Configuration for a single adapter.""" """Geographic bounding box for adapter region filtering."""
name: str = Field(description="Unique adapter identifier") north: float = Field(ge=-90, le=90, description="Northern latitude bound")
enabled: bool = Field(default=True, description="Whether adapter is active") south: float = Field(ge=-90, le=90, description="Southern latitude bound")
cadence_s: int = Field(description="Poll interval in seconds") east: float = Field(ge=-180, le=180, description="Eastern longitude bound")
settings: dict[str, Any] = Field( west: float = Field(ge=-180, le=180, description="Western longitude bound")
default_factory=dict, description="Adapter-specific settings"
) @model_validator(mode="after")
paused_at: datetime | None = Field( def validate_bounds(self) -> "RegionConfig":
default=None, description="When adapter was paused, if paused" if self.north <= self.south:
) raise ValueError(
updated_at: datetime = Field(description="Last configuration update time") f"north ({self.north}) must be greater than south ({self.south})"
)
@property if self.east == self.west:
def is_paused(self) -> bool: raise ValueError("east and west cannot be equal (zero-width bbox)")
"""Check if adapter is currently paused.""" return self
return self.paused_at is not None
class AdapterConfig(BaseModel):
class ApiKeyInfo(BaseModel): """Configuration for a single adapter."""
"""Metadata about an API key (without the decrypted value)."""
name: str = Field(description="Unique adapter identifier")
alias: str = Field(description="Key identifier/alias") enabled: bool = Field(default=True, description="Whether adapter is active")
created_at: datetime = Field(description="When key was created") cadence_s: int = Field(description="Poll interval in seconds")
rotated_at: datetime | None = Field( settings: dict[str, Any] = Field(
default=None, description="Last rotation time" default_factory=dict, description="Adapter-specific settings"
) )
last_used_at: datetime | None = Field( paused_at: datetime | None = Field(
default=None, description="Last usage time" default=None, description="When adapter was paused, if paused"
) )
updated_at: datetime = Field(description="Last configuration update time")
@property
def is_paused(self) -> bool:
"""Check if adapter is currently paused."""
return self.paused_at is not None
class StreamConfig(BaseModel):
"""Configuration for a JetStream stream."""
name: str = Field(description="Stream name")
max_age_s: int = Field(description="Maximum message age in seconds")
max_bytes: int = Field(description="Maximum stream size in bytes")
managed_max_bytes: bool = Field(
default=True, description="Whether max_bytes is auto-managed by supervisor"
)
updated_at: datetime = Field(description="Last configuration update time")
class ApiKeyInfo(BaseModel):
"""Metadata about an API key (without the decrypted value)."""
alias: str = Field(description="Key identifier/alias")
created_at: datetime = Field(description="When key was created")
rotated_at: datetime | None = Field(
default=None, description="Last rotation time"
)
last_used_at: datetime | None = Field(
default=None, description="Last usage time"
)