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 observer_locations (v23): migration, accessors, seed-from-config."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from meshai.config import SatpassConfig
|
|
|
|
|
from meshai.persistence import SCHEMA_VERSION, get_db
|
|
|
|
|
from meshai.persistence.observer_locations import (
|
|
|
|
|
get_observers,
|
|
|
|
|
seed_observers_from_config,
|
|
|
|
|
upsert_observer,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- schema / migration -------------------------------------------------------
|
|
|
|
|
|
fix(persistence): derive SCHEMA_VERSION from migrations; unbreak the red suite (#140)
* fix(persistence): derive SCHEMA_VERSION from the migrations directory
db.py hardcoded SCHEMA_VERSION = 26 while migrations/ had already reached
v29 (v27 dispatcher floor-drop counter, v28 mesh_observations, v29 IPAWS).
The migration runner globs the directory and applies every vN.sql it finds
regardless of the constant, so a fresh DB actually landed at 29 while the
constant claimed 26 -- a three-version drift that three tests were
correctly catching.
Derive it from the highest vN.sql present instead of bumping the literal,
so it cannot drift again the next time someone adds a migration. Falls back
to 0 if the directory is missing so import never fails; the migrations dir
sits alongside db.py and ships with the package (Dockerfile COPYs meshai/).
Adds a regression guard asserting the constant matches the highest
migration file, and updates three tests that hardcoded 26 as a literal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(tle): make TLE fixtures time-relative so they cannot expire
The ISS fixtures hardcoded epochs of 2026-06-30/07-01/07-02. tle_handler
sets STALE_DAYS = 14 and get_tle_by_norad() filters on epoch >= now - 14d,
so the fixtures silently aged out on 2026-07-02 and the tests began
failing -- a time bomb, not a regression.
Compute epochs relative to wall-clock now (base = now - 2d, +/-1d for
newer/older) with correct TLE epoch-field encoding and mod-10 checksum.
STALE_DAYS is untouched -- widening it in product code would have changed
production behavior to paper over a test bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(fire-tracker): pin config + history db to tmp_path
load_config() defaults HistoryConfig.database to the relative path
"conversations.db", resolved against the process CWD, so every test calling
load_config() with no override shares one file for the whole session. The
conftest DB-isolation fixture only covers MESHAI_DB_PATH, not this.
Point both the config dir and the history database at the test's tmp_path.
NOTE: this does NOT resolve the order-dependent failure -- the test still
passes standalone and fails in a full run, so the polluting state lives
somewhere other than config/history. Left failing rather than weakened;
root cause still unidentified.
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 13:03:40 -06:00
|
|
|
def test_schema_version_is_current():
|
|
|
|
|
# SCHEMA_VERSION is derived from the highest vN.sql in migrations/, so
|
|
|
|
|
# this just guards against the derivation returning something bogus
|
|
|
|
|
# (e.g. 0, which would mean the migrations dir wasn't found).
|
|
|
|
|
assert SCHEMA_VERSION >= 23
|
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 test_observer_locations_table_exists():
|
|
|
|
|
conn = get_db()
|
|
|
|
|
tables = {r["name"] for r in conn.execute(
|
|
|
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
|
|
|
).fetchall()}
|
|
|
|
|
assert "observer_locations" in tables
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 16:24:26 -06:00
|
|
|
def test_schema_meta_at_current():
|
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
|
|
|
conn = get_db()
|
|
|
|
|
row = conn.execute(
|
|
|
|
|
"SELECT value FROM schema_meta WHERE key='version'").fetchone()
|
fix(persistence): derive SCHEMA_VERSION from migrations; unbreak the red suite (#140)
* fix(persistence): derive SCHEMA_VERSION from the migrations directory
db.py hardcoded SCHEMA_VERSION = 26 while migrations/ had already reached
v29 (v27 dispatcher floor-drop counter, v28 mesh_observations, v29 IPAWS).
The migration runner globs the directory and applies every vN.sql it finds
regardless of the constant, so a fresh DB actually landed at 29 while the
constant claimed 26 -- a three-version drift that three tests were
correctly catching.
Derive it from the highest vN.sql present instead of bumping the literal,
so it cannot drift again the next time someone adds a migration. Falls back
to 0 if the directory is missing so import never fails; the migrations dir
sits alongside db.py and ships with the package (Dockerfile COPYs meshai/).
Adds a regression guard asserting the constant matches the highest
migration file, and updates three tests that hardcoded 26 as a literal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(tle): make TLE fixtures time-relative so they cannot expire
The ISS fixtures hardcoded epochs of 2026-06-30/07-01/07-02. tle_handler
sets STALE_DAYS = 14 and get_tle_by_norad() filters on epoch >= now - 14d,
so the fixtures silently aged out on 2026-07-02 and the tests began
failing -- a time bomb, not a regression.
Compute epochs relative to wall-clock now (base = now - 2d, +/-1d for
newer/older) with correct TLE epoch-field encoding and mod-10 checksum.
STALE_DAYS is untouched -- widening it in product code would have changed
production behavior to paper over a test bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(fire-tracker): pin config + history db to tmp_path
load_config() defaults HistoryConfig.database to the relative path
"conversations.db", resolved against the process CWD, so every test calling
load_config() with no override shares one file for the whole session. The
conftest DB-isolation fixture only covers MESHAI_DB_PATH, not this.
Point both the config dir and the history database at the test's tmp_path.
NOTE: this does NOT resolve the order-dependent failure -- the test still
passes standalone and fails in a full run, so the polluting state lives
somewhere other than config/history. Left failing rather than weakened;
root cause still unidentified.
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 13:03:40 -06:00
|
|
|
assert int(row["value"]) == SCHEMA_VERSION
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- accessors ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_upsert_and_get_roundtrip():
|
|
|
|
|
upsert_observer("boise", "Boise", 43.615, -116.202, alt_m=824.0)
|
|
|
|
|
obs = get_observers()
|
|
|
|
|
assert len(obs) == 1
|
|
|
|
|
r = obs[0]
|
|
|
|
|
assert r["slug"] == "boise"
|
|
|
|
|
assert r["name"] == "Boise"
|
|
|
|
|
assert r["lat"] == pytest.approx(43.615)
|
|
|
|
|
assert r["lon"] == pytest.approx(-116.202)
|
|
|
|
|
assert r["alt_m"] == pytest.approx(824.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upsert_updates_existing():
|
|
|
|
|
upsert_observer("site1", "Old Name", 40.0, -110.0)
|
|
|
|
|
upsert_observer("site1", "New Name", 41.0, -111.0, alt_m=500.0)
|
|
|
|
|
obs = {o["slug"]: o for o in get_observers()}
|
|
|
|
|
assert obs["site1"]["name"] == "New Name"
|
|
|
|
|
assert obs["site1"]["lat"] == pytest.approx(41.0)
|
|
|
|
|
assert obs["site1"]["alt_m"] == pytest.approx(500.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disabled_observers_excluded():
|
|
|
|
|
upsert_observer("on", "Enabled", 40.0, -110.0, enabled=True)
|
|
|
|
|
upsert_observer("off", "Disabled", 41.0, -111.0, enabled=False)
|
|
|
|
|
slugs = {o["slug"] for o in get_observers()}
|
|
|
|
|
assert "on" in slugs
|
|
|
|
|
assert "off" not in slugs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_alt_m_defaults_to_zero():
|
|
|
|
|
upsert_observer("noalt", "No Altitude", 40.0, -110.0)
|
|
|
|
|
r = get_observers()[0]
|
|
|
|
|
assert r["alt_m"] == pytest.approx(0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- seed from config ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_seed_observers_from_config():
|
|
|
|
|
cfg = SatpassConfig(observers=[
|
|
|
|
|
{"slug": "boise", "name": "Boise", "lat": 43.615, "lon": -116.202, "alt_m": 824.0},
|
|
|
|
|
{"slug": "twin", "name": "Twin Falls", "lat": 42.563, "lon": -114.461},
|
|
|
|
|
])
|
|
|
|
|
n = seed_observers_from_config(cfg)
|
|
|
|
|
assert n == 2
|
|
|
|
|
slugs = {o["slug"] for o in get_observers()}
|
|
|
|
|
assert slugs == {"boise", "twin"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_seed_respects_disabled_flag():
|
|
|
|
|
cfg = SatpassConfig(observers=[
|
|
|
|
|
{"slug": "a", "name": "A", "lat": 40.0, "lon": -110.0},
|
|
|
|
|
{"slug": "b", "name": "B", "lat": 41.0, "lon": -111.0, "enabled": False},
|
|
|
|
|
])
|
|
|
|
|
seed_observers_from_config(cfg)
|
|
|
|
|
slugs = {o["slug"] for o in get_observers()}
|
|
|
|
|
assert "a" in slugs
|
|
|
|
|
assert "b" not in slugs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_seed_is_idempotent_and_updates():
|
|
|
|
|
cfg = SatpassConfig(observers=[
|
|
|
|
|
{"slug": "x", "name": "X", "lat": 40.0, "lon": -110.0},
|
|
|
|
|
])
|
|
|
|
|
assert seed_observers_from_config(cfg) == 1
|
|
|
|
|
# Re-seed with an edited name — upsert, not duplicate.
|
|
|
|
|
cfg2 = SatpassConfig(observers=[
|
|
|
|
|
{"slug": "x", "name": "X Renamed", "lat": 40.0, "lon": -110.0},
|
|
|
|
|
])
|
|
|
|
|
seed_observers_from_config(cfg2)
|
|
|
|
|
obs = get_observers()
|
|
|
|
|
assert len(obs) == 1
|
|
|
|
|
assert obs[0]["name"] == "X Renamed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_seed_skips_malformed_entries():
|
|
|
|
|
cfg = SatpassConfig(observers=[
|
|
|
|
|
{"slug": "good", "name": "Good", "lat": 40.0, "lon": -110.0},
|
|
|
|
|
{"name": "MissingSlug", "lat": 41.0, "lon": -111.0}, # no slug
|
|
|
|
|
"not-a-dict",
|
|
|
|
|
])
|
|
|
|
|
n = seed_observers_from_config(cfg)
|
|
|
|
|
assert n == 1
|
|
|
|
|
assert {o["slug"] for o in get_observers()} == {"good"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_seed_empty_config_noop():
|
|
|
|
|
cfg = SatpassConfig(observers=[])
|
|
|
|
|
assert seed_observers_from_config(cfg) == 0
|
|
|
|
|
assert get_observers() == []
|