mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
v0.11.1: satpass_predict adapter (server-side pass alerts for fixed observers) (#101)
This commit is contained in:
parent
621148ac46
commit
86e8b6b56a
13 changed files with 933 additions and 3 deletions
|
|
@ -1870,6 +1870,38 @@ at parameter `00060`, gage height (ft) at `00065`, water temperature (°C) at
|
||||||
\
|
\
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### satpass_predict — server-side satellite pass alerts (v0.11.1)
|
||||||
|
|
||||||
|
- **Source:** the `events` table itself — reads the latest TLE per `norad_id`
|
||||||
|
emitted by `celestrak_tle` within the last 14 days, then propagates each
|
||||||
|
one with SGP4 against every configured fixed observer.
|
||||||
|
- **Stream:** `CENTRAL_SAT` (same stream as TLEs; v0.11.1 extends
|
||||||
|
`STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"]` to `("tle", "pass")`).
|
||||||
|
- **Subject:** `central.sat.pass.us.<state_lower>.<observer_slug>` — one
|
||||||
|
subject per observer. Multiple satellites passing the same observer
|
||||||
|
collapse to the same subject; the category-discriminated `Nats-Msg-Id`
|
||||||
|
(v0.10.8) keeps each pass distinct in JetStream's dedup window.
|
||||||
|
- **Dedup key shape:** `<observer_slug>:<norad_id>:<aos_iso>` — re-running
|
||||||
|
the same poll within an hour computes the same passes and dedups; new
|
||||||
|
TLEs landing between polls produce slightly different propagation paths
|
||||||
|
and hence different AOS times, naturally triggering republishes.
|
||||||
|
- **Severity bucket** from peak elevation: `>=60°` = 4 (zenith pass),
|
||||||
|
`>=30°` = 3 (high), `>=10°` = 2 (low; default gate threshold).
|
||||||
|
- **Geo:** `centroid = (observer.lon, observer.lat)` so the GUI map plots
|
||||||
|
the alert at the observer point, not at the satellite track.
|
||||||
|
- **Event.data fields:** `observer_name`, `observer_slug`, `observer_state`,
|
||||||
|
`norad_id`, `satellite_name`, `aos_time`, `los_time`, `peak_time`,
|
||||||
|
`max_elevation_deg`, `azimuth_at_aos`, `azimuth_at_los`, `duration_s`,
|
||||||
|
`tle_epoch` (the TLE epoch used for this prediction).
|
||||||
|
- **Cadence:** 1h. The adapter recomputes the 24h horizon every hour;
|
||||||
|
new TLEs landing between polls are picked up at the next poll.
|
||||||
|
- **Empty-TLE behaviour:** if no `celestrak_tle` events are in the table
|
||||||
|
(adapter still disabled, or hasn't polled yet), the adapter logs at
|
||||||
|
INFO and yields zero events — no exception.
|
||||||
|
|
||||||
|
\
|
||||||
|
---
|
||||||
|
|
||||||
## 7. Fall-off / removal semantics
|
## 7. Fall-off / removal semantics
|
||||||
|
|
||||||
Central adapters fall into three buckets for handling upstream events that
|
Central adapters fall into three buckets for handling upstream events that
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ dependencies = [
|
||||||
"shapely>=2.0",
|
"shapely>=2.0",
|
||||||
"tenacity>=9.1.4",
|
"tenacity>=9.1.4",
|
||||||
"uvicorn[standard]>=0.34.0",
|
"uvicorn[standard]>=0.34.0",
|
||||||
|
"sgp4>=2.25",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
35
sql/migrations/038_add_satpass_predict_adapter.sql
Normal file
35
sql/migrations/038_add_satpass_predict_adapter.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- Migration 038: register satpass_predict adapter row (v0.11.1)
|
||||||
|
--
|
||||||
|
-- Server-side complement to meshAI's per-user client-side pass computation.
|
||||||
|
-- Reads the latest TLE per norad_id from the events table (celestrak_tle
|
||||||
|
-- adapter, v0.11.0) and emits one Event per (observer, satellite, AOS)
|
||||||
|
-- tuple within a 24h horizon. Publishes on the existing CENTRAL_SAT
|
||||||
|
-- stream via the supervisor's STREAM_CATEGORY_DOMAINS extension
|
||||||
|
-- ("CENTRAL_SAT": ("tle", "pass")) -- no new stream is needed and
|
||||||
|
-- migration does NOT touch config.streams.
|
||||||
|
--
|
||||||
|
-- Ships disabled (`enabled=false`) -- operator-configures observers via
|
||||||
|
-- GUI, then enables. Default settings include one Treasure Valley observer
|
||||||
|
-- as a worked example operators can edit/extend in place.
|
||||||
|
--
|
||||||
|
-- Cadence 3600s (1h): the adapter recomputes the 24h horizon every hour.
|
||||||
|
-- New TLEs landing between polls are naturally picked up at the next poll.
|
||||||
|
--
|
||||||
|
-- Idempotent: ON CONFLICT (name) DO NOTHING preserves any operator-tuned
|
||||||
|
-- state (settings changed by hand, enabled flag flipped, cadence override).
|
||||||
|
|
||||||
|
INSERT INTO config.adapters (name, enabled, cadence_s, settings)
|
||||||
|
VALUES (
|
||||||
|
'satpass_predict',
|
||||||
|
false,
|
||||||
|
3600,
|
||||||
|
'{
|
||||||
|
"observers": [
|
||||||
|
{"name": "Treasure Valley", "slug": "treasure-valley",
|
||||||
|
"state": "ID", "lat": 43.6, "lon": -116.2, "elev_m": 0}
|
||||||
|
],
|
||||||
|
"min_elevation_deg": 10,
|
||||||
|
"horizon_hours": 24
|
||||||
|
}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
433
src/central/adapters/satpass_predict.py
Normal file
433
src/central/adapters/satpass_predict.py
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
"""Satellite pass predictor — server-side complement to client-side satpass.
|
||||||
|
|
||||||
|
Polls the ``events`` table for the latest TLE per ``norad_id`` (within the last
|
||||||
|
14 days), then propagates each one with SGP4 against every configured fixed
|
||||||
|
observer over a 24-hour horizon. Emits one Event per upcoming pass per
|
||||||
|
(observer, satellite) tuple. Dedup id is ``{observer_slug}:{norad_id}:{aos_iso}``
|
||||||
|
so re-running the same poll within the hour produces identical ids and is
|
||||||
|
swallowed by the dedup mixin; new TLEs landing between polls produce slightly
|
||||||
|
different propagation paths and hence different AOS times, naturally triggering
|
||||||
|
a republish.
|
||||||
|
|
||||||
|
Severity bucket from peak elevation:
|
||||||
|
|
||||||
|
>= 60° (zenith) -> 4
|
||||||
|
>= 30° (high) -> 3
|
||||||
|
>= 10° (low) -> 2
|
||||||
|
< 10° -> 1 (gated: not emitted)
|
||||||
|
|
||||||
|
Subject: ``central.sat.pass.us.<state>.<observer_slug>`` -- one subject per
|
||||||
|
observer. Multiple satellites passing the same observer collapse to the same
|
||||||
|
subject; the dedup-discriminated Nats-Msg-Id (v0.10.8: ``id:category``) keeps
|
||||||
|
each pass distinct in JetStream's dedup window.
|
||||||
|
|
||||||
|
Math: SGP4 propagation gives ECI; we rotate to ECEF via GMST (Vallado mean
|
||||||
|
sidereal formula) then to topocentric east-north-up using the observer's
|
||||||
|
geodetic position (spherical earth, 6378.137 km equatorial radius -- fine for
|
||||||
|
horizon/elevation determination, error << 0.1° in azimuth). Pass detection
|
||||||
|
walks a 60-second grid looking for elevation-crossing events at the configured
|
||||||
|
``min_elevation_deg`` threshold.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.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 ("
|
||||||
|
"adapter TEXT NOT NULL, event_id TEXT NOT NULL, "
|
||||||
|
"first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
||||||
|
"last_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
||||||
|
"PRIMARY KEY (adapter, event_id))"
|
||||||
|
)
|
||||||
|
|
||||||
|
_LATEST_TLES_SQL = """
|
||||||
|
SELECT DISTINCT ON (payload->'data'->'data'->>'norad_id')
|
||||||
|
(payload->'data'->'data'->>'norad_id')::int AS norad_id,
|
||||||
|
payload->'data'->'data'->>'satellite_name' AS satellite_name,
|
||||||
|
payload->'data'->'data'->>'tle_line1' AS tle_line1,
|
||||||
|
payload->'data'->'data'->>'tle_line2' AS tle_line2,
|
||||||
|
payload->'data'->'data'->>'epoch' AS tle_epoch
|
||||||
|
FROM events
|
||||||
|
WHERE adapter = 'celestrak_tle'
|
||||||
|
AND time > now() - interval '14 days'
|
||||||
|
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
|
||||||
|
return (
|
||||||
|
r * math.cos(lat_r) * math.cos(lon_r),
|
||||||
|
r * math.cos(lat_r) * math.sin(lon_r),
|
||||||
|
r * math.sin(lat_r),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _topocentric_az_el(
|
||||||
|
sat_ecef_km: tuple[float, float, float],
|
||||||
|
obs_ecef_km: tuple[float, float, float],
|
||||||
|
obs_lat_deg: float,
|
||||||
|
obs_lon_deg: float,
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""Return ``(azimuth_deg, elevation_deg)`` from observer to satellite.
|
||||||
|
|
||||||
|
Azimuth measured from north, clockwise (0 = N, 90 = E). Elevation is the
|
||||||
|
angle above the local horizon (0 = horizon, 90 = zenith, negative = below).
|
||||||
|
"""
|
||||||
|
dx = sat_ecef_km[0] - obs_ecef_km[0]
|
||||||
|
dy = sat_ecef_km[1] - obs_ecef_km[1]
|
||||||
|
dz = sat_ecef_km[2] - obs_ecef_km[2]
|
||||||
|
|
||||||
|
lat_r = math.radians(obs_lat_deg)
|
||||||
|
lon_r = math.radians(obs_lon_deg)
|
||||||
|
sl, cl = math.sin(lat_r), math.cos(lat_r)
|
||||||
|
slo, clo = math.sin(lon_r), math.cos(lon_r)
|
||||||
|
|
||||||
|
east = -slo * dx + clo * dy
|
||||||
|
north = -sl * clo * dx - sl * slo * dy + cl * dz
|
||||||
|
up = cl * clo * dx + cl * slo * dy + sl * dz
|
||||||
|
|
||||||
|
horizontal = math.sqrt(east * east + north * north)
|
||||||
|
elevation = math.degrees(math.atan2(up, horizontal))
|
||||||
|
azimuth = math.degrees(math.atan2(east, north)) % 360.0
|
||||||
|
return azimuth, elevation
|
||||||
|
|
||||||
|
|
||||||
|
def _elev_at(
|
||||||
|
sat: Satrec,
|
||||||
|
t: datetime,
|
||||||
|
obs_ecef_km: tuple[float, float, float],
|
||||||
|
obs_lat_deg: float,
|
||||||
|
obs_lon_deg: float,
|
||||||
|
) -> tuple[float, float] | None:
|
||||||
|
"""Compute ``(azimuth_deg, elevation_deg)`` at instant ``t`` (UTC datetime).
|
||||||
|
|
||||||
|
Returns ``None`` if SGP4 reports a propagation error (decayed orbit, bad
|
||||||
|
epoch, etc.).
|
||||||
|
"""
|
||||||
|
jd, fr = jday(t.year, t.month, t.day, t.hour, t.minute, t.second + t.microsecond / 1e6)
|
||||||
|
err, pos_eci, _ = sat.sgp4(jd, fr)
|
||||||
|
if err:
|
||||||
|
return None
|
||||||
|
sat_ecef = _eci_to_ecef(pos_eci, _gmst_rad(jd, fr))
|
||||||
|
return _topocentric_az_el(sat_ecef, obs_ecef_km, obs_lat_deg, obs_lon_deg)
|
||||||
|
|
||||||
|
|
||||||
|
def _severity_from_elev(max_elev_deg: float) -> int:
|
||||||
|
"""Pass severity per the v0.11.1 bucketing rule."""
|
||||||
|
if max_elev_deg >= 60.0:
|
||||||
|
return 4
|
||||||
|
if max_elev_deg >= 30.0:
|
||||||
|
return 3
|
||||||
|
if max_elev_deg >= 10.0:
|
||||||
|
return 2
|
||||||
|
return 1 # below the gate; never emitted in practice
|
||||||
|
|
||||||
|
|
||||||
|
def _next_passes(
|
||||||
|
tle_line1: str,
|
||||||
|
tle_line2: str,
|
||||||
|
observer: "Observer",
|
||||||
|
ref_time: datetime,
|
||||||
|
horizon_hours: float,
|
||||||
|
min_elevation_deg: float,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Walk a 60-second grid; return all passes >= min_elevation_deg in window."""
|
||||||
|
try:
|
||||||
|
sat = Satrec.twoline2rv(tle_line1, tle_line2)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
obs_ecef = _observer_ecef(observer.lat, observer.lon, observer.elev_m)
|
||||||
|
|
||||||
|
passes: list[dict[str, Any]] = []
|
||||||
|
in_pass = False
|
||||||
|
aos_t: datetime | None = None
|
||||||
|
aos_az: float | None = None
|
||||||
|
peak_t: datetime | None = None
|
||||||
|
peak_e: float = -180.0
|
||||||
|
peak_az: float | None = None
|
||||||
|
|
||||||
|
t = ref_time
|
||||||
|
end = ref_time + timedelta(hours=horizon_hours)
|
||||||
|
step = timedelta(seconds=_PASS_STEP_S)
|
||||||
|
while t < end:
|
||||||
|
sample = _elev_at(sat, t, obs_ecef, observer.lat, observer.lon)
|
||||||
|
if sample is None:
|
||||||
|
t += step
|
||||||
|
continue
|
||||||
|
az, e = sample
|
||||||
|
if e >= min_elevation_deg:
|
||||||
|
if not in_pass:
|
||||||
|
in_pass = True
|
||||||
|
aos_t = t
|
||||||
|
aos_az = az
|
||||||
|
peak_t = t
|
||||||
|
peak_e = e
|
||||||
|
peak_az = az
|
||||||
|
elif e > peak_e:
|
||||||
|
peak_t = t
|
||||||
|
peak_e = e
|
||||||
|
peak_az = az
|
||||||
|
elif in_pass:
|
||||||
|
# threshold-crossing on the way down -> close the pass
|
||||||
|
passes.append({
|
||||||
|
"aos": aos_t, "aos_az": aos_az,
|
||||||
|
"peak": peak_t, "peak_az": peak_az, "max_elev_deg": peak_e,
|
||||||
|
"los": t, "los_az": az,
|
||||||
|
})
|
||||||
|
in_pass = False
|
||||||
|
aos_t = aos_az = peak_t = peak_az = None
|
||||||
|
peak_e = -180.0
|
||||||
|
t += step
|
||||||
|
|
||||||
|
# Pass still in progress at the horizon edge -- close it at the boundary.
|
||||||
|
if in_pass and aos_t and peak_t:
|
||||||
|
passes.append({
|
||||||
|
"aos": aos_t, "aos_az": aos_az,
|
||||||
|
"peak": peak_t, "peak_az": peak_az, "max_elev_deg": peak_e,
|
||||||
|
"los": end, "los_az": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return passes
|
||||||
|
|
||||||
|
|
||||||
|
# --- Settings + adapter -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Observer(BaseModel):
|
||||||
|
"""Fixed observer location for server-side pass prediction."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
state: str
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
elev_m: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class SatpassPredictSettings(BaseModel):
|
||||||
|
"""Per-observer list + threshold + horizon. Default observer ships disabled
|
||||||
|
until the operator edits the list to their site(s)."""
|
||||||
|
|
||||||
|
observers: list[Observer] = [
|
||||||
|
Observer(name="Treasure Valley", slug="treasure-valley",
|
||||||
|
state="ID", lat=43.6, lon=-116.2, elev_m=0.0),
|
||||||
|
]
|
||||||
|
min_elevation_deg: float = 10.0
|
||||||
|
horizon_hours: int = 24
|
||||||
|
|
||||||
|
|
||||||
|
class SatpassPredictAdapter(SourceAdapter):
|
||||||
|
"""Server-side satellite pass alerts for fixed observers."""
|
||||||
|
|
||||||
|
name = "satpass_predict"
|
||||||
|
display_name = "Satellite Pass Predictions"
|
||||||
|
description = (
|
||||||
|
"Predicts upcoming satellite passes over fixed observer locations "
|
||||||
|
"by propagating the latest TLE for each NORAD ID via SGP4. Reads "
|
||||||
|
"TLEs from the events table (celestrak_tle adapter); emits one "
|
||||||
|
"Event per (observer, satellite, AOS) tuple within a 24h horizon."
|
||||||
|
)
|
||||||
|
settings_schema = SatpassPredictSettings
|
||||||
|
requires_api_key = None
|
||||||
|
wizard_order = None
|
||||||
|
default_cadence_s = 3600 # 1h
|
||||||
|
data_class = "event"
|
||||||
|
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_observers = settings.get("observers") or []
|
||||||
|
self._observers: list[Observer] = [
|
||||||
|
o if isinstance(o, Observer) else Observer(**o) for o in raw_observers
|
||||||
|
]
|
||||||
|
self._min_elev: float = float(settings.get("min_elevation_deg") or 10.0)
|
||||||
|
self._horizon_h: float = float(settings.get("horizon_hours") or 24)
|
||||||
|
|
||||||
|
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(
|
||||||
|
"satpass_predict adapter started",
|
||||||
|
extra={
|
||||||
|
"observers": [o.slug for o in self._observers],
|
||||||
|
"min_elevation_deg": self._min_elev,
|
||||||
|
"horizon_hours": self._horizon_h,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
"satpass_predict config updated",
|
||||||
|
extra={
|
||||||
|
"observers": [o.slug for o in self._observers],
|
||||||
|
"min_elevation_deg": self._min_elev,
|
||||||
|
"horizon_hours": self._horizon_h,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fetch_latest_tles(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return rows: ``{norad_id, satellite_name, tle_line1, tle_line2, tle_epoch}``.
|
||||||
|
|
||||||
|
Empty list if no TLEs in the events table within the 14-day window.
|
||||||
|
Never raises -- caller handles empty.
|
||||||
|
"""
|
||||||
|
pool = self._config_store.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(_LATEST_TLES_SQL)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def _pass_to_event(
|
||||||
|
self,
|
||||||
|
p: dict[str, Any],
|
||||||
|
row: dict[str, Any],
|
||||||
|
observer: Observer,
|
||||||
|
) -> Event:
|
||||||
|
aos: datetime = p["aos"]
|
||||||
|
return Event(
|
||||||
|
id=f"{observer.slug}:{row['norad_id']}:{aos.isoformat()}",
|
||||||
|
adapter=self.name,
|
||||||
|
category="pass.satpass_predict",
|
||||||
|
time=p["peak"],
|
||||||
|
severity=_severity_from_elev(p["max_elev_deg"]),
|
||||||
|
geo=Geo(
|
||||||
|
centroid=(observer.lon, observer.lat),
|
||||||
|
regions=[f"US-{observer.state}"],
|
||||||
|
primary_region=f"US-{observer.state}",
|
||||||
|
),
|
||||||
|
data={
|
||||||
|
"observer_name": observer.name,
|
||||||
|
"observer_slug": observer.slug,
|
||||||
|
"observer_state": observer.state,
|
||||||
|
"norad_id": row["norad_id"],
|
||||||
|
"satellite_name": row["satellite_name"],
|
||||||
|
"aos_time": aos.isoformat(),
|
||||||
|
"los_time": p["los"].isoformat() if p["los"] else None,
|
||||||
|
"peak_time": p["peak"].isoformat(),
|
||||||
|
"max_elevation_deg": round(p["max_elev_deg"], 2),
|
||||||
|
"azimuth_at_aos": round(p["aos_az"], 1) if p["aos_az"] is not None else None,
|
||||||
|
"azimuth_at_los": round(p["los_az"], 1) if p["los_az"] is not None else None,
|
||||||
|
"duration_s": (p["los"] - aos).total_seconds() if p["los"] else None,
|
||||||
|
"tle_epoch": row["tle_epoch"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def poll(self) -> AsyncIterator[Event]:
|
||||||
|
if not self._observers:
|
||||||
|
logger.info("satpass_predict: no observers configured; nothing to predict")
|
||||||
|
return
|
||||||
|
rows = await self._fetch_latest_tles()
|
||||||
|
if not rows:
|
||||||
|
logger.info(
|
||||||
|
"satpass_predict: no TLEs available; nothing to predict "
|
||||||
|
"(is celestrak_tle enabled and has it polled at least once?)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
ref_time = datetime.now(timezone.utc)
|
||||||
|
yielded = 0
|
||||||
|
for observer in self._observers:
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
passes = _next_passes(
|
||||||
|
row["tle_line1"], row["tle_line2"], observer,
|
||||||
|
ref_time, self._horizon_h, self._min_elev,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"satpass_predict pass computation failed",
|
||||||
|
extra={"norad_id": row["norad_id"], "observer": observer.slug},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for p in passes:
|
||||||
|
yield self._pass_to_event(p, row, observer)
|
||||||
|
yielded += 1
|
||||||
|
|
||||||
|
self.sweep_old_ids()
|
||||||
|
logger.info(
|
||||||
|
"satpass_predict poll completed",
|
||||||
|
extra={
|
||||||
|
"observers": [o.slug for o in self._observers],
|
||||||
|
"tles_considered": len(rows),
|
||||||
|
"events_yielded": yielded,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def subject_for(self, event: Event) -> str:
|
||||||
|
state = (event.data.get("observer_state") or "").lower() or "unknown"
|
||||||
|
slug = event.data.get("observer_slug") or "unknown"
|
||||||
|
return f"central.sat.pass.us.{state}.{slug}"
|
||||||
|
|
@ -50,6 +50,18 @@ class ConfigStore:
|
||||||
"""Close the connection pool."""
|
"""Close the connection pool."""
|
||||||
await self._pool.close()
|
await self._pool.close()
|
||||||
|
|
||||||
|
def get_pool(self) -> asyncpg.Pool:
|
||||||
|
"""Return the underlying connection pool.
|
||||||
|
|
||||||
|
v0.11.1: introduced for the ``satpass_predict`` adapter which needs
|
||||||
|
ad-hoc reads against the ``events`` table to fetch the latest TLE
|
||||||
|
per ``norad_id`` (the supervisor's adapter scheduler doesn't pipe
|
||||||
|
Postgres access through any of the existing ConfigStore methods).
|
||||||
|
Single-method-surface escape hatch -- adapters acquire from the
|
||||||
|
pool themselves, no celestrak/tle-specific shape leaks into
|
||||||
|
ConfigStore."""
|
||||||
|
return self._pool
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# System configuration
|
# System configuration
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -2938,7 +2938,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"],
|
"Space": ["swpc_alerts", "swpc_kindex", "swpc_protons", "celestrak_tle", "satpass_predict"],
|
||||||
"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"],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{# satpass_predict server-side pass alert. 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('observer_name') is not none %}<dt>Observer</dt><dd>{{ d.observer_name }}{% if d.get('observer_state') %} ({{ d.observer_state }}){% endif %}</dd>{% endif %}
|
||||||
|
{% if d.get('aos_time') is not none %}<dt>AOS (rise)</dt><dd>{{ d.aos_time }}{% if d.get('azimuth_at_aos') is not none %} — azimuth {{ "%.0f"|format(d.azimuth_at_aos) }}°{% endif %}</dd>{% endif %}
|
||||||
|
{% if d.get('peak_time') is not none %}<dt>Peak</dt><dd>{{ d.peak_time }}{% if d.get('max_elevation_deg') is not none %} — max elevation {{ "%.0f"|format(d.max_elevation_deg) }}°{% endif %}</dd>{% endif %}
|
||||||
|
{% if d.get('los_time') is not none %}<dt>LOS (set)</dt><dd>{{ d.los_time }}{% if d.get('azimuth_at_los') is not none %} — azimuth {{ "%.0f"|format(d.azimuth_at_los) }}°{% endif %}</dd>{% endif %}
|
||||||
|
{% if d.get('duration_s') is not none %}<dt>Duration</dt><dd>{{ "%.0f"|format(d.duration_s) }} sec</dd>{% endif %}
|
||||||
|
{% if d.get('tle_epoch') is not none %}<dt>TLE epoch</dt><dd>{{ d.tle_epoch }}</dd>{% endif %}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{%- set d = (event.data.get('data') or {}).get('data') or {} -%}
|
||||||
|
{%- if d.get('satellite_name') and d.get('peak_time') and d.get('max_elevation_deg') is not none -%}
|
||||||
|
{{ d.satellite_name }} passes overhead at {{ d.peak_time[11:16] }} UTC — max elevation {{ "%.0f"|format(d.max_elevation_deg) }}°
|
||||||
|
{%- 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",),
|
"CENTRAL_SAT": ("tle", "pass"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -444,8 +444,10 @@ def test_central_sat_registered_in_streams():
|
||||||
|
|
||||||
|
|
||||||
def test_central_sat_in_supervisor_family_map():
|
def test_central_sat_in_supervisor_family_map():
|
||||||
|
"""v0.11.0 set this to ('tle',); v0.11.1 extended to ('tle', 'pass') so
|
||||||
|
satpass_predict events also route to CENTRAL_SAT."""
|
||||||
from central.supervisor import STREAM_CATEGORY_DOMAINS
|
from central.supervisor import STREAM_CATEGORY_DOMAINS
|
||||||
assert STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"] == ("tle",)
|
assert "tle" in STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"]
|
||||||
|
|
||||||
|
|
||||||
def test_celestrak_tle_in_space_adapter_group():
|
def test_celestrak_tle_in_space_adapter_group():
|
||||||
|
|
|
||||||
|
|
@ -1156,6 +1156,12 @@ _SAMPLE_INNER = {
|
||||||
"mean_motion_rev_per_day": 15.49672912,
|
"mean_motion_rev_per_day": 15.49672912,
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
|
"satpass_predict": {
|
||||||
|
"satellite_name": "ISS (ZARYA)",
|
||||||
|
"norad_id": 25544,
|
||||||
|
"peak_time": "2026-06-09T15:39:37+00:00",
|
||||||
|
"max_elevation_deg": 40.3,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
|
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
|
||||||
|
|
@ -1181,6 +1187,7 @@ _EXPECTED_SUBJECT = {
|
||||||
"itd_511_cameras": "Camera: I-84 Mountain Home",
|
"itd_511_cameras": "Camera: I-84 Mountain Home",
|
||||||
"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°",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
377
tests/test_satpass_predict.py
Normal file
377
tests/test_satpass_predict.py
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
"""Tests for the v0.11.1 satpass_predict adapter.
|
||||||
|
|
||||||
|
Deterministic via a fixed ISS TLE + fixed observer + pinned reference time.
|
||||||
|
The TLE comes from the v0.11.0 stations fixture (epoch 2026-06-08T19:17 UTC);
|
||||||
|
reference time pinned at 2026-06-09T07:00 UTC; observer is Treasure Valley
|
||||||
|
(43.6, -116.2, 0m elev). This combination produces a known ISS pass starting
|
||||||
|
at ~15:36 UTC the same day (verified via the sgp4 sanity script during Phase
|
||||||
|
A of v0.11.1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from central.adapter import SourceAdapter
|
||||||
|
from central.adapters.satpass_predict import (
|
||||||
|
Observer,
|
||||||
|
SatpassPredictAdapter,
|
||||||
|
SatpassPredictSettings,
|
||||||
|
_gmst_rad,
|
||||||
|
_next_passes,
|
||||||
|
_observer_ecef,
|
||||||
|
_severity_from_elev,
|
||||||
|
_topocentric_az_el,
|
||||||
|
)
|
||||||
|
from central.config_models import AdapterConfig
|
||||||
|
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Pinned observer + reference time.
|
||||||
|
_OBS = Observer(name="Treasure Valley", slug="treasure-valley",
|
||||||
|
state="ID", lat=43.6, lon=-116.2, elev_m=0.0)
|
||||||
|
_REF = datetime(2026, 6, 9, 7, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(tmp_path: Path) -> SatpassPredictAdapter:
|
||||||
|
cfg = AdapterConfig(
|
||||||
|
name="satpass_predict",
|
||||||
|
enabled=True,
|
||||||
|
cadence_s=3600,
|
||||||
|
settings={"observers": [_OBS.model_dump()],
|
||||||
|
"min_elevation_deg": 10.0, "horizon_hours": 24},
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
return SatpassPredictAdapter(cfg, MagicMock(), tmp_path / "cursors.db")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pure math helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_gmst_rad_returns_radians_in_canonical_range():
|
||||||
|
"""GMST output must wrap into [0, 2π)."""
|
||||||
|
import math as m
|
||||||
|
val = _gmst_rad(2460835.0, 0.5) # arbitrary post-2000 JD
|
||||||
|
assert 0.0 <= val < 2.0 * m.pi
|
||||||
|
|
||||||
|
|
||||||
|
def test_observer_ecef_for_north_pole_and_equator():
|
||||||
|
"""Sanity: north pole sits on z-axis; equator at lon=0 sits on x-axis."""
|
||||||
|
pole = _observer_ecef(90.0, 0.0, 0.0)
|
||||||
|
assert abs(pole[0]) < 1e-6 and abs(pole[1]) < 1e-6
|
||||||
|
assert pole[2] > 6378.0 # ~6378.137 km
|
||||||
|
|
||||||
|
eq_zero = _observer_ecef(0.0, 0.0, 0.0)
|
||||||
|
assert eq_zero[0] > 6378.0 and abs(eq_zero[1]) < 1e-6 and abs(eq_zero[2]) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_topocentric_zenith_satellite_returns_90_elevation():
|
||||||
|
"""A satellite directly overhead must read elevation 90°, any azimuth."""
|
||||||
|
obs_lat, obs_lon = 43.6, -116.2
|
||||||
|
obs = _observer_ecef(obs_lat, obs_lon, 0.0)
|
||||||
|
# 400km straight up = scale observer position vector by (R+400)/R
|
||||||
|
import math as m
|
||||||
|
r_obs = m.sqrt(sum(c * c for c in obs))
|
||||||
|
r_sat = r_obs + 400.0
|
||||||
|
scale = r_sat / r_obs
|
||||||
|
sat_ecef = (obs[0] * scale, obs[1] * scale, obs[2] * scale)
|
||||||
|
az, el = _topocentric_az_el(sat_ecef, obs, obs_lat, obs_lon)
|
||||||
|
assert abs(el - 90.0) < 0.01, f"expected zenith elevation, got {el}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_topocentric_below_horizon_returns_negative_elevation():
|
||||||
|
"""Satellite on the opposite side of the earth = below horizon."""
|
||||||
|
obs = _observer_ecef(0.0, 0.0, 0.0) # equator, prime meridian
|
||||||
|
antipode = (-obs[0] * 2.0, 0.0, 0.0) # other side, well below
|
||||||
|
_, el = _topocentric_az_el(antipode, obs, 0.0, 0.0)
|
||||||
|
assert el < -10.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Severity bucketing -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("max_elev, expected", [
|
||||||
|
(90.0, 4), # zenith
|
||||||
|
(60.0, 4), # boundary -> 4
|
||||||
|
(59.99, 3),
|
||||||
|
(30.0, 3), # boundary -> 3
|
||||||
|
(29.99, 2),
|
||||||
|
(10.0, 2), # boundary -> 2 (gate threshold; emit)
|
||||||
|
(9.99, 1), # below gate -> 1 (should never emit in practice)
|
||||||
|
(0.0, 1),
|
||||||
|
])
|
||||||
|
def test_severity_from_elev_buckets(max_elev, expected):
|
||||||
|
assert _severity_from_elev(max_elev) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pass detection (the load-bearing math test) ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_iss_next_pass_over_treasure_valley_is_chronologically_sane():
|
||||||
|
"""Pinned TLE + observer + ref time produces ONE known ISS pass in 24h.
|
||||||
|
AOS < peak < LOS, max_elev in (10, 90), positive duration."""
|
||||||
|
passes = _next_passes(
|
||||||
|
_ISS_L1, _ISS_L2, _OBS,
|
||||||
|
ref_time=_REF, horizon_hours=24, min_elevation_deg=10.0,
|
||||||
|
)
|
||||||
|
assert len(passes) > 0, "expected at least one ISS pass over Boise in next 24h"
|
||||||
|
p = passes[0]
|
||||||
|
assert p["aos"] < p["peak"] <= p["los"]
|
||||||
|
assert 10.0 < p["max_elev_deg"] < 90.0
|
||||||
|
assert (p["los"] - p["aos"]).total_seconds() > 0
|
||||||
|
# And the pass must lie inside the 24h horizon (ref + 24h = 2026-06-10T07:00 UTC).
|
||||||
|
horizon_end = datetime(2026, 6, 10, 7, 0, 0, tzinfo=timezone.utc)
|
||||||
|
assert p["aos"] >= _REF
|
||||||
|
assert p["los"] <= horizon_end
|
||||||
|
|
||||||
|
|
||||||
|
def test_iss_pass_has_plausible_azimuths():
|
||||||
|
"""Azimuth at AOS and LOS should be valid 0-360° readings."""
|
||||||
|
passes = _next_passes(
|
||||||
|
_ISS_L1, _ISS_L2, _OBS,
|
||||||
|
ref_time=_REF, horizon_hours=24, min_elevation_deg=10.0,
|
||||||
|
)
|
||||||
|
p = passes[0]
|
||||||
|
assert 0.0 <= p["aos_az"] < 360.0
|
||||||
|
# los_az may be None if the pass ran to the horizon edge, but for ISS
|
||||||
|
# against the pinned ref it completes within 24h.
|
||||||
|
if p["los_az"] is not None:
|
||||||
|
assert 0.0 <= p["los_az"] < 360.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_elevation_gate_filters_lower_passes():
|
||||||
|
"""Same TLE, raise the gate to 80° -- now zero passes (ISS at 51.6°
|
||||||
|
inclination from latitude 43.6° can't reach 80° often)."""
|
||||||
|
passes_low = _next_passes(_ISS_L1, _ISS_L2, _OBS, _REF, 24, 10.0)
|
||||||
|
passes_high = _next_passes(_ISS_L1, _ISS_L2, _OBS, _REF, 24, 80.0)
|
||||||
|
assert len(passes_low) > 0
|
||||||
|
# No 80°+ passes today (would require near-overhead crossing).
|
||||||
|
for p in passes_high:
|
||||||
|
assert p["max_elev_deg"] >= 80.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_tle_returns_empty_pass_list():
|
||||||
|
"""A garbage TLE must not crash; just yield no passes."""
|
||||||
|
passes = _next_passes("not a tle", "also not", _OBS, _REF, 24, 10.0)
|
||||||
|
assert passes == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- _build_event / _pass_to_event ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _row_for_iss():
|
||||||
|
return {
|
||||||
|
"norad_id": 25544, "satellite_name": "ISS (ZARYA)",
|
||||||
|
"tle_line1": _ISS_L1, "tle_line2": _ISS_L2,
|
||||||
|
"tle_epoch": "2026-06-08T19:17:55+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pass_event_shape(adapter):
|
||||||
|
passes = _next_passes(_ISS_L1, _ISS_L2, _OBS, _REF, 24, 10.0)
|
||||||
|
assert passes
|
||||||
|
ev = adapter._pass_to_event(passes[0], _row_for_iss(), _OBS)
|
||||||
|
# Identity
|
||||||
|
assert ev.adapter == "satpass_predict"
|
||||||
|
assert ev.category == "pass.satpass_predict"
|
||||||
|
# Dedup id shape: {observer_slug}:{norad_id}:{aos_iso}
|
||||||
|
assert ev.id.startswith("treasure-valley:25544:")
|
||||||
|
assert ":2026-06-" in ev.id # AOS within the same UTC day window
|
||||||
|
# Severity bucket maps from peak elevation
|
||||||
|
assert ev.severity == _severity_from_elev(passes[0]["max_elev_deg"])
|
||||||
|
# Geo: centroid at the observer point
|
||||||
|
assert ev.geo.centroid == (-116.2, 43.6)
|
||||||
|
assert ev.geo.primary_region == "US-ID"
|
||||||
|
# data fields per spec
|
||||||
|
assert ev.data["observer_name"] == "Treasure Valley"
|
||||||
|
assert ev.data["observer_slug"] == "treasure-valley"
|
||||||
|
assert ev.data["observer_state"] == "ID"
|
||||||
|
assert ev.data["norad_id"] == 25544
|
||||||
|
assert ev.data["satellite_name"] == "ISS (ZARYA)"
|
||||||
|
assert ev.data["max_elevation_deg"] == round(passes[0]["max_elev_deg"], 2)
|
||||||
|
assert ev.data["duration_s"] > 0
|
||||||
|
assert ev.data["tle_epoch"] == "2026-06-08T19:17:55+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_subject_for_uses_observer_state_and_slug(adapter):
|
||||||
|
passes = _next_passes(_ISS_L1, _ISS_L2, _OBS, _REF, 24, 10.0)
|
||||||
|
ev = adapter._pass_to_event(passes[0], _row_for_iss(), _OBS)
|
||||||
|
assert adapter.subject_for(ev) == "central.sat.pass.us.id.treasure-valley"
|
||||||
|
|
||||||
|
|
||||||
|
def test_subject_for_falls_back_when_state_or_slug_missing(adapter):
|
||||||
|
from central.models import Event, Geo
|
||||||
|
ev = Event(
|
||||||
|
id="x", adapter="satpass_predict", category="pass.satpass_predict",
|
||||||
|
time=datetime.now(timezone.utc), severity=2, geo=Geo(), data={},
|
||||||
|
)
|
||||||
|
assert adapter.subject_for(ev) == "central.sat.pass.us.unknown.unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# --- poll() integration with mocked pool ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_pool_returning(rows):
|
||||||
|
"""Build a MagicMock pool that yields ``rows`` from any SELECT."""
|
||||||
|
pool = MagicMock()
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.fetch = AsyncMock(return_value=rows)
|
||||||
|
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
|
||||||
|
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_empty_tles_table_logs_and_yields_zero(tmp_path):
|
||||||
|
"""v0.11.1 spec: empty TLE table -> 0 events, INFO log, no exception."""
|
||||||
|
cfg = AdapterConfig(
|
||||||
|
name="satpass_predict", enabled=True, cadence_s=3600,
|
||||||
|
settings={"observers": [_OBS.model_dump()],
|
||||||
|
"min_elevation_deg": 10.0, "horizon_hours": 24},
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
config_store = MagicMock()
|
||||||
|
config_store.get_pool.return_value = _mock_pool_returning([])
|
||||||
|
adapter = SatpassPredictAdapter(cfg, config_store, tmp_path / "cursors.db")
|
||||||
|
await adapter.startup()
|
||||||
|
try:
|
||||||
|
events = [e async for e in adapter.poll()]
|
||||||
|
assert events == []
|
||||||
|
finally:
|
||||||
|
await adapter.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_multi_observer_yields_per_observer_pass_list(tmp_path):
|
||||||
|
"""Two observers in settings → each observer gets its own pass list against
|
||||||
|
the same TLE. Boise (43.6, -116.2) and Salt Lake City (40.76, -111.89)
|
||||||
|
both see ISS but with slightly different AOS times -> different events."""
|
||||||
|
boise = _OBS
|
||||||
|
slc = Observer(name="Salt Lake City", slug="slc",
|
||||||
|
state="UT", lat=40.76, lon=-111.89, elev_m=0.0)
|
||||||
|
cfg = AdapterConfig(
|
||||||
|
name="satpass_predict", enabled=True, cadence_s=3600,
|
||||||
|
settings={"observers": [boise.model_dump(), slc.model_dump()],
|
||||||
|
"min_elevation_deg": 10.0, "horizon_hours": 24},
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
config_store = MagicMock()
|
||||||
|
config_store.get_pool.return_value = _mock_pool_returning([_row_for_iss()])
|
||||||
|
adapter = SatpassPredictAdapter(cfg, config_store, tmp_path / "cursors.db")
|
||||||
|
await adapter.startup()
|
||||||
|
try:
|
||||||
|
events = [e async for e in adapter.poll()]
|
||||||
|
# We don't pin counts (number of passes per 24h varies with the pinned
|
||||||
|
# ref time), but each observer must have at least one event distinct
|
||||||
|
# from the other.
|
||||||
|
boise_evs = [e for e in events if e.data["observer_slug"] == "treasure-valley"]
|
||||||
|
slc_evs = [e for e in events if e.data["observer_slug"] == "slc"]
|
||||||
|
assert boise_evs, "no Boise passes"
|
||||||
|
assert slc_evs, "no Salt Lake City passes"
|
||||||
|
# Subject routing differs by state.
|
||||||
|
assert adapter.subject_for(boise_evs[0]) == "central.sat.pass.us.id.treasure-valley"
|
||||||
|
assert adapter.subject_for(slc_evs[0]) == "central.sat.pass.us.ut.slc"
|
||||||
|
finally:
|
||||||
|
await adapter.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Settings / apply_config / dedup-mixin regression ----------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_settings_match_spec():
|
||||||
|
s = SatpassPredictSettings()
|
||||||
|
assert s.min_elevation_deg == 10.0
|
||||||
|
assert s.horizon_hours == 24
|
||||||
|
assert len(s.observers) == 1
|
||||||
|
assert s.observers[0].slug == "treasure-valley"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inherits_dedup_mixin_from_source_adapter(tmp_path):
|
||||||
|
"""v0.9.1 regression guard."""
|
||||||
|
assert issubclass(SatpassPredictAdapter, SourceAdapter)
|
||||||
|
a = SatpassPredictAdapter(
|
||||||
|
AdapterConfig(
|
||||||
|
name="satpass_predict", enabled=False, cadence_s=3600,
|
||||||
|
settings={}, updated_at=datetime.now(timezone.utc),
|
||||||
|
),
|
||||||
|
MagicMock(),
|
||||||
|
tmp_path / "cursors.db",
|
||||||
|
)
|
||||||
|
assert callable(a.is_published)
|
||||||
|
assert callable(a.mark_published)
|
||||||
|
assert callable(a.sweep_old_ids)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_config_updates_observers_and_threshold(adapter):
|
||||||
|
new_obs = Observer(name="Sandpoint", slug="sandpoint",
|
||||||
|
state="ID", lat=48.27, lon=-116.55, elev_m=600.0)
|
||||||
|
new_cfg = AdapterConfig(
|
||||||
|
name="satpass_predict", enabled=True, cadence_s=3600,
|
||||||
|
settings={"observers": [new_obs.model_dump()],
|
||||||
|
"min_elevation_deg": 25.0, "horizon_hours": 12},
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await adapter.apply_config(new_cfg)
|
||||||
|
assert len(adapter._observers) == 1
|
||||||
|
assert adapter._observers[0].slug == "sandpoint"
|
||||||
|
assert adapter._min_elev == 25.0
|
||||||
|
assert adapter._horizon_h == 12.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stream registry + family map + GUI wiring ----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_central_sat_family_includes_pass_token():
|
||||||
|
"""v0.11.1: pass.* categories also route to CENTRAL_SAT."""
|
||||||
|
from central.supervisor import STREAM_CATEGORY_DOMAINS
|
||||||
|
assert STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"] == ("tle", "pass")
|
||||||
|
|
||||||
|
|
||||||
|
def test_satpass_predict_in_space_adapter_group():
|
||||||
|
from central.gui.routes import ADAPTER_GROUPS
|
||||||
|
assert "satpass_predict" in ADAPTER_GROUPS["Space"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Partials render cleanly (v0.10.0 pattern) ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_partial_renders_cleanly_with_real_pass(adapter):
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
templates_dir = Path(__file__).parent.parent / "src" / "central" / "gui" / "templates"
|
||||||
|
env = Environment(loader=FileSystemLoader(str(templates_dir)), autoescape=True)
|
||||||
|
tmpl = env.get_template("_event_summaries/satpass_predict.html")
|
||||||
|
passes = _next_passes(_ISS_L1, _ISS_L2, _OBS, _REF, 24, 10.0)
|
||||||
|
ev = adapter._pass_to_event(passes[0], _row_for_iss(), _OBS)
|
||||||
|
rendered = tmpl.render(event={
|
||||||
|
"data": {"data": {"data": ev.model_dump(mode="json")["data"]}}
|
||||||
|
}).strip()
|
||||||
|
assert "ISS (ZARYA)" in rendered, f"got: {rendered!r}"
|
||||||
|
assert "max elevation" in rendered
|
||||||
|
assert "UTC" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_partial_renders_cleanly(adapter):
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
templates_dir = Path(__file__).parent.parent / "src" / "central" / "gui" / "templates"
|
||||||
|
env = Environment(loader=FileSystemLoader(str(templates_dir)), autoescape=True)
|
||||||
|
tmpl = env.get_template("_event_rows/satpass_predict.html")
|
||||||
|
passes = _next_passes(_ISS_L1, _ISS_L2, _OBS, _REF, 24, 10.0)
|
||||||
|
ev = adapter._pass_to_event(passes[0], _row_for_iss(), _OBS)
|
||||||
|
rendered = tmpl.render(event={
|
||||||
|
"data": {"data": {"data": ev.model_dump(mode="json")["data"]}}
|
||||||
|
})
|
||||||
|
assert "<dt>Satellite</dt>" in rendered and "ISS (ZARYA)" in rendered
|
||||||
|
assert "<dt>Observer</dt>" in rendered and "Treasure Valley" in rendered
|
||||||
|
assert "<dt>AOS (rise)</dt>" in rendered
|
||||||
|
assert "<dt>Peak</dt>" in rendered
|
||||||
|
assert "<dt>LOS (set)</dt>" in rendered
|
||||||
|
assert "<dt>Duration</dt>" in rendered
|
||||||
18
uv.lock
generated
18
uv.lock
generated
|
|
@ -189,6 +189,7 @@ dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
|
{ name = "sgp4" },
|
||||||
{ name = "shapely" },
|
{ name = "shapely" },
|
||||||
{ name = "tenacity" },
|
{ name = "tenacity" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
|
@ -219,6 +220,7 @@ requires-dist = [
|
||||||
{ name = "pydantic", specifier = ">=2,<3" },
|
{ name = "pydantic", specifier = ">=2,<3" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.7.0" },
|
{ name = "pydantic-settings", specifier = ">=2.7.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||||
|
{ name = "sgp4", specifier = ">=2.25" },
|
||||||
{ name = "shapely", specifier = ">=2.0" },
|
{ name = "shapely", specifier = ">=2.0" },
|
||||||
{ name = "tenacity", specifier = ">=9.1.4" },
|
{ name = "tenacity", specifier = ">=9.1.4" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||||
|
|
@ -893,6 +895,22 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sgp4"
|
||||||
|
version = "2.25"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6e/d0/fc467010d17742321f73b16a71acac88439a88f2b166641942a6566c9b2a/sgp4-2.25.tar.gz", hash = "sha256:e19edc6dcc25d69fb8fde0a267b8f0c44d7e915c7bcbeacf5d3a8b595baf0674", size = 181016, upload-time = "2025-08-04T18:02:33.765Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/71/864524bde46a02e636cc5de47b9a4e1f1ed18c7acc3f1319cf9fe1db3c7a/sgp4-2.25-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:170ec2882cd166ff9d8dccfb8018f86d5cc033ea8a07c27a1825999c62439f05", size = 162985, upload-time = "2025-08-04T18:01:55.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/cd/022aa419d9570d494dafd5103a71dda64c6ffc956a1c7f5b096a58a23a6a/sgp4-2.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64c7597a60b770caac51566b1f621d1cd74df0409ef19c5e7ea3505d0dfbc677", size = 161951, upload-time = "2025-08-04T18:01:56.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/1c/76dbf2190d30a770fe8ac57474d212e005f56f47e65dd6fcecdb546d454f/sgp4-2.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e1d18b8972643dd29e758e67c062cfb68fbe2421fe3f6398f1957a9825119f6", size = 236340, upload-time = "2025-08-04T18:01:57.778Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/a4/2fc9bf9cb75571222bd453407317e91193a3db1c559333c5e46ce7a014c9/sgp4-2.25-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35649388a06cbee7def24cbb789f452c31d42ed9e87bddd89935ed78f19451ed", size = 233080, upload-time = "2025-08-04T18:01:58.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/40/50ecdc518edd3a85ad74bda7a2196b53d5901256e3d7ab34225c96e8edc8/sgp4-2.25-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:911460477f1c52dcda2b3eb20538435b89b0a43668bcb5edd1e7700b7a1a0225", size = 235729, upload-time = "2025-08-04T18:01:59.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/dd/c1ee8571828debfd3e0f2297379a2a2af75024062c70cf76bdc121e77623/sgp4-2.25-cp312-cp312-win32.whl", hash = "sha256:128edd3d6061e833600d93e77d4c08d1a5002293997e368256b0b777ea525dda", size = 161899, upload-time = "2025-08-04T18:02:00.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/f8/7dae15af520dfe5def1f8620c2817203cbbf1a1bf154b2079add1200acd3/sgp4-2.25-cp312-cp312-win_amd64.whl", hash = "sha256:979eb60e74aff5dc318cfe1a6c817db884486bdfc8496d2c5bc07b05fe833280", size = 164137, upload-time = "2025-08-04T18:02:01.817Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/47/8231e3d4a88341316ec8d0eb98d3a8a972477d8b038555259522735a8371/sgp4-2.25-py3-none-any.whl", hash = "sha256:4f39ecf6c2663109fed04adfe9982815ac83893271b521d92d5b186820f8c78e", size = 137376, upload-time = "2026-04-27T18:29:23.71Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shapely"
|
name = "shapely"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue