feat(coverage): Shapely geometry gate ported from Central (multi-bbox, set-union) (#68)

Replaces the hand-rolled per-adapter region heuristics (which leaked LA/OR
broadcasts) with Central's proven mechanism: bounding-box(es) + Shapely
full-geometry intersection. Ports MonitoringArea/build_geom_json/
classify_geom_areas from the central repo; adds config.coverage.areas
(multi-box, set-union) and a CoverageFilter that gates every event on
geometry-in-any-area before broadcast. Adapter geometry enrichment (NWS
polygons, fail-closed) follows in the next phase.

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:
malice 2026-07-06 12:27:48 -06:00 committed by GitHub
commit 10564fa5df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 825 additions and 1 deletions

View file

@ -803,11 +803,16 @@ class Coverage:
Empty list means "not configured" adapters fall back to their own scope. 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 enabled: master switch; when False, adapters fall back to their own scope even
if bbox is populated. 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 bbox: list = field(default_factory=list) # [west, south, east, north]; empty = not configured
enabled: bool = True # master switch for deriving adapter scope from bbox 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 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 @dataclass

View file

@ -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"

View file

@ -41,6 +41,8 @@ except ImportError:
from meshai.notifications.pipeline.inhibitor import Inhibitor from meshai.notifications.pipeline.inhibitor import Inhibitor
from meshai.notifications.pipeline.grouper import Grouper from meshai.notifications.pipeline.grouper import Grouper
from meshai.notifications.pipeline.toggle_filter import ToggleFilter 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.digest import DigestAccumulator, Digest
from meshai.notifications.pipeline.scheduler import DigestScheduler from meshai.notifications.pipeline.scheduler import DigestScheduler
@ -111,8 +113,20 @@ def build_pipeline(config, llm_backend, connector=None) -> EventBus:
sorted(enabled_toggles), 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, 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, enabled_toggles=enabled_toggles,
) )
@ -125,6 +139,7 @@ def build_pipeline(config, llm_backend, connector=None) -> EventBus:
"inhibitor": inhibitor, "inhibitor": inhibitor,
"grouper": grouper, "grouper": grouper,
"toggle_filter": toggle_filter, "toggle_filter": toggle_filter,
"coverage_filter": coverage_filter,
"dispatcher": dispatcher, "dispatcher": dispatcher,
"accumulator": accumulator, "accumulator": accumulator,
"connector": connector, "connector": connector,
@ -321,6 +336,7 @@ __all__ = [
"Inhibitor", "Inhibitor",
"Grouper", "Grouper",
"ToggleFilter", "ToggleFilter",
"CoverageFilter",
"DigestAccumulator", "DigestAccumulator",
"Digest", "Digest",
"DigestScheduler", "DigestScheduler",

View file

@ -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,
)

View file

@ -15,3 +15,4 @@ uvicorn[standard]>=0.27.0
aiomqtt>=2.0.0 aiomqtt>=2.0.0
sgp4>=2.22 sgp4>=2.22
tzdata tzdata
shapely>=2.0

View file

@ -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"