From 997729241e17098f4ad32f8daca4b99acdcc6fca Mon Sep 17 00:00:00 2001 From: malice Date: Mon, 6 Jul 2026 08:27:53 -0600 Subject: [PATCH] feat(coverage): universal coverage-bbox foundation (config + derivation module) (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Opus 4.8 (1M context) --- work/meshai/config.py | 20 ++ work/meshai/config_loader.py | 1 + work/meshai/coverage.py | 359 ++++++++++++++++++++++++++ work/tests/test_coverage.py | 475 +++++++++++++++++++++++++++++++++++ 4 files changed, 855 insertions(+) create mode 100644 work/meshai/coverage.py create mode 100644 work/tests/test_coverage.py diff --git a/work/meshai/config.py b/work/meshai/config.py index 949f674..c182374 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -792,6 +792,23 @@ class DangerZonesConfig: 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 class Config: """Main configuration container.""" @@ -817,6 +834,7 @@ class Config: dashboard: DashboardConfig = field(default_factory=DashboardConfig) notifications: NotificationsConfig = field(default_factory=NotificationsConfig) danger_zones: DangerZonesConfig = field(default_factory=DangerZonesConfig) + coverage: Coverage = field(default_factory=Coverage) _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) elif key == "dashboard" and isinstance(value, dict): 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): # v0.5: notifications.toggles is a dict of family -> NotificationToggle kwargs[key] = { diff --git a/work/meshai/config_loader.py b/work/meshai/config_loader.py index 976b7c0..a24270e 100644 --- a/work/meshai/config_loader.py +++ b/work/meshai/config_loader.py @@ -56,6 +56,7 @@ SECTION_TO_FILE: dict[str, str] = { "llm": "llm.yaml", "dashboard": "dashboard.yaml", "danger_zones": "danger_zones.yaml", + "coverage": "config.yaml", } # Fields that should be written to local.yaml instead of domain files diff --git a/work/meshai/coverage.py b/work/meshai/coverage.py new file mode 100644 index 0000000..9ea4a5f --- /dev/null +++ b/work/meshai/coverage.py @@ -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 diff --git a/work/tests/test_coverage.py b/work/tests/test_coverage.py new file mode 100644 index 0000000..36028c4 --- /dev/null +++ b/work/tests/test_coverage.py @@ -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