feat(phase4c): native SGP4 satpass — standalone satellite passes, no Central (#39)
meshai can now predict + broadcast satellite passes locally without Central.
Data plumbing (4c-1):
- env/tle_fetch.py: keyless Celestrak GP fetcher (GROUP/CATNR, FORMAT=tle) →
upserts the existing sat_tles table via a shared upsert_tle() helper
extracted into tle_handler (Central ingest refactored to call it, unchanged)
- observer_locations table (v23, SCHEMA_VERSION 22->23) + persistence helpers;
seeded from SatpassConfig.observers in main._init_components
- SatpassConfig: observers, tle_groups, norad_ids, tle_refresh_seconds,
min_elevation_deg, window_hours
Predictor + source-agnostic gate (4c-2):
- extracted gate_consolidated_pass(consolidated, *, now) from
consolidate_satpass_pending: dedup-vs-satpass_events + rate cap + format_pass
+ deferred commit. Central path byte-identical (114 tests unchanged)
- env/satpass.py: native adapter predicts passes for each sat x observer via
pass_predictor.compute_passes, consolidates IN-MEMORY per canonical hour
bucket (earliest AOS / latest LOS / max-el observer supplies peak_compass +
entry/exit observers), runs the shared gate, emits sat_pass. Commit rides
event.data so satpass_events dedups across ticks — NO satpass_pending, NO
Central-consumer timer dependency (works with Central off)
- registered in env/store.py gated on enabled and feed_source==native
32 new tests (tle_fetch 16, observer_locations 14... satpass_native 8, minus
overlaps); full suite 10-failure baseline (1672 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:25:55 -06:00
|
|
|
"""Tests for the native SGP4 satpass adapter (env.satpass).
|
|
|
|
|
|
|
|
|
|
The native adapter computes every observer for a satellite in ONE tick, so it
|
|
|
|
|
consolidates in-memory and gates synchronously via the SHARED
|
|
|
|
|
`satpass_handler.gate_consolidated_pass` — with NO `satpass_pending` buffer and
|
|
|
|
|
NO Central consumer/timer. These tests monkeypatch `compute_passes` and
|
|
|
|
|
`get_observers` (no SGP4 / no network) and seed a fresh `sat_tles` row, then
|
|
|
|
|
exercise: multi-observer consolidation, cross-tick dedup via `satpass_events`,
|
|
|
|
|
resilient empty cases, the precomposed aos→peak→los wire, and a guard that the
|
|
|
|
|
Central path still feeds the shared gate the correctly-merged consolidation.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from meshai.env.satpass import SatpassAdapter
|
|
|
|
|
from meshai.config import SatpassConfig
|
|
|
|
|
from meshai.central.pass_predictor import PassInfo
|
|
|
|
|
from meshai.central.tle_handler import upsert_tle
|
|
|
|
|
from meshai.persistence import get_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# A valid ISS TLE so _resolve_tles / get_tle_by_norad has real data to return.
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
# An hour-aligned base so both observers' AOS land in the same hour bucket
|
|
|
|
|
# (same canonical id) and therefore consolidate into ONE pass.
|
|
|
|
|
T0 = (1783000000 // 3600) * 3600 + 100 # aligned + 100s
|
|
|
|
|
|
|
|
|
|
_BOISE = {"slug": "boise", "name": "Boise", "lat": 43.6, "lon": -116.2, "alt_m": 0.0}
|
|
|
|
|
_TWIN = {"slug": "twin", "name": "Twin Falls", "lat": 42.5, "lon": -114.4, "alt_m": 0.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _dt(epoch: int) -> datetime:
|
|
|
|
|
return datetime.fromtimestamp(epoch, tz=timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pass(aos: int, los: int, max_el: float,
|
|
|
|
|
az_aos: float, az_los: float, az_peak: float) -> PassInfo:
|
|
|
|
|
peak = (aos + los) // 2
|
|
|
|
|
return PassInfo(
|
|
|
|
|
aos_time=_dt(aos), los_time=_dt(los), peak_time=_dt(peak),
|
|
|
|
|
max_elevation=max_el,
|
|
|
|
|
azimuth_at_aos=az_aos, azimuth_at_los=az_los, azimuth_at_peak=az_peak,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_iss_tle():
|
|
|
|
|
"""Seed a FRESH ISS TLE into sat_tles so the adapter resolves it."""
|
|
|
|
|
conn = get_db()
|
|
|
|
|
fresh = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
upsert_tle(conn, 25544, "ISS (ZARYA)", ISS_L1, ISS_L2, fresh)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _enable_satpass_db(dry_run=False, max_per_hour=100, norad_ids=None):
|
|
|
|
|
"""Set satpass adapter_config in the test DB (the gate reads it)."""
|
|
|
|
|
from meshai.adapter_config import invalidate_cache
|
|
|
|
|
conn = get_db()
|
|
|
|
|
conn.execute("UPDATE adapter_config SET value_json='true' "
|
|
|
|
|
"WHERE adapter='satpass' AND key='enabled'")
|
|
|
|
|
conn.execute("UPDATE adapter_config SET value_json=? "
|
|
|
|
|
"WHERE adapter='satpass' AND key='dry_run'",
|
|
|
|
|
(json.dumps(dry_run),))
|
|
|
|
|
conn.execute("UPDATE adapter_config SET value_json=? "
|
|
|
|
|
"WHERE adapter='satpass' AND key='max_broadcasts_per_hour'",
|
|
|
|
|
(json.dumps(max_per_hour),))
|
|
|
|
|
if norad_ids is not None:
|
|
|
|
|
conn.execute("UPDATE adapter_config SET value_json=? "
|
|
|
|
|
"WHERE adapter='satpass' AND key='norad_ids'",
|
|
|
|
|
(json.dumps(norad_ids),))
|
|
|
|
|
invalidate_cache()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _adapter(**overrides) -> SatpassAdapter:
|
|
|
|
|
cfg = SatpassConfig(enabled=True, feed_source="native",
|
|
|
|
|
norad_ids=[25544], min_elevation_deg=10.0,
|
|
|
|
|
window_hours=24, **overrides)
|
|
|
|
|
return SatpassAdapter(cfg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _patch_predictor(monkeypatch, fake):
|
|
|
|
|
monkeypatch.setattr("meshai.central.pass_predictor.compute_passes", fake)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _patch_observers(monkeypatch, observers):
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"meshai.persistence.observer_locations.get_observers",
|
|
|
|
|
lambda *a, **k: list(observers))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Two observers, same pass: boise rises first (entry), twin sets last (exit)
|
|
|
|
|
# AND twin has the higher max elevation (so it supplies max_el + peak).
|
|
|
|
|
def _two_observer_pass(l1, l2, lat, lon, alt, window_h, min_el, now):
|
|
|
|
|
if abs(lat - _BOISE["lat"]) < 0.1:
|
|
|
|
|
return [_pass(T0, T0 + 300, 40.0, az_aos=225, az_los=300, az_peak=90)]
|
|
|
|
|
if abs(lat - _TWIN["lat"]) < 0.1:
|
|
|
|
|
return [_pass(T0 + 60, T0 + 400, 70.0, az_aos=270, az_los=45, az_peak=180)]
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 1. MULTI-OBSERVER CONSOLIDATION
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
def test_two_observers_consolidate_to_one_broadcast(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter()
|
|
|
|
|
changed = adapter.tick(now=T0 - 3600) # any now; predictor ignores it
|
|
|
|
|
|
|
|
|
|
assert changed is True
|
|
|
|
|
staged = adapter.get_events()
|
|
|
|
|
assert len(staged) == 1, "two observers, one pass -> ONE consolidated event"
|
|
|
|
|
|
|
|
|
|
evt = staged[0]
|
|
|
|
|
cid = f"25544:{T0 // 3600}"
|
|
|
|
|
assert evt["event_id"] == cid
|
|
|
|
|
|
|
|
|
|
# satpass_events row proves the merge: earliest AOS, latest LOS,
|
|
|
|
|
# max-elevation from the higher observer, both observers recorded.
|
|
|
|
|
row = get_db().execute(
|
|
|
|
|
"SELECT observer, max_elevation, aos_at, los_at FROM satpass_events "
|
|
|
|
|
"WHERE event_id=?", (cid,)).fetchone()
|
|
|
|
|
assert row is not None
|
|
|
|
|
assert row["max_elevation"] == 70.0 # twin (higher)
|
|
|
|
|
assert row["aos_at"] == T0 # boise (earliest AOS)
|
|
|
|
|
assert row["los_at"] == T0 + 400 # twin (latest LOS)
|
|
|
|
|
assert "boise" in row["observer"] and "twin" in row["observer"]
|
|
|
|
|
|
2026-07-06 20:44:02 -06:00
|
|
|
# Wire carries entry->exit region (FRIENDLY names) + aos->peak->los sweep.
|
feat(phase4c): native SGP4 satpass — standalone satellite passes, no Central (#39)
meshai can now predict + broadcast satellite passes locally without Central.
Data plumbing (4c-1):
- env/tle_fetch.py: keyless Celestrak GP fetcher (GROUP/CATNR, FORMAT=tle) →
upserts the existing sat_tles table via a shared upsert_tle() helper
extracted into tle_handler (Central ingest refactored to call it, unchanged)
- observer_locations table (v23, SCHEMA_VERSION 22->23) + persistence helpers;
seeded from SatpassConfig.observers in main._init_components
- SatpassConfig: observers, tle_groups, norad_ids, tle_refresh_seconds,
min_elevation_deg, window_hours
Predictor + source-agnostic gate (4c-2):
- extracted gate_consolidated_pass(consolidated, *, now) from
consolidate_satpass_pending: dedup-vs-satpass_events + rate cap + format_pass
+ deferred commit. Central path byte-identical (114 tests unchanged)
- env/satpass.py: native adapter predicts passes for each sat x observer via
pass_predictor.compute_passes, consolidates IN-MEMORY per canonical hour
bucket (earliest AOS / latest LOS / max-el observer supplies peak_compass +
entry/exit observers), runs the shared gate, emits sat_pass. Commit rides
event.data so satpass_events dedups across ticks — NO satpass_pending, NO
Central-consumer timer dependency (works with Central off)
- registered in env/store.py gated on enabled and feed_source==native
32 new tests (tle_fetch 16, observer_locations 14... satpass_native 8, minus
overlaps); full suite 10-failure baseline (1672 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:25:55 -06:00
|
|
|
wire = evt["wire"]
|
2026-07-06 20:44:02 -06:00
|
|
|
assert "(Boise→Twin Falls)" in wire # friendly entry -> exit
|
feat(phase4c): native SGP4 satpass — standalone satellite passes, no Central (#39)
meshai can now predict + broadcast satellite passes locally without Central.
Data plumbing (4c-1):
- env/tle_fetch.py: keyless Celestrak GP fetcher (GROUP/CATNR, FORMAT=tle) →
upserts the existing sat_tles table via a shared upsert_tle() helper
extracted into tle_handler (Central ingest refactored to call it, unchanged)
- observer_locations table (v23, SCHEMA_VERSION 22->23) + persistence helpers;
seeded from SatpassConfig.observers in main._init_components
- SatpassConfig: observers, tle_groups, norad_ids, tle_refresh_seconds,
min_elevation_deg, window_hours
Predictor + source-agnostic gate (4c-2):
- extracted gate_consolidated_pass(consolidated, *, now) from
consolidate_satpass_pending: dedup-vs-satpass_events + rate cap + format_pass
+ deferred commit. Central path byte-identical (114 tests unchanged)
- env/satpass.py: native adapter predicts passes for each sat x observer via
pass_predictor.compute_passes, consolidates IN-MEMORY per canonical hour
bucket (earliest AOS / latest LOS / max-el observer supplies peak_compass +
entry/exit observers), runs the shared gate, emits sat_pass. Commit rides
event.data so satpass_events dedups across ticks — NO satpass_pending, NO
Central-consumer timer dependency (works with Central off)
- registered in env/store.py gated on enabled and feed_source==native
32 new tests (tle_fetch 16, observer_locations 14... satpass_native 8, minus
overlaps); full suite 10-failure baseline (1672 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:25:55 -06:00
|
|
|
assert "SW→S→NE" in wire # aos -> peak(twin) -> los
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 2. CROSS-TICK DEDUP (satpass_events remembers after commit)
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
def test_second_tick_does_not_rebroadcast_after_commit(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter()
|
|
|
|
|
|
|
|
|
|
# Tick 1: stages the pass. Simulate a successful mesh send by firing the
|
|
|
|
|
# commit closure the gate attached (this is what the dispatcher does).
|
|
|
|
|
assert adapter.tick(now=T0 - 3600) is True
|
|
|
|
|
staged = adapter.get_events()
|
|
|
|
|
assert len(staged) == 1
|
|
|
|
|
commit = staged[0]["data"]["_on_broadcast_committed"]
|
|
|
|
|
commit(float(T0)) # marks satpass_events.last_broadcast_at
|
|
|
|
|
|
2026-07-05 14:38:40 -06:00
|
|
|
# Tick 2 (interval elapsed): the pass is STILL imminent (AOS 30 min out,
|
|
|
|
|
# inside the 60-min lead) so the imminence gate would stage it — but the
|
|
|
|
|
# satpass_events commit from tick 1 must suppress the re-broadcast.
|
|
|
|
|
assert adapter.tick(now=T0 - 1800) is False
|
feat(phase4c): native SGP4 satpass — standalone satellite passes, no Central (#39)
meshai can now predict + broadcast satellite passes locally without Central.
Data plumbing (4c-1):
- env/tle_fetch.py: keyless Celestrak GP fetcher (GROUP/CATNR, FORMAT=tle) →
upserts the existing sat_tles table via a shared upsert_tle() helper
extracted into tle_handler (Central ingest refactored to call it, unchanged)
- observer_locations table (v23, SCHEMA_VERSION 22->23) + persistence helpers;
seeded from SatpassConfig.observers in main._init_components
- SatpassConfig: observers, tle_groups, norad_ids, tle_refresh_seconds,
min_elevation_deg, window_hours
Predictor + source-agnostic gate (4c-2):
- extracted gate_consolidated_pass(consolidated, *, now) from
consolidate_satpass_pending: dedup-vs-satpass_events + rate cap + format_pass
+ deferred commit. Central path byte-identical (114 tests unchanged)
- env/satpass.py: native adapter predicts passes for each sat x observer via
pass_predictor.compute_passes, consolidates IN-MEMORY per canonical hour
bucket (earliest AOS / latest LOS / max-el observer supplies peak_compass +
entry/exit observers), runs the shared gate, emits sat_pass. Commit rides
event.data so satpass_events dedups across ticks — NO satpass_pending, NO
Central-consumer timer dependency (works with Central off)
- registered in env/store.py gated on enabled and feed_source==native
32 new tests (tle_fetch 16, observer_locations 14... satpass_native 8, minus
overlaps); full suite 10-failure baseline (1672 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:25:55 -06:00
|
|
|
assert adapter.get_events() == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_second_tick_without_commit_is_not_deduped(monkeypatch):
|
|
|
|
|
# Guard the semantics: dedup persists ONLY after the broadcast commits.
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter()
|
|
|
|
|
assert adapter.tick(now=T0 - 3600) is True
|
2026-07-05 14:38:40 -06:00
|
|
|
# No commit fired -> last_broadcast_at still NULL. The pass is still
|
|
|
|
|
# imminent at T0-1800 (AOS 30 min out) so it re-stages next tick.
|
|
|
|
|
assert adapter.tick(now=T0 - 1800) is True
|
feat(phase4c): native SGP4 satpass — standalone satellite passes, no Central (#39)
meshai can now predict + broadcast satellite passes locally without Central.
Data plumbing (4c-1):
- env/tle_fetch.py: keyless Celestrak GP fetcher (GROUP/CATNR, FORMAT=tle) →
upserts the existing sat_tles table via a shared upsert_tle() helper
extracted into tle_handler (Central ingest refactored to call it, unchanged)
- observer_locations table (v23, SCHEMA_VERSION 22->23) + persistence helpers;
seeded from SatpassConfig.observers in main._init_components
- SatpassConfig: observers, tle_groups, norad_ids, tle_refresh_seconds,
min_elevation_deg, window_hours
Predictor + source-agnostic gate (4c-2):
- extracted gate_consolidated_pass(consolidated, *, now) from
consolidate_satpass_pending: dedup-vs-satpass_events + rate cap + format_pass
+ deferred commit. Central path byte-identical (114 tests unchanged)
- env/satpass.py: native adapter predicts passes for each sat x observer via
pass_predictor.compute_passes, consolidates IN-MEMORY per canonical hour
bucket (earliest AOS / latest LOS / max-el observer supplies peak_compass +
entry/exit observers), runs the shared gate, emits sat_pass. Commit rides
event.data so satpass_events dedups across ticks — NO satpass_pending, NO
Central-consumer timer dependency (works with Central off)
- registered in env/store.py gated on enabled and feed_source==native
32 new tests (tle_fetch 16, observer_locations 14... satpass_native 8, minus
overlaps); full suite 10-failure baseline (1672 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:25:55 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 3. RESILIENT EMPTY CASES (never crash, yield nothing)
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
def test_no_observers_yields_nothing(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter()
|
|
|
|
|
assert adapter.tick(now=T0) is False
|
|
|
|
|
assert adapter.get_events() == []
|
|
|
|
|
assert adapter.health_status["is_loaded"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_fresh_tles_yields_nothing(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
# Do NOT seed sat_tles -> get_tle_by_norad returns None.
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter()
|
|
|
|
|
assert adapter.tick(now=T0) is False
|
|
|
|
|
assert adapter.get_events() == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_below_min_elevation_yields_nothing(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
# Predictor filters by min_el internally; a too-low pass => empty list.
|
|
|
|
|
_patch_predictor(monkeypatch, lambda *a, **k: [])
|
|
|
|
|
|
|
|
|
|
adapter = _adapter()
|
|
|
|
|
assert adapter.tick(now=T0) is False
|
|
|
|
|
assert adapter.get_events() == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 4. PRECOMPOSED WIRE renders aos->peak->los through format_pass
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
def test_emitted_event_renders_aos_peak_los(monkeypatch):
|
|
|
|
|
from meshai.notifications.renderers.composer import compose_mesh_message
|
|
|
|
|
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter()
|
|
|
|
|
adapter.tick(now=T0 - 3600)
|
|
|
|
|
evt = adapter.get_events()[0]
|
|
|
|
|
|
|
|
|
|
event = adapter.to_event(evt)
|
|
|
|
|
assert event is not None
|
|
|
|
|
assert event.category == "sat_pass"
|
|
|
|
|
assert event.severity == "immediate" # max_el 70 -> immediate
|
|
|
|
|
assert event.data.get("_meshai_precomposed") is True
|
|
|
|
|
assert callable(event.data.get("_on_broadcast_committed"))
|
|
|
|
|
|
|
|
|
|
# Precomposed: composer returns the wire verbatim, with the peak point
|
|
|
|
|
# rendered between aos and los.
|
|
|
|
|
composed = compose_mesh_message(event)
|
|
|
|
|
assert composed == evt["wire"]
|
|
|
|
|
assert "SW→S→NE" in composed # aos -> peak -> los
|
|
|
|
|
assert composed.startswith("\U0001F6F0") # satellite emoji
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 5. SHARED-GATE EXTRACTION GUARD (Central path still feeds the gate right)
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
def test_central_consolidate_feeds_shared_gate_merged(monkeypatch):
|
|
|
|
|
"""consolidate_satpass_pending must merge observers and hand the SAME
|
|
|
|
|
shared gate a correctly-consolidated dict (earliest AOS / latest LOS /
|
|
|
|
|
max-el observer / entry+exit / observer_list). Spying the gate proves the
|
|
|
|
|
Central path routes through the extracted, source-agnostic function."""
|
|
|
|
|
import meshai.central.satpass_handler as sh
|
|
|
|
|
|
|
|
|
|
conn = get_db()
|
|
|
|
|
cid = f"25544:{T0 // 3600}"
|
|
|
|
|
# Two pending rows for the same canonical id (boise entry, twin exit+peak).
|
|
|
|
|
rows = [
|
|
|
|
|
(cid, "boise", "ISS", 25544, 40.0, T0, T0 + 300, "SW", "NW", "E", T0, T0 + 5),
|
|
|
|
|
(cid, "twin", "ISS", 25544, 70.0, T0 + 60, T0 + 400, "W", "NE", "S", T0, T0 + 5),
|
|
|
|
|
]
|
|
|
|
|
for r in rows:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO satpass_pending("
|
|
|
|
|
"consolidated_id, observer, sat_name, norad_id, max_elevation, "
|
|
|
|
|
"aos_at, los_at, aos_compass, los_compass, peak_compass, "
|
|
|
|
|
"received_at, due_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", r)
|
|
|
|
|
|
|
|
|
|
captured = {}
|
|
|
|
|
|
|
|
|
|
def _spy(consolidated, *, now):
|
|
|
|
|
captured["c"] = consolidated
|
|
|
|
|
captured["now"] = now
|
|
|
|
|
return None # short-circuit: no broadcast side effects
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(sh, "gate_consolidated_pass", _spy)
|
|
|
|
|
|
|
|
|
|
result = sh.consolidate_satpass_pending(cid)
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
c = captured["c"]
|
|
|
|
|
assert c["consolidated_id"] == cid
|
|
|
|
|
assert c["norad_id"] == 25544
|
|
|
|
|
assert c["max_elevation"] == 70.0 # twin (higher)
|
|
|
|
|
assert c["aos_epoch"] == T0 # boise earliest AOS
|
|
|
|
|
assert c["los_epoch"] == T0 + 400 # twin latest LOS
|
|
|
|
|
assert c["aos_compass"] == "SW" # boise
|
|
|
|
|
assert c["los_compass"] == "NE" # twin
|
|
|
|
|
assert c["peak_compass"] == "S" # twin (max-el observer)
|
|
|
|
|
assert c["entry_observer"] == "boise"
|
|
|
|
|
assert c["exit_observer"] == "twin"
|
|
|
|
|
assert c["observer_list"] == "boise,twin"
|
|
|
|
|
|
|
|
|
|
# Pending buffer is drained regardless of the gate's decision.
|
|
|
|
|
left = conn.execute(
|
|
|
|
|
"SELECT COUNT(*) AS n FROM satpass_pending WHERE consolidated_id=?",
|
|
|
|
|
(cid,)).fetchone()
|
|
|
|
|
assert left["n"] == 0
|
2026-07-05 14:38:40 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 6. IMMINENCE BROADCAST TRIGGER (satpass's "just received" analog)
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
#
|
|
|
|
|
# Satpass is NOT an API-delta feed — it recomputes the SAME future passes
|
|
|
|
|
# every poll. Its "newly received" signal is IMMINENCE: a pass is staged for
|
|
|
|
|
# broadcast only once its AOS is both in the FUTURE and within the near-term
|
|
|
|
|
# lead window (default 60 min). A far-future pass is predicted (on schedule)
|
|
|
|
|
# but not staged; a past AOS is never staged. Combined with the store's
|
|
|
|
|
# received-delta seen-set, each pass then emits exactly once as it crosses in.
|
|
|
|
|
|
|
|
|
|
# The fixed mock pass sits at AOS=T0 (both observers consolidate to one).
|
|
|
|
|
|
|
|
|
|
def test_far_future_pass_not_staged(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter() # default broadcast_lead_seconds = 3600 (60 min)
|
|
|
|
|
# AOS=T0 is 4 hours ahead of now -> well outside the 60-min lead window.
|
|
|
|
|
assert adapter.tick(now=T0 - 4 * 3600) is False
|
|
|
|
|
assert adapter.get_events() == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_imminent_pass_is_staged(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter()
|
|
|
|
|
# AOS=T0 is 30 min ahead of now -> inside the 60-min lead window.
|
|
|
|
|
assert adapter.tick(now=T0 - 1800) is True
|
|
|
|
|
staged = adapter.get_events()
|
|
|
|
|
assert len(staged) == 1
|
|
|
|
|
assert staged[0]["event_id"] == f"25544:{T0 // 3600}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_past_aos_never_staged(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter()
|
|
|
|
|
# now is 10 min AFTER AOS=T0 -> the pass is in the past, never staged.
|
|
|
|
|
assert adapter.tick(now=T0 + 600) is False
|
|
|
|
|
assert adapter.get_events() == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_configurable_lead_window(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle()
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
# With a 3-hour lead, an AOS 2 hours out is now imminent.
|
|
|
|
|
adapter = _adapter(broadcast_lead_seconds=3 * 3600)
|
|
|
|
|
assert adapter.tick(now=T0 - 2 * 3600) is True
|
|
|
|
|
assert len(adapter.get_events()) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 7. NORAD_IDS FILTER IS STRICT (non-listed sats never predicted)
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
# A non-ISS TLE (a stand-in for a GOES/METEOR/FENGYUN weather sat the fetcher
|
|
|
|
|
# stocks via tle_groups). Different NORAD so a filter break would surface as a
|
|
|
|
|
# second consolidated pass with a different canonical id.
|
|
|
|
|
OTHER_L1 = "1 43226U 18022A 26182.50000000 .00000000 00000-0 00000-0 0 9999"
|
|
|
|
|
OTHER_L2 = "2 43226 0.0300 100.0000 0001000 90.0000 270.0000 1.00270000 12345"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_other_tle():
|
|
|
|
|
conn = get_db()
|
|
|
|
|
fresh = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
upsert_tle(conn, 43226, "GOES-17", OTHER_L1, OTHER_L2, fresh)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_norad_ids_filter_excludes_non_listed_sats(monkeypatch):
|
|
|
|
|
_enable_satpass_db(dry_run=False)
|
|
|
|
|
_seed_iss_tle() # 25544 — configured
|
|
|
|
|
_seed_other_tle() # 43226 — fresh but NOT in norad_ids
|
|
|
|
|
_patch_observers(monkeypatch, [_BOISE, _TWIN])
|
|
|
|
|
# Predictor would return a pass for ANY satellite (keyed on observer lat),
|
|
|
|
|
# so if 43226 leaked through it would produce a second staged pass.
|
|
|
|
|
_patch_predictor(monkeypatch, _two_observer_pass)
|
|
|
|
|
|
|
|
|
|
adapter = _adapter() # norad_ids=[25544]
|
|
|
|
|
assert adapter.tick(now=T0 - 1800) is True
|
|
|
|
|
staged = adapter.get_events()
|
|
|
|
|
# Only the configured NORAD is predicted, despite 43226 being fresh.
|
|
|
|
|
assert len(staged) == 1
|
|
|
|
|
assert staged[0]["norad_id"] == 25544
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_norad_ids_handles_comma_string():
|
|
|
|
|
# A GUI-persisted comma string must NOT be char-iterated into garbage ids.
|
|
|
|
|
assert SatpassAdapter._parse_norad_ids("25544, 33591") == [25544, 33591]
|
|
|
|
|
assert SatpassAdapter._parse_norad_ids([25544, "33591"]) == [25544, 33591]
|
|
|
|
|
assert SatpassAdapter._parse_norad_ids([]) == []
|