From 81ade1e32393390c511f3252f58a7a2ac1bd549c Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Sun, 16 Aug 2026 01:20:12 +0000 Subject: [PATCH] fix(satpass): region-tag satpass events via observer_list Satpass events carry no lat/lon/geometry, so CoverageFilter had no way to region-tag them for region_routes matching. gate_consolidated_pass() now attaches observer_list (comma-joined observer slugs, same shape already computed for the audit column) into the event data, which coverage_area.observer_region_names() reads. Defensive/fail-open: an empty observer_list yields no region tag rather than raising. --- work/meshai/env/satellite/pass_format.py | 15 ++- work/tests/test_satpass_native.py | 73 +++++++++++++++ work/tests/test_satpass_region_tagging.py | 108 ++++++++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) diff --git a/work/meshai/env/satellite/pass_format.py b/work/meshai/env/satellite/pass_format.py index 12900f6..1fb3fed 100644 --- a/work/meshai/env/satellite/pass_format.py +++ b/work/meshai/env/satellite/pass_format.py @@ -373,7 +373,20 @@ def gate_consolidated_pass(consolidated: dict, *, # Prepare data dict with callbacks severity_word = _map_severity(max_el) - data = {"_meshai_precomposed": True, "_severity_override": severity_word} + data = { + "_meshai_precomposed": True, + "_severity_override": severity_word, + # Region tagging: coverage_area.observer_region_names() reads + # event.data["observer_list"] as a comma-joined string of observer + # slugs (the same shape already computed above for the audit + # column). Satpass events carry no lat/lon/geometry, so this is the + # ONLY path that lets CoverageFilter region-tag a satpass event for + # region_routes matching. `observer_list` is already defensive + # (falls back to entry_obs, or "") so this never raises; an empty + # string is fail-open in observer_region_names() (-> no region tag, + # not an exception). + "observer_list": observer_list, + } _attach_commit(data, event_id=consolidated_id, event_log_row_id=None) return wire, data diff --git a/work/tests/test_satpass_native.py b/work/tests/test_satpass_native.py index 8a35ef1..63bacb1 100644 --- a/work/tests/test_satpass_native.py +++ b/work/tests/test_satpass_native.py @@ -354,3 +354,76 @@ def test_parse_norad_ids_handles_comma_string(): assert SatpassAdapter._parse_norad_ids("25544, 33591") == [25544, 33591] assert SatpassAdapter._parse_norad_ids([25544, "33591"]) == [25544, 33591] assert SatpassAdapter._parse_norad_ids([]) == [] + + +# ══════════════════════════════════════════════════════════════════════ +# 8. observer_list FLOWS INTO THE GATE'S data DICT (region-tagging fix) +# ══════════════════════════════════════════════════════════════════════ +# +# gate_consolidated_pass() builds the event `data` dict that rides all the +# way to the dispatcher via to_event(). Satpass events carry no lat/lon/ +# geometry, so coverage_area.observer_region_names() reading +# event.data["observer_list"] is the ONLY path that can region-tag a +# satpass event for the region_routes matrix. These tests pin that the +# gate actually populates it (and stays safe when it can't). + +def test_gate_consolidated_pass_data_contains_observer_list(): + """The (wire, data) tuple returned by gate_consolidated_pass() must + carry the same comma-joined observer_list already written to the + satpass_events audit column.""" + _enable_satpass_db(dry_run=False) + from meshai.env.satellite import pass_format as sh + + consolidated = { + "consolidated_id": "25544:900000", + "norad_id": 25544, + "sat_name": "ISS (ZARYA)", + "max_elevation": 70.0, + "aos_epoch": 1_000_000, + "los_epoch": 1_000_300, + "aos_compass": "SW", + "los_compass": "NE", + "peak_compass": "S", + "entry_observer": "Boise", + "exit_observer": "Twin Falls", + "observer_list": "boise,twin", + } + result = sh.gate_consolidated_pass(consolidated, now=0) + assert result is not None + _, data = result + assert data["observer_list"] == "boise,twin" + + +def test_gate_consolidated_pass_missing_observer_list_is_defensive(): + """A `consolidated` dict with no observer_list must not raise, and the + resulting data["observer_list"] must be falsy (never a garbage value + that would resolve to a bogus region).""" + _enable_satpass_db(dry_run=False) + from meshai.env.satellite import pass_format as sh + + consolidated = { + "consolidated_id": "25544:900001", + "norad_id": 25544, + "sat_name": "ISS (ZARYA)", + "max_elevation": 40.0, + "aos_epoch": 2_000_000, + "los_epoch": 2_000_300, + "aos_compass": "SW", + "los_compass": "NE", + "peak_compass": "S", + # entry_observer / exit_observer / observer_list all deliberately absent + } + result = sh.gate_consolidated_pass(consolidated, now=0) + assert result is not None + _, data = result + assert not data.get("observer_list") + + # And observer_region_names() must treat that as "no region", not raise. + from meshai.coverage_area import MonitoringArea, observer_region_names + from meshai.notifications.events import make_event + + event = make_event(source="satpass", category="sat_pass", severity="routine", + title="Pass", data=data) + areas = [MonitoringArea(north=44.0, south=42.0, east=-113.0, west=-117.0, + name="SW Idaho")] + assert observer_region_names(event, areas) == [] diff --git a/work/tests/test_satpass_region_tagging.py b/work/tests/test_satpass_region_tagging.py index db48d73..ff885df 100644 --- a/work/tests/test_satpass_region_tagging.py +++ b/work/tests/test_satpass_region_tagging.py @@ -245,3 +245,111 @@ class TestSatpassToggle: def test_categories_for_toggle_satpass_returns_sat_pass(self): result = categories_for_toggle("satpass") assert result == ["sat_pass"] + + +# --------------------------------------------------------------------------- +# End-to-end: a satpass Event built through the REAL native-adapter path +# (predict -> consolidate -> gate_consolidated_pass -> to_event) must +# region-tag via event_region_names(), using the two production observers. +# This is the regression test for the observer_list wiring bug: before the +# fix, gate_consolidated_pass()'s data dict never carried observer_list, so +# this resolved to [] no matter how the areas were configured. +# --------------------------------------------------------------------------- + +# Production ground stations (see adapter_config observers): Treasure Valley +# (Boise area) and Magic Valley (Twin Falls area). +_TREASURE_VALLEY = {"slug": "treasure_valley", "name": "Treasure Valley", + "lat": 43.6, "lon": -116.2, "alt_m": 0.0} +_MAGIC_VALLEY = {"slug": "magic_valley", "name": "Magic Valley", + "lat": 42.5558, "lon": -114.4701, "alt_m": 0.0} + +# Coarse named boxes that cover each observer, standing in for the real +# region_routes cells ("SW Idaho" covers the Treasure Valley/Boise area, +# "SC Idaho" covers the Magic Valley/Twin Falls area). +_SW_IDAHO = MonitoringArea(north=44.5, south=42.8, east=-115.0, west=-117.5, + name="SW Idaho") +_SC_IDAHO_PROD = MonitoringArea(north=43.2, south=42.0, east=-113.0, west=-115.2, + name="SC Idaho") +_PROD_AREAS = [_SW_IDAHO, _SC_IDAHO_PROD] + + +class TestSatpassEndToEndRegionTagging: + def test_real_gate_path_region_tags_nonempty(self, monkeypatch): + """Drive the actual SatpassAdapter tick -> gate -> to_event path + (no shortcuts through gate_consolidated_pass or make_event) with the + two production observer coordinates, then confirm event_region_names + returns a non-empty list built from event.data['observer_list'].""" + import json as _json + from datetime import datetime, timezone + + from meshai.adapter_config import invalidate_cache + from meshai.config import SatpassConfig + from meshai.env.satellite.pass_predictor import PassInfo + from meshai.env.satellite.tle_store import upsert_tle + from meshai.env.satpass import SatpassAdapter + from meshai.persistence import get_db + + # -- seed a fresh ISS TLE -------------------------------------------------- + conn = get_db() + fresh = datetime.now(timezone.utc).isoformat() + iss_l1 = "1 25544U 98067A 26182.50000000 .00016717 00000-0 10270-3 0 9008" + iss_l2 = "2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12345" + upsert_tle(conn, 25544, "ISS (ZARYA)", iss_l1, iss_l2, fresh) + + # -- enable satpass, dry_run off so the gate actually returns data -------- + conn.execute("UPDATE adapter_config SET value_json='true' " + "WHERE adapter='satpass' AND key='enabled'") + conn.execute("UPDATE adapter_config SET value_json='false' " + "WHERE adapter='satpass' AND key='dry_run'") + conn.execute("UPDATE adapter_config SET value_json=? " + "WHERE adapter='satpass' AND key='max_broadcasts_per_hour'", + (_json.dumps(100),)) + invalidate_cache() + + # -- patch observers + predictor ------------------------------------------- + monkeypatch.setattr( + "meshai.persistence.observer_locations.get_observers", + lambda *a, **k: [_TREASURE_VALLEY, _MAGIC_VALLEY]) + + t0 = (1783000000 // 3600) * 3600 + 100 + + def _fake_compute_passes(l1, l2, lat, lon, alt, window_h, min_el, now): + def _pi(aos, los, max_el, az_aos, az_los, az_peak): + peak = (aos + los) // 2 + return PassInfo( + aos_time=datetime.fromtimestamp(aos, tz=timezone.utc), + los_time=datetime.fromtimestamp(los, tz=timezone.utc), + peak_time=datetime.fromtimestamp(peak, tz=timezone.utc), + max_elevation=max_el, + azimuth_at_aos=az_aos, azimuth_at_los=az_los, + azimuth_at_peak=az_peak) + if abs(lat - _TREASURE_VALLEY["lat"]) < 0.1: + return [_pi(t0, t0 + 300, 40.0, 225, 300, 90)] + if abs(lat - _MAGIC_VALLEY["lat"]) < 0.1: + return [_pi(t0 + 60, t0 + 400, 70.0, 270, 45, 180)] + return [] + + monkeypatch.setattr( + "meshai.env.satellite.pass_predictor.compute_passes", + _fake_compute_passes) + + cfg = SatpassConfig(enabled=True, feed_source="native", + norad_ids=[25544], min_elevation_deg=10.0, + window_hours=24) + adapter = SatpassAdapter(cfg) + assert adapter.tick(now=t0 - 1800) is True # AOS 30 min out -> imminent + + staged = adapter.get_events() + assert len(staged) == 1 + event = adapter.to_event(staged[0]) + assert event is not None + + # The bug: before the fix, data["observer_list"] was never set, so + # this was always [] regardless of area config. + assert event.data.get("observer_list"), ( + "gate_consolidated_pass() did not populate observer_list on the " + "event data dict") + + names = event_region_names(event, _PROD_AREAS) + assert names, "satpass event failed to region-tag via observer_list" + assert set(names) == {"SW Idaho", "SC Idaho"}