mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(coverage): universal coverage-bbox foundation (config + derivation module) (#59)
Phase 1 of replacing per-adapter geographic scoping with one coverage bbox. Adds a `coverage.bbox` [W,S,E,N] config and meshai/coverage.py — a pure, tested derivation layer: geometry primitives (point_in_bbox, intersects, centroid, grid_points, arcgis_envelope) + static US-state and avalanche-center bbox tables + resolve_adapter_coverage() mapping one bbox to each native adapter's effective scope. Central-fed adapters return None (Central governs). No adapter wiring or GUI yet — foundation only. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b38c16b3c2
commit
997729241e
4 changed files with 855 additions and 0 deletions
|
|
@ -792,6 +792,23 @@ class DangerZonesConfig:
|
||||||
f"({sorted(VALID_TOGGLES)})")
|
f"({sorted(VALID_TOGGLES)})")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Coverage:
|
||||||
|
"""Universal geographic coverage bounding box.
|
||||||
|
|
||||||
|
A single [west, south, east, north] bbox that drives every native adapter's
|
||||||
|
effective scope in Phase 2+. Phase 1 defines the shape; no adapter is wired yet.
|
||||||
|
|
||||||
|
bbox: [west, south, east, north] = [min_lon, min_lat, max_lon, max_lat].
|
||||||
|
Empty list means "not configured" — adapters fall back to their own scope.
|
||||||
|
enabled: master switch; when False, adapters fall back to their own scope even
|
||||||
|
if bbox is populated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
bbox: list = field(default_factory=list) # [west, south, east, north]; empty = not configured
|
||||||
|
enabled: bool = True # master switch for deriving adapter scope from bbox
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
"""Main configuration container."""
|
"""Main configuration container."""
|
||||||
|
|
@ -817,6 +834,7 @@ class Config:
|
||||||
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
|
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
|
||||||
notifications: NotificationsConfig = field(default_factory=NotificationsConfig)
|
notifications: NotificationsConfig = field(default_factory=NotificationsConfig)
|
||||||
danger_zones: DangerZonesConfig = field(default_factory=DangerZonesConfig)
|
danger_zones: DangerZonesConfig = field(default_factory=DangerZonesConfig)
|
||||||
|
coverage: Coverage = field(default_factory=Coverage)
|
||||||
|
|
||||||
_config_path: Optional[Path] = field(default=None, repr=False)
|
_config_path: Optional[Path] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
@ -980,6 +998,8 @@ def _dict_to_dataclass(cls, data: dict):
|
||||||
kwargs[key] = _dict_to_dataclass(EnvironmentalConfig, value)
|
kwargs[key] = _dict_to_dataclass(EnvironmentalConfig, value)
|
||||||
elif key == "dashboard" and isinstance(value, dict):
|
elif key == "dashboard" and isinstance(value, dict):
|
||||||
kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
|
kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
|
||||||
|
elif key == "coverage" and isinstance(value, dict):
|
||||||
|
kwargs[key] = _dict_to_dataclass(Coverage, value)
|
||||||
elif key == "toggles" and isinstance(value, dict):
|
elif key == "toggles" and isinstance(value, dict):
|
||||||
# v0.5: notifications.toggles is a dict of family -> NotificationToggle
|
# v0.5: notifications.toggles is a dict of family -> NotificationToggle
|
||||||
kwargs[key] = {
|
kwargs[key] = {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ SECTION_TO_FILE: dict[str, str] = {
|
||||||
"llm": "llm.yaml",
|
"llm": "llm.yaml",
|
||||||
"dashboard": "dashboard.yaml",
|
"dashboard": "dashboard.yaml",
|
||||||
"danger_zones": "danger_zones.yaml",
|
"danger_zones": "danger_zones.yaml",
|
||||||
|
"coverage": "config.yaml",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fields that should be written to local.yaml instead of domain files
|
# Fields that should be written to local.yaml instead of domain files
|
||||||
|
|
|
||||||
359
work/meshai/coverage.py
Normal file
359
work/meshai/coverage.py
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
"""Universal coverage bounding-box derivation for MeshAI.
|
||||||
|
|
||||||
|
Bbox convention (used consistently throughout this module and the Phase 2+ wiring):
|
||||||
|
bbox = [west, south, east, north]
|
||||||
|
= [min_lon, min_lat, max_lon, max_lat]
|
||||||
|
|
||||||
|
This matches the order used by the USGS-quake native adapter (env/usgs_quake.py)
|
||||||
|
and the Roads511/WZDx config fields. It is also the natural ArcGIS envelope order
|
||||||
|
(xmin, ymin, xmax, ymax).
|
||||||
|
|
||||||
|
Central-fed adapters (feed_source="central") ALWAYS return None from
|
||||||
|
resolve_adapter_coverage — Central governs their geographic scope entirely.
|
||||||
|
|
||||||
|
All functions here are pure (no I/O, no network, deterministic) so they can be
|
||||||
|
unit-tested without any runtime environment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Geometry primitives
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def bbox_is_valid(bbox) -> bool:
|
||||||
|
"""Return True iff *bbox* is a well-formed [W, S, E, N] bounding box.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Must be a sequence of exactly 4 numbers.
|
||||||
|
- west < east (non-zero width).
|
||||||
|
- south < north (non-zero height).
|
||||||
|
- Latitudes in [-90, 90].
|
||||||
|
- Longitudes in [-180, 180].
|
||||||
|
"""
|
||||||
|
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
|
||||||
|
return False
|
||||||
|
west, south, east, north = bbox
|
||||||
|
try:
|
||||||
|
west, south, east, north = float(west), float(south), float(east), float(north)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if not (-180 <= west <= 180 and -180 <= east <= 180):
|
||||||
|
return False
|
||||||
|
if not (-90 <= south <= 90 and -90 <= north <= 90):
|
||||||
|
return False
|
||||||
|
if west >= east:
|
||||||
|
return False
|
||||||
|
if south >= north:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def point_in_bbox(lat: float, lon: float, bbox) -> bool:
|
||||||
|
"""Return True iff (lat, lon) lies inside *bbox* = [W, S, E, N]."""
|
||||||
|
west, south, east, north = bbox
|
||||||
|
return south <= lat <= north and west <= lon <= east
|
||||||
|
|
||||||
|
|
||||||
|
def bbox_intersects(a, b) -> bool:
|
||||||
|
"""Return True iff two [W, S, E, N] bounding boxes overlap (share any area).
|
||||||
|
|
||||||
|
Boxes that only share an edge or a corner are considered to *not* intersect
|
||||||
|
(strict inequality on all four sides).
|
||||||
|
"""
|
||||||
|
# a overlaps b iff a's west is west of b's east AND a's east is east of b's west
|
||||||
|
# AND a's south is south of b's north AND a's north is north of b's south.
|
||||||
|
a_west, a_south, a_east, a_north = a
|
||||||
|
b_west, b_south, b_east, b_north = b
|
||||||
|
return (
|
||||||
|
a_west < b_east and a_east > b_west
|
||||||
|
and a_south < b_north and a_north > b_south
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def centroid(bbox) -> tuple[float, float]:
|
||||||
|
"""Return the (lat, lon) center of *bbox* = [W, S, E, N]."""
|
||||||
|
west, south, east, north = bbox
|
||||||
|
return ((south + north) / 2.0, (west + east) / 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def grid_points(bbox, cols: int = 3, rows: int = 3) -> list[tuple[float, float]]:
|
||||||
|
"""Return a cols×rows grid of (lat, lon) sample points across *bbox*.
|
||||||
|
|
||||||
|
Points are placed at the *centers* of each grid cell so none sit on the
|
||||||
|
bounding-box edges. Returned in row-major order (south to north, west to east).
|
||||||
|
|
||||||
|
Used by the traffic adapter to distribute flow-query points across the
|
||||||
|
coverage area.
|
||||||
|
"""
|
||||||
|
west, south, east, north = bbox
|
||||||
|
lon_step = (east - west) / cols
|
||||||
|
lat_step = (north - south) / rows
|
||||||
|
points = []
|
||||||
|
for row in range(rows):
|
||||||
|
lat = south + (row + 0.5) * lat_step
|
||||||
|
for col in range(cols):
|
||||||
|
lon = west + (col + 0.5) * lon_step
|
||||||
|
points.append((lat, lon))
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def arcgis_envelope(bbox) -> dict:
|
||||||
|
"""Return ArcGIS FeatureServer spatial-filter query params for *bbox*.
|
||||||
|
|
||||||
|
The returned dict is ready to merge into a FeatureServer query string:
|
||||||
|
{"geometry": "xmin,ymin,xmax,ymax",
|
||||||
|
"geometryType": "esriGeometryEnvelope",
|
||||||
|
"spatialRel": "esriSpatialRelIntersects",
|
||||||
|
"inSR": "4326"}
|
||||||
|
|
||||||
|
xmin=west, ymin=south, xmax=east, ymax=north (standard ArcGIS convention).
|
||||||
|
"""
|
||||||
|
west, south, east, north = bbox
|
||||||
|
return {
|
||||||
|
"geometry": f"{west},{south},{east},{north}",
|
||||||
|
"geometryType": "esriGeometryEnvelope",
|
||||||
|
"spatialRel": "esriSpatialRelIntersects",
|
||||||
|
"inSR": "4326",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# US state bbox table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Approximate [west, south, east, north] bboxes per 2-letter US state code.
|
||||||
|
# Err slightly LARGE so border events aren't missed. Values are not exact
|
||||||
|
# legal boundaries; they are generous bounding rectangles for intersection tests.
|
||||||
|
US_STATE_BBOXES: dict[str, list[float]] = {
|
||||||
|
# Western / Mountain states — thorough
|
||||||
|
"AK": [-180.0, 51.0, -130.0, 71.5],
|
||||||
|
"AZ": [-114.9, 31.3, -109.0, 37.0],
|
||||||
|
"CA": [-124.5, 32.5, -114.1, 42.0],
|
||||||
|
"CO": [-109.1, 36.9, -102.0, 41.1],
|
||||||
|
"HI": [-160.3, 18.9, -154.8, 22.3],
|
||||||
|
"ID": [-117.3, 41.9, -111.0, 49.1],
|
||||||
|
"MT": [-116.1, 44.3, -104.0, 49.1],
|
||||||
|
"NM": [-109.1, 31.3, -103.0, 37.0],
|
||||||
|
"NV": [-120.0, 35.0, -114.0, 42.1],
|
||||||
|
"OR": [-124.6, 41.9, -116.5, 46.3],
|
||||||
|
"UT": [-114.1, 36.9, -109.0, 42.1],
|
||||||
|
"WA": [-124.7, 45.5, -116.9, 49.1],
|
||||||
|
"WY": [-111.1, 40.9, -104.0, 45.1],
|
||||||
|
|
||||||
|
# Pacific / Southwest
|
||||||
|
"AS": [-171.1, -14.6, -168.1, -11.0], # American Samoa
|
||||||
|
"GU": [144.6, 13.2, 145.0, 13.7], # Guam
|
||||||
|
"MP": [145.0, 14.9, 146.1, 20.6], # N. Mariana Islands
|
||||||
|
"PR": [-67.3, 17.9, -65.6, 18.6], # Puerto Rico
|
||||||
|
"VI": [-65.1, 17.7, -64.6, 18.4], # US Virgin Islands
|
||||||
|
|
||||||
|
# Great Plains
|
||||||
|
"KS": [-102.1, 36.9, -94.6, 40.1],
|
||||||
|
"ND": [-104.1, 45.9, -96.6, 49.1],
|
||||||
|
"NE": [-104.1, 39.9, -95.3, 43.1],
|
||||||
|
"OK": [-103.1, 33.6, -94.4, 37.1],
|
||||||
|
"SD": [-104.1, 42.4, -96.4, 45.9],
|
||||||
|
"TX": [-106.7, 25.8, -93.5, 36.6],
|
||||||
|
|
||||||
|
# Midwest
|
||||||
|
"IA": [-96.7, 40.3, -90.1, 43.6],
|
||||||
|
"IL": [-91.5, 36.9, -87.0, 42.5],
|
||||||
|
"IN": [-88.1, 37.8, -84.8, 41.8],
|
||||||
|
"MI": [-90.4, 41.7, -82.4, 48.3],
|
||||||
|
"MN": [-97.3, 43.5, -89.5, 49.4],
|
||||||
|
"MO": [-95.8, 35.9, -89.1, 40.6],
|
||||||
|
"OH": [-84.8, 38.4, -80.5, 42.0],
|
||||||
|
"WI": [-92.9, 42.5, -86.8, 47.1],
|
||||||
|
|
||||||
|
# South
|
||||||
|
"AL": [-88.5, 30.1, -84.9, 35.0],
|
||||||
|
"AR": [-94.6, 33.0, -89.6, 36.5],
|
||||||
|
"FL": [-87.6, 24.4, -80.0, 31.1],
|
||||||
|
"GA": [-85.6, 30.4, -80.8, 35.0],
|
||||||
|
"KY": [-89.6, 36.5, -81.9, 39.2],
|
||||||
|
"LA": [-94.1, 28.9, -88.8, 33.0],
|
||||||
|
"MS": [-91.7, 30.2, -88.1, 35.0],
|
||||||
|
"NC": [-84.3, 33.9, -75.5, 36.6],
|
||||||
|
"SC": [-83.4, 32.0, -78.5, 35.2],
|
||||||
|
"TN": [-90.3, 34.9, -81.6, 36.7],
|
||||||
|
"VA": [-83.7, 36.5, -75.2, 39.5],
|
||||||
|
"WV": [-82.7, 37.2, -77.7, 40.6],
|
||||||
|
|
||||||
|
# Northeast
|
||||||
|
"CT": [-73.7, 41.0, -71.8, 42.1],
|
||||||
|
"DC": [-77.1, 38.8, -76.9, 39.0],
|
||||||
|
"DE": [-75.8, 38.4, -75.0, 39.9],
|
||||||
|
"MA": [-73.5, 41.2, -69.9, 42.9],
|
||||||
|
"MD": [-79.5, 37.9, -75.0, 39.7],
|
||||||
|
"ME": [-71.1, 43.0, -66.9, 47.5],
|
||||||
|
"NH": [-72.6, 42.7, -70.7, 45.3],
|
||||||
|
"NJ": [-75.6, 38.9, -73.9, 41.4],
|
||||||
|
"NY": [-79.8, 40.5, -71.9, 45.0],
|
||||||
|
"PA": [-80.5, 39.7, -74.7, 42.3],
|
||||||
|
"RI": [-71.9, 41.1, -71.1, 42.0],
|
||||||
|
"VT": [-73.4, 42.7, -71.5, 45.1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def states_for_bbox(bbox) -> list[str]:
|
||||||
|
"""Return sorted state codes whose bbox intersects the given coverage bbox.
|
||||||
|
|
||||||
|
Uses bbox_intersects; a state whose bounding rectangle overlaps the coverage
|
||||||
|
rectangle is included. Deduped and sorted alphabetically.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for code, state_bbox in US_STATE_BBOXES.items():
|
||||||
|
if bbox_intersects(bbox, state_bbox):
|
||||||
|
result.append(code)
|
||||||
|
return sorted(set(result))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Avalanche center bbox table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Approximate [west, south, east, north] bboxes for US avalanche centers.
|
||||||
|
# IDs match what the AvalancheConfig.center_ids field expects.
|
||||||
|
# Err large — we want to include a center if there's any reasonable chance
|
||||||
|
# its forecast area overlaps the user's coverage box.
|
||||||
|
AVALANCHE_CENTER_BBOXES: dict[str, list[float]] = {
|
||||||
|
# Idaho
|
||||||
|
"SNFAC": [-116.5, 43.0, -113.5, 45.5], # Sawtooth NF (Sun Valley / Stanley area)
|
||||||
|
"PAC": [-117.0, 43.5, -115.0, 44.8], # Payette Avalanche Center (SW Idaho)
|
||||||
|
"IPAC": [-117.3, 46.5, -115.0, 49.1], # Idaho Panhandle (N Idaho)
|
||||||
|
|
||||||
|
# Montana
|
||||||
|
"GNFAC": [-112.5, 44.0, -108.5, 46.5], # Gallatin NF (SW Montana / NW Wyoming)
|
||||||
|
"FAC": [-115.5, 46.5, -112.5, 49.1], # Flathead Avalanche Center (NW Montana)
|
||||||
|
"WCMAC": [-114.5, 46.0, -111.5, 48.5], # West Central Montana
|
||||||
|
|
||||||
|
# Wyoming
|
||||||
|
"BTAC": [-111.5, 42.5, -109.0, 44.5], # Bridger-Teton (Tetons / Jackson Hole)
|
||||||
|
|
||||||
|
# Colorado
|
||||||
|
"CAIC": [-109.1, 36.9, -102.0, 41.1], # Colorado Avalanche Information Center
|
||||||
|
"CBAC": [-108.0, 38.0, -105.5, 39.5], # Crested Butte Avalanche Center
|
||||||
|
|
||||||
|
# Utah
|
||||||
|
"UAC": [-114.1, 36.9, -109.0, 42.1], # Utah Avalanche Center
|
||||||
|
|
||||||
|
# Washington / Oregon
|
||||||
|
"NWAC": [-124.7, 45.5, -120.5, 49.1], # Northwest Avalanche Center (WA + OR Cascades)
|
||||||
|
"COAA": [-122.5, 43.5, -120.0, 45.5], # Central Oregon Avalanche Association
|
||||||
|
|
||||||
|
# California
|
||||||
|
"SAC": [-121.0, 38.5, -119.0, 40.5], # Sierra Avalanche Center (Lake Tahoe)
|
||||||
|
"ESAC": [-119.5, 37.0, -117.5, 38.8], # Eastern Sierra Avalanche Center (Mammoth)
|
||||||
|
|
||||||
|
# New Mexico / Arizona
|
||||||
|
"NWRFC": [-108.5, 35.5, -105.5, 37.5], # New Mexico (Taos area)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def avalanche_centers_for_bbox(bbox) -> list[str]:
|
||||||
|
"""Return sorted center IDs whose area intersects the given coverage bbox."""
|
||||||
|
result = []
|
||||||
|
for center_id, center_bbox in AVALANCHE_CENTER_BBOXES.items():
|
||||||
|
if bbox_intersects(bbox, center_bbox):
|
||||||
|
result.append(center_id)
|
||||||
|
return sorted(set(result))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-adapter resolver
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Adapters whose feed_source can be "central"; when it is, coverage is
|
||||||
|
# governed entirely by Central and we return None.
|
||||||
|
_CENTRAL_CAPABLE = frozenset({
|
||||||
|
"fires", "nws", "swpc", "ducting", "avalanche", "usgs", "usgs_quake",
|
||||||
|
"traffic", "roads511", "wzdx", "firms", "satpass",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Adapters that are inherently global and have no meaningful geographic scope.
|
||||||
|
_GLOBAL_ADAPTERS = frozenset({"swpc"})
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_adapter_coverage(
|
||||||
|
adapter: str,
|
||||||
|
coverage_bbox: list,
|
||||||
|
feed_source: str = "native",
|
||||||
|
) -> dict | None:
|
||||||
|
"""Map one universal coverage bbox to an adapter's effective scope params.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
adapter: The adapter name (e.g. "nws", "fires", "satpass").
|
||||||
|
coverage_bbox: The [west, south, east, north] coverage bbox from config.
|
||||||
|
feed_source: "native" or "central". Central-fed adapters always
|
||||||
|
return None (Central governs coverage).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None — when the caller should fall back to the adapter's own config:
|
||||||
|
- feed_source == "central"
|
||||||
|
- coverage_bbox is empty or invalid
|
||||||
|
- adapter is global (swpc) or unknown
|
||||||
|
|
||||||
|
dict — containing ONLY the keys relevant to that adapter:
|
||||||
|
fires / wfigs / nicf:
|
||||||
|
{"envelope": arcgis_envelope(bbox), "bbox": bbox}
|
||||||
|
nws:
|
||||||
|
{"areas": states_for_bbox(bbox), "bbox": bbox}
|
||||||
|
wzdx:
|
||||||
|
{"states": states_for_bbox(bbox), "bbox": bbox}
|
||||||
|
usgs_quake / firms / roads511 / usgs:
|
||||||
|
{"bbox": bbox}
|
||||||
|
avalanche:
|
||||||
|
{"center_ids": avalanche_centers_for_bbox(bbox)}
|
||||||
|
traffic:
|
||||||
|
{"points": grid_points(bbox)}
|
||||||
|
satpass / ducting:
|
||||||
|
{"centroid": centroid(bbox)}
|
||||||
|
"""
|
||||||
|
# Central governs scope — never override.
|
||||||
|
if feed_source == "central":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# No valid bbox — caller falls back to adapter's own config.
|
||||||
|
if not coverage_bbox or not bbox_is_valid(coverage_bbox):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Global adapters have no geographic scope.
|
||||||
|
if adapter in _GLOBAL_ADAPTERS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bbox = list(coverage_bbox) # defensive copy
|
||||||
|
|
||||||
|
if adapter in ("fires", "wfigs", "nicf"):
|
||||||
|
return {
|
||||||
|
"envelope": arcgis_envelope(bbox),
|
||||||
|
"bbox": bbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
if adapter == "nws":
|
||||||
|
return {
|
||||||
|
"areas": states_for_bbox(bbox),
|
||||||
|
"bbox": bbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
if adapter == "wzdx":
|
||||||
|
return {
|
||||||
|
"states": states_for_bbox(bbox),
|
||||||
|
"bbox": bbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
if adapter in ("usgs_quake", "firms", "roads511", "usgs"):
|
||||||
|
return {"bbox": bbox}
|
||||||
|
|
||||||
|
if adapter == "avalanche":
|
||||||
|
return {"center_ids": avalanche_centers_for_bbox(bbox)}
|
||||||
|
|
||||||
|
if adapter == "traffic":
|
||||||
|
return {"points": grid_points(bbox)}
|
||||||
|
|
||||||
|
if adapter in ("satpass", "ducting"):
|
||||||
|
return {"centroid": centroid(bbox)}
|
||||||
|
|
||||||
|
# Unknown adapter or inherently global — no coverage override.
|
||||||
|
return None
|
||||||
475
work/tests/test_coverage.py
Normal file
475
work/tests/test_coverage.py
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
"""Tests for meshai/coverage.py — pure coverage-bbox derivation module.
|
||||||
|
|
||||||
|
Bbox convention: [west, south, east, north] = [min_lon, min_lat, max_lon, max_lat].
|
||||||
|
All functions are pure (no I/O) so no fixtures are needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meshai.coverage import (
|
||||||
|
AVALANCHE_CENTER_BBOXES,
|
||||||
|
US_STATE_BBOXES,
|
||||||
|
arcgis_envelope,
|
||||||
|
avalanche_centers_for_bbox,
|
||||||
|
bbox_intersects,
|
||||||
|
bbox_is_valid,
|
||||||
|
centroid,
|
||||||
|
grid_points,
|
||||||
|
point_in_bbox,
|
||||||
|
resolve_adapter_coverage,
|
||||||
|
states_for_bbox,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reference boxes used in multiple tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Magic Valley / south-central Idaho
|
||||||
|
IDAHO_BOX = [-116.5, 42.0, -112.0, 44.0]
|
||||||
|
|
||||||
|
# Straddles the ID/OR border (western edge is inside Oregon)
|
||||||
|
ID_OR_BOX = [-118.0, 43.5, -115.0, 46.0]
|
||||||
|
|
||||||
|
# A box clearly out in the Pacific — no US states
|
||||||
|
PACIFIC_BOX = [-170.0, 20.0, -160.0, 25.0] # hits HI
|
||||||
|
|
||||||
|
# Very small Wyoming box (no avalanche centers overlap)
|
||||||
|
SMALL_WY_BOX = [-106.0, 41.5, -105.5, 42.0]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# bbox_is_valid
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_box_passes():
|
||||||
|
assert bbox_is_valid([-116.5, 42.0, -112.0, 44.0]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_box_tuple_passes():
|
||||||
|
assert bbox_is_valid((-116.5, 42.0, -112.0, 44.0)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_wrong_length():
|
||||||
|
assert bbox_is_valid([-116.5, 42.0, -112.0]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_empty():
|
||||||
|
assert bbox_is_valid([]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_west_equals_east():
|
||||||
|
assert bbox_is_valid([-112.0, 42.0, -112.0, 44.0]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_west_greater_than_east():
|
||||||
|
assert bbox_is_valid([-110.0, 42.0, -116.0, 44.0]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_south_equals_north():
|
||||||
|
assert bbox_is_valid([-116.5, 44.0, -112.0, 44.0]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_south_greater_than_north():
|
||||||
|
assert bbox_is_valid([-116.5, 46.0, -112.0, 42.0]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_lat_out_of_range():
|
||||||
|
assert bbox_is_valid([-116.5, -91.0, -112.0, 44.0]) is False
|
||||||
|
assert bbox_is_valid([-116.5, 42.0, -112.0, 91.0]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_lon_out_of_range():
|
||||||
|
assert bbox_is_valid([-181.0, 42.0, -112.0, 44.0]) is False
|
||||||
|
assert bbox_is_valid([-116.5, 42.0, 181.0, 44.0]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_non_numeric():
|
||||||
|
assert bbox_is_valid([-116.5, "bad", -112.0, 44.0]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_none():
|
||||||
|
assert bbox_is_valid(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# point_in_bbox
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_inside_box():
|
||||||
|
assert point_in_bbox(43.0, -114.0, IDAHO_BOX) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_on_south_edge():
|
||||||
|
# On-edge counts as inside (inclusive)
|
||||||
|
assert point_in_bbox(42.0, -114.0, IDAHO_BOX) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_on_north_edge():
|
||||||
|
assert point_in_bbox(44.0, -114.0, IDAHO_BOX) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_outside_box_south():
|
||||||
|
assert point_in_bbox(41.9, -114.0, IDAHO_BOX) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_outside_box_north():
|
||||||
|
assert point_in_bbox(44.1, -114.0, IDAHO_BOX) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_outside_box_west():
|
||||||
|
assert point_in_bbox(43.0, -117.0, IDAHO_BOX) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_outside_box_east():
|
||||||
|
assert point_in_bbox(43.0, -111.0, IDAHO_BOX) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# bbox_intersects
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlapping_boxes_intersect():
|
||||||
|
# Boxes share a large area
|
||||||
|
a = [-116.0, 42.0, -112.0, 44.0]
|
||||||
|
b = [-114.0, 43.0, -110.0, 45.0]
|
||||||
|
assert bbox_intersects(a, b) is True
|
||||||
|
assert bbox_intersects(b, a) is True # symmetric
|
||||||
|
|
||||||
|
|
||||||
|
def test_disjoint_boxes_do_not_intersect_east_west():
|
||||||
|
a = [-120.0, 42.0, -116.0, 44.0]
|
||||||
|
b = [-114.0, 42.0, -110.0, 44.0]
|
||||||
|
assert bbox_intersects(a, b) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_disjoint_boxes_do_not_intersect_north_south():
|
||||||
|
a = [-116.0, 42.0, -112.0, 44.0]
|
||||||
|
b = [-116.0, 45.0, -112.0, 47.0]
|
||||||
|
assert bbox_intersects(a, b) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_touching_edge_does_not_intersect():
|
||||||
|
# Boxes share only an edge — strict intersection → False
|
||||||
|
a = [-116.0, 42.0, -112.0, 44.0]
|
||||||
|
b = [-112.0, 42.0, -108.0, 44.0]
|
||||||
|
assert bbox_intersects(a, b) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_boxes_intersect():
|
||||||
|
a = [-116.0, 42.0, -112.0, 44.0]
|
||||||
|
assert bbox_intersects(a, a) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_box_contained_in_other():
|
||||||
|
outer = [-120.0, 40.0, -110.0, 50.0]
|
||||||
|
inner = [-116.0, 42.0, -112.0, 44.0]
|
||||||
|
assert bbox_intersects(outer, inner) is True
|
||||||
|
assert bbox_intersects(inner, outer) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# centroid
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_centroid_basic():
|
||||||
|
lat, lon = centroid([-116.0, 42.0, -112.0, 44.0])
|
||||||
|
assert lat == pytest.approx(43.0)
|
||||||
|
assert lon == pytest.approx(-114.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_centroid_asymmetric():
|
||||||
|
lat, lon = centroid([-116.5, 42.0, -112.0, 44.0])
|
||||||
|
assert lat == pytest.approx(43.0)
|
||||||
|
assert lon == pytest.approx(-114.25)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# grid_points
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_points_default_count():
|
||||||
|
pts = grid_points(IDAHO_BOX)
|
||||||
|
assert len(pts) == 9 # 3x3
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_points_custom_count():
|
||||||
|
pts = grid_points(IDAHO_BOX, cols=4, rows=2)
|
||||||
|
assert len(pts) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_points_all_inside_box():
|
||||||
|
"""All returned points must lie strictly inside the bbox (not on edges)."""
|
||||||
|
west, south, east, north = IDAHO_BOX
|
||||||
|
for lat, lon in grid_points(IDAHO_BOX, cols=3, rows=3):
|
||||||
|
assert south < lat < north, f"lat {lat} not strictly inside [{south}, {north}]"
|
||||||
|
assert west < lon < east, f"lon {lon} not strictly inside [{west}, {east}]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_points_1x1_is_centroid():
|
||||||
|
lat, lon = grid_points(IDAHO_BOX, cols=1, rows=1)[0]
|
||||||
|
clat, clon = centroid(IDAHO_BOX)
|
||||||
|
assert lat == pytest.approx(clat)
|
||||||
|
assert lon == pytest.approx(clon)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_points_return_type():
|
||||||
|
pts = grid_points(IDAHO_BOX)
|
||||||
|
assert all(isinstance(p, tuple) and len(p) == 2 for p in pts)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# arcgis_envelope
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcgis_envelope_keys():
|
||||||
|
result = arcgis_envelope(IDAHO_BOX)
|
||||||
|
assert set(result.keys()) == {"geometry", "geometryType", "spatialRel", "inSR"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcgis_envelope_geometry_string():
|
||||||
|
result = arcgis_envelope([-116.5, 42.0, -112.0, 44.0])
|
||||||
|
assert result["geometry"] == "-116.5,42.0,-112.0,44.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcgis_envelope_static_fields():
|
||||||
|
result = arcgis_envelope(IDAHO_BOX)
|
||||||
|
assert result["geometryType"] == "esriGeometryEnvelope"
|
||||||
|
assert result["spatialRel"] == "esriSpatialRelIntersects"
|
||||||
|
assert result["inSR"] == "4326"
|
||||||
|
|
||||||
|
|
||||||
|
def test_arcgis_envelope_known_box():
|
||||||
|
bbox = [-117.0, 43.0, -113.0, 46.0]
|
||||||
|
env = arcgis_envelope(bbox)
|
||||||
|
assert env["geometry"] == "-117.0,43.0,-113.0,46.0"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# states_for_bbox
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_idaho_box_returns_id():
|
||||||
|
states = states_for_bbox(IDAHO_BOX)
|
||||||
|
assert "ID" in states
|
||||||
|
|
||||||
|
|
||||||
|
def test_idaho_box_does_not_return_far_states():
|
||||||
|
"""A south-central Idaho box should not pull in distant states."""
|
||||||
|
states = states_for_bbox(IDAHO_BOX)
|
||||||
|
for far in ("FL", "ME", "TX", "NY"):
|
||||||
|
assert far not in states, f"unexpected far state {far} in {states}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_or_straddling_box_returns_both():
|
||||||
|
states = states_for_bbox(ID_OR_BOX)
|
||||||
|
assert "ID" in states
|
||||||
|
assert "OR" in states
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_is_sorted():
|
||||||
|
states = states_for_bbox(ID_OR_BOX)
|
||||||
|
assert states == sorted(states)
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_is_deduped():
|
||||||
|
states = states_for_bbox(IDAHO_BOX)
|
||||||
|
assert len(states) == len(set(states))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pacific_box_returns_only_hi():
|
||||||
|
states = states_for_bbox(PACIFIC_BOX)
|
||||||
|
# Hawaii bbox is [-160.3, 18.9, -154.8, 22.3]; PACIFIC_BOX overlaps it.
|
||||||
|
assert "HI" in states
|
||||||
|
# Continental states should not appear.
|
||||||
|
for continental in ("ID", "OR", "WA", "CA", "TX"):
|
||||||
|
assert continental not in states
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_bboxes_table_has_all_50_plus_dc():
|
||||||
|
"""The table must have at least 51 entries (50 states + DC)."""
|
||||||
|
# We may also have territories; just require 50 states + DC minimum.
|
||||||
|
required = {
|
||||||
|
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC", "FL",
|
||||||
|
"GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME",
|
||||||
|
"MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH",
|
||||||
|
"NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI",
|
||||||
|
"SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY",
|
||||||
|
}
|
||||||
|
assert required.issubset(set(US_STATE_BBOXES.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# avalanche_centers_for_bbox
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_idaho_box_includes_snfac():
|
||||||
|
centers = avalanche_centers_for_bbox(IDAHO_BOX)
|
||||||
|
assert "SNFAC" in centers
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_is_sorted_deduped():
|
||||||
|
centers = avalanche_centers_for_bbox(IDAHO_BOX)
|
||||||
|
assert centers == sorted(centers)
|
||||||
|
assert len(centers) == len(set(centers))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pacific_box_no_continental_centers():
|
||||||
|
centers = avalanche_centers_for_bbox(PACIFIC_BOX)
|
||||||
|
for c in ("SNFAC", "CAIC", "UAC"):
|
||||||
|
assert c not in centers
|
||||||
|
|
||||||
|
|
||||||
|
def test_avalanche_center_bboxes_includes_snfac_key():
|
||||||
|
assert "SNFAC" in AVALANCHE_CENTER_BBOXES
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# resolve_adapter_coverage
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ---- None-returning cases ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_central_feed_returns_none():
|
||||||
|
assert resolve_adapter_coverage("nws", IDAHO_BOX, feed_source="central") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_bbox_returns_none():
|
||||||
|
assert resolve_adapter_coverage("nws", [], feed_source="native") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_bbox_returns_none():
|
||||||
|
assert resolve_adapter_coverage("nws", [-116.5, 44.0, -112.0, 42.0]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_swpc_returns_none():
|
||||||
|
"""SWPC is global — no geographic scope regardless of bbox."""
|
||||||
|
assert resolve_adapter_coverage("swpc", IDAHO_BOX) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_adapter_returns_none():
|
||||||
|
assert resolve_adapter_coverage("no_such_adapter", IDAHO_BOX) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- fires adapter -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fires_returns_envelope_and_bbox():
|
||||||
|
result = resolve_adapter_coverage("fires", IDAHO_BOX)
|
||||||
|
assert result is not None
|
||||||
|
assert "envelope" in result
|
||||||
|
assert "bbox" in result
|
||||||
|
assert result["bbox"] == IDAHO_BOX
|
||||||
|
env = result["envelope"]
|
||||||
|
assert "geometry" in env
|
||||||
|
assert env["geometryType"] == "esriGeometryEnvelope"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- nws adapter ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_nws_returns_areas_and_bbox():
|
||||||
|
result = resolve_adapter_coverage("nws", IDAHO_BOX)
|
||||||
|
assert result is not None
|
||||||
|
assert "areas" in result
|
||||||
|
assert "bbox" in result
|
||||||
|
assert "ID" in result["areas"]
|
||||||
|
assert result["bbox"] == IDAHO_BOX
|
||||||
|
|
||||||
|
|
||||||
|
def test_nws_areas_is_list():
|
||||||
|
result = resolve_adapter_coverage("nws", IDAHO_BOX)
|
||||||
|
assert isinstance(result["areas"], list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- wzdx adapter --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_wzdx_returns_states_and_bbox():
|
||||||
|
result = resolve_adapter_coverage("wzdx", IDAHO_BOX)
|
||||||
|
assert result is not None
|
||||||
|
assert "states" in result
|
||||||
|
assert "bbox" in result
|
||||||
|
assert "ID" in result["states"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- bbox-only adapters --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("adapter", ["usgs_quake", "firms", "roads511", "usgs"])
|
||||||
|
def test_bbox_only_adapters(adapter):
|
||||||
|
result = resolve_adapter_coverage(adapter, IDAHO_BOX)
|
||||||
|
assert result is not None
|
||||||
|
assert list(result.keys()) == ["bbox"]
|
||||||
|
assert result["bbox"] == IDAHO_BOX
|
||||||
|
|
||||||
|
|
||||||
|
# ---- avalanche adapter ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_avalanche_returns_center_ids():
|
||||||
|
result = resolve_adapter_coverage("avalanche", IDAHO_BOX)
|
||||||
|
assert result is not None
|
||||||
|
assert "center_ids" in result
|
||||||
|
assert "SNFAC" in result["center_ids"]
|
||||||
|
# Should NOT have a bbox key (avalanche uses center IDs, not a raw bbox)
|
||||||
|
assert "bbox" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---- traffic adapter -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_traffic_returns_points():
|
||||||
|
result = resolve_adapter_coverage("traffic", IDAHO_BOX)
|
||||||
|
assert result is not None
|
||||||
|
assert "points" in result
|
||||||
|
pts = result["points"]
|
||||||
|
assert isinstance(pts, list)
|
||||||
|
assert len(pts) == 9 # default 3x3 grid
|
||||||
|
|
||||||
|
|
||||||
|
def test_traffic_points_are_tuples():
|
||||||
|
result = resolve_adapter_coverage("traffic", IDAHO_BOX)
|
||||||
|
assert all(isinstance(p, tuple) and len(p) == 2 for p in result["points"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---- satpass / ducting adapters ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("adapter", ["satpass", "ducting"])
|
||||||
|
def test_satpass_ducting_returns_centroid(adapter):
|
||||||
|
result = resolve_adapter_coverage(adapter, IDAHO_BOX)
|
||||||
|
assert result is not None
|
||||||
|
assert "centroid" in result
|
||||||
|
lat, lon = result["centroid"]
|
||||||
|
assert isinstance(lat, float)
|
||||||
|
assert isinstance(lon, float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_satpass_centroid_is_correct():
|
||||||
|
result = resolve_adapter_coverage("satpass", [-116.0, 42.0, -112.0, 44.0])
|
||||||
|
lat, lon = result["centroid"]
|
||||||
|
assert lat == pytest.approx(43.0)
|
||||||
|
assert lon == pytest.approx(-114.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- defensive copy of bbox ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_does_not_mutate_input_bbox():
|
||||||
|
"""The returned bbox should be a copy; mutating it must not affect the original."""
|
||||||
|
original = [-116.5, 42.0, -112.0, 44.0]
|
||||||
|
result = resolve_adapter_coverage("usgs_quake", original)
|
||||||
|
result["bbox"].append(999) # mutate return value
|
||||||
|
assert len(original) == 4 # original untouched
|
||||||
Loading…
Add table
Add a link
Reference in a new issue