diff --git a/docs/CONSUMER-INTEGRATION.md b/docs/CONSUMER-INTEGRATION.md index 0526acb..51cf4b4 100644 --- a/docs/CONSUMER-INTEGRATION.md +++ b/docs/CONSUMER-INTEGRATION.md @@ -1899,6 +1899,46 @@ at parameter `00060`, gage height (ft) at `00065`, water temperature (°C) at (adapter still disabled, or hasn't polled yet), the adapter logs at INFO and yields zero events — no exception. +### sat_positions — live global satellite positions (v0.12.0) + +- **Source:** same as `satpass_predict` — reads the latest TLE per `norad_id` + emitted by `celestrak_tle` (within the configurable `max_tle_age_days` + window, default 14 days), then propagates each with SGP4. Unlike + `satpass_predict`, no observer is involved — this adapter is the **global** + counterpart, publishing where each satellite *is* rather than when it + passes overhead at a fixed site. +- **Data class:** `telemetry`. Surfaces on `/telemetry`, not `/events`; 60s + ticks across ~190 sats would drown discrete-event signal otherwise. +- **Stream:** `CENTRAL_SAT` (same stream as TLEs and passes; v0.12.0 extends + `STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"]` to `("tle", "pass", "position")`). +- **Subject:** `central.sat.position.` — one subject per satellite, + globally (no state coord because positions are global). Consumers can + subscribe to `central.sat.position.>` for the whole live feed, or pin to + a single satellite (`central.sat.position.25544` = ISS). +- **Dedup key shape:** `:` where `position_iso` is + the propagation timestamp truncated to whole seconds. Two ticks landing + in the same second collapse (defensive at 60s cadence). +- **Severity:** always 1 (informational telemetry, no alerting). +- **Geo:** `centroid = (lon_deg, lat_deg)` — the sub-satellite point, so a + consumer-side map can plot the satellite directly. No `geometry` overlay + in v1 (a 60s forward-track LineString is plausible for v0.12.1+ if a + consumer asks). +- **Event.data fields:** `norad_id`, `satellite_name`, `lon_deg`, `lat_deg`, + `alt_km` (km above the equatorial radius, sub-satellite point altitude), + `velocity_kmps` (orbital speed magnitude from SGP4 ECI velocity), `heading_deg` + (great-circle bearing of motion, derived by finite-difference between + positions 1s apart; 0=N, 90=E), `tle_epoch`. +- **Cadence:** 60s default. LEO at 60s ticks gives a watchable live map + (~462 km of ground track per tick at ~7.7 km/s). GEO satellites barely + move at minute scale; if an operator pins `track_only_norad_ids` to GEO + only, dropping cadence to 300s is reasonable. +- **Settings:** `track_only_norad_ids` empty = track every NORAD ID with a + fresh TLE in the events table (default, derive-from-celestrak_tle). + Non-empty list pins to those IDs only. `max_tle_age_days = 14` bounds + how stale a TLE can be before propagation is considered too drifty. +- **Empty-TLE behaviour:** logs INFO and yields zero events, same as + `satpass_predict`. Enable `celestrak_tle` first. + \ --- diff --git a/sql/migrations/039_add_sat_positions_adapter.sql b/sql/migrations/039_add_sat_positions_adapter.sql new file mode 100644 index 0000000..44f52c1 --- /dev/null +++ b/sql/migrations/039_add_sat_positions_adapter.sql @@ -0,0 +1,58 @@ +-- Migration 039: register sat_positions adapter + bump CENTRAL_SAT cap (v0.12.0) +-- +-- Live global satellite-position publisher: one telemetry event per tracked +-- NORAD ID per poll on subject central.sat.position.. Complement +-- to satpass_predict (observer-anchored alerts -- v0.11.1): sat_positions +-- is the *global* counterpart, "where is sat X right now" rather than +-- "when is sat X overhead at observer Y". +-- +-- Publishes on the existing CENTRAL_SAT stream via the supervisor's +-- STREAM_CATEGORY_DOMAINS extension ("CENTRAL_SAT": ("tle", "pass", +-- "position")) -- no new stream is created. +-- +-- Cadence 60s (1 minute) by default. LEO sub-satellite points drift +-- visibly at minute scale (~7.7 km/s * 60s = 462 km of ground track), so +-- 60s ticks give a watchable live map. GEO sats barely move at minute +-- scale, but cadence is operator-tunable per-adapter -- if a future +-- operator pin-list contains only GEO sats they can drop cadence to +-- 300s with no code change. +-- +-- Ships disabled (enabled=false) -- celestrak_tle must be enabled and +-- have polled at least once for sat_positions to have TLE data to +-- propagate. Operator enables via GUI after celestrak_tle is producing. +-- +-- Settings shape: +-- track_only_norad_ids: empty list = track every NORAD ID with a fresh +-- TLE in the events table (default behavior, derive-from-celestrak_tle). +-- Operator-pinned non-empty list restricts the set. +-- max_tle_age_days: 14 = TLEs older than 14d are skipped to keep +-- SGP4 propagation drift bounded. Operator can tighten (e.g. 3d) for +-- drag-sensitive LEO accuracy or widen for stale-tolerant feeds. +-- +-- CENTRAL_SAT max_bytes bump from 1 GiB -> 5 GiB. Sizing rationale: +-- celestrak_tle alone produced ~190 sats * 1 envelope/day ~= 190 events/day +-- ~= ~1.4k events/week; that fit in 1 GiB easily. +-- sat_positions adds ~190 sats * 1440 ticks/day (60s cadence) +-- ~= 273,600 events/day ~= 1.92M events/week. At ~1 KB per envelope +-- including CloudEvents wrapper, that's ~1.9 GiB/week. Plus the existing +-- TLE + pass envelopes the stream already carries: ~3 GiB headroom needed. +-- 5 GiB (5368709120 bytes) gives operator-tunable margin without +-- over-provisioning. +-- +-- Idempotent: ON CONFLICT clauses preserve any operator-tuned state. + +UPDATE config.streams + SET max_bytes = 5368709120 + WHERE name = 'CENTRAL_SAT'; + +INSERT INTO config.adapters (name, enabled, cadence_s, settings) +VALUES ( + 'sat_positions', + false, + 60, + '{ + "track_only_norad_ids": [], + "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 new file mode 100644 index 0000000..368e784 --- /dev/null +++ b/src/central/adapters/sat_common.py @@ -0,0 +1,65 @@ +"""Shared SGP4 / ECEF helpers for satellite adapters. + +Extracted from satpass_predict.py in v0.12.0 when sat_positions needed the +same primitives. Moving them to a sibling module (rather than having +sat_positions cross-import from satpass_predict) keeps both adapters +peers of a common helper, matching the wfigs_common / swpc_common +precedent. + +All helpers here are pure math: no I/O, no global state. Tests can pin +them to reference TLEs at known reference times. +""" + +from __future__ import annotations + +import math + +EARTH_RADIUS_KM = 6378.137 + + +def gmst_rad(jd: float, fr: float) -> float: + """Greenwich Mean Sidereal Time in radians (Vallado, simplified). + + Accurate to within milliseconds for any post-1900 epoch -- plenty for + horizon/elevation and sub-satellite point work. + """ + t = (jd + fr - 2451545.0) / 36525.0 + gmst_sec = ( + 67310.54841 + + (876600.0 * 3600.0 + 8640184.812866) * t + + 0.093104 * t * t + - 6.2e-6 * t * t * t + ) + return (gmst_sec % 86400.0) * (2.0 * math.pi / 86400.0) + + +def eci_to_ecef( + pos_eci_km: tuple[float, float, float], theta: float, +) -> tuple[float, float, float]: + """Rotate ECI coordinates to ECEF by GMST angle theta (radians).""" + x, y, z = pos_eci_km + ct = math.cos(theta) + st = math.sin(theta) + return (ct * x + st * y, -st * x + ct * y, z) + + +def subsatellite_point( + pos_ecef_km: tuple[float, float, float], +) -> tuple[float, float, float]: + """ECEF (km) -> ``(lon_deg, lat_deg, alt_km)``. + + Sub-satellite point is the ground location directly beneath the satellite + on a spherical earth. Longitude normalised to [-180, 180]. Altitude is + geocentric height above the equatorial radius (not WGS-84 height above + ellipsoid -- close enough for footprint-radius and tracking-map work). + """ + x, y, z = pos_ecef_km + horizontal = math.sqrt(x * x + y * y) + lat = math.degrees(math.atan2(z, horizontal)) + lon = math.degrees(math.atan2(y, x)) + if lon > 180.0: + lon -= 360.0 + elif lon < -180.0: + lon += 360.0 + alt = math.sqrt(x * x + y * y + z * z) - EARTH_RADIUS_KM + return lon, lat, alt diff --git a/src/central/adapters/sat_positions.py b/src/central/adapters/sat_positions.py new file mode 100644 index 0000000..f635a3f --- /dev/null +++ b/src/central/adapters/sat_positions.py @@ -0,0 +1,286 @@ +"""Global satellite positions -- live sub-satellite point publisher (v0.12.0). + +Complement to satpass_predict (which is observer-anchored: "when does sat X +pass over observer Y?"). This adapter is global: "where is sat X right now?" +Publishes one telemetry Event per tracked satellite per poll (default 60s) +on subject ``central.sat.position.`` so any consumer (meshAI, GUI +map, ...) can render a live world map of every tracked satellite without +caring about observers. + +TLE source is the same celestrak_tle events table that satpass_predict +reads -- enable that adapter first. Empty TLE table = zero events yielded, +no exception. + +Math: SGP4 propagation gives ECI position + velocity; ECEF rotation gives +the sub-satellite point. Velocity magnitude is the orbital speed. Heading +is the great-circle bearing of motion derived by finite-difference between +the current position and a position 1 second later (avoids rotating the +velocity vector through GMST + the earth-rotation cross term). + +Severity 1 (informational telemetry). ``data_class = "telemetry"`` so these +events surface on /telemetry, not /events -- 60s ticks across ~190 sats +would drown discrete-event signal otherwise. + +Dedup id ``:`` where position_iso is the +propagation timestamp truncated to whole seconds. Two ticks landing in the +same second collapse (defensive at 60s cadence; matters if cadence is ever +tightened past once-per-second). +""" + +from __future__ import annotations + +import logging +import math +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, 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))" +) + +# Parameterized on max_tle_age_days so operator-tightened windows (e.g. 3 days +# for high-drag LEO emphasis) apply without a string-interpolated interval. +_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 _great_circle_bearing( + lon1_deg: float, lat1_deg: float, lon2_deg: float, lat2_deg: float, +) -> float: + """Initial bearing from point 1 to point 2 in [0, 360) degrees. + + 0 = north, 90 = east, 180 = south, 270 = west. Used for instantaneous + heading via finite-difference between two SGP4 samples 1 second apart. + """ + lat1 = math.radians(lat1_deg) + lat2 = math.radians(lat2_deg) + dlon = math.radians(lon2_deg - lon1_deg) + y = math.sin(dlon) * math.cos(lat2) + x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon) + return math.degrees(math.atan2(y, x)) % 360.0 + + +def _propagate_position( + sat: Satrec, t: datetime, +) -> tuple[tuple[float, float, float], tuple[float, float, float], float, float, float] | None: + """One SGP4 step. Returns ``(pos_ecef, vel_eci, lon, lat, alt)`` or None on err.""" + jd, fr = jday(t.year, t.month, t.day, t.hour, t.minute, t.second + t.microsecond / 1e6) + err, pos_eci, vel_eci = sat.sgp4(jd, fr) + if err: + return None + pos_ecef = eci_to_ecef(pos_eci, gmst_rad(jd, fr)) + lon, lat, alt = subsatellite_point(pos_ecef) + return pos_ecef, vel_eci, lon, lat, alt + + +class SatPositionsSettings(BaseModel): + """track_only_norad_ids empty = track every NORAD ID with a fresh TLE + (derive-from-celestrak_tle, the default). Non-empty list pins to those + NORAD IDs only -- useful for "I only care about the ISS and Starlink-N". + max_tle_age_days bounds how stale a TLE can be before the propagation + is considered too drifty to publish; LEO drag means TLEs go stale in + days, GEO satellites are good for months. 14 is a safe default.""" + track_only_norad_ids: list[int] = [] + max_tle_age_days: int = 14 + + +class SatPositionsAdapter(SourceAdapter): + """Live global satellite-position telemetry.""" + + name = "sat_positions" + display_name = "Live Satellite Positions" + description = ( + "Publishes the current sub-satellite point (lon, lat, alt) for every " + "tracked NORAD ID by propagating the latest TLE via SGP4. One " + "telemetry event per satellite per poll (default 60s) so consumers " + "can render a live world map of where the satellites are right now. " + "Source TLEs come from the celestrak_tle adapter -- enable that first." + ) + settings_schema = SatPositionsSettings + requires_api_key = None + wizard_order = None + default_cadence_s = 60 + 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._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_positions adapter started", + extra={ + "track_only_count": len(self._track_only), + "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_positions config updated", + extra={ + "track_only_count": len(self._track_only), + "max_tle_age_days": self._max_tle_age_days, + }, + ) + + async def _fetch_latest_tles(self) -> list[dict[str, Any]]: + """Latest TLE row per norad_id within the configured age window. + Empty list if no TLEs available; never raises.""" + 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], + ref_time: datetime, + lon_deg: float, + lat_deg: float, + alt_km: float, + velocity_kmps: float, + heading_deg: float, + ) -> Event: + # Truncate to whole seconds so the dedup id collapses two ticks that + # land in the same second. Defensive at 60s cadence; relevant if the + # operator ever drops cadence below 1s (won't happen, but the bug + # would be silent dedup-window collisions instead of a clear error). + position_iso = ref_time.replace(microsecond=0).isoformat() + return Event( + id=f"{row['norad_id']}:{position_iso}", + adapter=self.name, + category="position.sat_positions", + time=ref_time, + severity=1, + geo=Geo(centroid=(lon_deg, lat_deg)), + data={ + "norad_id": row["norad_id"], + "satellite_name": row["satellite_name"], + "lon_deg": round(lon_deg, 4), + "lat_deg": round(lat_deg, 4), + "alt_km": round(alt_km, 1), + "velocity_kmps": round(velocity_kmps, 3), + "heading_deg": round(heading_deg, 1), + "tle_epoch": row["tle_epoch"], + }, + ) + + async def poll(self) -> AsyncIterator[Event]: + rows = await self._fetch_latest_tles() + if not rows: + logger.info( + "sat_positions: no TLEs available; nothing to publish " + "(is celestrak_tle enabled and has it polled at least once?)" + ) + return + + ref_time = datetime.now(timezone.utc) + ref_plus_1s = ref_time + timedelta(seconds=1) + yielded = 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: + logger.warning( + "sat_positions: TLE parse failed", + extra={"norad_id": row["norad_id"]}, + ) + continue + sample = _propagate_position(sat, ref_time) + if sample is None: + logger.warning( + "sat_positions: SGP4 propagation failed", + extra={"norad_id": row["norad_id"]}, + ) + continue + _, vel_eci, lon_deg, lat_deg, alt_km = sample + # Velocity magnitude in ECI -- close enough to ECEF for "the sat + # is moving at X km/s" consumer text (earth rotation is ~0.46 + # km/s at equator vs ~7.7 km/s LEO orbital speed, sub-6% delta). + velocity_kmps = math.sqrt( + vel_eci[0] ** 2 + vel_eci[1] ** 2 + vel_eci[2] ** 2, + ) + sample_next = _propagate_position(sat, ref_plus_1s) + if sample_next is None: + heading_deg = 0.0 + else: + _, _, lon_next, lat_next, _ = sample_next + heading_deg = _great_circle_bearing( + lon_deg, lat_deg, lon_next, lat_next, + ) + yield self._build_event( + row, ref_time, lon_deg, lat_deg, alt_km, + velocity_kmps, heading_deg, + ) + yielded += 1 + + self.sweep_old_ids() + logger.info( + "sat_positions poll completed", + extra={ + "tles_considered": len(rows), + "track_only_count": len(self._track_only), + "events_yielded": yielded, + }, + ) + + def subject_for(self, event: Event) -> str: + return f"central.sat.position.{event.data['norad_id']}" diff --git a/src/central/adapters/satpass_predict.py b/src/central/adapters/satpass_predict.py index 6fdbda3..a1d2e40 100644 --- a/src/central/adapters/satpass_predict.py +++ b/src/central/adapters/satpass_predict.py @@ -43,18 +43,18 @@ from pydantic import BaseModel from sgp4.api import Satrec, jday from central.adapter import SourceAdapter +from central.adapters.sat_common import ( + EARTH_RADIUS_KM, + eci_to_ecef, + gmst_rad, + subsatellite_point, +) from central.config_models import AdapterConfig from central.config_store import ConfigStore from central.models import Event, Geo logger = logging.getLogger(__name__) -# Earth equatorial radius (WGS-84). Used for observer ECEF position; we treat -# the earth as spherical for topocentric look-angle math -- ellipsoidal effects -# matter for centimeter-level GPS work but are well below 0.1° in elevation, -# more than enough for "is the satellite above the horizon" decisions. -_EARTH_RADIUS_KM = 6378.137 - _PASS_STEP_S = 60 # 60-second grid for elevation sampling _DEDUP_DDL = ( "CREATE TABLE IF NOT EXISTS published_ids (" @@ -81,35 +81,11 @@ ORDER BY payload->'data'->'data'->>'norad_id', time DESC # --- Pure math helpers (no I/O) --------------------------------------------- -def _gmst_rad(jd: float, fr: float) -> float: - """Greenwich Mean Sidereal Time in radians (Vallado, simplified). - - Accurate to within milliseconds for any post-1900 epoch -- plenty for - horizon/elevation work. - """ - t = (jd + fr - 2451545.0) / 36525.0 - gmst_sec = ( - 67310.54841 - + (876600.0 * 3600.0 + 8640184.812866) * t - + 0.093104 * t * t - - 6.2e-6 * t * t * t - ) - return (gmst_sec % 86400.0) * (2.0 * math.pi / 86400.0) - - -def _eci_to_ecef(pos_eci_km: tuple[float, float, float], theta: float) -> tuple[float, float, float]: - """Rotate ECI coordinates to ECEF by GMST angle theta (radians).""" - x, y, z = pos_eci_km - ct = math.cos(theta) - st = math.sin(theta) - return (ct * x + st * y, -st * x + ct * y, z) - - def _observer_ecef(lat_deg: float, lon_deg: float, elev_m: float) -> tuple[float, float, float]: """Observer position in ECEF km (spherical earth, sub-0.1° precision).""" lat_r = math.radians(lat_deg) lon_r = math.radians(lon_deg) - r = _EARTH_RADIUS_KM + elev_m / 1000.0 + r = EARTH_RADIUS_KM + elev_m / 1000.0 return ( r * math.cos(lat_r) * math.cos(lon_r), r * math.cos(lat_r) * math.sin(lon_r), @@ -164,7 +140,7 @@ def _sample_at( err, pos_eci, _ = sat.sgp4(jd, fr) if err: return None - sat_ecef = _eci_to_ecef(pos_eci, _gmst_rad(jd, fr)) + sat_ecef = eci_to_ecef(pos_eci, gmst_rad(jd, fr)) az, el = _topocentric_az_el(sat_ecef, obs_ecef_km, obs_lat_deg, obs_lon_deg) return az, el, sat_ecef @@ -185,26 +161,6 @@ def _elev_at( return None if sample is None else (sample[0], sample[1]) -def _subsatellite_point(pos_ecef_km: tuple[float, float, float]) -> tuple[float, float, float]: - """ECEF (km) -> ``(lon_deg, lat_deg, alt_km)``. - - Sub-satellite point is the ground location directly beneath the satellite - on a spherical earth. Longitude normalised to [-180, 180]. Altitude is - geocentric height above the equatorial radius (not WGS-84 height above - ellipsoid -- close enough for footprint-radius math). - """ - x, y, z = pos_ecef_km - horizontal = math.sqrt(x * x + y * y) - lat = math.degrees(math.atan2(z, horizontal)) - lon = math.degrees(math.atan2(y, x)) - if lon > 180.0: - lon -= 360.0 - elif lon < -180.0: - lon += 360.0 - alt = math.sqrt(x * x + y * y + z * z) - _EARTH_RADIUS_KM - return lon, lat, alt - - def _visibility_footprint( lon_deg: float, lat_deg: float, alt_km: float, n_vertices: int = 32, ) -> dict[str, Any] | None: @@ -225,7 +181,7 @@ def _visibility_footprint( """ if alt_km <= 0: return None - r_earth = _EARTH_RADIUS_KM + r_earth = EARTH_RADIUS_KM radius_km = r_earth * math.acos(r_earth / (r_earth + alt_km)) angular = radius_km / r_earth lat1 = math.radians(lat_deg) @@ -329,7 +285,7 @@ def _next_passes( t += step continue az, e, sat_ecef = sample - subsat = _subsatellite_point(sat_ecef) # (lon, lat, alt) + subsat = subsatellite_point(sat_ecef) # (lon, lat, alt) if e >= min_elevation_deg: if not in_pass: in_pass = True diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index 3b784b7..02789ba 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"], + "Space": ["swpc_alerts", "swpc_kindex", "swpc_protons", "celestrak_tle", "satpass_predict", "sat_positions"], "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_positions.html b/src/central/gui/templates/_event_rows/sat_positions.html new file mode 100644 index 0000000..134d3ec --- /dev/null +++ b/src/central/gui/templates/_event_rows/sat_positions.html @@ -0,0 +1,8 @@ +{# sat_positions live position 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('lat_deg') is not none and d.get('lon_deg') is not none %}
Sub-satellite point
{{ "%.4f"|format(d.lat_deg) }}°, {{ "%.4f"|format(d.lon_deg) }}°
{% endif %} +{% if d.get('alt_km') is not none %}
Altitude
{{ "%.1f"|format(d.alt_km) }} km
{% endif %} +{% if d.get('velocity_kmps') is not none %}
Velocity
{{ "%.3f"|format(d.velocity_kmps) }} km/s
{% endif %} +{% if d.get('heading_deg') is not none %}
Heading
{{ "%.1f"|format(d.heading_deg) }}°
{% 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_positions.html b/src/central/gui/templates/_event_summaries/sat_positions.html new file mode 100644 index 0000000..e29c367 --- /dev/null +++ b/src/central/gui/templates/_event_summaries/sat_positions.html @@ -0,0 +1,6 @@ +{%- set d = (event.data.get('data') or {}).get('data') or {} -%} +{%- if d.get('satellite_name') and d.get('lat_deg') is not none and d.get('lon_deg') is not none -%} +{%- set ns = 'N' if d.lat_deg >= 0 else 'S' -%} +{%- set we = 'E' if d.lon_deg >= 0 else 'W' -%} +{{ d.satellite_name }} at {{ "%.1f"|format(d.lat_deg|abs) }}°{{ ns }} {{ "%.1f"|format(d.lon_deg|abs) }}°{{ we }}, alt {{ "%.0f"|format(d.alt_km) }}km, {{ "%.1f"|format(d.velocity_kmps) }}km/s +{%- endif -%} diff --git a/src/central/supervisor.py b/src/central/supervisor.py index 2f79c28..6bfde04 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"), + "CENTRAL_SAT": ("tle", "pass", "position"), } diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index 656e817..951e27e 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -1162,6 +1162,15 @@ _SAMPLE_INNER = { "peak_time": "2026-06-09T15:39:37+00:00", "max_elevation_deg": 40.3, }, + "sat_positions": { + "satellite_name": "ISS (ZARYA)", + "norad_id": 25544, + "lat_deg": 43.6, + "lon_deg": -116.2, + "alt_km": 408.5, + "velocity_kmps": 7.66, + "heading_deg": 87.3, + }, } # Exact expected subjects for the deterministic adapters. swpc_alerts is omitted @@ -1188,6 +1197,7 @@ _EXPECTED_SUBJECT = { "avalanche_org": "Avalanche advisory — Banner Summit (Considerable)", "celestrak_tle": "TLE update: ISS (ZARYA) (NORAD 25544) — 92.9min orbit at 51.6°", "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", } diff --git a/tests/test_sat_common.py b/tests/test_sat_common.py new file mode 100644 index 0000000..eaf9fe6 --- /dev/null +++ b/tests/test_sat_common.py @@ -0,0 +1,114 @@ +"""Tests for the shared satellite-math helpers extracted in v0.12.0. + +These pin the public API surface (no leading underscores) and the numerical +behavior at known reference points. They duplicate some property tests from +test_satpass_predict.py by design -- those test the helpers via internal +re-exports (aliased imports), while these test the module's published +interface directly. If the public names ever drift or get renamed, these +fail first. +""" + +from __future__ import annotations + +import math +from datetime import datetime, timezone + +import pytest +from sgp4.api import Satrec, jday + +from central.adapters.sat_common import ( + EARTH_RADIUS_KM, + eci_to_ecef, + gmst_rad, + subsatellite_point, +) + + +# Live TLE from the v0.11.0 stations fixture, ISS (NORAD 25544). +_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) + + +class TestEarthRadius: + def test_value_matches_wgs84_equatorial(self): + assert EARTH_RADIUS_KM == pytest.approx(6378.137, abs=1e-6) + + +class TestGmstRad: + def test_returns_value_in_canonical_range(self): + val = gmst_rad(2460835.0, 0.5) # arbitrary post-2000 JD + assert 0.0 <= val < 2.0 * math.pi + + def test_monotonic_within_a_day(self): + """GMST advances ~2π per sidereal day. Two samples 6h apart must + differ by roughly π/2 (modulo wraparound).""" + v0 = gmst_rad(2460835.0, 0.0) + v1 = gmst_rad(2460835.0, 0.25) + delta = (v1 - v0) % (2.0 * math.pi) + # 6h sidereal angle is slightly more than π/2 (sidereal day < solar day). + assert math.pi / 2.0 < delta < math.pi / 2.0 + 0.02 + + +class TestEciToEcef: + def test_zero_rotation_is_identity(self): + result = eci_to_ecef((100.0, 200.0, 300.0), 0.0) + assert result == pytest.approx((100.0, 200.0, 300.0)) + + def test_rotation_preserves_magnitude(self): + """Rotation about the z-axis preserves the vector norm.""" + pos = (3000.0, 4000.0, 5000.0) + rot = eci_to_ecef(pos, math.pi / 3.0) + mag_in = math.sqrt(sum(c * c for c in pos)) + mag_out = math.sqrt(sum(c * c for c in rot)) + assert mag_out == pytest.approx(mag_in, rel=1e-12) + + def test_z_component_unaffected(self): + """Earth-rotation axis is z; z component never changes under GMST rotation.""" + _, _, z = eci_to_ecef((1.0, 2.0, 42.0), 1.3) + assert z == 42.0 + + +class TestSubsatellitePoint: + def test_north_pole_returns_pole(self): + lon, lat, alt = subsatellite_point((0.0, 0.0, 7000.0)) + assert lat == pytest.approx(90.0) + assert alt == pytest.approx(7000.0 - EARTH_RADIUS_KM) + + def test_equator_lon_zero(self): + lon, lat, alt = subsatellite_point((EARTH_RADIUS_KM + 400.0, 0.0, 0.0)) + assert lon == pytest.approx(0.0) + assert lat == pytest.approx(0.0) + assert alt == pytest.approx(400.0, abs=1e-6) + + def test_equator_lon_90_east(self): + lon, lat, alt = subsatellite_point((0.0, EARTH_RADIUS_KM + 400.0, 0.0)) + assert lon == pytest.approx(90.0) + assert lat == pytest.approx(0.0) + + def test_lon_normalised_into_180_range(self): + """A satellite over the antimeridian (-y axis) reads as -90°, never +270°.""" + lon, _, _ = subsatellite_point((0.0, -(EARTH_RADIUS_KM + 400.0), 0.0)) + assert -180.0 <= lon <= 180.0 + assert lon == pytest.approx(-90.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 + helpers regressed in a way that affects production output.""" + + def test_iss_sub_sat_point_at_pinned_ref_time(self): + sat = Satrec.twoline2rv(_ISS_L1, _ISS_L2) + jd, fr = jday(_REF.year, _REF.month, _REF.day, + _REF.hour, _REF.minute, _REF.second) + err, pos_eci, _ = sat.sgp4(jd, fr) + assert err == 0 + pos_ecef = eci_to_ecef(pos_eci, gmst_rad(jd, fr)) + lon, lat, alt = subsatellite_point(pos_ecef) + # ISS inclination 51.6° -- lat must lie within bounds + assert -52.0 <= lat <= 52.0 + # lon in valid range + assert -180.0 <= lon <= 180.0 + # ISS altitude ~400-420 km + assert 380.0 <= alt <= 460.0 diff --git a/tests/test_sat_positions.py b/tests/test_sat_positions.py new file mode 100644 index 0000000..23dda91 --- /dev/null +++ b/tests/test_sat_positions.py @@ -0,0 +1,284 @@ +"""Tests for the sat_positions adapter (v0.12.0). + +Deterministic: pinned ISS TLE + pinned reference time + mock asyncpg pool. +Covers math (sub-sat point in plausible range, velocity in ISS band, +heading wrapped into [0, 360)), event-record shape (severity, geo, +dedup id), subject derivation, empty-TLE-table case, track_only_norad_ids +gate, stale-TLE skip (via SQL parameter binding). +""" + +from __future__ import annotations + +import math +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_positions import ( + SatPositionsAdapter, + SatPositionsSettings, + _great_circle_bearing, + _propagate_position, +) +from central.config_models import AdapterConfig +from central.models import Event, Geo + + +# Live TLE from the v0.11.0 stations fixture, ISS (NORAD 25544). Same as +# test_satpass_predict.py + test_sat_common.py so all three 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[SatPositionsAdapter, AsyncMock]: + """Build an adapter with a mocked asyncpg pool that returns ``fetch_rows``. + + Returns (adapter, fetch_mock) so tests can also assert on what was passed + to conn.fetch (e.g. the timedelta interval for max_tle_age_days). + """ + cfg = AdapterConfig( + name="sat_positions", + enabled=True, + cadence_s=60, + 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 = SatPositionsAdapter(cfg, config_store, tmp_path / "cursors.db") + return adapter, fetch_mock + + +# --- Pure math via the helpers ---------------------------------------------- + + +class TestGreatCircleBearing: + def test_due_north_is_zero(self): + # Same longitude, north of point 1 -> bearing 0. + bearing = _great_circle_bearing(0.0, 0.0, 0.0, 10.0) + assert bearing == pytest.approx(0.0, abs=0.01) + + def test_due_east_is_ninety(self): + bearing = _great_circle_bearing(0.0, 0.0, 10.0, 0.0) + assert bearing == pytest.approx(90.0, abs=0.01) + + def test_due_south_is_one_eighty(self): + bearing = _great_circle_bearing(0.0, 10.0, 0.0, 0.0) + assert bearing == pytest.approx(180.0, abs=0.01) + + def test_wraps_into_canonical_range(self): + # SW direction -> bearing in [180, 270). + bearing = _great_circle_bearing(0.0, 0.0, -10.0, -10.0) + assert 180.0 <= bearing < 270.0 + + +class TestPropagatePosition: + def test_iss_at_ref_time_returns_plausible_position(self): + sat = Satrec.twoline2rv(_ISS_L1, _ISS_L2) + sample = _propagate_position(sat, _REF) + assert sample is not None + _, vel_eci, lon, lat, alt = sample + # ISS inclination 51.6° caps |lat| around there + assert -52.0 <= lat <= 52.0 + assert -180.0 <= lon <= 180.0 + assert 380.0 <= alt <= 460.0 + # |vel| roughly 7.66 km/s for ISS + vmag = math.sqrt(sum(c * c for c in vel_eci)) + assert 7.5 <= vmag <= 8.0 + + +# --- Adapter surface -------------------------------------------------------- + + +class TestSettingsDefaults: + def test_track_only_defaults_to_empty(self): + s = SatPositionsSettings() + assert s.track_only_norad_ids == [] + + def test_max_tle_age_defaults_to_14_days(self): + s = SatPositionsSettings() + assert s.max_tle_age_days == 14 + + +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_positions", + category="position.sat_positions", + time=_REF, + severity=1, + geo=Geo(centroid=(0.0, 0.0)), + data={"norad_id": 25544}, + ) + assert adapter.subject_for(ev) == "central.sat.position.25544" + + def test_subject_for_distinct_norad_ids_distinct_subjects(self, tmp_path): + adapter, _ = _make_adapter(tmp_path) + ev1 = Event(id="x", adapter="sat_positions", + category="position.sat_positions", time=_REF, + severity=1, geo=Geo(centroid=(0.0, 0.0)), + data={"norad_id": 25544}) + ev2 = Event(id="y", adapter="sat_positions", + category="position.sat_positions", time=_REF, + severity=1, geo=Geo(centroid=(0.0, 0.0)), + data={"norad_id": 43013}) + assert adapter.subject_for(ev1) != adapter.subject_for(ev2) + + +class TestBuildEvent: + def test_event_record_shape(self, tmp_path): + adapter, _ = _make_adapter(tmp_path) + ev = adapter._build_event( + row=_row(), + ref_time=_REF, + lon_deg=-116.2, + lat_deg=43.6, + alt_km=408.5, + velocity_kmps=7.66, + heading_deg=87.3, + ) + assert ev.severity == 1 + assert ev.category == "position.sat_positions" + assert ev.adapter == "sat_positions" + # Dedup id: : + assert ev.id == "25544:2026-06-09T07:00:00+00:00" + # geo.centroid populated for live-map plotting + assert ev.geo.centroid == (-116.2, 43.6) + # geo.geometry intentionally None (no overlay in v1) + assert ev.geo.geometry is None + # All declared data fields present + for k in ("norad_id", "satellite_name", "lon_deg", "lat_deg", + "alt_km", "velocity_kmps", "heading_deg", "tle_epoch"): + assert k in ev.data + + def test_dedup_id_collapses_sub_second_ticks(self, tmp_path): + """Two ref_times that differ only in microseconds yield the same dedup id.""" + adapter, _ = _make_adapter(tmp_path) + t1 = datetime(2026, 6, 9, 7, 0, 0, 1, tzinfo=timezone.utc) + t2 = datetime(2026, 6, 9, 7, 0, 0, 999999, tzinfo=timezone.utc) + ev1 = adapter._build_event(_row(), t1, 0.0, 0.0, 400.0, 7.5, 90.0) + ev2 = adapter._build_event(_row(), t2, 0.0, 0.0, 400.0, 7.5, 90.0) + assert ev1.id == ev2.id + + +# --- Poll loop -------------------------------------------------------------- + + +class TestPollEmptyTleTable: + @pytest.mark.asyncio + async def test_yields_zero_events_no_exception(self, tmp_path): + adapter, _ = _make_adapter(tmp_path, fetch_rows=[]) + await adapter.startup() + events = [ev async for ev in adapter.poll()] + assert events == [] + await adapter.shutdown() + + +class TestPollTrackOnlyGate: + @pytest.mark.asyncio + async def test_empty_list_publishes_all_rows(self, tmp_path): + rows = [_row(25544, "ISS (ZARYA)"), _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_list_filters_to_pinned_ids(self, tmp_path): + rows = [_row(25544, "ISS (ZARYA)"), _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: + """The max_tle_age_days setting is honored by binding a timedelta interval + to the SQL query. Verifying the parameter passed to conn.fetch is the + right interpretation of the spec's 'stale TLE skip' requirement: SQL + enforces the cutoff, and we assert it gets the configured value.""" + + @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() + # First positional arg after SQL is the interval timedelta. + args, _kwargs = fetch_mock.call_args + assert args[1] == timedelta(days=14) + + @pytest.mark.asyncio + async def test_operator_tightened_window_propagates_to_sql(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 TestPollPropagatesIss: + """End-to-end with a single real TLE: poll yields one event with all + fields populated within plausible ranges for ISS.""" + + @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.data["satellite_name"] == "ISS (ZARYA)" + # ISS inclination 51.6° bounds latitude + assert -52.0 <= ev.data["lat_deg"] <= 52.0 + assert -180.0 <= ev.data["lon_deg"] <= 180.0 + assert 380.0 <= ev.data["alt_km"] <= 460.0 + assert 7.5 <= ev.data["velocity_kmps"] <= 8.0 + assert 0.0 <= ev.data["heading_deg"] < 360.0 + assert ev.severity == 1 + # geo.centroid carries full SGP4 precision; data fields are rounded + # for operator-readability. They agree to 4 decimal places by design. + assert ev.geo.centroid[0] == pytest.approx(ev.data["lon_deg"], abs=1e-4) + assert ev.geo.centroid[1] == pytest.approx(ev.data["lat_deg"], abs=1e-4) diff --git a/tests/test_satpass_predict.py b/tests/test_satpass_predict.py index 5404554..69ba10f 100644 --- a/tests/test_satpass_predict.py +++ b/tests/test_satpass_predict.py @@ -17,16 +17,19 @@ from unittest.mock import AsyncMock, MagicMock import pytest from central.adapter import SourceAdapter +from central.adapters.sat_common import ( + eci_to_ecef as _eci_to_ecef, + gmst_rad as _gmst_rad, + subsatellite_point as _subsatellite_point, +) from central.adapters.satpass_predict import ( Observer, SatpassPredictAdapter, SatpassPredictSettings, _build_pass_geometry, - _gmst_rad, _next_passes, _observer_ecef, _severity_from_elev, - _subsatellite_point, _topocentric_az_el, _visibility_footprint, ) @@ -334,9 +337,10 @@ async def test_apply_config_updates_observers_and_threshold(adapter): def test_central_sat_family_includes_pass_token(): - """v0.11.1: pass.* categories also route to CENTRAL_SAT.""" + """v0.11.1: pass.* categories also route to CENTRAL_SAT. + v0.12.0: position.* extends the family for sat_positions telemetry.""" from central.supervisor import STREAM_CATEGORY_DOMAINS - assert STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"] == ("tle", "pass") + assert "pass" in STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"] def test_satpass_predict_in_space_adapter_group(): @@ -421,8 +425,7 @@ def test_subsatellite_point_real_iss_sample_via_sgp4(): jd, fr = jday(2026, 6, 8, 19, 17, 55.071168) err, pos_eci, _ = sat.sgp4(jd, fr) assert err == 0 - from central.adapters.satpass_predict import _eci_to_ecef, _gmst_rad as gmst - sat_ecef = _eci_to_ecef(pos_eci, gmst(jd, fr)) + sat_ecef = _eci_to_ecef(pos_eci, _gmst_rad(jd, fr)) lon, lat, alt = _subsatellite_point(sat_ecef) # ISS inclination is 51.6° so sub-sat latitude must stay within ±52°. assert -52.0 < lat < 52.0, f"ISS sub-sat lat {lat}° outside inclination envelope" diff --git a/tests/test_telemetry_separation.py b/tests/test_telemetry_separation.py index d90a1ba..9910543 100644 --- a/tests/test_telemetry_separation.py +++ b/tests/test_telemetry_separation.py @@ -12,7 +12,8 @@ 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). -_TELEMETRY = ["celestrak_tle", "itd_511_cameras", "nwis", "tomtom_flow"] +# v0.12.0 added sat_positions (60s sub-sat point per tracked satellite). +_TELEMETRY = ["celestrak_tle", "itd_511_cameras", "nwis", "sat_positions", "tomtom_flow"] # --- data_class defaults / registry split -----------------------------------