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