v0.12.0: sat_positions adapter (live global satellite positions) + sat_common refactor

## Architectural framing

The v0.11.1 `satpass_predict` adapter is **observer-anchored**: "when does satellite X pass over fixed observer Y, and what's the elevation/azimuth at that observer's site?" It answers a fixed-QTH question and emits one event per (observer, satellite, AOS) tuple.

The new `sat_positions` adapter is the **global** counterpart: "where is satellite X right now?" No observer. One event per tracked NORAD ID per poll, on subject `central.sat.position.<norad_id>`. Consumers (meshAI, GUI map widgets, anything that wants a live world map) subscribe to `central.sat.position.>` and plot.

They complement each other; neither replaces the other.

Direct quote from Matt's use-case: *"location of the sats... map of where the sats are then we have meshai or whatever other service calling central's data grab it and do whatever work it needed."* This adapter is that.

## sat_common extraction rationale

The four pure SGP4 / coordinate helpers (`EARTH_RADIUS_KM`, `gmst_rad`, `eci_to_ecef`, `subsatellite_point`) were private symbols inside `satpass_predict.py`. `sat_positions` needs the same three helpers. Three options were considered:

1. **Cross-import** from `satpass_predict.py` — creates an adapter-to-adapter dependency, ugly.
2. **Extract to `sat_common.py`** — matches the existing `wfigs_common.py` / `swpc_common.py` precedent. Both adapters become siblings of a shared helper module. ✓ chosen.
3. **Duplicate** — math drift over time.

Symbol names dropped their leading underscore on extraction (public-API convention matching `swpc_common.parse_swpc_timestamp` / `wfigs_common.severity_from_acres`). Existing internal call sites in `satpass_predict.py` were updated via mechanical `replace_all`. Observer-specific helpers (`_observer_ecef`, `_topocentric_az_el`, `_visibility_footprint`, `_severity_from_elev`, `_build_pass_geometry`, `_next_passes`) stay in `satpass_predict.py` per YAGNI — they're not used by `sat_positions` today.

Existing `tests/test_satpass_predict.py` was updated mechanically to import the helpers from `sat_common` via aliases (preserves the underscore-prefixed local names in the tests so the rest of the test body needs no change). All 44 satpass_predict tests pass unchanged.

## CENTRAL_SAT stream cap bump

`config.streams.max_bytes` for `CENTRAL_SAT` goes from **1 GiB → 5 GiB** in migration 039. Sizing math:

- celestrak_tle: ~190 sats × 1 envelope/day = ~190 events/day = ~1.4k events/week. Fit in 1 GiB easily.
- sat_positions: ~190 sats × 1440 ticks/day (60s cadence) = **~273.6k events/day = ~1.9M events/week**. At ~1 KB per envelope including the CloudEvents wrapper, that's **~1.9 GiB/week**.
- Plus existing TLE + pass envelopes already on the stream → ~3 GiB headroom needed.
- 5 GiB = 5368709120 bytes = operator-tunable margin without over-provisioning.

`STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"]` extends from `("tle", "pass")` to `("tle", "pass", "position")` so the supervisor's retention sweep covers position events too.

## Subject + dedup

| Field | Value |
|---|---|
| Subject | `central.sat.position.<norad_id>` — one subject per satellite, globally |
| Dedup id | `<norad_id>:<position_iso>` where `position_iso` is the propagation timestamp truncated to whole seconds (defensive collapse if cadence is ever tightened) |
| Severity | 1 (informational telemetry, no alerting) |
| data_class | `telemetry` — surfaces on `/telemetry`, not `/events` |
| Cadence | 60s default; operator-tunable via standard `cadence_s` field |

## Settings shape

```json
{"track_only_norad_ids": [], "max_tle_age_days": 14}
```

- Empty `track_only_norad_ids` = track every NORAD ID with a fresh TLE in the events table (derive-from-celestrak_tle, default behavior).
- Non-empty list pins to those NORAD IDs only (operator override — "I only care about the ISS and these 12 Starlink sats").
- `max_tle_age_days` bounds TLE freshness; LEO drag means TLEs go stale in days, GEO is good for months. Parameterized into the SQL query as a timedelta interval so operator-tightened windows (e.g. 3d) apply without code change.

## Event.data fields

`norad_id`, `satellite_name`, `lon_deg`, `lat_deg`, `alt_km`, `velocity_kmps`, `heading_deg`, `tle_epoch`.

- `lon_deg`/`lat_deg`/`alt_km`: sub-satellite point via SGP4 → ECI → ECEF rotation → spherical-earth lon/lat/alt.
- `velocity_kmps`: magnitude of the SGP4 ECI velocity vector. ECI vs ECEF difference is ~6% for LEO (earth rotation 0.46 km/s vs 7.7 km/s orbital speed); fine for consumer "the sat is moving at X km/s" text.
- `heading_deg`: great-circle initial bearing from the sub-sat point at `t` to the sub-sat point at `t+1s` (finite-difference; simpler than rotating velocity through GMST + the earth-rotation cross term).

## Diff size — flag for review

**+894 / -63 = +831 net** across 14 files. Spec budget was ≤700 lines. **Over by ~131 net** (or ~194 gross).

Breakdown:
- `sat_positions.py`: 286 lines (under the ≤350 adapter line cap ✓)
- `sat_common.py`: 65 lines (the extraction)
- Migration 039: 58 lines (heavy on inline comments documenting the size math; could trim ~25 lines if you want)
- satpass_predict.py: net -1 line (refactor; lost 4 helper defs and one constant comment, gained 5-line import block)
- Templates: 14 lines (event_rows + event_summaries partials)
- Wiring: 4 lines (supervisor + ADAPTER_GROUPS)
- Docs (CONSUMER-INTEGRATION.md): 40 lines (required by `tests/test_consumer_doc.py::test_every_adapter_has_a_subsection`)
- **Tests: 426 lines.** This is the bulk of the overage.

The tests are all spec-mandated (sub-sat math, velocity range, heading range, build_event, subject_for, empty-TLE, track_only gate, stale-TLE skip, sat_common helpers, regression-guard on the moved helpers via test_satpass_predict.py preservation). I could shrink `test_sat_positions.py` by consolidating the 11 spec-mandated tests into fewer parameterized cases, but each test pins one behavior the spec called out by name. Flagging for your call: keep as-is, or do you want a tighter parameterized version?

## Test plan

- [x] `pytest tests/test_sat_common.py tests/test_sat_positions.py` — **28 new tests, all pass**.
- [x] `pytest tests/test_satpass_predict.py` — **44/44 pass** (regression guard: existing tests work after the sat_common extraction).
- [x] `pytest tests/test_events_feed_frontend.py` — **119/119 pass** (JSON-feed coverage extended to include sat_positions sample event + expected subject string).
- [x] `pytest tests/test_telemetry_separation.py` — **9/9 pass** (`_TELEMETRY` pin extended to include `sat_positions`).
- [x] `pytest tests/test_consumer_doc.py` — **6/6 pass** (CONSUMER-INTEGRATION.md `### sat_positions` subsection added).
- [x] `pytest tests/test_producer_doc.py` — **10/10 pass** (no PRODUCER-INTEGRATION update needed; CENTRAL_SAT stream is pre-existing).
- [x] Full sweep `pytest tests/` (excluding postgres-dep files): **1209 passed, 1 skipped, 0 failures**.
- [x] Ruff: clean on all new code. 3 pre-existing F841 unused-variable warnings (supervisor.py:390 `poll_start`, test_events_feed_frontend.py:425 / :466 `result`) confirmed via `git blame` to be from commits May 2026 — not introduced.

## Deploy plan

1. Squash-merge → tag v0.12.0 at merge SHA → push tag.
2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (no new dep).
3. **`central-migrate`** to apply migration 039 (seeds `config.adapters` row + bumps `config.streams.max_bytes` for CENTRAL_SAT).
4. `sudo systemctl restart central-supervisor` (picks up STREAM_CATEGORY_DOMAINS extension + new adapter discovery).
5. `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS change).
6. **No** `central-archive` restart (CENTRAL_SAT stream already exists; no new stream).
7. Verify: `nats stream info CENTRAL_SAT` shows max_bytes=5368709120; supervisor journal shows sat_positions discovered.
8. Smoke-test: enable celestrak_tle first if not already, wait for one poll, then enable sat_positions via GUI. Within 60s expect one `central.sat.position.<norad_id>` event per tracked sat on the stream.

## Halt acknowledgment

Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
malice 2026-06-09 15:23:32 -06:00 committed by GitHub
commit c49f2db95f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 894 additions and 63 deletions

View file

@ -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.<norad_id>` — 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:** `<norad_id>:<position_iso>` 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.
\
---

View file

@ -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.<norad_id>. 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;

View file

@ -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

View file

@ -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.<norad_id>`` 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 ``<norad_id>:<position_iso>`` 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']}"

View file

@ -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

View file

@ -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"],

View file

@ -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 %}<dt>Satellite</dt><dd>{{ d.satellite_name }} (NORAD {{ d.norad_id }})</dd>{% endif %}
{% if d.get('lat_deg') is not none and d.get('lon_deg') is not none %}<dt>Sub-satellite point</dt><dd>{{ "%.4f"|format(d.lat_deg) }}°, {{ "%.4f"|format(d.lon_deg) }}°</dd>{% endif %}
{% if d.get('alt_km') is not none %}<dt>Altitude</dt><dd>{{ "%.1f"|format(d.alt_km) }} km</dd>{% endif %}
{% if d.get('velocity_kmps') is not none %}<dt>Velocity</dt><dd>{{ "%.3f"|format(d.velocity_kmps) }} km/s</dd>{% endif %}
{% if d.get('heading_deg') is not none %}<dt>Heading</dt><dd>{{ "%.1f"|format(d.heading_deg) }}°</dd>{% endif %}
{% if d.get('tle_epoch') is not none %}<dt>TLE epoch</dt><dd>{{ d.tle_epoch }}</dd>{% endif %}

View file

@ -0,0 +1,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 -%}

View file

@ -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"),
}

View file

@ -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",
}

114
tests/test_sat_common.py Normal file
View file

@ -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

284
tests/test_sat_positions.py Normal file
View file

@ -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: <norad_id>:<position_iso (seconds-truncated)>
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)

View file

@ -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"

View file

@ -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 -----------------------------------