mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-22 02:24:38 +02:00
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:
parent
1ea56b67fd
commit
dfcc0c3a5c
2 changed files with 572 additions and 488 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue