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).
289 lines
9.6 KiB
Python
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(),
|
|
}
|