mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
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:
parent
7bdc872174
commit
c49f2db95f
14 changed files with 894 additions and 63 deletions
|
|
@ -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
|
(adapter still disabled, or hasn't polled yet), the adapter logs at
|
||||||
INFO and yields zero events — no exception.
|
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.
|
||||||
|
|
||||||
\
|
\
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
58
sql/migrations/039_add_sat_positions_adapter.sql
Normal file
58
sql/migrations/039_add_sat_positions_adapter.sql
Normal 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;
|
||||||
65
src/central/adapters/sat_common.py
Normal file
65
src/central/adapters/sat_common.py
Normal 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
|
||||||
286
src/central/adapters/sat_positions.py
Normal file
286
src/central/adapters/sat_positions.py
Normal 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']}"
|
||||||
|
|
@ -43,18 +43,18 @@ from pydantic import BaseModel
|
||||||
from sgp4.api import Satrec, jday
|
from sgp4.api import Satrec, jday
|
||||||
|
|
||||||
from central.adapter import SourceAdapter
|
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_models import AdapterConfig
|
||||||
from central.config_store import ConfigStore
|
from central.config_store import ConfigStore
|
||||||
from central.models import Event, Geo
|
from central.models import Event, Geo
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
_PASS_STEP_S = 60 # 60-second grid for elevation sampling
|
||||||
_DEDUP_DDL = (
|
_DEDUP_DDL = (
|
||||||
"CREATE TABLE IF NOT EXISTS published_ids ("
|
"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) ---------------------------------------------
|
# --- 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]:
|
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)."""
|
"""Observer position in ECEF km (spherical earth, sub-0.1° precision)."""
|
||||||
lat_r = math.radians(lat_deg)
|
lat_r = math.radians(lat_deg)
|
||||||
lon_r = math.radians(lon_deg)
|
lon_r = math.radians(lon_deg)
|
||||||
r = _EARTH_RADIUS_KM + elev_m / 1000.0
|
r = EARTH_RADIUS_KM + elev_m / 1000.0
|
||||||
return (
|
return (
|
||||||
r * math.cos(lat_r) * math.cos(lon_r),
|
r * math.cos(lat_r) * math.cos(lon_r),
|
||||||
r * math.cos(lat_r) * math.sin(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)
|
err, pos_eci, _ = sat.sgp4(jd, fr)
|
||||||
if err:
|
if err:
|
||||||
return None
|
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)
|
az, el = _topocentric_az_el(sat_ecef, obs_ecef_km, obs_lat_deg, obs_lon_deg)
|
||||||
return az, el, sat_ecef
|
return az, el, sat_ecef
|
||||||
|
|
||||||
|
|
@ -185,26 +161,6 @@ def _elev_at(
|
||||||
return None if sample is None else (sample[0], sample[1])
|
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(
|
def _visibility_footprint(
|
||||||
lon_deg: float, lat_deg: float, alt_km: float, n_vertices: int = 32,
|
lon_deg: float, lat_deg: float, alt_km: float, n_vertices: int = 32,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
|
|
@ -225,7 +181,7 @@ def _visibility_footprint(
|
||||||
"""
|
"""
|
||||||
if alt_km <= 0:
|
if alt_km <= 0:
|
||||||
return None
|
return None
|
||||||
r_earth = _EARTH_RADIUS_KM
|
r_earth = EARTH_RADIUS_KM
|
||||||
radius_km = r_earth * math.acos(r_earth / (r_earth + alt_km))
|
radius_km = r_earth * math.acos(r_earth / (r_earth + alt_km))
|
||||||
angular = radius_km / r_earth
|
angular = radius_km / r_earth
|
||||||
lat1 = math.radians(lat_deg)
|
lat1 = math.radians(lat_deg)
|
||||||
|
|
@ -329,7 +285,7 @@ def _next_passes(
|
||||||
t += step
|
t += step
|
||||||
continue
|
continue
|
||||||
az, e, sat_ecef = sample
|
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 e >= min_elevation_deg:
|
||||||
if not in_pass:
|
if not in_pass:
|
||||||
in_pass = True
|
in_pass = True
|
||||||
|
|
|
||||||
|
|
@ -2975,7 +2975,7 @@ DEFAULT_TIME = "last_24h"
|
||||||
ADAPTER_GROUPS = {
|
ADAPTER_GROUPS = {
|
||||||
"Disasters": ["gdacs", "firms", "inciweb", "wfigs_incidents", "wfigs_perimeters"],
|
"Disasters": ["gdacs", "firms", "inciweb", "wfigs_incidents", "wfigs_perimeters"],
|
||||||
"Weather": ["nws"],
|
"Weather": ["nws"],
|
||||||
"Space": ["swpc_alerts", "swpc_kindex", "swpc_protons", "celestrak_tle", "satpass_predict"],
|
"Space": ["swpc_alerts", "swpc_kindex", "swpc_protons", "celestrak_tle", "satpass_predict", "sat_positions"],
|
||||||
"Geophysical": ["usgs_quake", "nwis"],
|
"Geophysical": ["usgs_quake", "nwis"],
|
||||||
"Earth Observation": ["eonet"],
|
"Earth Observation": ["eonet"],
|
||||||
"Transportation": ["wzdx", "tomtom_flow", "tomtom_incidents", "itd_511", "itd_511_cameras"],
|
"Transportation": ["wzdx", "tomtom_flow", "tomtom_incidents", "itd_511", "itd_511_cameras"],
|
||||||
|
|
|
||||||
8
src/central/gui/templates/_event_rows/sat_positions.html
Normal file
8
src/central/gui/templates/_event_rows/sat_positions.html
Normal 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 %}
|
||||||
|
|
@ -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 -%}
|
||||||
|
|
@ -141,7 +141,7 @@ STREAM_CATEGORY_DOMAINS: dict[str, tuple[str, ...]] = {
|
||||||
"CENTRAL_TRAFFIC_FLOW": ("flow",),
|
"CENTRAL_TRAFFIC_FLOW": ("flow",),
|
||||||
"CENTRAL_TRAFFIC_CAMERAS": ("camera",),
|
"CENTRAL_TRAFFIC_CAMERAS": ("camera",),
|
||||||
"CENTRAL_AVY": ("avy",),
|
"CENTRAL_AVY": ("avy",),
|
||||||
"CENTRAL_SAT": ("tle", "pass"),
|
"CENTRAL_SAT": ("tle", "pass", "position"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1162,6 +1162,15 @@ _SAMPLE_INNER = {
|
||||||
"peak_time": "2026-06-09T15:39:37+00:00",
|
"peak_time": "2026-06-09T15:39:37+00:00",
|
||||||
"max_elevation_deg": 40.3,
|
"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
|
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
|
||||||
|
|
@ -1188,6 +1197,7 @@ _EXPECTED_SUBJECT = {
|
||||||
"avalanche_org": "Avalanche advisory — Banner Summit (Considerable)",
|
"avalanche_org": "Avalanche advisory — Banner Summit (Considerable)",
|
||||||
"celestrak_tle": "TLE update: ISS (ZARYA) (NORAD 25544) — 92.9min orbit at 51.6°",
|
"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°",
|
"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
114
tests/test_sat_common.py
Normal 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
284
tests/test_sat_positions.py
Normal 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)
|
||||||
|
|
@ -17,16 +17,19 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from central.adapter import SourceAdapter
|
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 (
|
from central.adapters.satpass_predict import (
|
||||||
Observer,
|
Observer,
|
||||||
SatpassPredictAdapter,
|
SatpassPredictAdapter,
|
||||||
SatpassPredictSettings,
|
SatpassPredictSettings,
|
||||||
_build_pass_geometry,
|
_build_pass_geometry,
|
||||||
_gmst_rad,
|
|
||||||
_next_passes,
|
_next_passes,
|
||||||
_observer_ecef,
|
_observer_ecef,
|
||||||
_severity_from_elev,
|
_severity_from_elev,
|
||||||
_subsatellite_point,
|
|
||||||
_topocentric_az_el,
|
_topocentric_az_el,
|
||||||
_visibility_footprint,
|
_visibility_footprint,
|
||||||
)
|
)
|
||||||
|
|
@ -334,9 +337,10 @@ async def test_apply_config_updates_observers_and_threshold(adapter):
|
||||||
|
|
||||||
|
|
||||||
def test_central_sat_family_includes_pass_token():
|
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
|
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():
|
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)
|
jd, fr = jday(2026, 6, 8, 19, 17, 55.071168)
|
||||||
err, pos_eci, _ = sat.sgp4(jd, fr)
|
err, pos_eci, _ = sat.sgp4(jd, fr)
|
||||||
assert err == 0
|
assert err == 0
|
||||||
from central.adapters.satpass_predict import _eci_to_ecef, _gmst_rad as gmst
|
sat_ecef = _eci_to_ecef(pos_eci, _gmst_rad(jd, fr))
|
||||||
sat_ecef = _eci_to_ecef(pos_eci, gmst(jd, fr))
|
|
||||||
lon, lat, alt = _subsatellite_point(sat_ecef)
|
lon, lat, alt = _subsatellite_point(sat_ecef)
|
||||||
# ISS inclination is 51.6° so sub-sat latitude must stay within ±52°.
|
# 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"
|
assert -52.0 < lat < 52.0, f"ISS sub-sat lat {lat}° outside inclination envelope"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ from central.gui import routes
|
||||||
|
|
||||||
# Adapters with data_class="telemetry" (the pinned split; grow as telemetry adapters land).
|
# Adapters with data_class="telemetry" (the pinned split; grow as telemetry adapters land).
|
||||||
# v0.11.0 added celestrak_tle (orbital state -- continuous-ish refresh, telemetry-class).
|
# v0.11.0 added celestrak_tle (orbital state -- continuous-ish refresh, telemetry-class).
|
||||||
_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 -----------------------------------
|
# --- data_class defaults / registry split -----------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue