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.
This commit is contained in:
Matt Johnson 2026-08-16 01:20:12 +00:00
commit 81ade1e323
3 changed files with 195 additions and 1 deletions

View file

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

View file

@ -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) == []

View file

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