mirror of
https://github.com/zvx-echo6/central.git
synced 2026-08-26 17:31:39 +00:00
v0.13.0: sat_orbits adapter (forward-orbit-track per satellite) + antimeridian splitter
New SourceAdapter publishes one forward-orbit-track LineString per tracked
satellite per poll (5min cadence, 90min horizon, 60s vertex resolution).
Drives the "each sat's path" map view Matt asked for after enabling the
satellite family and seeing overlapping orange visibility-footprint
circles + a polar-orbit ground track wrapping the wrong way across the
antimeridian.
Companion to v0.12.0 sat_positions: one publishes the current sub-sat POINT
per minute, sat_orbits publishes the LINE of where it's going. Complement,
not replacement.
data_class=telemetry (continuous trajectory state, surfaces on /telemetry).
Geo carries both centroid (current sub-sat point for the "here it is" dot)
and geometry (the forward track LineString or MultiLineString).
Antimeridian splitter is the key new sat_common primitive: walks the
vertex list, splits at +/-180 crossings, interpolates lat at the crossing
point for crisp dateline termination. ALSO fixes the v0.11.2
satpass_predict "wrong-way wrap" bug by rewiring _build_pass_geometry's
ground_track through the same splitter (sibling concern, documented in
the PR body as intentional scope-coupling).
CENTRAL_SAT stream STREAM_CATEGORY_DOMAINS extends from
('tle', 'pass', 'position') to ('tle', 'pass', 'position', 'orbit'). No
max_bytes bump needed; 6 sats x 12 polls/hour x 24 hours x ~5KB = ~8.5
MB/day, negligible against the 5 GiB cap.
GUI events_list.html adds a small per-NORAD-ID color helper using
golden-angle HSL hue distribution. sat_orbits events render with
per-satellite colors; other adapters keep their existing per-adapter
palette color (additive).
Phase A sanity verified: ISS TLE at 2026-06-09T07:00 UTC propagates to
91 vertices over 90min, first vertex matches v0.11.1's known sub-sat
point (170.66 lon, -17.15 lat, 417.4 km alt), one antimeridian crossing
splits the track into a 2-segment MultiLineString.
44/44 satpass_predict regression-guard tests pass after the
_build_pass_geometry rewire. One new test specifically exercises the
splitter inside _build_pass_geometry for a synthesized polar-orbit
ground_track.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8e388dabd5
commit
03602c02f8
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
|
||||
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
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
EARTH_RADIUS_KM = 6378.137
|
||||
|
||||
|
|
@ -63,3 +64,53 @@ def subsatellite_point(
|
|||
lon += 360.0
|
||||
alt = math.sqrt(x * x + y * y + z * z) - EARTH_RADIUS_KM
|
||||
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,
|
||||
eci_to_ecef,
|
||||
gmst_rad,
|
||||
split_antimeridian,
|
||||
subsatellite_point,
|
||||
)
|
||||
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]] = []
|
||||
track = p.get("ground_track") or []
|
||||
if len(track) >= 2:
|
||||
geometries.append({
|
||||
"type": "LineString",
|
||||
"coordinates": [[lon, lat] for lon, lat in track],
|
||||
})
|
||||
# v0.13.0: ground-track goes through split_antimeridian so polar-orbit
|
||||
# passes crossing the dateline render as MultiLineString instead of
|
||||
# the visible "wrong-way wrap" Leaflet draws when consecutive vertices
|
||||
# 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")
|
||||
if peak_subsat:
|
||||
lon, lat, alt = peak_subsat
|
||||
|
|
|
|||
|
|
@ -2975,7 +2975,7 @@ DEFAULT_TIME = "last_24h"
|
|||
ADAPTER_GROUPS = {
|
||||
"Disasters": ["gdacs", "firms", "inciweb", "wfigs_incidents", "wfigs_perimeters"],
|
||||
"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"],
|
||||
"Earth Observation": ["eonet"],
|
||||
"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" };
|
||||
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.
|
||||
function flattenCoords(coords, out) {
|
||||
if (coords.length && typeof coords[0] === "number") {
|
||||
|
|
@ -416,7 +435,7 @@
|
|||
if (!geom) return;
|
||||
|
||||
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);
|
||||
|
||||
// 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_CAMERAS": ("camera",),
|
||||
"CENTRAL_AVY": ("avy",),
|
||||
"CENTRAL_SAT": ("tle", "pass", "position"),
|
||||
"CENTRAL_SAT": ("tle", "pass", "position", "orbit"),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1178,6 +1178,17 @@ _SAMPLE_INNER = {
|
|||
"magnitude": -3.4,
|
||||
"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
|
||||
|
|
@ -1206,6 +1217,7 @@ _EXPECTED_SUBJECT = {
|
|||
"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",
|
||||
"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,
|
||||
eci_to_ecef,
|
||||
gmst_rad,
|
||||
split_antimeridian,
|
||||
subsatellite_point,
|
||||
)
|
||||
|
||||
|
|
@ -93,6 +94,65 @@ class TestSubsatellitePoint:
|
|||
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:
|
||||
"""End-to-end: TLE -> SGP4 ECI -> ECEF -> sub-sat point. Pins the math
|
||||
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]
|
||||
|
||||
|
||||
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():
|
||||
"""Defensive: pass dict with no track + no peak_subsat -> None (don't
|
||||
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).
|
||||
# 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).
|
||||
_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 -----------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue