meshai/work/tests/test_satpass_native.py

429 lines
19 KiB
Python
Raw Permalink Normal View History

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
chore(central-ripout 2b): relocate satellite code to env/satellite/ (#162) * chore(central-ripout 2b): create env/satellite package, move pass_predictor pass_predictor.py was 100% live (no dead entrypoint) — SGP4 pass computation used by both the native satpass adapter and the on-demand !satpass command. Straight move, no code changes: meshai.central.pass_predictor -> meshai.env.satellite.pass_predictor. Owner directive: satellite code gets its own folder under the feed adapters, separate from env.satpass (the adapter) to avoid colliding with env/satpass.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): split satpass_handler.py -> env/satellite/pass_format.py satpass_handler.py was a split file: live wire-formatting/gate logic plus dead Central-envelope ingest machinery whose only caller was the already-deleted central/consumer.py NATS bridge. Moved (live, verified via rg — external callers in env/satpass.py and commands/satpass_cmd.py, or transitively called by them): gate_consolidated_pass, format_pass, _check_rate_cap, _upsert_satpass, _attach_commit, _map_severity, _canonical_id, _azimuth_to_compass, _short_sat_name, _collapse_compass, _region_paren, _is_synthetic_observer, _format_time_12h/24h, _format_ampm, _tz_abbr, _date_label, plus the _SHORT_SAT_NAMES/_SHORT_NAME_SUBSTR/_SYNTHETIC_OBSERVERS tables. Dropped (dead — zero callers outside the already-deleted consumer.py and handle_satpass/consolidate_satpass_pending themselves; verified with rg): handle_satpass, consolidate_satpass_pending, _cleanup_pending, load_pending_schedule, _log_event_returning_id, _coerce_float, _coerce_int, _parse_iso_epoch, _now, CONSOLIDATION_DELAY, _pending_consolidation_ids, drain_pending_consolidation_ids, _elevation_bucket (already-orphaned pre-ripout: superseded by numeric "max NN°" wire format, zero callers anywhere but its own tests), SCHEMA_SATPASS_EVENTS/SCHEMA_SATPASS_PENDING (unused string constants — actual schema lives in persistence/migrations/*.sql, never imported). Also dropped now-unused `json`/`time`/`Any` imports. Straight code move otherwise — no logic changes to any moved function. Two docstrings updated for accuracy (module docstring, and gate_consolidated_pass's docstring which referenced the now-deleted Central consumer path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): split tle_handler.py -> env/satellite/tle_store.py tle_handler.py was a split file: live storage helpers plus a dead Central-envelope ingest entrypoint whose only caller was the already-deleted central/consumer.py NATS bridge. Moved (live — used by env.tle_fetch, env.satpass, commands.satpass_cmd, verified via rg): upsert_tle, get_fresh_tles, get_tle_by_norad, search_tle_by_name. Dropped (dead — handle_tle's only callers were tests and the deleted consumer.py; verified with rg): handle_tle. Straight code move otherwise — no logic changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): repoint satellite consumers at env.satellite Rewire the three production consumers (including their lazy/function-body imports, not just module-top ones) to the new location: - env/satpass.py: central.tle_handler -> env.satellite.tle_store, central.pass_predictor -> env.satellite.pass_predictor, central.satpass_handler -> env.satellite.pass_format - env/tle_fetch.py: central.tle_handler.upsert_tle -> env.satellite.tle_store - commands/satpass_cmd.py: all three, same mapping Also refreshed docstrings that pointed at the old module paths or described the now-fully-deleted Central consolidation path (consolidate_satpass_pending / satpass_pending buffer) as a live alternative, and updated central/__init__.py's module docstring to stop listing the three relocated modules among central's remaining contents. No behavior changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): update satpass/tle test suite for the relocation Repoints every remaining test import at the new env.satellite.* modules and removes/adapts coverage for the Central envelope-ingest path deleted in this pass (handle_satpass, consolidate_satpass_pending, handle_tle, and the filtering/coercion/staleness logic that lived only inside them): - test_satpass_native.py, test_tle_fetch.py, test_satpass_command.py: import-path updates only (pass_predictor, tle_store). Also dropped test_satpass_command.py's TestTLEUpsert.test_returns_none_always (handle_tle-specific contract, no longer applicable) and rewrote its two latest-wins tests to call upsert_tle directly — same behavior under test, now exercised through the still-live primitive instead of the dead wrapper. - test_satpass_native.py: deleted test_central_consolidate_feeds_shared_gate_merged (spied on consolidate_satpass_pending, which no longer exists). The merge-across-observers logic it guarded is native-side (_consolidate) and already covered by test_two_observers_consolidate_to_one_broadcast. - test_satpass_handler.py: gutted to the one test that calls format_pass directly (test_format_pass_worst_case_fits_140); the rest exercised handle_satpass's observer/norad/elevation filters, which have no live equivalent (the native adapter filters at the config level, not per-envelope) and is redundant with test_satpass_native.py's dedup/wire coverage via the real SatpassAdapter path. - test_satpass_broadcast_safety.py: kept every test that calls format_pass or gate_consolidated_pass-adjacent REGISTRY checks directly (wire format, clean-format rules, REGISTRY defaults); deleted TestNoradIdTypeCoercion and TestStalenessGuard (handle_satpass-only logic, no live equivalent) and the 6 _elevation_bucket tests (_elevation_bucket itself was dead before this pass too — zero callers anywhere but its own tests, already superseded by the numeric "max NN°" wire format per its own docstring). - test_satpass_persisted_timer.py: dropped test_due_at_persisted_on_normal_ingest (handle_satpass-only); kept the two schema/migration tests, which don't touch satpass_handler. - Deleted outright (tested ONLY the dead Central envelope-ingest path, no live equivalent to port to): test_satpass_event_path.py, test_satpass_compass_fallback.py, test_satpass_wire_fields.py. Full suite: 2059 passed, 0 failed (was 0 failed on main pre-change). Satpass/TLE subset (99 tests across 8 files) verified green in isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:46:36 -06:00
consolidates in-memory and gates synchronously via
`env.satellite.pass_format.gate_consolidated_pass` with no buffer table and
no consumer/timer. These tests monkeypatch `compute_passes` and
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
`get_observers` (no SGP4 / no network) and seed a fresh `sat_tles` row, then
exercise: multi-observer consolidation, cross-tick dedup via `satpass_events`,
chore(central-ripout 2b): relocate satellite code to env/satellite/ (#162) * chore(central-ripout 2b): create env/satellite package, move pass_predictor pass_predictor.py was 100% live (no dead entrypoint) — SGP4 pass computation used by both the native satpass adapter and the on-demand !satpass command. Straight move, no code changes: meshai.central.pass_predictor -> meshai.env.satellite.pass_predictor. Owner directive: satellite code gets its own folder under the feed adapters, separate from env.satpass (the adapter) to avoid colliding with env/satpass.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): split satpass_handler.py -> env/satellite/pass_format.py satpass_handler.py was a split file: live wire-formatting/gate logic plus dead Central-envelope ingest machinery whose only caller was the already-deleted central/consumer.py NATS bridge. Moved (live, verified via rg — external callers in env/satpass.py and commands/satpass_cmd.py, or transitively called by them): gate_consolidated_pass, format_pass, _check_rate_cap, _upsert_satpass, _attach_commit, _map_severity, _canonical_id, _azimuth_to_compass, _short_sat_name, _collapse_compass, _region_paren, _is_synthetic_observer, _format_time_12h/24h, _format_ampm, _tz_abbr, _date_label, plus the _SHORT_SAT_NAMES/_SHORT_NAME_SUBSTR/_SYNTHETIC_OBSERVERS tables. Dropped (dead — zero callers outside the already-deleted consumer.py and handle_satpass/consolidate_satpass_pending themselves; verified with rg): handle_satpass, consolidate_satpass_pending, _cleanup_pending, load_pending_schedule, _log_event_returning_id, _coerce_float, _coerce_int, _parse_iso_epoch, _now, CONSOLIDATION_DELAY, _pending_consolidation_ids, drain_pending_consolidation_ids, _elevation_bucket (already-orphaned pre-ripout: superseded by numeric "max NN°" wire format, zero callers anywhere but its own tests), SCHEMA_SATPASS_EVENTS/SCHEMA_SATPASS_PENDING (unused string constants — actual schema lives in persistence/migrations/*.sql, never imported). Also dropped now-unused `json`/`time`/`Any` imports. Straight code move otherwise — no logic changes to any moved function. Two docstrings updated for accuracy (module docstring, and gate_consolidated_pass's docstring which referenced the now-deleted Central consumer path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): split tle_handler.py -> env/satellite/tle_store.py tle_handler.py was a split file: live storage helpers plus a dead Central-envelope ingest entrypoint whose only caller was the already-deleted central/consumer.py NATS bridge. Moved (live — used by env.tle_fetch, env.satpass, commands.satpass_cmd, verified via rg): upsert_tle, get_fresh_tles, get_tle_by_norad, search_tle_by_name. Dropped (dead — handle_tle's only callers were tests and the deleted consumer.py; verified with rg): handle_tle. Straight code move otherwise — no logic changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): repoint satellite consumers at env.satellite Rewire the three production consumers (including their lazy/function-body imports, not just module-top ones) to the new location: - env/satpass.py: central.tle_handler -> env.satellite.tle_store, central.pass_predictor -> env.satellite.pass_predictor, central.satpass_handler -> env.satellite.pass_format - env/tle_fetch.py: central.tle_handler.upsert_tle -> env.satellite.tle_store - commands/satpass_cmd.py: all three, same mapping Also refreshed docstrings that pointed at the old module paths or described the now-fully-deleted Central consolidation path (consolidate_satpass_pending / satpass_pending buffer) as a live alternative, and updated central/__init__.py's module docstring to stop listing the three relocated modules among central's remaining contents. No behavior changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): update satpass/tle test suite for the relocation Repoints every remaining test import at the new env.satellite.* modules and removes/adapts coverage for the Central envelope-ingest path deleted in this pass (handle_satpass, consolidate_satpass_pending, handle_tle, and the filtering/coercion/staleness logic that lived only inside them): - test_satpass_native.py, test_tle_fetch.py, test_satpass_command.py: import-path updates only (pass_predictor, tle_store). Also dropped test_satpass_command.py's TestTLEUpsert.test_returns_none_always (handle_tle-specific contract, no longer applicable) and rewrote its two latest-wins tests to call upsert_tle directly — same behavior under test, now exercised through the still-live primitive instead of the dead wrapper. - test_satpass_native.py: deleted test_central_consolidate_feeds_shared_gate_merged (spied on consolidate_satpass_pending, which no longer exists). The merge-across-observers logic it guarded is native-side (_consolidate) and already covered by test_two_observers_consolidate_to_one_broadcast. - test_satpass_handler.py: gutted to the one test that calls format_pass directly (test_format_pass_worst_case_fits_140); the rest exercised handle_satpass's observer/norad/elevation filters, which have no live equivalent (the native adapter filters at the config level, not per-envelope) and is redundant with test_satpass_native.py's dedup/wire coverage via the real SatpassAdapter path. - test_satpass_broadcast_safety.py: kept every test that calls format_pass or gate_consolidated_pass-adjacent REGISTRY checks directly (wire format, clean-format rules, REGISTRY defaults); deleted TestNoradIdTypeCoercion and TestStalenessGuard (handle_satpass-only logic, no live equivalent) and the 6 _elevation_bucket tests (_elevation_bucket itself was dead before this pass too — zero callers anywhere but its own tests, already superseded by the numeric "max NN°" wire format per its own docstring). - test_satpass_persisted_timer.py: dropped test_due_at_persisted_on_normal_ingest (handle_satpass-only); kept the two schema/migration tests, which don't touch satpass_handler. - Deleted outright (tested ONLY the dead Central envelope-ingest path, no live equivalent to port to): test_satpass_event_path.py, test_satpass_compass_fallback.py, test_satpass_wire_fields.py. Full suite: 2059 passed, 0 failed (was 0 failed on main pre-change). Satpass/TLE subset (99 tests across 8 files) verified green in isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:46:36 -06:00
resilient empty cases, and the precomposed aospeaklos wire.
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
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
import pytest
from meshai.env.satpass import SatpassAdapter
from meshai.config import SatpassConfig
chore(central-ripout 2b): relocate satellite code to env/satellite/ (#162) * chore(central-ripout 2b): create env/satellite package, move pass_predictor pass_predictor.py was 100% live (no dead entrypoint) — SGP4 pass computation used by both the native satpass adapter and the on-demand !satpass command. Straight move, no code changes: meshai.central.pass_predictor -> meshai.env.satellite.pass_predictor. Owner directive: satellite code gets its own folder under the feed adapters, separate from env.satpass (the adapter) to avoid colliding with env/satpass.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): split satpass_handler.py -> env/satellite/pass_format.py satpass_handler.py was a split file: live wire-formatting/gate logic plus dead Central-envelope ingest machinery whose only caller was the already-deleted central/consumer.py NATS bridge. Moved (live, verified via rg — external callers in env/satpass.py and commands/satpass_cmd.py, or transitively called by them): gate_consolidated_pass, format_pass, _check_rate_cap, _upsert_satpass, _attach_commit, _map_severity, _canonical_id, _azimuth_to_compass, _short_sat_name, _collapse_compass, _region_paren, _is_synthetic_observer, _format_time_12h/24h, _format_ampm, _tz_abbr, _date_label, plus the _SHORT_SAT_NAMES/_SHORT_NAME_SUBSTR/_SYNTHETIC_OBSERVERS tables. Dropped (dead — zero callers outside the already-deleted consumer.py and handle_satpass/consolidate_satpass_pending themselves; verified with rg): handle_satpass, consolidate_satpass_pending, _cleanup_pending, load_pending_schedule, _log_event_returning_id, _coerce_float, _coerce_int, _parse_iso_epoch, _now, CONSOLIDATION_DELAY, _pending_consolidation_ids, drain_pending_consolidation_ids, _elevation_bucket (already-orphaned pre-ripout: superseded by numeric "max NN°" wire format, zero callers anywhere but its own tests), SCHEMA_SATPASS_EVENTS/SCHEMA_SATPASS_PENDING (unused string constants — actual schema lives in persistence/migrations/*.sql, never imported). Also dropped now-unused `json`/`time`/`Any` imports. Straight code move otherwise — no logic changes to any moved function. Two docstrings updated for accuracy (module docstring, and gate_consolidated_pass's docstring which referenced the now-deleted Central consumer path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): split tle_handler.py -> env/satellite/tle_store.py tle_handler.py was a split file: live storage helpers plus a dead Central-envelope ingest entrypoint whose only caller was the already-deleted central/consumer.py NATS bridge. Moved (live — used by env.tle_fetch, env.satpass, commands.satpass_cmd, verified via rg): upsert_tle, get_fresh_tles, get_tle_by_norad, search_tle_by_name. Dropped (dead — handle_tle's only callers were tests and the deleted consumer.py; verified with rg): handle_tle. Straight code move otherwise — no logic changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): repoint satellite consumers at env.satellite Rewire the three production consumers (including their lazy/function-body imports, not just module-top ones) to the new location: - env/satpass.py: central.tle_handler -> env.satellite.tle_store, central.pass_predictor -> env.satellite.pass_predictor, central.satpass_handler -> env.satellite.pass_format - env/tle_fetch.py: central.tle_handler.upsert_tle -> env.satellite.tle_store - commands/satpass_cmd.py: all three, same mapping Also refreshed docstrings that pointed at the old module paths or described the now-fully-deleted Central consolidation path (consolidate_satpass_pending / satpass_pending buffer) as a live alternative, and updated central/__init__.py's module docstring to stop listing the three relocated modules among central's remaining contents. No behavior changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): update satpass/tle test suite for the relocation Repoints every remaining test import at the new env.satellite.* modules and removes/adapts coverage for the Central envelope-ingest path deleted in this pass (handle_satpass, consolidate_satpass_pending, handle_tle, and the filtering/coercion/staleness logic that lived only inside them): - test_satpass_native.py, test_tle_fetch.py, test_satpass_command.py: import-path updates only (pass_predictor, tle_store). Also dropped test_satpass_command.py's TestTLEUpsert.test_returns_none_always (handle_tle-specific contract, no longer applicable) and rewrote its two latest-wins tests to call upsert_tle directly — same behavior under test, now exercised through the still-live primitive instead of the dead wrapper. - test_satpass_native.py: deleted test_central_consolidate_feeds_shared_gate_merged (spied on consolidate_satpass_pending, which no longer exists). The merge-across-observers logic it guarded is native-side (_consolidate) and already covered by test_two_observers_consolidate_to_one_broadcast. - test_satpass_handler.py: gutted to the one test that calls format_pass directly (test_format_pass_worst_case_fits_140); the rest exercised handle_satpass's observer/norad/elevation filters, which have no live equivalent (the native adapter filters at the config level, not per-envelope) and is redundant with test_satpass_native.py's dedup/wire coverage via the real SatpassAdapter path. - test_satpass_broadcast_safety.py: kept every test that calls format_pass or gate_consolidated_pass-adjacent REGISTRY checks directly (wire format, clean-format rules, REGISTRY defaults); deleted TestNoradIdTypeCoercion and TestStalenessGuard (handle_satpass-only logic, no live equivalent) and the 6 _elevation_bucket tests (_elevation_bucket itself was dead before this pass too — zero callers anywhere but its own tests, already superseded by the numeric "max NN°" wire format per its own docstring). - test_satpass_persisted_timer.py: dropped test_due_at_persisted_on_normal_ingest (handle_satpass-only); kept the two schema/migration tests, which don't touch satpass_handler. - Deleted outright (tested ONLY the dead Central envelope-ingest path, no live equivalent to port to): test_satpass_event_path.py, test_satpass_compass_fallback.py, test_satpass_wire_fields.py. Full suite: 2059 passed, 0 failed (was 0 failed on main pre-change). Satpass/TLE subset (99 tests across 8 files) verified green in isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:46:36 -06:00
from meshai.env.satellite.pass_predictor import PassInfo
from meshai.env.satellite.tle_store import upsert_tle
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
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):
chore(central-ripout 2b): relocate satellite code to env/satellite/ (#162) * chore(central-ripout 2b): create env/satellite package, move pass_predictor pass_predictor.py was 100% live (no dead entrypoint) — SGP4 pass computation used by both the native satpass adapter and the on-demand !satpass command. Straight move, no code changes: meshai.central.pass_predictor -> meshai.env.satellite.pass_predictor. Owner directive: satellite code gets its own folder under the feed adapters, separate from env.satpass (the adapter) to avoid colliding with env/satpass.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): split satpass_handler.py -> env/satellite/pass_format.py satpass_handler.py was a split file: live wire-formatting/gate logic plus dead Central-envelope ingest machinery whose only caller was the already-deleted central/consumer.py NATS bridge. Moved (live, verified via rg — external callers in env/satpass.py and commands/satpass_cmd.py, or transitively called by them): gate_consolidated_pass, format_pass, _check_rate_cap, _upsert_satpass, _attach_commit, _map_severity, _canonical_id, _azimuth_to_compass, _short_sat_name, _collapse_compass, _region_paren, _is_synthetic_observer, _format_time_12h/24h, _format_ampm, _tz_abbr, _date_label, plus the _SHORT_SAT_NAMES/_SHORT_NAME_SUBSTR/_SYNTHETIC_OBSERVERS tables. Dropped (dead — zero callers outside the already-deleted consumer.py and handle_satpass/consolidate_satpass_pending themselves; verified with rg): handle_satpass, consolidate_satpass_pending, _cleanup_pending, load_pending_schedule, _log_event_returning_id, _coerce_float, _coerce_int, _parse_iso_epoch, _now, CONSOLIDATION_DELAY, _pending_consolidation_ids, drain_pending_consolidation_ids, _elevation_bucket (already-orphaned pre-ripout: superseded by numeric "max NN°" wire format, zero callers anywhere but its own tests), SCHEMA_SATPASS_EVENTS/SCHEMA_SATPASS_PENDING (unused string constants — actual schema lives in persistence/migrations/*.sql, never imported). Also dropped now-unused `json`/`time`/`Any` imports. Straight code move otherwise — no logic changes to any moved function. Two docstrings updated for accuracy (module docstring, and gate_consolidated_pass's docstring which referenced the now-deleted Central consumer path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): split tle_handler.py -> env/satellite/tle_store.py tle_handler.py was a split file: live storage helpers plus a dead Central-envelope ingest entrypoint whose only caller was the already-deleted central/consumer.py NATS bridge. Moved (live — used by env.tle_fetch, env.satpass, commands.satpass_cmd, verified via rg): upsert_tle, get_fresh_tles, get_tle_by_norad, search_tle_by_name. Dropped (dead — handle_tle's only callers were tests and the deleted consumer.py; verified with rg): handle_tle. Straight code move otherwise — no logic changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): repoint satellite consumers at env.satellite Rewire the three production consumers (including their lazy/function-body imports, not just module-top ones) to the new location: - env/satpass.py: central.tle_handler -> env.satellite.tle_store, central.pass_predictor -> env.satellite.pass_predictor, central.satpass_handler -> env.satellite.pass_format - env/tle_fetch.py: central.tle_handler.upsert_tle -> env.satellite.tle_store - commands/satpass_cmd.py: all three, same mapping Also refreshed docstrings that pointed at the old module paths or described the now-fully-deleted Central consolidation path (consolidate_satpass_pending / satpass_pending buffer) as a live alternative, and updated central/__init__.py's module docstring to stop listing the three relocated modules among central's remaining contents. No behavior changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(central-ripout 2b): update satpass/tle test suite for the relocation Repoints every remaining test import at the new env.satellite.* modules and removes/adapts coverage for the Central envelope-ingest path deleted in this pass (handle_satpass, consolidate_satpass_pending, handle_tle, and the filtering/coercion/staleness logic that lived only inside them): - test_satpass_native.py, test_tle_fetch.py, test_satpass_command.py: import-path updates only (pass_predictor, tle_store). Also dropped test_satpass_command.py's TestTLEUpsert.test_returns_none_always (handle_tle-specific contract, no longer applicable) and rewrote its two latest-wins tests to call upsert_tle directly — same behavior under test, now exercised through the still-live primitive instead of the dead wrapper. - test_satpass_native.py: deleted test_central_consolidate_feeds_shared_gate_merged (spied on consolidate_satpass_pending, which no longer exists). The merge-across-observers logic it guarded is native-side (_consolidate) and already covered by test_two_observers_consolidate_to_one_broadcast. - test_satpass_handler.py: gutted to the one test that calls format_pass directly (test_format_pass_worst_case_fits_140); the rest exercised handle_satpass's observer/norad/elevation filters, which have no live equivalent (the native adapter filters at the config level, not per-envelope) and is redundant with test_satpass_native.py's dedup/wire coverage via the real SatpassAdapter path. - test_satpass_broadcast_safety.py: kept every test that calls format_pass or gate_consolidated_pass-adjacent REGISTRY checks directly (wire format, clean-format rules, REGISTRY defaults); deleted TestNoradIdTypeCoercion and TestStalenessGuard (handle_satpass-only logic, no live equivalent) and the 6 _elevation_bucket tests (_elevation_bucket itself was dead before this pass too — zero callers anywhere but its own tests, already superseded by the numeric "max NN°" wire format per its own docstring). - test_satpass_persisted_timer.py: dropped test_due_at_persisted_on_normal_ingest (handle_satpass-only); kept the two schema/migration tests, which don't touch satpass_handler. - Deleted outright (tested ONLY the dead Central envelope-ingest path, no live equivalent to port to): test_satpass_event_path.py, test_satpass_compass_fallback.py, test_satpass_wire_fields.py. Full suite: 2059 passed, 0 failed (was 0 failed on main pre-change). Satpass/TLE subset (99 tests across 8 files) verified green in isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:46:36 -06:00
monkeypatch.setattr("meshai.env.satellite.pass_predictor.compute_passes", fake)
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
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"]
# 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"]
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
feat(env): native adapters broadcast only newly-RECEIVED items (no backlog) (#43) Replace the native path's "scan accumulated state + suppress what we've already broadcast" model with "broadcast only what newly arrived from the API this poll." Storage is unchanged (self._events + firms_pixels etc. are populated for EVERY received item, so the LLM/get_active backlog is intact); only the BROADCAST decision changes. - env/store.py: per-adapter in-memory seen-set (_seen) + _seeded. First data-bearing poll for an adapter seeds keys and emits NOTHING (that batch is pre-existing backlog); later polls emit only keys not seen before. Restart => empty sets => next poll re-seeds silently. Structurally impossible to broadcast backlog on cold start / restart / re-enable. Key = external_id -> event_id -> content hash, namespaced per adapter. self._events[key]=evt still runs unconditionally (storage preserved). - Fixes the ~175 (roads511) / ~782 (wzdx) cold-start bursts AND the latent quake/nws version (they only looked safe because Central pre-populated their broadcast tables). - env/satpass.py: broadcast on AOS IMMINENCE (now < aos <= now+lead, broadcast_lead_seconds default 3600), future-only; window_hours still governs prediction depth. Strict norad_ids post-filter + fixed _parse_norad_ids char-iteration bug (cause of GOES/METEOR leak). - Central path + broadcast-state tables untouched (native-only gate). 13 new tests; full suite at 10-failure baseline (1697 passed). Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
feat(env): native adapters broadcast only newly-RECEIVED items (no backlog) (#43) Replace the native path's "scan accumulated state + suppress what we've already broadcast" model with "broadcast only what newly arrived from the API this poll." Storage is unchanged (self._events + firms_pixels etc. are populated for EVERY received item, so the LLM/get_active backlog is intact); only the BROADCAST decision changes. - env/store.py: per-adapter in-memory seen-set (_seen) + _seeded. First data-bearing poll for an adapter seeds keys and emits NOTHING (that batch is pre-existing backlog); later polls emit only keys not seen before. Restart => empty sets => next poll re-seeds silently. Structurally impossible to broadcast backlog on cold start / restart / re-enable. Key = external_id -> event_id -> content hash, namespaced per adapter. self._events[key]=evt still runs unconditionally (storage preserved). - Fixes the ~175 (roads511) / ~782 (wzdx) cold-start bursts AND the latent quake/nws version (they only looked safe because Central pre-populated their broadcast tables). - env/satpass.py: broadcast on AOS IMMINENCE (now < aos <= now+lead, broadcast_lead_seconds default 3600), future-only; window_hours still governs prediction depth. Strict norad_ids post-filter + fixed _parse_norad_ids char-iteration bug (cause of GOES/METEOR leak). - Central path + broadcast-state tables untouched (native-only gate). 13 new tests; full suite at 10-failure baseline (1697 passed). Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
feat(env): native adapters broadcast only newly-RECEIVED items (no backlog) (#43) Replace the native path's "scan accumulated state + suppress what we've already broadcast" model with "broadcast only what newly arrived from the API this poll." Storage is unchanged (self._events + firms_pixels etc. are populated for EVERY received item, so the LLM/get_active backlog is intact); only the BROADCAST decision changes. - env/store.py: per-adapter in-memory seen-set (_seen) + _seeded. First data-bearing poll for an adapter seeds keys and emits NOTHING (that batch is pre-existing backlog); later polls emit only keys not seen before. Restart => empty sets => next poll re-seeds silently. Structurally impossible to broadcast backlog on cold start / restart / re-enable. Key = external_id -> event_id -> content hash, namespaced per adapter. self._events[key]=evt still runs unconditionally (storage preserved). - Fixes the ~175 (roads511) / ~782 (wzdx) cold-start bursts AND the latent quake/nws version (they only looked safe because Central pre-populated their broadcast tables). - env/satpass.py: broadcast on AOS IMMINENCE (now < aos <= now+lead, broadcast_lead_seconds default 3600), future-only; window_hours still governs prediction depth. Strict norad_ids post-filter + fixed _parse_norad_ids char-iteration bug (cause of GOES/METEOR leak). - Central path + broadcast-state tables untouched (native-only gate). 13 new tests; full suite at 10-failure baseline (1697 passed). Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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([]) == []
# ══════════════════════════════════════════════════════════════════════
# 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) == []