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

@ -19,7 +19,7 @@ from tenacity import (
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__)
@ -194,18 +194,59 @@ class NWSAdapter(SourceAdapter):
def __init__( def __init__(
self, self,
config: NWSAdapterConfig, config: AdapterConfig,
cursor_db_path: Path, cursor_db_path: Path,
) -> None: ) -> None:
self.config = config
self.states = set(s.upper() for s in config.states)
self.cursor_db_path = cursor_db_path self.cursor_db_path = cursor_db_path
self._session: aiohttp.ClientSession | None = None self._session: aiohttp.ClientSession | None = None
self._db: sqlite3.Connection | None = None self._db: sqlite3.Connection | None = None
# Extract settings from unified config
self.contact_email: str = config.settings.get("contact_email", "")
# Parse region from settings
region_dict = config.settings.get("region")
if region_dict:
self.region: RegionConfig | None = RegionConfig(**region_dict)
else:
self.region = None
async def apply_config(self, new_config: AdapterConfig) -> None:
"""Apply new configuration from hot-reload."""
# Update contact email
self.contact_email = new_config.settings.get("contact_email", "")
# Update region
region_dict = new_config.settings.get("region")
if region_dict:
self.region = RegionConfig(**region_dict)
else:
self.region = None
logger.info(
"NWS config applied",
extra={
"region": region_dict,
"contact_email": self.contact_email,
},
)
def _point_in_region(self, centroid: tuple[float, float] | None) -> bool:
"""Check if centroid is within configured region bbox."""
if self.region is None:
# No region configured = accept all
return True
if centroid is None:
return False
lon, lat = centroid
return (
self.region.west <= lon <= self.region.east
and self.region.south <= lat <= self.region.north
)
async def startup(self) -> None: async def startup(self) -> None:
"""Initialize HTTP session and cursor database.""" """Initialize HTTP session and cursor database."""
user_agent = f"Central/{__version__} ({self.config.contact_email})" user_agent = f"Central/{__version__} ({self.contact_email})"
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
headers={"User-Agent": user_agent}, headers={"User-Agent": user_agent},
timeout=aiohttp.ClientTimeout(total=30), timeout=aiohttp.ClientTimeout(total=30),
@ -234,7 +275,17 @@ class NWSAdapter(SourceAdapter):
""") """)
self._db.commit() self._db.commit()
logger.info("NWS adapter started", extra={"states": list(self.states)}) logger.info(
"NWS adapter started",
extra={
"region": {
"north": self.region.north,
"south": self.region.south,
"east": self.region.east,
"west": self.region.west,
} if self.region else None,
},
)
async def shutdown(self) -> None: async def shutdown(self) -> None:
"""Close HTTP session and database.""" """Close HTTP session and database."""
@ -366,8 +417,13 @@ class NWSAdapter(SourceAdapter):
same_codes = geocode.get("SAME", []) same_codes = geocode.get("SAME", [])
ugc_codes = geocode.get("UGC", []) ugc_codes = geocode.get("UGC", [])
feature_states = _extract_states_from_codes(same_codes, ugc_codes) # Compute geometry data first
if not feature_states.intersection(self.states): geometry = feature.get("geometry")
centroid = _compute_centroid(geometry)
bbox = _compute_bbox(geometry)
# Filter by region bbox (client-side filtering)
if not self._point_in_region(centroid):
return None return None
event_id = feature.get("id") event_id = feature.get("id")
@ -388,9 +444,6 @@ class NWSAdapter(SourceAdapter):
severity_str = props.get("severity", "Unknown") severity_str = props.get("severity", "Unknown")
severity = SEVERITY_MAP.get(severity_str) severity = SEVERITY_MAP.get(severity_str)
geometry = feature.get("geometry")
centroid = _compute_centroid(geometry)
bbox = _compute_bbox(geometry)
regions = _build_regions(same_codes, ugc_codes) regions = _build_regions(same_codes, ugc_codes)
primary_region = regions[0] if regions else None primary_region = regions[0] if regions else None

View file

@ -3,7 +3,26 @@
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 RegionConfig(BaseModel):
"""Geographic bounding box for adapter region filtering."""
north: float = Field(ge=-90, le=90, description="Northern latitude bound")
south: float = Field(ge=-90, le=90, description="Southern latitude bound")
east: float = Field(ge=-180, le=180, description="Eastern longitude bound")
west: float = Field(ge=-180, le=180, description="Western longitude bound")
@model_validator(mode="after")
def validate_bounds(self) -> "RegionConfig":
if self.north <= self.south:
raise ValueError(
f"north ({self.north}) must be greater than south ({self.south})"
)
if self.east == self.west:
raise ValueError("east and west cannot be equal (zero-width bbox)")
return self
class AdapterConfig(BaseModel): class AdapterConfig(BaseModel):
@ -26,6 +45,18 @@ class AdapterConfig(BaseModel):
return self.paused_at is not None 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): class ApiKeyInfo(BaseModel):
"""Metadata about an API key (without the decrypted value).""" """Metadata about an API key (without the decrypted value)."""