mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(persistence): make satpass/avalanche/ducting LLM-queryable (#74)
Close the LLM data gaps: add build_satpass_detail (satpass_events was written but had no reader), and give avalanche + ducting durable tables (v24/v25) with native writers + env_reporter readers so the mesh LLM can answer avalanche, satellite-pass, and RF-propagation questions. Persistence-only; no broadcast/ gating changes. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d479ca537a
commit
3fb4e6e65c
9 changed files with 570 additions and 8 deletions
301
work/tests/test_env_reporter_llm_gaps.py
Normal file
301
work/tests/test_env_reporter_llm_gaps.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"""LLM-persistence-gap tests: satpass reader, avalanche + ducting
|
||||
durable tables (writer -> table -> env_reporter reader).
|
||||
|
||||
Uses the autouse conftest fixture which points MESHAI_DB_PATH at a fresh
|
||||
tmp file and runs init_db (so all migrations, now through v25, apply).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.env.avalanche import AvalancheAdapter
|
||||
from meshai.env.ducting import DuctingAdapter
|
||||
from meshai.notifications.env_reporter import EnvReporter
|
||||
from meshai.persistence import get_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reporter():
|
||||
return EnvReporter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# migrations: the two new tables exist on a fresh boot
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_new_tables_exist_after_init():
|
||||
conn = get_db()
|
||||
names = {r["name"] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
||||
assert "avalanche_events" in names
|
||||
assert "ducting_events" in names
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 1. satpass reader (data already persists via the native satpass path)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _seed_satpass(conn, *, event_id, sat_name, aos_at, norad_id=25544,
|
||||
observer="Boise", max_elevation=45.0, los_at=None):
|
||||
now = int(time.time())
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO satpass_events(event_id, norad_id, sat_name, "
|
||||
"observer, max_elevation, aos_at, los_at, payload_json, first_seen_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(event_id, norad_id, sat_name, observer, max_elevation, aos_at,
|
||||
los_at or (aos_at + 600), "{}", now),
|
||||
)
|
||||
|
||||
|
||||
def test_satpass_detail_empty_when_no_passes(reporter):
|
||||
assert reporter.build_satpass_detail() == ""
|
||||
|
||||
|
||||
def test_satpass_detail_renders_upcoming(reporter):
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_satpass(conn, event_id="P1", sat_name="ISS (ZARYA)",
|
||||
aos_at=now + 1800, max_elevation=52.0, observer="Boise")
|
||||
text = reporter.build_satpass_detail()
|
||||
assert "UPCOMING SATELLITE PASSES" in text
|
||||
assert "ISS (ZARYA)" in text
|
||||
assert "max el 52" in text
|
||||
assert "Boise" in text
|
||||
|
||||
|
||||
def test_satpass_detail_ignores_past_passes(reporter):
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
# A pass whose AOS already happened must not appear.
|
||||
_seed_satpass(conn, event_id="P_old", sat_name="NOAA 19",
|
||||
aos_at=now - 3600)
|
||||
assert reporter.build_satpass_detail() == ""
|
||||
|
||||
|
||||
def test_satpass_detail_meta_off(reporter):
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_satpass(conn, event_id="P1", sat_name="ISS", aos_at=now + 600)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO adapter_meta(adapter, include_in_llm_context, "
|
||||
"updated_at) VALUES ('satpass', 0, ?)", (time.time(),))
|
||||
assert reporter.build_satpass_detail() == ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 2. avalanche: adapter writer -> avalanche_events -> reader
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _avy_config():
|
||||
cfg = MagicMock()
|
||||
cfg.center_ids = ["SNFAC"]
|
||||
cfg.tick_seconds = 1800
|
||||
cfg.season_months = [12, 1, 2, 3, 4]
|
||||
return cfg
|
||||
|
||||
|
||||
def test_avalanche_writer_persists_row_then_reader_reads_it(reporter):
|
||||
adapter = AvalancheAdapter(_avy_config())
|
||||
now = time.time()
|
||||
# Synthetic assessment mirroring _fetch()'s stored-event dict shape.
|
||||
adapter._events = [{
|
||||
"source": "avalanche",
|
||||
"event_id": "avy_SNFAC_banner_summit",
|
||||
"center_id": "SNFAC",
|
||||
"zone_name": "Banner Summit",
|
||||
"danger_level": 4,
|
||||
"danger_name": "High",
|
||||
"travel_advice": "Very dangerous avalanche conditions.",
|
||||
"lat": 44.3,
|
||||
"lon": -115.2,
|
||||
"expires": now + 6 * 3600,
|
||||
"fetched_at": now,
|
||||
}]
|
||||
adapter._persist_events()
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM avalanche_events WHERE event_id=?",
|
||||
("avy_SNFAC_banner_summit",)).fetchone()
|
||||
assert row is not None
|
||||
assert row["danger_level"] == 4
|
||||
assert row["zone_name"] == "Banner Summit"
|
||||
|
||||
text = reporter.build_avalanche_detail()
|
||||
assert "AVALANCHE ADVISORIES" in text
|
||||
assert "Banner Summit" in text
|
||||
assert "High (4)" in text
|
||||
|
||||
|
||||
def test_avalanche_writer_upserts_in_place(reporter):
|
||||
adapter = AvalancheAdapter(_avy_config())
|
||||
now = time.time()
|
||||
base = {
|
||||
"source": "avalanche",
|
||||
"event_id": "avy_SNFAC_z1",
|
||||
"center_id": "SNFAC",
|
||||
"zone_name": "Zone One",
|
||||
"danger_level": 2,
|
||||
"danger_name": "Moderate",
|
||||
"travel_advice": "",
|
||||
"lat": 44.0,
|
||||
"lon": -115.0,
|
||||
"expires": now + 6 * 3600,
|
||||
"fetched_at": now,
|
||||
}
|
||||
adapter._events = [dict(base)]
|
||||
adapter._persist_events()
|
||||
# danger rises -> re-persist should update the same row, not duplicate.
|
||||
adapter._events = [dict(base, danger_level=4, danger_name="High")]
|
||||
adapter._persist_events()
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT danger_level FROM avalanche_events WHERE event_id=?",
|
||||
("avy_SNFAC_z1",)).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["danger_level"] == 4
|
||||
|
||||
|
||||
def test_avalanche_detail_excludes_expired(reporter):
|
||||
adapter = AvalancheAdapter(_avy_config())
|
||||
now = time.time()
|
||||
adapter._events = [{
|
||||
"source": "avalanche",
|
||||
"event_id": "avy_SNFAC_old",
|
||||
"center_id": "SNFAC",
|
||||
"zone_name": "Stale Zone",
|
||||
"danger_level": 3,
|
||||
"danger_name": "Considerable",
|
||||
"travel_advice": "",
|
||||
"lat": 44.0,
|
||||
"lon": -115.0,
|
||||
"expires": now - 3600, # already expired
|
||||
"fetched_at": now,
|
||||
}]
|
||||
adapter._persist_events()
|
||||
assert reporter.build_avalanche_detail() == ""
|
||||
|
||||
|
||||
def test_avalanche_detail_empty_when_no_rows(reporter):
|
||||
assert reporter.build_avalanche_detail() == ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. ducting: adapter writer -> ducting_events -> reader
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _ducting_config():
|
||||
cfg = MagicMock()
|
||||
cfg.latitude = 43.6
|
||||
cfg.longitude = -116.2
|
||||
cfg.tick_seconds = 10800
|
||||
return cfg
|
||||
|
||||
|
||||
def test_ducting_writer_persists_row_then_reader_reads_it(reporter):
|
||||
adapter = DuctingAdapter(_ducting_config())
|
||||
adapter._status = {
|
||||
"condition": "surface_duct",
|
||||
"tier": "surface_duct",
|
||||
"min_gradient": -120.0,
|
||||
"duct_base_m": 110,
|
||||
"duct_thickness_m": 650,
|
||||
"assessment": "Ducting -- extended UHF range likely",
|
||||
"fetched_at": time.time(),
|
||||
}
|
||||
adapter._persist_status()
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM ducting_events WHERE id=?",
|
||||
("ducting_43.6_-116.2",)).fetchone()
|
||||
assert row is not None
|
||||
assert row["tier"] == "surface_duct"
|
||||
assert row["min_gradient"] == -120.0
|
||||
|
||||
text = reporter.build_ducting_detail()
|
||||
assert "RF PROPAGATION" in text
|
||||
assert "surface_duct" in text
|
||||
assert "extended UHF range" in text
|
||||
|
||||
|
||||
def test_ducting_writer_keeps_single_current_row(reporter):
|
||||
adapter = DuctingAdapter(_ducting_config())
|
||||
adapter._status = {
|
||||
"condition": "normal", "tier": "normal", "min_gradient": 118.0,
|
||||
"duct_base_m": None, "duct_thickness_m": None,
|
||||
"assessment": "Normal propagation", "fetched_at": time.time(),
|
||||
}
|
||||
adapter._persist_status()
|
||||
adapter._status = dict(adapter._status, condition="super_refraction",
|
||||
tier="super_refraction", min_gradient=40.0,
|
||||
assessment="Enhanced range possible",
|
||||
fetched_at=time.time() + 1)
|
||||
adapter._persist_status()
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT tier FROM ducting_events WHERE id=?",
|
||||
("ducting_43.6_-116.2",)).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["tier"] == "super_refraction"
|
||||
|
||||
|
||||
def test_ducting_detail_empty_when_no_rows(reporter):
|
||||
assert reporter.build_ducting_detail() == ""
|
||||
|
||||
|
||||
def test_ducting_detail_meta_off(reporter):
|
||||
adapter = DuctingAdapter(_ducting_config())
|
||||
adapter._status = {
|
||||
"condition": "surface_duct", "tier": "surface_duct",
|
||||
"min_gradient": -120.0, "duct_base_m": 110, "duct_thickness_m": 650,
|
||||
"assessment": "Ducting", "fetched_at": time.time(),
|
||||
}
|
||||
adapter._persist_status()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO adapter_meta(adapter, include_in_llm_context, "
|
||||
"updated_at) VALUES ('ducting', 0, ?)", (time.time(),))
|
||||
assert reporter.build_ducting_detail() == ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# build_all wires the three new blocks
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_build_all_includes_new_blocks(reporter):
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_satpass(conn, event_id="P1", sat_name="ISS", aos_at=now + 1200)
|
||||
|
||||
avy = AvalancheAdapter(_avy_config())
|
||||
avy._events = [{
|
||||
"source": "avalanche", "event_id": "avy_SNFAC_z",
|
||||
"center_id": "SNFAC", "zone_name": "Banner", "danger_level": 4,
|
||||
"danger_name": "High", "travel_advice": "", "lat": 44.3, "lon": -115.2,
|
||||
"expires": time.time() + 6 * 3600, "fetched_at": time.time(),
|
||||
}]
|
||||
avy._persist_events()
|
||||
|
||||
duct = DuctingAdapter(_ducting_config())
|
||||
duct._status = {
|
||||
"condition": "surface_duct", "tier": "surface_duct",
|
||||
"min_gradient": -120.0, "duct_base_m": 110, "duct_thickness_m": 650,
|
||||
"assessment": "Ducting", "fetched_at": time.time(),
|
||||
}
|
||||
duct._persist_status()
|
||||
|
||||
text = reporter.build_all()
|
||||
assert "UPCOMING SATELLITE PASSES" in text
|
||||
assert "AVALANCHE ADVISORIES" in text
|
||||
assert "RF PROPAGATION" in text
|
||||
|
|
@ -14,8 +14,8 @@ from meshai.persistence.observer_locations import (
|
|||
|
||||
# -- schema / migration -------------------------------------------------------
|
||||
|
||||
def test_schema_version_is_23():
|
||||
assert SCHEMA_VERSION == 23
|
||||
def test_schema_version_is_25():
|
||||
assert SCHEMA_VERSION == 25
|
||||
|
||||
|
||||
def test_observer_locations_table_exists():
|
||||
|
|
@ -26,11 +26,11 @@ def test_observer_locations_table_exists():
|
|||
assert "observer_locations" in tables
|
||||
|
||||
|
||||
def test_schema_meta_at_23():
|
||||
def test_schema_meta_at_current():
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT value FROM schema_meta WHERE key='version'").fetchone()
|
||||
assert int(row["value"]) == 23
|
||||
assert int(row["value"]) == 25
|
||||
|
||||
|
||||
# -- accessors ----------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -99,8 +99,9 @@ def _ingest_envelope(norad_id=25544, observer="Boise", max_el=72.5,
|
|||
# ── schema / migration ───────────────────────────────────────────────
|
||||
|
||||
def test_schema_version_is_current():
|
||||
# Bumped to 23 by the native-satpass observer_locations migration (v23).
|
||||
assert SCHEMA_VERSION == 23
|
||||
# Bumped to 25 by the LLM-persistence migrations (v24 avalanche_events,
|
||||
# v25 ducting_events); was 23 at the native-satpass observer_locations (v23).
|
||||
assert SCHEMA_VERSION == 25
|
||||
|
||||
|
||||
def test_v22_migration_applies_and_adds_due_at_column(tmp_path, monkeypatch):
|
||||
|
|
@ -113,7 +114,7 @@ def test_v22_migration_applies_and_adds_due_at_column(tmp_path, monkeypatch):
|
|||
close_thread_connection()
|
||||
conn = init_db()
|
||||
row = conn.execute("SELECT value FROM schema_meta WHERE key='version'").fetchone()
|
||||
assert int(row["value"]) == 23
|
||||
assert int(row["value"]) == 25
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(satpass_pending)")}
|
||||
assert "due_at" in cols
|
||||
close_thread_connection()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue