diff --git a/work/meshai/config.py b/work/meshai/config.py index 5dc3ae3..26e5710 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -803,11 +803,16 @@ class Coverage: 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. + areas: multi-box list for the Shapely geometry gate (Phase 3+). Each entry is + {"name": str, "west": float, "south": float, "east": float, "north": float}. + When non-empty, takes precedence over bbox for the CoverageFilter gate. + When empty, CoverageFilter falls back to bbox (if set) for back-compat. """ bbox: list = field(default_factory=list) # [west, south, east, north]; empty = not configured enabled: bool = True # master switch for deriving adapter scope from bbox excluded_adapters: list = field(default_factory=list) # adapters that OPT OUT of coverage bbox and use their own config + areas: list = field(default_factory=list) # multi-box list [{name,west,south,east,north}]; empty = use bbox fallback @dataclass diff --git a/work/meshai/coverage_area.py b/work/meshai/coverage_area.py new file mode 100644 index 0000000..39021ce --- /dev/null +++ b/work/meshai/coverage_area.py @@ -0,0 +1,203 @@ +"""Geographic coverage area primitives. + +Attribution: MonitoringArea, build_geom_json, and classify_geom_areas are +ported verbatim from central/src/central/monitoring_area.py (v0.14.0+). +The data source is adapted: Central loads boxes from asyncpg; here they +come from meshai config (plain dicts/floats). The Shapely algorithm and +geom-json normalization are byte-identical to Central's implementation. + +Uses shapely>=2.0 (manylinux wheels bundle GEOS — no apt package required +on python:3.11-slim-bookworm). +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any + +from shapely.geometry import box as _shapely_box +from shapely.geometry import shape + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# MonitoringArea — frozen bbox dataclass (ported from central) +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class MonitoringArea: + """Bounding box that events must intersect to pass the coverage gate.""" + + north: float + south: float + east: float + west: float + + def as_box(self): + # shapely box(minx, miny, maxx, maxy) -> (west, south, east, north) + return _shapely_box(self.west, self.south, self.east, self.north) + + +# --------------------------------------------------------------------------- +# build_geom_json — geo-dict → GeoJSON string (ported from central) +# --------------------------------------------------------------------------- + +def build_geom_json(geo_data: dict[str, Any] | None) -> str | None: + """Build a GeoJSON string from an event's geo dict. + + Priority (matches Central's v0.9.8+ rule): + 1. full ``geometry`` dict (wins over everything) + 2. ``bbox`` [minlon, minlat, maxlon, maxlat] — rendered as a 4-corner Polygon + 3. ``centroid`` [lon, lat] — rendered as a Point + + Returns None if no usable shape is present. + Ported verbatim from central.monitoring_area.build_geom_json. + """ + if not geo_data: + return None + + geometry = geo_data.get("geometry") + if geometry: + return json.dumps(geometry) + + bbox = geo_data.get("bbox") + centroid = geo_data.get("centroid") + + if bbox and len(bbox) == 4: + min_lon, min_lat, max_lon, max_lat = bbox + return json.dumps({ + "type": "Polygon", + "coordinates": [[ + [min_lon, min_lat], + [max_lon, min_lat], + [max_lon, max_lat], + [min_lon, max_lat], + [min_lon, min_lat], + ]], + }) + if centroid and len(centroid) == 2: + return json.dumps({ + "type": "Point", + "coordinates": centroid, + }) + + return None + + +# --------------------------------------------------------------------------- +# classify_geom_areas — set-union Shapely intersection (ported from central) +# --------------------------------------------------------------------------- + +def classify_geom_areas( + geom_json: str | None, + areas: list[MonitoringArea], +) -> str: + """Classify a GeoJSON string against a list of monitoring areas (set union). + + Returns one of: + 'null-geom' -- no geometry; always kept (fail-open) + 'no-area' -- empty area list; keep everything + 'in-bounds' -- geometry intersects AT LEAST ONE area; keep + 'out-of-bounds' -- geometry lies entirely outside every area; drop + 'invalid-geom' -- geometry could not be evaluated; kept (fail-open) + + An event is kept if it intersects ANY configured area (set union). + Uses intersects() so border-straddlers and points-on-edge are kept. + The filter must never drop an event because of a parse failure. + Ported verbatim from central.monitoring_area.classify_geom_areas (v0.14.0). + """ + if geom_json is None: + return "null-geom" + if not areas: + return "no-area" + try: + geom = shape(json.loads(geom_json)) + for area in areas: + if geom.intersects(area.as_box()): + return "in-bounds" + return "out-of-bounds" + except Exception: + return "invalid-geom" + + +# --------------------------------------------------------------------------- +# areas_from_config — meshai config → MonitoringArea list +# --------------------------------------------------------------------------- + +def areas_from_config(coverage_cfg) -> list[MonitoringArea]: + """Build a MonitoringArea list from meshai config.Coverage. + + Uses ``coverage_cfg.areas`` (list of dicts with name/west/south/east/north) + if non-empty. Falls back to synthesising a single area from the legacy + ``coverage_cfg.bbox`` ([w, s, e, n]) when ``areas`` is empty, so existing + single-bbox deployments need no config migration. + + Returns an empty list when neither source is populated, which causes + classify_geom_areas to return 'no-area' and keep everything (pre-feature + default — identical to Central's NULL-bbox behaviour). + """ + areas_list = getattr(coverage_cfg, "areas", None) or [] + if areas_list: + result: list[MonitoringArea] = [] + for a in areas_list: + if isinstance(a, dict): + try: + result.append(MonitoringArea( + north=float(a["north"]), + south=float(a["south"]), + east=float(a["east"]), + west=float(a["west"]), + )) + except (KeyError, TypeError, ValueError) as exc: + logger.warning("Skipping malformed coverage area %r: %s", a, exc) + return result + + # Fall back to the legacy single-bbox field + bbox = getattr(coverage_cfg, "bbox", None) or [] + if bbox and len(bbox) == 4: + west, south, east, north = (float(v) for v in bbox) + return [MonitoringArea(north=north, south=south, east=east, west=west)] + + return [] + + +# --------------------------------------------------------------------------- +# event_in_areas — convenience wrapper over build_geom_json + classify +# --------------------------------------------------------------------------- + +def event_in_areas(event: Any, areas: list[MonitoringArea]) -> bool: + """Return True if the event's geometry intersects any configured area. + + Geometry extraction priority (mirrors Central's archive chain): + 1. event.data["geometry"] — full GeoJSON dict (richest, preferred) + 2. event.data["bbox"] — [west, south, east, north] + 3. (event.lon, event.lat) — Point centroid [lon, lat] in GeoJSON order + + Fail-open semantics (matching Central): + 'null-geom' (no geometry at all) → True (keep) + 'invalid-geom' (parse/Shapely error) → True (keep) + 'no-area' (areas list empty) → True (keep) + 'out-of-bounds' → False (drop) + """ + data: dict[str, Any] = getattr(event, "data", None) or {} + geo: dict[str, Any] = {} + + geometry = data.get("geometry") + if geometry: + geo["geometry"] = geometry + else: + bbox = data.get("bbox") + if bbox: + geo["bbox"] = bbox + else: + lat = getattr(event, "lat", None) + lon = getattr(event, "lon", None) + if lat is not None and lon is not None: + geo["centroid"] = [lon, lat] # GeoJSON coordinate order: [lon, lat] + + geom_json = build_geom_json(geo if geo else None) + result = classify_geom_areas(geom_json, areas) + return result != "out-of-bounds" diff --git a/work/meshai/notifications/pipeline/__init__.py b/work/meshai/notifications/pipeline/__init__.py index d6d2908..4add628 100644 --- a/work/meshai/notifications/pipeline/__init__.py +++ b/work/meshai/notifications/pipeline/__init__.py @@ -41,6 +41,8 @@ except ImportError: from meshai.notifications.pipeline.inhibitor import Inhibitor from meshai.notifications.pipeline.grouper import Grouper from meshai.notifications.pipeline.toggle_filter import ToggleFilter +from meshai.notifications.pipeline.coverage_filter import CoverageFilter +from meshai.coverage_area import areas_from_config from meshai.notifications.pipeline.digest import DigestAccumulator, Digest from meshai.notifications.pipeline.scheduler import DigestScheduler @@ -111,8 +113,20 @@ def build_pipeline(config, llm_backend, connector=None) -> EventBus: sorted(enabled_toggles), ) - toggle_filter = ToggleFilter( + # Coverage filter: Shapely bbox geometry gate (set-union over all areas). + # Inserted between toggle_filter and _tee so geographic gating runs on + # every event that passes the user-preference toggle check. + # Chain: inhibitor → grouper → toggle_filter → coverage_filter → _tee + _coverage_areas = areas_from_config(config.coverage) + coverage_filter = CoverageFilter( next_handler=_tee, + areas=_coverage_areas, + enabled=getattr(config.coverage, "enabled", True), + excluded_adapters=set(getattr(config.coverage, "excluded_adapters", None) or []), + ) + + toggle_filter = ToggleFilter( + next_handler=coverage_filter.handle, enabled_toggles=enabled_toggles, ) @@ -125,6 +139,7 @@ def build_pipeline(config, llm_backend, connector=None) -> EventBus: "inhibitor": inhibitor, "grouper": grouper, "toggle_filter": toggle_filter, + "coverage_filter": coverage_filter, "dispatcher": dispatcher, "accumulator": accumulator, "connector": connector, @@ -321,6 +336,7 @@ __all__ = [ "Inhibitor", "Grouper", "ToggleFilter", + "CoverageFilter", "DigestAccumulator", "Digest", "DigestScheduler", diff --git a/work/meshai/notifications/pipeline/coverage_filter.py b/work/meshai/notifications/pipeline/coverage_filter.py new file mode 100644 index 0000000..2f74aac --- /dev/null +++ b/work/meshai/notifications/pipeline/coverage_filter.py @@ -0,0 +1,82 @@ +"""Coverage area gate — geographic event filter. + +Mirrors ToggleFilter's construction + integration pattern exactly: +same base interface (a callable ``.handle(event)``), constructed from config, +inserted in the pipeline chain adjacent to ToggleFilter. + +Events that lie entirely outside ALL configured bounding boxes are dropped. +Events with no parseable geometry, or events whose source is in +``excluded_adapters``, are kept (fail-open, matching Central's choke-point +semantics). + +When coverage is disabled (``coverage.enabled`` is False) or no areas are +configured (``areas`` is empty), the filter is a no-op and passes everything. +""" + +import logging +from typing import Callable + +from meshai.notifications.events import Event +from meshai.coverage_area import MonitoringArea, event_in_areas + + +class CoverageFilter: + """Gate events against one or more geographic bounding boxes (set union). + + Construction mirrors ToggleFilter: + coverage_filter = CoverageFilter( + next_handler=_tee, + areas=areas_from_config(config.coverage), + enabled=config.coverage.enabled, + excluded_adapters=set(config.coverage.excluded_adapters), + ) + + Pipeline placement (inserted between ToggleFilter and _tee): + inhibitor → grouper → toggle_filter → coverage_filter → _tee + """ + + def __init__( + self, + next_handler: Callable[[Event], None], + areas: list[MonitoringArea] | None = None, + enabled: bool = True, + excluded_adapters: set[str] | None = None, + ): + """Initialize. + + Args: + next_handler: Callable that receives non-dropped events. + areas: List of MonitoringArea bounding boxes. Empty list or None + means the filter is a no-op (keeps everything). + enabled: Master switch; False makes the filter a no-op. + excluded_adapters: Set of source/adapter names that bypass the gate. + Matching is against event.source. + """ + self._next = next_handler + self._areas: list[MonitoringArea] = areas or [] + self._enabled = enabled + self._excluded: set[str] = excluded_adapters or set() + self._logger = logging.getLogger("meshai.pipeline.coverage_filter") + + def handle(self, event: Event) -> None: + """Pass the event through, or drop it if outside all coverage areas.""" + # No-op when disabled or no areas configured + if not self._enabled or not self._areas: + self._next(event) + return + + # Bypass for excluded adapters (opt-out list from config) + if event.source in self._excluded: + self._next(event) + return + + if event_in_areas(event, self._areas): + self._next(event) + else: + self._logger.debug( + "DROPPED event %s — out of all coverage areas " + "(source=%s category=%s)", + event.id, + event.source, + event.category, + ) diff --git a/work/requirements.txt b/work/requirements.txt index 9a24c23..c9754b1 100644 --- a/work/requirements.txt +++ b/work/requirements.txt @@ -15,3 +15,4 @@ uvicorn[standard]>=0.27.0 aiomqtt>=2.0.0 sgp4>=2.22 tzdata +shapely>=2.0 diff --git a/work/tests/test_coverage_area.py b/work/tests/test_coverage_area.py new file mode 100644 index 0000000..086d4c2 --- /dev/null +++ b/work/tests/test_coverage_area.py @@ -0,0 +1,517 @@ +"""Tests for meshai/coverage_area.py — Shapely geometry gate. + +Includes a reproduction of the LA/OR leak that originally triggered this work: +an event whose geometry is firmly inside California must be DROPPED when only +an Idaho coverage box is configured. + +Bbox/coordinate convention (GeoJSON): + bbox = [west, south, east, north] = [min_lon, min_lat, max_lon, max_lat] + Point = [lon, lat] + +Idaho reference area (magic-valley to eastern border, generous): + west=-117, south=42, east=-111, north=44 +""" + +from __future__ import annotations + +import json + +import pytest + +from meshai.coverage_area import ( + MonitoringArea, + areas_from_config, + build_geom_json, + classify_geom_areas, + event_in_areas, +) +from meshai.notifications.events import make_event +from meshai.notifications.pipeline.coverage_filter import CoverageFilter +from meshai.config import Coverage + + +# --------------------------------------------------------------------------- +# Reference areas +# --------------------------------------------------------------------------- + +IDAHO = MonitoringArea(north=44.0, south=42.0, east=-111.0, west=-117.0) +# Second non-contiguous box covering Yellowstone / eastern corner of WY +YELLOWSTONE = MonitoringArea(north=45.2, south=44.0, east=-109.5, west=-111.5) + +IDAHO_AREAS = [IDAHO] +TWO_AREAS = [IDAHO, YELLOWSTONE] + +# A point firmly inside Idaho (Twin Falls area) +TF_LON, TF_LAT = -114.47, 42.56 + +# A point at downtown Los Angeles — should be OUT of Idaho +LA_LON, LA_LAT = -118.24, 34.05 + +# A CA polygon enclosing the LA area (4-corner box + close) +CA_POLYGON_COORDS = [ + [-119.0, 33.5], + [-117.5, 33.5], + [-117.5, 34.6], + [-119.0, 34.6], + [-119.0, 33.5], +] +CA_POLYGON_GEOJSON = json.dumps({ + "type": "Polygon", + "coordinates": [CA_POLYGON_COORDS], +}) + +# A polygon straddling the Idaho western border (partly in Oregon, partly Idaho) +STRADDLER_COORDS = [ + [-117.5, 42.5], # in Oregon + [-116.0, 42.5], # in Idaho + [-116.0, 43.5], # in Idaho + [-117.5, 43.5], # in Oregon + [-117.5, 42.5], +] +STRADDLER_GEOJSON = json.dumps({ + "type": "Polygon", + "coordinates": [STRADDLER_COORDS], +}) + + +# =========================================================================== +# MonitoringArea.as_box +# =========================================================================== + +class TestMonitoringArea: + def test_as_box_is_shapely_polygon(self): + from shapely.geometry import box + b = IDAHO.as_box() + expected = box(IDAHO.west, IDAHO.south, IDAHO.east, IDAHO.north) + assert b.equals(expected) + + def test_frozen_immutability(self): + with pytest.raises(Exception): + IDAHO.north = 99.0 # type: ignore[misc] + + +# =========================================================================== +# build_geom_json — geometry extraction priority +# =========================================================================== + +class TestBuildGeomJson: + def test_none_input_returns_none(self): + assert build_geom_json(None) is None + + def test_empty_dict_returns_none(self): + assert build_geom_json({}) is None + + def test_geometry_wins_over_bbox(self): + geo = { + "geometry": {"type": "Point", "coordinates": [TF_LON, TF_LAT]}, + "bbox": [-115.0, 42.0, -113.0, 43.0], + } + result = build_geom_json(geo) + assert json.loads(result) == {"type": "Point", "coordinates": [TF_LON, TF_LAT]} + + def test_bbox_renders_as_polygon(self): + geo = {"bbox": [-115.0, 42.0, -113.0, 43.0]} + result = build_geom_json(geo) + parsed = json.loads(result) + assert parsed["type"] == "Polygon" + coords = parsed["coordinates"][0] + # should be a closed ring of 5 points + assert len(coords) == 5 + assert coords[0] == coords[-1] + + def test_centroid_renders_as_point(self): + geo = {"centroid": [TF_LON, TF_LAT]} + result = build_geom_json(geo) + parsed = json.loads(result) + assert parsed == {"type": "Point", "coordinates": [TF_LON, TF_LAT]} + + def test_bbox_wins_over_centroid(self): + geo = { + "bbox": [-115.0, 42.0, -113.0, 43.0], + "centroid": [LA_LON, LA_LAT], + } + result = build_geom_json(geo) + assert "Polygon" in result + + def test_returns_none_if_no_usable_shape(self): + geo = {"name": "irrelevant"} + assert build_geom_json(geo) is None + + +# =========================================================================== +# classify_geom_areas +# =========================================================================== + +class TestClassifyGeomAreas: + def test_null_geom_returns_null_geom(self): + assert classify_geom_areas(None, IDAHO_AREAS) == "null-geom" + + def test_no_area_returns_no_area(self): + geom_json = json.dumps({"type": "Point", "coordinates": [TF_LON, TF_LAT]}) + assert classify_geom_areas(geom_json, []) == "no-area" + + def test_idaho_point_in_bounds(self): + geom_json = json.dumps({"type": "Point", "coordinates": [TF_LON, TF_LAT]}) + assert classify_geom_areas(geom_json, IDAHO_AREAS) == "in-bounds" + + def test_la_point_out_of_bounds(self): + """LA repro: a point at (34.05N, 118.24W) must be OUT of an Idaho box.""" + geom_json = json.dumps({"type": "Point", "coordinates": [LA_LON, LA_LAT]}) + assert classify_geom_areas(geom_json, IDAHO_AREAS) == "out-of-bounds" + + def test_ca_polygon_out_of_bounds(self): + """CA polygon straddling 34N should be fully outside Idaho.""" + assert classify_geom_areas(CA_POLYGON_GEOJSON, IDAHO_AREAS) == "out-of-bounds" + + def test_straddler_polygon_in_bounds(self): + """Polygon crossing the ID/OR border still intersects Idaho → in-bounds.""" + assert classify_geom_areas(STRADDLER_GEOJSON, IDAHO_AREAS) == "in-bounds" + + def test_invalid_geom_returns_invalid_geom(self): + assert classify_geom_areas("this is not json", IDAHO_AREAS) == "invalid-geom" + + def test_empty_geom_string_returns_invalid_geom(self): + assert classify_geom_areas("", IDAHO_AREAS) == "invalid-geom" + + def test_multi_area_union_point_in_second_box(self): + """A point inside the Yellowstone box (not Idaho) → in-bounds via union.""" + # Yellowstone area: north=45.2, south=44.0, east=-109.5, west=-111.5 + ys_point = json.dumps({"type": "Point", "coordinates": [-110.5, 44.5]}) + assert classify_geom_areas(ys_point, TWO_AREAS) == "in-bounds" + + def test_multi_area_union_point_in_neither_box(self): + """A point in Wyoming (but not in either box) → out-of-bounds.""" + # Far eastern Wyoming, outside both boxes + wy_point = json.dumps({"type": "Point", "coordinates": [-104.0, 42.5]}) + assert classify_geom_areas(wy_point, TWO_AREAS) == "out-of-bounds" + + def test_border_edge_point_is_in_bounds(self): + """A point exactly on the Idaho box edge → in-bounds (intersects semantics).""" + # Exactly on the western edge of Idaho box (lon=-117, lat=43) + edge = json.dumps({"type": "Point", "coordinates": [-117.0, 43.0]}) + assert classify_geom_areas(edge, IDAHO_AREAS) == "in-bounds" + + +# =========================================================================== +# areas_from_config +# =========================================================================== + +class TestAreasFromConfig: + def test_from_areas_list(self): + cov = Coverage(areas=[ + {"name": "Idaho", "west": -117.0, "south": 42.0, "east": -111.0, "north": 44.0} + ]) + areas = areas_from_config(cov) + assert len(areas) == 1 + a = areas[0] + assert a.west == -117.0 and a.south == 42.0 + assert a.east == -111.0 and a.north == 44.0 + + def test_falls_back_to_bbox(self): + cov = Coverage(bbox=[-117.0, 42.0, -111.0, 44.0]) + areas = areas_from_config(cov) + assert len(areas) == 1 + assert areas[0].west == -117.0 + + def test_areas_takes_precedence_over_bbox(self): + cov = Coverage( + bbox=[-110.0, 30.0, -100.0, 35.0], # TX — totally different + areas=[{"name": "ID", "west": -117.0, "south": 42.0, "east": -111.0, "north": 44.0}], + ) + areas = areas_from_config(cov) + assert len(areas) == 1 + assert areas[0].west == -117.0 # Idaho, not TX + + def test_empty_config_returns_empty(self): + cov = Coverage() + assert areas_from_config(cov) == [] + + def test_multi_area_list(self): + cov = Coverage(areas=[ + {"name": "Idaho", "west": -117.0, "south": 42.0, "east": -111.0, "north": 44.0}, + {"name": "Yellowstone", "west": -111.5, "south": 44.0, "east": -109.5, "north": 45.2}, + ]) + areas = areas_from_config(cov) + assert len(areas) == 2 + + def test_malformed_area_dict_skipped(self): + cov = Coverage(areas=[ + {"name": "bad"}, # missing west/south/east/north + {"name": "Idaho", "west": -117.0, "south": 42.0, "east": -111.0, "north": 44.0}, + ]) + areas = areas_from_config(cov) + assert len(areas) == 1 # only the valid one + + +# =========================================================================== +# event_in_areas +# =========================================================================== + +class TestEventInAreas: + def _event_with_geom(self, geometry_dict, lat=None, lon=None): + return make_event( + source="nws", category="weather_warning", severity="priority", + title="Test", lat=lat, lon=lon, + data={"geometry": geometry_dict}, + ) + + def _event_with_centroid(self, lat, lon): + return make_event( + source="nws", category="weather_warning", severity="priority", + title="Test", lat=lat, lon=lon, + ) + + def test_idaho_centroid_kept(self): + event = self._event_with_centroid(TF_LAT, TF_LON) + assert event_in_areas(event, IDAHO_AREAS) is True + + def test_la_centroid_dropped(self): + """LA-repro: centroid at (34.05, -118.24) must be dropped by Idaho gate.""" + event = self._event_with_centroid(LA_LAT, LA_LON) + assert event_in_areas(event, IDAHO_AREAS) is False + + def test_ca_polygon_dropped(self): + """LA-repro: event carrying a CA polygon must be dropped by Idaho gate.""" + ca_geom = {"type": "Polygon", "coordinates": [CA_POLYGON_COORDS]} + event = self._event_with_geom(ca_geom) + assert event_in_areas(event, IDAHO_AREAS) is False + + def test_idaho_polygon_kept(self): + idaho_geom = { + "type": "Polygon", + "coordinates": [[ + [-115.0, 42.5], [-113.0, 42.5], [-113.0, 43.5], + [-115.0, 43.5], [-115.0, 42.5], + ]], + } + event = self._event_with_geom(idaho_geom) + assert event_in_areas(event, IDAHO_AREAS) is True + + def test_no_geometry_kept_fail_open(self): + """Event with no lat/lon and no data geometry → null-geom → kept.""" + event = make_event( + source="swpc", category="kp_index", severity="priority", + title="No geometry event", + ) + assert event_in_areas(event, IDAHO_AREAS) is True + + def test_empty_areas_kept(self): + event = self._event_with_centroid(LA_LAT, LA_LON) + assert event_in_areas(event, []) is True + + def test_event_with_bbox_in_data(self): + """Event carrying data[bbox] (not geometry) is classified via bbox polygon.""" + event = make_event( + source="firms", category="wildfire_hotspot", severity="priority", + title="Fire", + data={"bbox": [-114.5, 42.5, -113.5, 43.5]}, # Idaho bbox + ) + assert event_in_areas(event, IDAHO_AREAS) is True + + def test_event_with_out_of_area_bbox_in_data(self): + event = make_event( + source="firms", category="wildfire_hotspot", severity="priority", + title="Fire", + data={"bbox": [-119.0, 33.5, -117.5, 34.6]}, # CA bbox + ) + assert event_in_areas(event, IDAHO_AREAS) is False + + +# =========================================================================== +# CoverageFilter — unit tests +# =========================================================================== + +class TestCoverageFilter: + def _make_filter(self, areas=None, enabled=True, excluded=None, received=None): + if received is None: + received = [] + flt = CoverageFilter( + next_handler=received.append, + areas=areas if areas is not None else IDAHO_AREAS, + enabled=enabled, + excluded_adapters=set(excluded or []), + ) + return flt, received + + # -- LA leak repro ------------------------------------------------------- + + def test_la_event_with_polygon_dropped(self): + """LA-repro: event with CA polygon GeoJSON is dropped by Idaho gate.""" + flt, received = self._make_filter() + ca_geom = {"type": "Polygon", "coordinates": [CA_POLYGON_COORDS]} + event = make_event( + source="nws", category="weather_warning", severity="priority", + title="Severe Thunderstorm Warning — Los Angeles County", + lat=LA_LAT, lon=LA_LON, + data={"geometry": ca_geom}, + ) + flt.handle(event) + assert len(received) == 0, "LA event with CA polygon must be dropped" + + def test_la_event_with_centroid_dropped(self): + """LA-repro: event with centroid at LA is dropped by Idaho gate.""" + flt, received = self._make_filter() + event = make_event( + source="nws", category="weather_warning", severity="priority", + title="LA weather alert", lat=LA_LAT, lon=LA_LON, + ) + flt.handle(event) + assert len(received) == 0, "LA centroid event must be dropped" + + # -- Idaho events pass --------------------------------------------------- + + def test_idaho_event_kept(self): + flt, received = self._make_filter() + event = make_event( + source="nws", category="weather_warning", severity="priority", + title="Idaho alert", lat=TF_LAT, lon=TF_LON, + ) + flt.handle(event) + assert len(received) == 1 + + # -- Disabled / no-areas pass-through ------------------------------------ + + def test_coverage_disabled_keeps_everything(self): + flt, received = self._make_filter(enabled=False) + event = make_event( + source="nws", category="weather_warning", severity="priority", + title="LA alert", lat=LA_LAT, lon=LA_LON, + ) + flt.handle(event) + assert len(received) == 1 + + def test_empty_areas_keeps_everything(self): + flt, received = self._make_filter(areas=[]) + event = make_event( + source="nws", category="weather_warning", severity="priority", + title="LA alert", lat=LA_LAT, lon=LA_LON, + ) + flt.handle(event) + assert len(received) == 1 + + # -- Excluded adapter bypass --------------------------------------------- + + def test_excluded_adapter_bypasses_gate(self): + """Events from excluded adapters pass even if out of area.""" + flt, received = self._make_filter(excluded=["swpc"]) + event = make_event( + source="swpc", category="kp_index", severity="priority", + title="Solar event", lat=LA_LAT, lon=LA_LON, + ) + flt.handle(event) + assert len(received) == 1 + + def test_non_excluded_adapter_is_gated(self): + """Events from non-excluded adapters still go through the gate.""" + flt, received = self._make_filter(excluded=["swpc"]) + event = make_event( + source="nws", category="weather_warning", severity="priority", + title="LA alert", lat=LA_LAT, lon=LA_LON, + ) + flt.handle(event) + assert len(received) == 0 + + # -- fail-open ----------------------------------------------------------- + + def test_no_geometry_event_kept_fail_open(self): + """Events with no geometry (null-geom) are kept (fail-open).""" + flt, received = self._make_filter() + event = make_event( + source="swpc", category="kp_index", severity="priority", + title="Solar storm — no location", + ) + flt.handle(event) + assert len(received) == 1 + + # -- multi-area union ---------------------------------------------------- + + def test_multi_area_point_in_second_box_kept(self): + flt, received = self._make_filter(areas=TWO_AREAS) + # A point in the Yellowstone box (not Idaho proper) + event = make_event( + source="nws", category="weather_warning", severity="priority", + title="Yellowstone area alert", lat=44.5, lon=-110.5, + ) + flt.handle(event) + assert len(received) == 1 + + def test_multi_area_point_in_neither_dropped(self): + flt, received = self._make_filter(areas=TWO_AREAS) + # Far eastern Wyoming — outside both boxes + event = make_event( + source="nws", category="weather_warning", severity="priority", + title="Wyoming alert", lat=42.5, lon=-104.0, + ) + flt.handle(event) + assert len(received) == 0 + + +# =========================================================================== +# Pipeline wiring — CoverageFilter is in the chain +# =========================================================================== + +class TestCoverageFilterPipelineWiring: + def test_coverage_filter_wired_in_pipeline(self): + """build_pipeline stashes coverage_filter in _pipeline_components.""" + from unittest.mock import MagicMock, AsyncMock + from meshai.notifications.pipeline import build_pipeline + + config = Coverage.__new__(Coverage) # we need a full Config below + from meshai.config import Config + config = Config() + config.coverage = Coverage( + areas=[{"name": "Idaho", "west": -117.0, "south": 42.0, + "east": -111.0, "north": 44.0}], + enabled=True, + ) + mock_backend = MagicMock() + mock_backend.generate = AsyncMock(return_value="stub") + + bus = build_pipeline(config, mock_backend) + comps = bus._pipeline_components + assert "coverage_filter" in comps + cf = comps["coverage_filter"] + assert hasattr(cf, "handle") + assert isinstance(cf, CoverageFilter) + + def test_coverage_filter_gate_fires_in_pipeline(self): + """Events emitted to the bus from out-of-area are dropped before dispatch.""" + from unittest.mock import MagicMock, AsyncMock + from meshai.notifications.pipeline import build_pipeline + from meshai.config import Config, NotificationToggle + + config = Config() + config.coverage = Coverage( + areas=[{"name": "Idaho", "west": -117.0, "south": 42.0, + "east": -111.0, "north": 44.0}], + enabled=True, + ) + # Enable weather toggle so ToggleFilter passes the event through to + # CoverageFilter (otherwise toggle filter swallows it first) + config.notifications.toggles["weather"].enabled = True + + mock_backend = MagicMock() + mock_backend.generate = AsyncMock(return_value="stub") + + received_by_tee: list = [] + bus = build_pipeline(config, mock_backend) + + # Monkeypatch the coverage_filter's next_handler to observe what reaches _tee + cf = bus._pipeline_components["coverage_filter"] + cf._next = received_by_tee.append # type: ignore[assignment] + + # Emit an out-of-area LA event + la_event = make_event( + source="nws", category="weather_warning", severity="priority", + title="LA Thunderstorm Warning", lat=LA_LAT, lon=LA_LON, + ) + bus.emit(la_event) + assert len(received_by_tee) == 0, "LA event must not reach _tee" + + # Emit an in-area Idaho event + id_event = make_event( + source="nws", category="weather_warning", severity="priority", + title="Idaho Thunderstorm Warning", lat=TF_LAT, lon=TF_LON, + ) + bus.emit(id_event) + assert len(received_by_tee) == 1, "Idaho event must reach _tee"