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 the key via GUI `/api-keys` then the adapter picks it up on the next
config-change notification. 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.
\ \
--- ---

View file

@ -0,0 +1,37 @@
-- Migration 041: register sat_orbits adapter (v0.13.0)
--
-- Forward-orbit-track publisher. One LineString telemetry event per tracked
-- satellite per poll, projecting the next 90 minutes of sub-satellite
-- ground track. Line counterpart to sat_positions (which publishes the
-- current sub-sat POINT per minute). Drives the "each sat's path" map
-- view Matt asked for after enabling the satellite family.
--
-- Subject: central.sat.orbit.<norad_id>. Stream: existing CENTRAL_SAT.
-- The supervisor's STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"] extends from
-- ("tle", "pass", "position") to ("tle", "pass", "position", "orbit")
-- in code (not migration) so the retention sweep covers orbit events.
--
-- No max_bytes bump needed on CENTRAL_SAT. Volume estimate: 6 sats x 12
-- polls/hour (300s cadence) x 24 hours = 1728 events/day at ~5 KB each
-- (~90 vertices) = ~8.5 MB/day. Negligible against the 5 GiB cap from
-- v0.12.0.
--
-- Ships disabled (enabled=false). celestrak_tle must be enabled and
-- polling before sat_orbits has TLE data to propagate; missing-TLE path
-- is graceful (INFO log + zero events).
--
-- Idempotent: ON CONFLICT preserves operator-tuned state.
INSERT INTO config.adapters (name, enabled, cadence_s, settings)
VALUES (
'sat_orbits',
false,
300,
'{
"track_only_norad_ids": [],
"forward_minutes": 90,
"sample_seconds": 60,
"max_tle_age_days": 14
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;

View file

@ -13,6 +13,7 @@ them to reference TLEs at known reference times.
from __future__ import annotations from __future__ import annotations
import math import math
from typing import Any
EARTH_RADIUS_KM = 6378.137 EARTH_RADIUS_KM = 6378.137
@ -63,3 +64,53 @@ def subsatellite_point(
lon += 360.0 lon += 360.0
alt = math.sqrt(x * x + y * y + z * z) - EARTH_RADIUS_KM alt = math.sqrt(x * x + y * y + z * z) - EARTH_RADIUS_KM
return lon, lat, alt return lon, lat, alt
def split_antimeridian(
coords: list[tuple[float, float]],
) -> dict[str, Any] | None:
"""Split a (lon, lat) polyline at antimeridian (+/-180) crossings.
Returns None if fewer than 2 vertices. Returns a GeoJSON LineString dict
if no crossings (the common case). Returns a MultiLineString dict when
one or more crossings exist; each crossing closes the current segment at
sign(prev_lon)*180 with a linearly-interpolated latitude, then starts
the next segment at sign(cur_lon)*180 with the same lat. Linear lon/lat
interpolation has sub-0.1 degree error at LEO orbital speeds, well below
Leaflet rendering precision.
Crossing detection: ``abs(cur_lon - prev_lon) > 180``. The "short way"
around the globe between two points is always <=180 degrees of longitude,
so a larger jump only happens when the segment wraps across the dateline.
"""
if len(coords) < 2:
return None
segments: list[list[list[float]]] = []
current: list[list[float]] = [[float(coords[0][0]), float(coords[0][1])]]
for i in range(1, len(coords)):
prev_lon, prev_lat = float(coords[i - 1][0]), float(coords[i - 1][1])
cur_lon, cur_lat = float(coords[i][0]), float(coords[i][1])
if abs(cur_lon - prev_lon) > 180.0:
close_lon = 180.0 if prev_lon >= 0 else -180.0
start_lon = 180.0 if cur_lon >= 0 else -180.0
# Fraction of the segment that lies on the "prev" side of the
# antimeridian. Guard the denominator: both endpoints exactly on
# the dateline (degenerate) -> just split at midpoint of lats.
denom = (180.0 - abs(prev_lon)) + (180.0 - abs(cur_lon))
if denom <= 0:
interp_lat = (prev_lat + cur_lat) / 2.0
else:
frac = (180.0 - abs(prev_lon)) / denom
interp_lat = prev_lat + frac * (cur_lat - prev_lat)
current.append([close_lon, interp_lat])
segments.append(current)
current = [[start_lon, interp_lat], [cur_lon, cur_lat]]
else:
current.append([cur_lon, cur_lat])
segments.append(current)
if len(segments) == 1:
return {"type": "LineString", "coordinates": segments[0]}
return {"type": "MultiLineString", "coordinates": segments}

View file

@ -0,0 +1,275 @@
"""Forward-orbit-track publisher (v0.13.0).
Line counterpart to sat_positions: one LineString (or antimeridian-split
MultiLineString) per tracked satellite per poll, projecting the next 90
minutes of sub-satellite track. ``data_class = "telemetry"`` so these
events surface on /telemetry, not /events -- they're continuous-state
data, not alerts. Geo carries both centroid (current sub-sat point, for
the "where it is" dot) and geometry (the forward track, for the
"where it's going" line).
Math reuses the sat_common SGP4/ECEF/lat-lon helpers; the antimeridian
splitter (also in sat_common) handles polar-orbit dateline crossings so
Leaflet doesn't draw the "wrong-way wrap" Matt saw with the v0.11.2
satpass_predict ground-track render.
"""
from __future__ import annotations
import logging
import sqlite3
from collections.abc import AsyncIterator
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from pydantic import BaseModel
from sgp4.api import Satrec, jday
from central.adapter import SourceAdapter
from central.adapters.sat_common import (
eci_to_ecef,
gmst_rad,
split_antimeridian,
subsatellite_point,
)
from central.config_models import AdapterConfig
from central.config_store import ConfigStore
from central.models import Event, Geo
logger = logging.getLogger(__name__)
_DEDUP_DDL = (
"CREATE TABLE IF NOT EXISTS published_ids ("
"adapter TEXT NOT NULL, event_id TEXT NOT NULL, "
"first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
"last_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
"PRIMARY KEY (adapter, event_id))"
)
_LATEST_TLES_SQL = """
SELECT DISTINCT ON (payload->'data'->'data'->>'norad_id')
(payload->'data'->'data'->>'norad_id')::int AS norad_id,
payload->'data'->'data'->>'satellite_name' AS satellite_name,
payload->'data'->'data'->>'tle_line1' AS tle_line1,
payload->'data'->'data'->>'tle_line2' AS tle_line2,
payload->'data'->'data'->>'epoch' AS tle_epoch
FROM events
WHERE adapter = 'celestrak_tle'
AND time > now() - $1::interval
ORDER BY payload->'data'->'data'->>'norad_id', time DESC
"""
def _propagate_track(
sat: Satrec,
start: datetime,
forward_minutes: int,
sample_seconds: int,
) -> list[tuple[float, float, float]]:
"""Sub-sat track from start, forward_minutes ahead, every sample_seconds.
Returns list of (lon, lat, alt) tuples. Vertices that fail SGP4
propagation are silently skipped (decayed orbit / numerical edge case);
the caller can detect a degenerate result via len() check.
"""
track: list[tuple[float, float, float]] = []
total_steps = (forward_minutes * 60) // sample_seconds
for k in range(total_steps + 1):
t = start + timedelta(seconds=k * sample_seconds)
jd, fr = jday(
t.year, t.month, t.day, t.hour, t.minute,
t.second + t.microsecond / 1e6,
)
err, pos_eci, _ = sat.sgp4(jd, fr)
if err:
continue
pos_ecef = eci_to_ecef(pos_eci, gmst_rad(jd, fr))
lon, lat, alt = subsatellite_point(pos_ecef)
track.append((lon, lat, alt))
return track
class SatOrbitsSettings(BaseModel):
"""track_only_norad_ids empty = track every NORAD ID with a fresh TLE
(derive-from-celestrak_tle default). forward_minutes 90 covers ~1 LEO
orbit at 7.7 km/s. sample_seconds 60 yields ~90 vertices per event;
lower = smoother but heavier JSON. max_tle_age_days bounds TLE
freshness for SGP4 accuracy."""
track_only_norad_ids: list[int] = []
forward_minutes: int = 90
sample_seconds: int = 60
max_tle_age_days: int = 14
class SatOrbitsAdapter(SourceAdapter):
"""Forward-orbit-track telemetry: one LineString per satellite per poll."""
name = "sat_orbits"
display_name = "Satellite Orbit Tracks"
description = (
"Forward-projects each tracked satellite's sub-satellite track for "
"the next 90 minutes (~1 LEO orbit), publishing one LineString "
"telemetry event per satellite per poll. Companion to sat_positions "
"(current point per sat) and satpass_predict (observer-anchored "
"passes). Antimeridian-aware: polar orbits split into MultiLineString "
"to render cleanly across the dateline in Leaflet."
)
settings_schema = SatOrbitsSettings
requires_api_key = None
wizard_order = None
default_cadence_s = 300 # 5 min
data_class = "telemetry"
enrichment_locations = []
def __init__(
self,
config: AdapterConfig,
config_store: ConfigStore,
cursor_db_path: Path,
) -> None:
self._config_store = config_store
self._cursor_db_path = cursor_db_path
self._db: sqlite3.Connection | None = None
self._apply_settings(config.settings or {})
def _apply_settings(self, settings: dict[str, Any]) -> None:
raw_ids = settings.get("track_only_norad_ids") or []
self._track_only: set[int] = {int(n) for n in raw_ids}
self._forward_minutes: int = int(settings.get("forward_minutes") or 90)
self._sample_seconds: int = int(settings.get("sample_seconds") or 60)
self._max_tle_age_days: int = int(settings.get("max_tle_age_days") or 14)
async def startup(self) -> None:
self._db = sqlite3.connect(self._cursor_db_path)
self._db.execute(_DEDUP_DDL)
self._db.execute(
"CREATE INDEX IF NOT EXISTS published_ids_last_seen ON published_ids (last_seen)"
)
self._db.commit()
logger.info(
"sat_orbits adapter started",
extra={
"track_only_count": len(self._track_only),
"forward_minutes": self._forward_minutes,
"sample_seconds": self._sample_seconds,
"max_tle_age_days": self._max_tle_age_days,
},
)
async def shutdown(self) -> None:
if self._db:
self._db.close()
self._db = None
async def apply_config(self, new_config: AdapterConfig) -> None:
self._apply_settings(new_config.settings or {})
logger.info(
"sat_orbits config updated",
extra={
"track_only_count": len(self._track_only),
"forward_minutes": self._forward_minutes,
"sample_seconds": self._sample_seconds,
},
)
async def _fetch_latest_tles(self) -> list[dict[str, Any]]:
pool = self._config_store.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
_LATEST_TLES_SQL, timedelta(days=self._max_tle_age_days),
)
return [dict(r) for r in rows]
def _build_event(
self,
row: dict[str, Any],
propagation_start: datetime,
track: list[tuple[float, float, float]],
) -> Event | None:
if len(track) < 2:
return None
# First vertex is "current" -- the propagation_start sample.
current_lon, current_lat, current_alt = track[0]
geometry = split_antimeridian([(p[0], p[1]) for p in track])
# Truncate to whole seconds so the dedup id collapses two ticks that
# land in the same second. Matches the sat_positions convention.
prop_iso = propagation_start.replace(microsecond=0).isoformat()
return Event(
id=f"{row['norad_id']}:{prop_iso}",
adapter=self.name,
category="orbit.sat_orbits",
time=propagation_start,
severity=1,
geo=Geo(
centroid=(current_lon, current_lat),
geometry=geometry,
),
data={
"norad_id": row["norad_id"],
"satellite_name": row["satellite_name"],
"propagation_start_iso": prop_iso,
"forward_minutes": self._forward_minutes,
"sample_seconds": self._sample_seconds,
"vertex_count": len(track),
"current_lon_deg": round(current_lon, 4),
"current_lat_deg": round(current_lat, 4),
"current_alt_km": round(current_alt, 1),
"tle_epoch": row["tle_epoch"],
},
)
async def poll(self) -> AsyncIterator[Event]:
rows = await self._fetch_latest_tles()
if not rows:
logger.info(
"sat_orbits: no TLEs available; nothing to publish "
"(is celestrak_tle enabled and has it polled at least once?)"
)
return
propagation_start = datetime.now(timezone.utc)
yielded = 0
skipped_parse = 0
skipped_short = 0
for row in rows:
if self._track_only and row["norad_id"] not in self._track_only:
continue
try:
sat = Satrec.twoline2rv(row["tle_line1"], row["tle_line2"])
except Exception:
skipped_parse += 1
logger.warning(
"sat_orbits: TLE parse failed",
extra={"norad_id": row["norad_id"]},
)
continue
track = _propagate_track(
sat, propagation_start,
self._forward_minutes, self._sample_seconds,
)
ev = self._build_event(row, propagation_start, track)
if ev is None:
skipped_short += 1
logger.warning(
"sat_orbits: track too short after propagation",
extra={"norad_id": row["norad_id"], "vertices": len(track)},
)
continue
yield ev
yielded += 1
self.sweep_old_ids()
logger.info(
"sat_orbits poll completed",
extra={
"tles_considered": len(rows),
"track_only_count": len(self._track_only),
"events_yielded": yielded,
"skipped_parse": skipped_parse,
"skipped_short": skipped_short,
},
)
def subject_for(self, event: Event) -> str:
return f"central.sat.orbit.{event.data['norad_id']}"

View file

@ -47,6 +47,7 @@ from central.adapters.sat_common import (
EARTH_RADIUS_KM, EARTH_RADIUS_KM,
eci_to_ecef, eci_to_ecef,
gmst_rad, gmst_rad,
split_antimeridian,
subsatellite_point, subsatellite_point,
) )
from central.config_models import AdapterConfig from central.config_models import AdapterConfig
@ -216,11 +217,15 @@ def _build_pass_geometry(p: dict[str, Any]) -> dict[str, Any] | None:
""" """
geometries: list[dict[str, Any]] = [] geometries: list[dict[str, Any]] = []
track = p.get("ground_track") or [] track = p.get("ground_track") or []
if len(track) >= 2: # v0.13.0: ground-track goes through split_antimeridian so polar-orbit
geometries.append({ # passes crossing the dateline render as MultiLineString instead of
"type": "LineString", # the visible "wrong-way wrap" Leaflet draws when consecutive vertices
"coordinates": [[lon, lat] for lon, lat in track], # jump +179 -> -179. For non-crossing tracks (the common case incl.
}) # all CONUS ISS passes) the splitter returns a LineString identical
# in shape to the prior inline construction.
track_geom = split_antimeridian(list(track))
if track_geom is not None:
geometries.append(track_geom)
peak_subsat = p.get("peak_subsat") peak_subsat = p.get("peak_subsat")
if peak_subsat: if peak_subsat:
lon, lat, alt = peak_subsat lon, lat, alt = peak_subsat

View file

@ -2975,7 +2975,7 @@ DEFAULT_TIME = "last_24h"
ADAPTER_GROUPS = { ADAPTER_GROUPS = {
"Disasters": ["gdacs", "firms", "inciweb", "wfigs_incidents", "wfigs_perimeters"], "Disasters": ["gdacs", "firms", "inciweb", "wfigs_incidents", "wfigs_perimeters"],
"Weather": ["nws"], "Weather": ["nws"],
"Space": ["swpc_alerts", "swpc_kindex", "swpc_protons", "celestrak_tle", "satpass_predict", "sat_positions", "n2yo_visualpasses"], "Space": ["swpc_alerts", "swpc_kindex", "swpc_protons", "celestrak_tle", "satpass_predict", "sat_positions", "n2yo_visualpasses", "sat_orbits"],
"Geophysical": ["usgs_quake", "nwis"], "Geophysical": ["usgs_quake", "nwis"],
"Earth Observation": ["eonet"], "Earth Observation": ["eonet"],
"Transportation": ["wzdx", "tomtom_flow", "tomtom_incidents", "itd_511", "itd_511_cameras"], "Transportation": ["wzdx", "tomtom_flow", "tomtom_incidents", "itd_511", "itd_511_cameras"],

View file

@ -0,0 +1,12 @@
{# sat_orbits forward-orbit-track telemetry. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('satellite_name') is not none %}<dt>Satellite</dt><dd>{{ d.satellite_name }} (NORAD {{ d.norad_id }})</dd>{% endif %}
{% if d.get('forward_minutes') is not none %}<dt>Forward duration</dt><dd>{{ d.forward_minutes }} minutes</dd>{% endif %}
{% if d.get('current_lat_deg') is not none and d.get('current_lon_deg') is not none %}
{%- set ns = 'N' if d.current_lat_deg >= 0 else 'S' -%}
{%- set we = 'E' if d.current_lon_deg >= 0 else 'W' -%}
<dt>Current position</dt><dd>{{ "%.4f"|format(d.current_lat_deg|abs) }}°{{ ns }}, {{ "%.4f"|format(d.current_lon_deg|abs) }}°{{ we }}</dd>{% endif %}
{% if d.get('current_alt_km') is not none %}<dt>Current altitude</dt><dd>{{ "%.1f"|format(d.current_alt_km) }} km</dd>{% endif %}
{% if d.get('sample_seconds') is not none %}<dt>Sample resolution</dt><dd>every {{ d.sample_seconds }} seconds</dd>{% endif %}
{% if d.get('vertex_count') is not none %}<dt>Vertex count</dt><dd>{{ d.vertex_count }}</dd>{% endif %}
{% if d.get('tle_epoch') is not none %}<dt>TLE epoch</dt><dd>{{ d.tle_epoch }}</dd>{% endif %}

View file

@ -0,0 +1,4 @@
{%- set d = (event.data.get('data') or {}).get('data') or {} -%}
{%- if d.get('satellite_name') and d.get('forward_minutes') is not none and d.get('propagation_start_iso') -%}
{{ d.satellite_name }} orbital track — {{ d.forward_minutes }}min forward from {{ d.propagation_start_iso[11:16] }} UTC
{%- endif -%}

View file

@ -153,6 +153,25 @@
var FLOW_COLOR = { "1": "#2ecc71", "2": "#f1c40f", "3": "#e67e22", "4": "#e74c3c" }; var FLOW_COLOR = { "1": "#2ecc71", "2": "#f1c40f", "3": "#e67e22", "4": "#e74c3c" };
function flowColor(sev) { return FLOW_COLOR[sev] || "#7f8c8d"; } function flowColor(sev) { return FLOW_COLOR[sev] || "#7f8c8d"; }
// v0.13.0: sat_orbits colors per NORAD ID so six tracked satellites
// render as six distinguishable lines on the map (the v0.11.2 "all
// passes are orange" problem). event_id shape is "<norad_id>:<iso>"
// so JS reads the first colon-token. Golden-angle hue distribution
// (137.508° step) gives well-spread, repeatable colors per sat.
// Additive: other adapters keep their existing palette color.
function orbitColorForNoradId(norad) {
var hue = (norad * 137.508) % 360;
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);
}
// Flatten arbitrarily-nested GeoJSON coordinates into a flat [lng, lat] list. // Flatten arbitrarily-nested GeoJSON coordinates into a flat [lng, lat] list.
function flattenCoords(coords, out) { function flattenCoords(coords, out) {
if (coords.length && typeof coords[0] === "number") { if (coords.length && typeof coords[0] === "number") {
@ -416,7 +435,7 @@
if (!geom) return; if (!geom) return;
var adapter = row.dataset.adapter || ""; var adapter = row.dataset.adapter || "";
var color = adapter === "tomtom_flow" ? flowColor(row.dataset.severity) : getAdapterColor(adapter); var color = getRowColor(adapter, row);
var op = severityOpacity(row); var op = severityOpacity(row);
// Point-like geometries (Points + zero-extent polygons from // Point-like geometries (Points + zero-extent polygons from

View file

@ -141,7 +141,7 @@ STREAM_CATEGORY_DOMAINS: dict[str, tuple[str, ...]] = {
"CENTRAL_TRAFFIC_FLOW": ("flow",), "CENTRAL_TRAFFIC_FLOW": ("flow",),
"CENTRAL_TRAFFIC_CAMERAS": ("camera",), "CENTRAL_TRAFFIC_CAMERAS": ("camera",),
"CENTRAL_AVY": ("avy",), "CENTRAL_AVY": ("avy",),
"CENTRAL_SAT": ("tle", "pass", "position"), "CENTRAL_SAT": ("tle", "pass", "position", "orbit"),
} }

View file

@ -1178,6 +1178,17 @@ _SAMPLE_INNER = {
"magnitude": -3.4, "magnitude": -3.4,
"max_elevation_deg": 47.0, "max_elevation_deg": 47.0,
}, },
"sat_orbits": {
"satellite_name": "ISS (ZARYA)",
"norad_id": 25544,
"propagation_start_iso": "2026-06-09T22:35:00+00:00",
"forward_minutes": 90,
"sample_seconds": 60,
"vertex_count": 91,
"current_lon_deg": 170.6553,
"current_lat_deg": -17.1487,
"current_alt_km": 417.4,
},
} }
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted # Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
@ -1206,6 +1217,7 @@ _EXPECTED_SUBJECT = {
"satpass_predict": "ISS (ZARYA) passes overhead at 15:39 UTC — max elevation 40°", "satpass_predict": "ISS (ZARYA) passes overhead at 15:39 UTC — max elevation 40°",
"sat_positions": "ISS (ZARYA) at 43.6°N 116.2°W, alt 408km, 7.7km/s", "sat_positions": "ISS (ZARYA) at 43.6°N 116.2°W, alt 408km, 7.7km/s",
"n2yo_visualpasses": "ISS (ZARYA) visible pass at 21:14 UTC — mag -3.4, peak 47°", "n2yo_visualpasses": "ISS (ZARYA) visible pass at 21:14 UTC — mag -3.4, peak 47°",
"sat_orbits": "ISS (ZARYA) orbital track — 90min forward from 22:35 UTC",
} }

View file

@ -20,6 +20,7 @@ from central.adapters.sat_common import (
EARTH_RADIUS_KM, EARTH_RADIUS_KM,
eci_to_ecef, eci_to_ecef,
gmst_rad, gmst_rad,
split_antimeridian,
subsatellite_point, subsatellite_point,
) )
@ -93,6 +94,65 @@ class TestSubsatellitePoint:
assert lon == pytest.approx(-90.0) assert lon == pytest.approx(-90.0)
class TestSplitAntimeridian:
"""v0.13.0 splitter. Returns None for <2 vertices; LineString for tracks
with no crossing; MultiLineString when crossings exist. Each crossing
closes the current segment at sign(prev)*180 with linearly-interpolated
lat and starts the next at sign(cur)*180 with the same lat."""
def test_none_for_empty(self):
assert split_antimeridian([]) is None
def test_none_for_single_vertex(self):
assert split_antimeridian([(0.0, 0.0)]) is None
def test_linestring_for_no_crossing(self):
result = split_antimeridian([(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)])
assert result["type"] == "LineString"
assert len(result["coordinates"]) == 3
def test_multilinestring_for_eastward_crossing(self):
"""+170 -> +179 -> -179 -> -170 crosses +180 once."""
result = split_antimeridian([
(170.0, 0.0), (179.0, 0.0), (-179.0, 0.0), (-170.0, 0.0),
])
assert result["type"] == "MultiLineString"
assert len(result["coordinates"]) == 2
# First segment closes at +180
assert result["coordinates"][0][-1] == [180.0, 0.0]
# Second segment starts at -180
assert result["coordinates"][1][0] == [-180.0, 0.0]
def test_multilinestring_for_westward_crossing(self):
"""-170 -> -179 -> +179 -> +170 crosses -180 once."""
result = split_antimeridian([
(-170.0, 0.0), (-179.0, 0.0), (179.0, 0.0), (170.0, 0.0),
])
assert result["type"] == "MultiLineString"
assert len(result["coordinates"]) == 2
# First segment closes at -180; second starts at +180.
assert result["coordinates"][0][-1] == [-180.0, 0.0]
assert result["coordinates"][1][0] == [180.0, 0.0]
def test_two_crossings_produce_three_segments(self):
"""Polar-orbit-like sequence crossing the dateline twice in 6 vertices."""
result = split_antimeridian([
(170.0, 50.0), (179.0, 60.0), (-179.0, 70.0),
(-179.0, -70.0), (179.0, -60.0), (170.0, -50.0),
])
assert result["type"] == "MultiLineString"
assert len(result["coordinates"]) == 3
def test_interpolated_lat_at_crossing(self):
"""Lat interpolates linearly between pre- and post-crossing vertices.
+179 lat=0 -> -179 lat=10 should put the +/-180 vertex at lat=5."""
result = split_antimeridian([(179.0, 0.0), (-179.0, 10.0)])
assert result["type"] == "MultiLineString"
# Crossing point lat is 5.0
assert result["coordinates"][0][-1] == [180.0, 5.0]
assert result["coordinates"][1][0] == [-180.0, 5.0]
class TestIssRoundTripViaSgp4: class TestIssRoundTripViaSgp4:
"""End-to-end: TLE -> SGP4 ECI -> ECEF -> sub-sat point. Pins the math """End-to-end: TLE -> SGP4 ECI -> ECEF -> sub-sat point. Pins the math
against a known orbital configuration. Drift from this would mean the against a known orbital configuration. Drift from this would mean the

285
tests/test_sat_orbits.py Normal file
View file

@ -0,0 +1,285 @@
"""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

View file

@ -543,6 +543,32 @@ def test_build_pass_geometry_returns_geometrycollection_with_both_shapes():
assert ring[0] == ring[-1] assert ring[0] == ring[-1]
def test_build_pass_geometry_uses_split_antimeridian_for_polar_track():
"""v0.13.0: a synthesized polar-orbit ground_track that crosses +/-180
must produce a MultiLineString inside the GeometryCollection, NOT the
"wrong-way wrap" LineString the v0.11.2 inline builder produced.
Wired into _build_pass_geometry via sat_common.split_antimeridian."""
polar_track = [
(170.0, 60.0), (175.0, 65.0), (179.0, 70.0),
(-179.0, 75.0), (-175.0, 80.0), (-170.0, 85.0),
]
geom = _build_pass_geometry({
"ground_track": polar_track,
"peak_subsat": (-179.0, 75.0, 400.0),
})
assert geom is not None
assert geom["type"] == "GeometryCollection"
types = [g["type"] for g in geom["geometries"]]
assert "MultiLineString" in types, (
f"polar track must split at antimeridian; got types={types}"
)
mls = next(g for g in geom["geometries"] if g["type"] == "MultiLineString")
assert len(mls["coordinates"]) == 2 # one crossing -> two segments
# First segment closes at +180; second starts at -180.
assert mls["coordinates"][0][-1][0] == 180.0
assert mls["coordinates"][1][0][0] == -180.0
def test_build_pass_geometry_returns_none_when_inputs_missing(): def test_build_pass_geometry_returns_none_when_inputs_missing():
"""Defensive: pass dict with no track + no peak_subsat -> None (don't """Defensive: pass dict with no track + no peak_subsat -> None (don't
write an empty GeometryCollection to the wire).""" write an empty GeometryCollection to the wire)."""

View file

@ -13,7 +13,9 @@ from central.gui import routes
# Adapters with data_class="telemetry" (the pinned split; grow as telemetry adapters land). # 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.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.12.0 added sat_positions (60s sub-sat point per tracked satellite).
_TELEMETRY = ["celestrak_tle", "itd_511_cameras", "nwis", "sat_positions", "tomtom_flow"] # 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 ----------------------------------- # --- data_class defaults / registry split -----------------------------------