From 3f1fec98468810ead021ab5f2a85a1701fbdb930 Mon Sep 17 00:00:00 2001 From: malice Date: Tue, 9 Jun 2026 18:50:47 -0600 Subject: [PATCH] v0.13.0: sat_orbits adapter (forward-orbit-track per satellite) + antimeridian splitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `:` (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) --- docs/CONSUMER-INTEGRATION.md | 41 +++ sql/migrations/041_add_sat_orbits_adapter.sql | 37 +++ src/central/adapters/sat_common.py | 51 ++++ src/central/adapters/sat_orbits.py | 275 +++++++++++++++++ src/central/adapters/satpass_predict.py | 15 +- src/central/gui/routes.py | 2 +- .../gui/templates/_event_rows/sat_orbits.html | 12 + .../_event_summaries/sat_orbits.html | 4 + src/central/gui/templates/events_list.html | 21 +- src/central/supervisor.py | 2 +- tests/test_events_feed_frontend.py | 12 + tests/test_sat_common.py | 60 ++++ tests/test_sat_orbits.py | 285 ++++++++++++++++++ tests/test_satpass_predict.py | 26 ++ tests/test_telemetry_separation.py | 4 +- 15 files changed, 838 insertions(+), 9 deletions(-) create mode 100644 sql/migrations/041_add_sat_orbits_adapter.sql create mode 100644 src/central/adapters/sat_orbits.py create mode 100644 src/central/gui/templates/_event_rows/sat_orbits.html create mode 100644 src/central/gui/templates/_event_summaries/sat_orbits.html create mode 100644 tests/test_sat_orbits.py diff --git a/docs/CONSUMER-INTEGRATION.md b/docs/CONSUMER-INTEGRATION.md index f0b43e0..a7e342a 100644 --- a/docs/CONSUMER-INTEGRATION.md +++ b/docs/CONSUMER-INTEGRATION.md @@ -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.` — 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:** `:` 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. + \ --- diff --git a/sql/migrations/041_add_sat_orbits_adapter.sql b/sql/migrations/041_add_sat_orbits_adapter.sql new file mode 100644 index 0000000..608aa6f --- /dev/null +++ b/sql/migrations/041_add_sat_orbits_adapter.sql @@ -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.. 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; diff --git a/src/central/adapters/sat_common.py b/src/central/adapters/sat_common.py index 368e784..0b6b4d0 100644 --- a/src/central/adapters/sat_common.py +++ b/src/central/adapters/sat_common.py @@ -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} diff --git a/src/central/adapters/sat_orbits.py b/src/central/adapters/sat_orbits.py new file mode 100644 index 0000000..505e4fe --- /dev/null +++ b/src/central/adapters/sat_orbits.py @@ -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']}" diff --git a/src/central/adapters/satpass_predict.py b/src/central/adapters/satpass_predict.py index a1d2e40..877d464 100644 --- a/src/central/adapters/satpass_predict.py +++ b/src/central/adapters/satpass_predict.py @@ -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 diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index dca1901..b46a1c1 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -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"], diff --git a/src/central/gui/templates/_event_rows/sat_orbits.html b/src/central/gui/templates/_event_rows/sat_orbits.html new file mode 100644 index 0000000..e7378b0 --- /dev/null +++ b/src/central/gui/templates/_event_rows/sat_orbits.html @@ -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 %}
Satellite
{{ d.satellite_name }} (NORAD {{ d.norad_id }})
{% endif %} +{% if d.get('forward_minutes') is not none %}
Forward duration
{{ d.forward_minutes }} minutes
{% 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' -%} +
Current position
{{ "%.4f"|format(d.current_lat_deg|abs) }}°{{ ns }}, {{ "%.4f"|format(d.current_lon_deg|abs) }}°{{ we }}
{% endif %} +{% if d.get('current_alt_km') is not none %}
Current altitude
{{ "%.1f"|format(d.current_alt_km) }} km
{% endif %} +{% if d.get('sample_seconds') is not none %}
Sample resolution
every {{ d.sample_seconds }} seconds
{% endif %} +{% if d.get('vertex_count') is not none %}
Vertex count
{{ d.vertex_count }}
{% endif %} +{% if d.get('tle_epoch') is not none %}
TLE epoch
{{ d.tle_epoch }}
{% endif %} diff --git a/src/central/gui/templates/_event_summaries/sat_orbits.html b/src/central/gui/templates/_event_summaries/sat_orbits.html new file mode 100644 index 0000000..b641a48 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/sat_orbits.html @@ -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 -%} diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html index d5102cc..a6d09bb 100644 --- a/src/central/gui/templates/events_list.html +++ b/src/central/gui/templates/events_list.html @@ -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 ":" + // 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 diff --git a/src/central/supervisor.py b/src/central/supervisor.py index 6bfde04..7c56b54 100644 --- a/src/central/supervisor.py +++ b/src/central/supervisor.py @@ -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"), } diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index b535bbb..00c42de 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -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", } diff --git a/tests/test_sat_common.py b/tests/test_sat_common.py index eaf9fe6..7efecce 100644 --- a/tests/test_sat_common.py +++ b/tests/test_sat_common.py @@ -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 diff --git a/tests/test_sat_orbits.py b/tests/test_sat_orbits.py new file mode 100644 index 0000000..7772aa6 --- /dev/null +++ b/tests/test_sat_orbits.py @@ -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 diff --git a/tests/test_satpass_predict.py b/tests/test_satpass_predict.py index 69ba10f..70a3ea0 100644 --- a/tests/test_satpass_predict.py +++ b/tests/test_satpass_predict.py @@ -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).""" diff --git a/tests/test_telemetry_separation.py b/tests/test_telemetry_separation.py index 9910543..8ff36ec 100644 --- a/tests/test_telemetry_separation.py +++ b/tests/test_telemetry_separation.py @@ -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 -----------------------------------