mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
## 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)
108 lines
4.3 KiB
Python
108 lines
4.3 KiB
Python
"""Tests for v0.7.4 telemetry/event separation: SourceAdapter.data_class,
|
|
registry split, class-scoped filter options, and the data_class SQL filter.
|
|
Registry-derived (no hardcoded adapter lists beyond the nwis pin). No live DB.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from central.adapter import SourceAdapter
|
|
from central.adapter_discovery import discover_adapters
|
|
from central.gui import routes
|
|
|
|
# Adapters with data_class="telemetry" (the pinned split; grow as telemetry adapters land).
|
|
# v0.11.0 added celestrak_tle (orbital state -- continuous-ish refresh, telemetry-class).
|
|
# v0.12.0 added sat_positions (60s sub-sat point per tracked satellite).
|
|
# v0.13.0 added sat_orbits (90min forward-track LineString per satellite, every 5min).
|
|
_TELEMETRY = ["celestrak_tle", "itd_511_cameras", "nwis", "sat_orbits",
|
|
"sat_positions", "tomtom_flow"]
|
|
|
|
|
|
# --- data_class defaults / registry split -----------------------------------
|
|
|
|
def test_base_default_is_event():
|
|
assert SourceAdapter.data_class == "event"
|
|
|
|
|
|
def test_registry_split_event_vs_telemetry():
|
|
reg = discover_adapters()
|
|
by_class = {}
|
|
for name, cls in reg.items():
|
|
by_class.setdefault(getattr(cls, "data_class", "event"), []).append(name)
|
|
assert sorted(by_class.get("telemetry", [])) == _TELEMETRY
|
|
# Everything else is event-class; the split must cover the whole registry.
|
|
assert sorted(by_class.get("event", [])) == sorted(n for n in reg if n not in _TELEMETRY)
|
|
assert len(by_class.get("event", [])) == len(reg) - len(_TELEMETRY)
|
|
|
|
|
|
def test_class_adapter_names():
|
|
assert "nwis" not in routes._class_adapter_names("event")
|
|
assert sorted(routes._class_adapter_names("telemetry")) == _TELEMETRY
|
|
assert "usgs_quake" in routes._class_adapter_names("event")
|
|
|
|
|
|
# --- class-scoped chip-picker / legend options -------------------------------
|
|
|
|
def test_event_options_exclude_nwis():
|
|
flat, grouped = routes._adapter_filter_options("event")
|
|
names = {a["name"] for a in flat}
|
|
assert "nwis" not in names
|
|
assert len(flat) == len(discover_adapters()) - len(_TELEMETRY)
|
|
grouped_values = {opt["value"] for _, items in grouped for opt in items}
|
|
assert "nwis" not in grouped_values
|
|
|
|
|
|
def test_telemetry_options_only_nwis():
|
|
flat, grouped = routes._adapter_filter_options("telemetry")
|
|
assert sorted(a["name"] for a in flat) == _TELEMETRY
|
|
grouped_values = [opt["value"] for _, items in grouped for opt in items]
|
|
assert sorted(grouped_values) == _TELEMETRY
|
|
|
|
|
|
def test_colors_stable_across_classes():
|
|
"""A given adapter keeps the same color on /events and /telemetry (colors
|
|
are keyed to the full registry, not the per-tab subset)."""
|
|
full, _ = routes._adapter_filter_options()
|
|
full_color = {a["name"]: a["color"] for a in full}
|
|
ev, _ = routes._adapter_filter_options("event")
|
|
for a in ev:
|
|
assert a["color"] == full_color[a["name"]]
|
|
|
|
|
|
# --- data_class SQL filter (captured SQL) ------------------------------------
|
|
|
|
async def _capture(parsed):
|
|
captured = {}
|
|
|
|
async def fake_fetch(query, *args):
|
|
captured["query"] = query
|
|
captured["params"] = list(args)
|
|
return []
|
|
|
|
conn = MagicMock()
|
|
conn.fetch = fake_fetch
|
|
pool = MagicMock()
|
|
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
|
|
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
with patch("central.gui.routes.get_pool", return_value=pool):
|
|
await routes._fetch_events(parsed)
|
|
return captured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_class_adapters_adds_adapter_any_condition():
|
|
parsed, _ = routes._parse_events_params({"time": "all"}, default_offset=0)
|
|
parsed["class_adapters"] = routes._class_adapter_names("event")
|
|
cap = await _capture(parsed)
|
|
assert "adapter = ANY($" in cap["query"]
|
|
assert routes._class_adapter_names("event") in cap["params"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_class_adapters_no_class_condition():
|
|
"""events.json path: no class_adapters -> no extra adapter filter (all classes)."""
|
|
parsed, _ = routes._parse_events_params({"time": "all"}) # cursor-mode, no class
|
|
assert parsed.get("class_adapters") is None
|
|
cap = await _capture(parsed)
|
|
# The only adapter=ANY would come from a user filter, which we didn't set.
|
|
assert "adapter = ANY($" not in cap["query"]
|