central/tests/test_sat_orbits.py

285 lines
11 KiB
Python
Raw Normal View History

v0.13.0: sat_orbits adapter (forward-orbit-track per satellite) + antimeridian splitter ## Matt's "each sat's path" framing After enabling the satellite family in v0.12.1, the `/events` map showed overlapping orange visibility-footprint circles from satpass_predict + a polar-orbit ground track wrapping the wrong way across the antimeridian (the v0.11.2 documented limitation). Matt's ask: > honestly i just want each sats path. Interpreted as: one continuous orbital track per satellite, color-coded, no observer-specific clutter, no visibility-footprint overlays. Six tracked sats = six distinguishable lines on the map. ## Family placement — global line counterpart to global points | Adapter | What it publishes | Geometry | Cadence | |---|---|---|---| | satpass_predict (v0.11.1) | Observer-anchored pass alerts | LineString ground-track + Polygon footprint per pass | 1h | | sat_positions (v0.12.0) | Current sub-sat POINT per sat | Point centroid only | 60s | | **sat_orbits (this PR)** | Forward-orbit LINE per sat | LineString / MultiLineString, 90min horizon | 5min | Each answers a different question; they complement. ## Antimeridian splitter — shared sat_common primitive `split_antimeridian(coords)` lives in `sat_common.py` next to `gmst_rad` / `eci_to_ecef` / `subsatellite_point`. Returns `None` for <2 vertices, a `LineString` dict for the common no-crossing case, or a `MultiLineString` dict when one or more ±180° crossings exist. Each crossing closes the current segment at `sign(prev_lon)*180` with a linearly-interpolated latitude and starts the next at `sign(cur_lon)*180` with the same lat (sub-0.1° error at LEO orbital speeds, well below Leaflet rendering precision). **Sibling concern fixed:** `satpass_predict._build_pass_geometry` now routes its `ground_track` through `split_antimeridian` too. This was the v0.11.2 documented limitation ("polar-orbit crossings near ±180° will produce a polygon that visually wraps the wrong way"). Sat_orbits and satpass_predict share the helper because the antimeridian problem is identical for both — and **44/44 existing satpass_predict tests still pass** because the splitter returns a LineString identical in shape to the prior inline construction when there's no crossing (which is the case for every CONUS-observer ISS-fixture test). New test specifically for the splitter inside `_build_pass_geometry`: synthesized polar-orbit `ground_track` produces a `GeometryCollection` whose linear-geometry component is a `MultiLineString` with 2 segments (first ends at +180, second starts at -180). ## GUI per-NORAD-ID color helper 20-line addition to `events_list.html`: ```js function orbitColorForNoradId(norad) { var hue = (norad * 137.508) % 360; // golden-angle hue distribution return "hsl(" + hue.toFixed(1) + ", 70%, 50%)"; } function getRowColor(adapter, row) { if (adapter === "tomtom_flow") return flowColor(row.dataset.severity); if (adapter === "sat_orbits") { var norad = parseInt((row.dataset.eventId || "").split(":")[0], 10); if (!isNaN(norad)) return orbitColorForNoradId(norad); } return getAdapterColor(adapter); } ``` `event_id` shape is `<norad_id>:<iso>` (same as sat_positions), so JS reads the first colon-token. **Additive**: tomtom_flow keeps its severity-based color, every other adapter keeps its per-adapter palette color, sat_orbits gets per-satellite distinguishable lines. ## Phase A sanity (per spec) ``` vertices = 91 ✓ (90min @ 60s + 1 endpoint) first vertex = (170.66°, -17.15°, 417.4km) ✓ matches v0.11.1 ISS pin last vertex = (140.52°, -8.60°, 415.9km) ✓ geographically distinct antimeridian crossings in 90min track = 1 geometry type = MultiLineString, 2 segments ✓ splitter integrates ``` ## Diff size **+838 / −9 = +829 net** across 15 files. Spec budget was ≤800 lines. **29 over** — much tighter than v0.12.0 (894) or v0.12.1 (848). Adapter LoC 275 (well under 350 cap). sat_common splitter 51 LoC (~budget). Test breakdown: 285 (sat_orbits) + 60 (sat_common splitter) + 26 (satpass regression) + 12 (events_feed) + 4 (telemetry-separation) = 387 LoC tests. Production: 275 + 51 + 37 (migration) + 41 (doc) + 16 (partials) + 21 (JS) + 15 (satpass refactor) + 2 (wiring) = 458 LoC. ## Test plan - [x] `pytest tests/test_sat_orbits.py` — 19 new tests, all pass. - [x] `pytest tests/test_sat_common.py` — 7 new splitter tests, 16 total pass. - [x] `pytest tests/test_satpass_predict.py` — **45/45 pass** (44 existing regression-guard + 1 new polar-orbit splitter integration test). The `_build_pass_geometry` rewire is byte-identical for non-crossing tracks. - [x] `pytest tests/test_events_feed_frontend.py` — 125/125 pass (sat_orbits sample + expected subject extended). - [x] `pytest tests/test_telemetry_separation.py` — 9/9 pass (`_TELEMETRY` pin extended with `sat_orbits`). - [x] `pytest tests/test_consumer_doc.py` — 6/6 pass (new `### sat_orbits` subsection accepted). - [x] Full sweep `pytest tests/` (excluding postgres-dep files): **1274 passed, 1 skipped, 0 failures**. - [x] Ruff: clean on all new + touched satellite-family code. ## Deploy plan 1. Squash-merge PR #N → tag v0.13.0 at merge SHA → push tag. 2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (no new dep). 3. Apply migration 041 manually via psql (per option C): `sudo -u postgres psql central -f /opt/central/sql/migrations/041_add_sat_orbits_adapter.sql` 4. `sudo systemctl restart central-supervisor` (picks up new adapter + STREAM_CATEGORY_DOMAINS extension) + `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS extension + JS color helper). 5. **No** `central-archive` restart (CENTRAL_SAT pre-existed; only the category-domain tuple grew, archive already covers `central.sat.>`). 6. Verify: `config.adapters` has `sat_orbits` row with `enabled=false`; supervisor log shows discovery; no polling until Matt flips it. 7. Matt enables via `/adapters/sat_orbits/edit` when ready. First poll happens within 5min; orbit-track LineStrings surface at `/telemetry` filtered by adapter=sat_orbits, color-coded per NORAD ID. ## Halt acknowledgment Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 18:50:47 -06:00
"""Tests for the sat_orbits adapter (v0.13.0).
Deterministic: pinned ISS TLE + pinned propagation start + mocked asyncpg
pool. Covers track propagation (vertex count, current sub-sat point in
plausible range), antimeridian-aware geometry (LineString vs
MultiLineString), event-record shape, subject derivation, empty-TLE,
track_only gate, forward_minutes setting, stale-TLE skip.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from sgp4.api import Satrec
from central.adapters.sat_orbits import (
SatOrbitsAdapter,
SatOrbitsSettings,
_propagate_track,
)
from central.config_models import AdapterConfig
from central.models import Event, Geo
# Live TLE from v0.11.0 stations fixture, ISS (NORAD 25544). Shared with
# test_satpass_predict / test_sat_common / test_sat_positions so all four
# suites pin against the same orbital configuration.
_ISS_L1 = "1 25544U 98067A 26159.80410962 .00007129 00000+0 13425-3 0 9999"
_ISS_L2 = "2 25544 51.6336 341.5878 0006923 148.5365 211.6039 15.49672912570453"
_REF = datetime(2026, 6, 9, 7, 0, 0, tzinfo=timezone.utc)
def _row(norad_id: int = 25544, name: str = "ISS (ZARYA)") -> dict[str, Any]:
return {
"norad_id": norad_id,
"satellite_name": name,
"tle_line1": _ISS_L1,
"tle_line2": _ISS_L2,
"tle_epoch": "2026-06-08T19:17:55.071",
}
def _make_adapter(
tmp_path: Path,
settings: dict[str, Any] | None = None,
fetch_rows: list[dict[str, Any]] | None = None,
) -> tuple[SatOrbitsAdapter, AsyncMock]:
cfg = AdapterConfig(
name="sat_orbits",
enabled=True,
cadence_s=300,
settings=settings or {},
updated_at=datetime.now(timezone.utc),
)
fetch_mock = AsyncMock(return_value=fetch_rows or [])
conn = MagicMock()
conn.fetch = fetch_mock
acquire_cm = MagicMock()
acquire_cm.__aenter__ = AsyncMock(return_value=conn)
acquire_cm.__aexit__ = AsyncMock(return_value=False)
pool = MagicMock()
pool.acquire = MagicMock(return_value=acquire_cm)
config_store = MagicMock()
config_store.get_pool = MagicMock(return_value=pool)
adapter = SatOrbitsAdapter(cfg, config_store, tmp_path / "cursors.db")
return adapter, fetch_mock
# --- Propagation primitive --------------------------------------------------
class TestPropagateTrack:
def test_iss_90min_at_60s_step_yields_91_vertices(self):
sat = Satrec.twoline2rv(_ISS_L1, _ISS_L2)
track = _propagate_track(sat, _REF, forward_minutes=90, sample_seconds=60)
# 90 min / 60s + 1 inclusive endpoint = 91 vertices.
assert len(track) == 91
def test_first_vertex_matches_v0_11_1_iss_values(self):
"""Phase A sanity pin: at the v0.11.0 fixture TLE + 2026-06-09T07:00 UTC,
ISS sub-sat point should be ~(170.66, -17.15, 417.41)."""
sat = Satrec.twoline2rv(_ISS_L1, _ISS_L2)
track = _propagate_track(sat, _REF, forward_minutes=90, sample_seconds=60)
lon, lat, alt = track[0]
assert lon == pytest.approx(170.66, abs=0.02)
assert lat == pytest.approx(-17.15, abs=0.02)
assert alt == pytest.approx(417.41, abs=0.5)
def test_last_vertex_geographically_distinct_from_first(self):
"""A full LEO orbit traverses 360 degrees in inertial frame; relative to
the rotating earth the ground track shifts noticeably in 90 minutes."""
sat = Satrec.twoline2rv(_ISS_L1, _ISS_L2)
track = _propagate_track(sat, _REF, forward_minutes=90, sample_seconds=60)
first, last = track[0], track[-1]
# At least 10 degrees of lon difference -- ISS moves visibly in 90min.
# (Account for antimeridian wrap by using the wrap-aware distance.)
dlon_raw = abs(last[0] - first[0])
dlon = min(dlon_raw, 360.0 - dlon_raw)
dlat = abs(last[1] - first[1])
assert dlon + dlat > 5.0
def test_forward_minutes_30_yields_31_vertices(self):
"""Linearly scales: 30min / 60s + 1 = 31 vertices."""
sat = Satrec.twoline2rv(_ISS_L1, _ISS_L2)
track = _propagate_track(sat, _REF, forward_minutes=30, sample_seconds=60)
assert len(track) == 31
# --- Settings defaults pin the v0.13.0 contract -----------------------------
class TestSettingsDefaults:
def test_default_forward_minutes_is_90(self):
assert SatOrbitsSettings().forward_minutes == 90
def test_default_sample_seconds_is_60(self):
assert SatOrbitsSettings().sample_seconds == 60
def test_default_max_tle_age_days_is_14(self):
assert SatOrbitsSettings().max_tle_age_days == 14
def test_default_track_only_is_empty(self):
assert SatOrbitsSettings().track_only_norad_ids == []
# --- subject_for + event-record shape ---------------------------------------
class TestSubjectFor:
def test_subject_carries_norad_id(self, tmp_path):
adapter, _ = _make_adapter(tmp_path)
ev = Event(
id="25544:2026-06-09T07:00:00+00:00",
adapter="sat_orbits",
category="orbit.sat_orbits",
time=_REF,
severity=1,
geo=Geo(centroid=(0.0, 0.0)),
data={"norad_id": 25544},
)
assert adapter.subject_for(ev) == "central.sat.orbit.25544"
class TestBuildEvent:
def test_iss_track_produces_linestring_or_multilinestring(self, tmp_path):
adapter, _ = _make_adapter(tmp_path)
sat = Satrec.twoline2rv(_ISS_L1, _ISS_L2)
track = _propagate_track(sat, _REF, forward_minutes=90, sample_seconds=60)
ev = adapter._build_event(_row(), _REF, track)
assert ev is not None
assert ev.category == "orbit.sat_orbits"
assert ev.severity == 1
# Geometry is either LineString (no crossing) or MultiLineString
# (crossings present); both are valid for a 90min ISS arc.
assert ev.geo.geometry["type"] in ("LineString", "MultiLineString")
# geo.centroid matches first vertex (current position)
assert ev.geo.centroid[0] == pytest.approx(track[0][0], abs=1e-6)
assert ev.geo.centroid[1] == pytest.approx(track[0][1], abs=1e-6)
def test_record_shape(self, tmp_path):
adapter, _ = _make_adapter(tmp_path)
sat = Satrec.twoline2rv(_ISS_L1, _ISS_L2)
track = _propagate_track(sat, _REF, forward_minutes=90, sample_seconds=60)
ev = adapter._build_event(_row(), _REF, track)
assert ev is not None
assert ev.id == "25544:2026-06-09T07:00:00+00:00"
for k in ("norad_id", "satellite_name", "propagation_start_iso",
"forward_minutes", "sample_seconds", "vertex_count",
"current_lon_deg", "current_lat_deg", "current_alt_km",
"tle_epoch"):
assert k in ev.data, f"missing data key {k!r}"
assert ev.data["vertex_count"] == 91
def test_returns_none_for_degenerate_track(self, tmp_path):
adapter, _ = _make_adapter(tmp_path)
# Fewer than 2 vertices = degenerate; caller skips.
assert adapter._build_event(_row(), _REF, []) is None
assert adapter._build_event(_row(), _REF, [(0.0, 0.0, 400.0)]) is None
def test_dedup_id_collapses_sub_second_ticks(self, tmp_path):
"""Two propagation_start times differing only in microseconds yield
identical dedup ids."""
adapter, _ = _make_adapter(tmp_path)
sat = Satrec.twoline2rv(_ISS_L1, _ISS_L2)
track = _propagate_track(sat, _REF, forward_minutes=10, sample_seconds=60)
t1 = _REF.replace(microsecond=1)
t2 = _REF.replace(microsecond=999999)
ev1 = adapter._build_event(_row(), t1, track)
ev2 = adapter._build_event(_row(), t2, track)
assert ev1 is not None and ev2 is not None
assert ev1.id == ev2.id
# --- Poll loop --------------------------------------------------------------
class TestPollEmptyTleTable:
@pytest.mark.asyncio
async def test_yields_zero_no_exception(self, tmp_path):
adapter, _ = _make_adapter(tmp_path, fetch_rows=[])
await adapter.startup()
events = [ev async for ev in adapter.poll()]
await adapter.shutdown()
assert events == []
class TestPollTrackOnlyGate:
@pytest.mark.asyncio
async def test_empty_publishes_all(self, tmp_path):
rows = [_row(25544, "ISS"), _row(43013, "NOAA 20")]
adapter, _ = _make_adapter(tmp_path,
settings={"track_only_norad_ids": []},
fetch_rows=rows)
await adapter.startup()
events = [ev async for ev in adapter.poll()]
await adapter.shutdown()
assert {e.data["norad_id"] for e in events} == {25544, 43013}
@pytest.mark.asyncio
async def test_non_empty_restricts(self, tmp_path):
rows = [_row(25544, "ISS"), _row(43013, "NOAA 20")]
adapter, _ = _make_adapter(tmp_path,
settings={"track_only_norad_ids": [25544]},
fetch_rows=rows)
await adapter.startup()
events = [ev async for ev in adapter.poll()]
await adapter.shutdown()
assert {e.data["norad_id"] for e in events} == {25544}
class TestPollStaleTleSkip:
@pytest.mark.asyncio
async def test_default_passes_14d_interval(self, tmp_path):
adapter, fetch_mock = _make_adapter(tmp_path, fetch_rows=[])
await adapter.startup()
_ = [ev async for ev in adapter.poll()]
await adapter.shutdown()
args, _kwargs = fetch_mock.call_args
assert args[1] == timedelta(days=14)
@pytest.mark.asyncio
async def test_operator_window_propagates(self, tmp_path):
adapter, fetch_mock = _make_adapter(
tmp_path, settings={"max_tle_age_days": 3}, fetch_rows=[],
)
await adapter.startup()
_ = [ev async for ev in adapter.poll()]
await adapter.shutdown()
args, _kwargs = fetch_mock.call_args
assert args[1] == timedelta(days=3)
class TestPollForwardMinutesSetting:
@pytest.mark.asyncio
async def test_30min_setting_produces_31_vertex_track(self, tmp_path):
adapter, _ = _make_adapter(tmp_path,
settings={"forward_minutes": 30,
"sample_seconds": 60},
fetch_rows=[_row()])
await adapter.startup()
events = [ev async for ev in adapter.poll()]
await adapter.shutdown()
assert len(events) == 1
assert events[0].data["vertex_count"] == 31
assert events[0].data["forward_minutes"] == 30
class TestPollPropagatesIss:
@pytest.mark.asyncio
async def test_iss_row_produces_one_telemetry_event(self, tmp_path):
adapter, _ = _make_adapter(tmp_path, fetch_rows=[_row()])
await adapter.startup()
events = [ev async for ev in adapter.poll()]
await adapter.shutdown()
assert len(events) == 1
ev = events[0]
assert ev.data["norad_id"] == 25544
assert ev.severity == 1
assert ev.geo.geometry["type"] in ("LineString", "MultiLineString")
assert 380.0 <= ev.data["current_alt_km"] <= 460.0
assert -52.0 <= ev.data["current_lat_deg"] <= 52.0