mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
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:
parent
8e388dabd5
commit
3f1fec9846
15 changed files with 838 additions and 9 deletions
|
|
@ -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.
|
||||||
|
|
||||||
\
|
\
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
37
sql/migrations/041_add_sat_orbits_adapter.sql
Normal file
37
sql/migrations/041_add_sat_orbits_adapter.sql
Normal 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;
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
275
src/central/adapters/sat_orbits.py
Normal file
275
src/central/adapters/sat_orbits.py
Normal 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']}"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
12
src/central/gui/templates/_event_rows/sat_orbits.html
Normal file
12
src/central/gui/templates/_event_rows/sat_orbits.html
Normal 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 %}
|
||||||
|
|
@ -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 -%}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
285
tests/test_sat_orbits.py
Normal 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
|
||||||
|
|
@ -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)."""
|
||||||
|
|
|
||||||
|
|
@ -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 -----------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue