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)
This commit is contained in:
malice 2026-06-09 18:50:47 -06:00 committed by GitHub
commit 3f1fec9846
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 838 additions and 9 deletions

View file

@ -1979,6 +1979,47 @@ at parameter `00060`, gage height (ft) at `00065`, water temperature (°C) at
the key via GUI `/api-keys` then the adapter picks it up on the next
config-change notification.
### sat_orbits — forward orbital track per satellite (v0.13.0)
- **Source:** same as `sat_positions` and `satpass_predict` — reads the
latest TLE per `norad_id` from `celestrak_tle`'s events. Empty TLE table
= zero events yielded, no exception.
- **Data class:** `telemetry`. Surfaces on `/telemetry`, not `/events`;
these are continuous-state trajectories, not alerts.
- **Stream:** `CENTRAL_SAT` (existing; v0.13.0 extends
`STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"]` to
`("tle", "pass", "position", "orbit")`).
- **Subject:** `central.sat.orbit.<norad_id>` — one subject per satellite.
Consumers can subscribe to `central.sat.orbit.>` for every tracked
satellite's forward track, or pin to a single satellite.
- **Dedup key shape:** `<norad_id>:<propagation_iso>` where
`propagation_iso` is the propagation start time truncated to whole
seconds. Matches the `sat_positions` convention.
- **Severity:** always 1 (informational telemetry).
- **Geo:**
- `centroid = (current_lon_deg, current_lat_deg)` — first vertex of the
track, anchoring the "here's where it IS" dot on the map.
- `geometry` = GeoJSON `LineString` for the common case (no
antimeridian crossing), or `MultiLineString` when the forward track
crosses ±180° (polar orbits, mid-Pacific orbits). Antimeridian
splitting lives in `sat_common.split_antimeridian` and is shared
with `satpass_predict`'s ground-track render — v0.13.0 also fixes
the v0.11.2 satpass_predict "wrong-way wrap" bug as a sibling concern.
- **Event.data fields:** `norad_id`, `satellite_name`,
`propagation_start_iso`, `forward_minutes`, `sample_seconds`,
`vertex_count`, `current_lon_deg`, `current_lat_deg`, `current_alt_km`,
`tle_epoch`.
- **Cadence:** 300s (5 min) default. Lower than `sat_positions`' 60s
because a forward LineString covers ~90 minutes of orbit; the
trajectory doesn't change meaningfully tick-to-tick at that horizon.
- **Settings:** `track_only_norad_ids` (empty = all fresh TLEs;
non-empty pins to those IDs), `forward_minutes = 90` (~1 LEO orbit),
`sample_seconds = 60` (~90 vertices per event), `max_tle_age_days = 14`.
- **GUI rendering:** sat_orbits events are colored **per-NORAD-ID** in
the events map (golden-angle HSL hue distribution off `norad_id`) so
multiple tracked satellites render as distinguishable lines. Other
adapters keep their existing per-adapter palette color.
\
---