mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
738ffc71c3
commit
81ade1e323
3 changed files with 195 additions and 1 deletions
15
work/meshai/env/satellite/pass_format.py
vendored
15
work/meshai/env/satellite/pass_format.py
vendored
|
|
@ -373,7 +373,20 @@ def gate_consolidated_pass(consolidated: dict, *,
|
||||||
|
|
||||||
# Prepare data dict with callbacks
|
# Prepare data dict with callbacks
|
||||||
severity_word = _map_severity(max_el)
|
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)
|
_attach_commit(data, event_id=consolidated_id, event_log_row_id=None)
|
||||||
|
|
||||||
return wire, data
|
return wire, data
|
||||||
|
|
|
||||||
|
|
@ -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([25544, "33591"]) == [25544, 33591]
|
assert SatpassAdapter._parse_norad_ids([25544, "33591"]) == [25544, 33591]
|
||||||
assert SatpassAdapter._parse_norad_ids([]) == []
|
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) == []
|
||||||
|
|
|
||||||
|
|
@ -245,3 +245,111 @@ class TestSatpassToggle:
|
||||||
def test_categories_for_toggle_satpass_returns_sat_pass(self):
|
def test_categories_for_toggle_satpass_returns_sat_pass(self):
|
||||||
result = categories_for_toggle("satpass")
|
result = categories_for_toggle("satpass")
|
||||||
assert result == ["sat_pass"]
|
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"}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue