osint-dashboard/app/satellites.py
Sirius DevOps 6b5eec3824 feat(satellites): CelesTrak GP JSON satellites API with SGP4 propagation
GET /api/satellites?groups=stations,weather returns last-known satellite
positions propagated from CelesTrak GP JSON (OMM mean elements, not TLE)
via the real sgp4 library (TEME->geodetic), not two-body Kepler.

- Fetch gp.php?GROUP=...&FORMAT=JSON at most once per 2h per group,
  cached with a last-good blob fallback for 403 / stale responses.
- SatNOGS TLE fallback only when the CelesTrak cache is empty.
- bbox viewport culling; unknown group -> 400.
- overlay_catalog() gains id=satellites (kind=points, /api/satellites).
- OMM path handles NORAD cat numbers >= 100000 (no TLE round-trip).
2026-08-31 21:42:50 -04:00

289 lines
9.6 KiB
Python

"""CelesTrak satellites last-known overlay.
Fetches GP **JSON** (OMM mean elements — not TLE) per group at most once per
2 hours, caches the element blob, and propagates positions with a real SGP4
library on every request. Positions move every second; the *element set* is
what we cache, not the derived lat/lon.
Catalog numbers >= 100000 only fit OMM/JSON, never a 5-column TLE field, so
elements are initialized through :func:`sgp4.omm.initialize` (which consumes
the CelesTrak GP JSON fields verbatim) rather than round-tripping to TLE.
CelesTrak usage policy is non-negotiable: fetch the GP JSON blob at most once
per 2 hours per group, never fan out every GROUP, never also fetch
``GROUP=active`` plus subsets, and identify with ``OSINT_USER_AGENT``.
"""
from __future__ import annotations
import logging
import math
from datetime import datetime, timezone
from urllib.parse import quote
logger = logging.getLogger("osint.satellites")
CELESTRAK_GP = "https://celestrak.org/NORAD/elements/gp.php"
SATNOGS_TLE = "https://db.satnogs.org/api/tle/"
DEFAULT_GROUPS = ("stations", "weather")
ALLOWED_GROUPS = ("stations", "weather", "gps-ops", "starlink")
# CelesTrak policy: do not hit gp.php more than once per 2 hours per group.
SATELLITE_TTL = 2 * 3600.0
SOURCE_CELESTRAK = "celestrak"
SOURCE_SATNOGS = "satnogs"
DEFAULT_LIMIT = 2000
# WGS-84 ellipsoid for TEME -> geodetic.
_WGS84_A = 6378.137
_WGS84_F = 1.0 / 298.257223563
# Last-good element blob per group, kept past TTL so a 403 / "has not updated
# since ..." still serves the previous set instead of failing the overlay.
_last_good: dict[str, list[dict]] = {}
def parse_groups(raw: str | None) -> list[str]:
"""Validate + normalize a comma-separated group list. Raises ValueError.
Starlink is allowed only when explicitly requested (never in the default);
it is a large supplemental feed, not part of the stations/weather default.
"""
groups = [g.strip().lower() for g in (raw or "").split(",") if g.strip()]
if not groups:
raise ValueError("groups must be a non-empty comma-separated list")
bad = [g for g in groups if g not in ALLOWED_GROUPS]
if bad:
raise ValueError(f"unknown group(s): {', '.join(bad)}")
# Dedup, preserve order.
seen: set[str] = set()
out: list[str] = []
for g in groups:
if g not in seen:
seen.add(g)
out.append(g)
return out
def _teme_to_geodetic(
r: tuple[float, float, float],
jd: float,
fr: float,
) -> tuple[float, float, float]:
"""SGP4 TEME position (km) -> geodetic (lat_deg, lon_deg, alt_km).
Rotate TEME into an Earth-fixed frame via GMST, then iterate the WGS-84
geodetic conversion. Good to well under a km for a ground-track overlay.
"""
# GMST (radians) from UT1 ~= UTC here (sub-second error is negligible).
d = (jd + fr) - 2451545.0
t = d / 36525.0
gmst_s = (
67310.54841
+ (876600.0 * 3600.0 + 8640184.812866) * t
+ 0.093104 * t * t
- 6.2e-6 * t * t * t
)
theta = math.radians((gmst_s % 86400.0) / 240.0)
x, y, z = r
xe = x * math.cos(theta) + y * math.sin(theta)
ye = -x * math.sin(theta) + y * math.cos(theta)
ze = z
e2 = _WGS84_F * (2.0 - _WGS84_F)
p = math.sqrt(xe * xe + ye * ye)
lon = math.atan2(ye, xe)
lat = math.atan2(ze, p * (1.0 - e2))
alt = 0.0
for _ in range(10):
n = _WGS84_A / math.sqrt(1.0 - e2 * math.sin(lat) ** 2)
alt = p / math.cos(lat) - n
lat = math.atan2(ze, p * (1.0 - e2 * n / (n + alt)))
n = _WGS84_A / math.sqrt(1.0 - e2 * math.sin(lat) ** 2)
alt = p / math.cos(lat) - n
return math.degrees(lat), math.degrees(lon), alt
def propagate_gp(
elements: list[dict],
group: str,
now: datetime,
) -> list[dict]:
"""Propagate CelesTrak GP JSON elements to geodetic positions at ``now``.
Pure and deterministic given ``now``. Returns ``[{id, name, lat, lon,
alt_km, group}]``; malformed elements and propagation errors are skipped.
"""
from sgp4.api import Satrec, jday
import sgp4.omm as omm
jd, fr = jday(
now.year, now.month, now.day,
now.hour, now.minute, now.second + now.microsecond / 1e6,
)
out: list[dict] = []
for rec in elements:
if not isinstance(rec, dict):
continue
sat = Satrec()
try:
omm.initialize(sat, rec)
except (KeyError, ValueError, TypeError):
continue
err, r, _v = sat.sgp4(jd, fr)
if err != 0:
continue
lat, lon, alt = _teme_to_geodetic(r, jd, fr)
norad = rec.get("NORAD_CAT_ID")
out.append({
"id": str(norad) if norad is not None else "",
"name": rec.get("OBJECT_NAME") or str(norad or ""),
"lat": round(lat, 5),
"lon": round(lon, 5),
"alt_km": round(alt, 2),
"group": group,
})
return out
def _max_epoch(elements: list[dict]) -> str | None:
"""Most recent EPOCH across an element set (ISO-8601 lexical max)."""
epochs = [
str(e["EPOCH"]) for e in elements
if isinstance(e, dict) and e.get("EPOCH")
]
return max(epochs) if epochs else None
def propagate_satnogs_tle(
payload: list[dict],
group: str,
now: datetime,
) -> tuple[list[dict], str | None]:
"""Fallback parser for SatNOGS TLE JSON (``[{tle0,tle1,tle2,updated}]``).
Returns ``(satellites, epoch)`` where epoch is the max ``updated`` time.
Only used when the CelesTrak cache is completely empty.
"""
from sgp4.api import Satrec, jday
jd, fr = jday(
now.year, now.month, now.day,
now.hour, now.minute, now.second + now.microsecond / 1e6,
)
out: list[dict] = []
epochs: list[str] = []
for rec in payload or []:
if not isinstance(rec, dict):
continue
line1 = rec.get("tle1")
line2 = rec.get("tle2")
if not line1 or not line2:
continue
try:
sat = Satrec.twoline2rv(line1, line2)
except (ValueError, TypeError):
continue
e, r, _v = sat.sgp4(jd, fr)
if e != 0:
continue
lat, lon, alt = _teme_to_geodetic(r, jd, fr)
satnum = getattr(sat, "satnum_str", None) or rec.get("norad_cat_id")
name = (rec.get("tle0") or "").strip().lstrip("0").strip() or str(satnum or "")
out.append({
"id": str(satnum).strip() or "",
"name": name,
"lat": round(lat, 5),
"lon": round(lon, 5),
"alt_km": round(alt, 2),
"group": group,
})
if rec.get("updated"):
epochs.append(str(rec["updated"]))
return out, (max(epochs) if epochs else None)
async def _group_elements(group: str) -> tuple[list[dict], str | None]:
"""CelesTrak GP blob for one group, TTL-cached with a last-good fallback.
Returns ``(elements, epoch)``. On a fetch failure (403 / "has not updated
since ...") falls back to the previous successful blob for that group.
"""
from live_layers import _get_json, _ttl_get
url = f"{CELESTRAK_GP}?GROUP={quote(group)}&FORMAT=JSON"
async def _load() -> list[dict]:
data = await _get_json(url)
if not isinstance(data, list):
raise ValueError(f"unexpected CelesTrak payload for {group}")
if data:
_last_good[group] = data
return data
key = f"celestrak:gp:{group}"
try:
elements = await _ttl_get(key, SATELLITE_TTL, _load)
except Exception as exc: # noqa: BLE001
logger.warning("celestrak_fetch_failed group=%s: %s", group, exc)
elements = _last_good.get(group, [])
if not elements:
return [], None
return elements, _max_epoch(elements)
async def fetch_satellites(
groups: list[str],
bbox: str | None = None,
limit: int = DEFAULT_LIMIT,
) -> dict:
"""Assemble the ``/api/satellites`` payload for the requested groups."""
from live_layers import _get_json, _ttl_get, filter_points_bbox, parse_bbox
now = datetime.now(timezone.utc)
satellites: list[dict] = []
epoch: str | None = None
source = SOURCE_CELESTRAK
for group in groups:
elements, group_epoch = await _group_elements(group)
if not elements:
continue
if group_epoch and (epoch is None or group_epoch > epoch):
epoch = group_epoch
satellites.extend(propagate_gp(elements, group, now))
if not satellites:
# Fallback only when the CelesTrak cache is entirely empty — never
# poll both providers every cycle.
async def _load_satnogs() -> list[dict]:
data = await _get_json(SATNOGS_TLE, params={"format": "json"})
return data if isinstance(data, list) else []
try:
satnogs = await _ttl_get("satnogs:tle", SATELLITE_TTL, _load_satnogs)
except Exception as exc: # noqa: BLE001
logger.warning("satnogs_fetch_failed: %s", exc)
satnogs = []
if satnogs:
source = SOURCE_SATNOGS
for group in groups:
rows, sn_epoch = propagate_satnogs_tle(satnogs, group, now)
if sn_epoch and (epoch is None or sn_epoch > epoch):
epoch = sn_epoch
satellites.extend(rows)
if bbox:
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
satellites = filter_points_bbox(
satellites, minlon, minlat, maxlon, maxlat, limit,
)
else:
satellites = satellites[:limit]
return {
"satellites": satellites,
"source": source,
"tle_epoch": epoch,
"timestamp": now.isoformat(),
}