201 lines
6.8 KiB
Python
201 lines
6.8 KiB
Python
|
|
"""CelesTrak satellites overlay: GP JSON parser, 2h cache, groups, bbox."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
import satellites
|
||
|
|
from live_layers import _cache, overlay_catalog
|
||
|
|
from main import app
|
||
|
|
|
||
|
|
BASE = "http://test"
|
||
|
|
|
||
|
|
# Two real CelesTrak GP JSON records (trimmed to the OMM fields sgp4 needs).
|
||
|
|
ISS = {
|
||
|
|
"OBJECT_NAME": "ISS (ZARYA)", "OBJECT_ID": "1998-067A",
|
||
|
|
"EPOCH": "2026-08-31T11:11:23.184384", "MEAN_MOTION": 15.4894954,
|
||
|
|
"ECCENTRICITY": 0.00050456, "INCLINATION": 51.6314,
|
||
|
|
"RA_OF_ASC_NODE": 287.5025, "ARG_OF_PERICENTER": 92.8598,
|
||
|
|
"MEAN_ANOMALY": 267.2968, "EPHEMERIS_TYPE": 0,
|
||
|
|
"CLASSIFICATION_TYPE": "U", "NORAD_CAT_ID": 25544,
|
||
|
|
"ELEMENT_SET_NO": 999, "REV_AT_EPOCH": 58342,
|
||
|
|
"BSTAR": 9.9862358e-5, "MEAN_MOTION_DOT": 5.046e-5,
|
||
|
|
"MEAN_MOTION_DDOT": 0,
|
||
|
|
}
|
||
|
|
HST = {
|
||
|
|
"OBJECT_NAME": "HST", "OBJECT_ID": "1990-037B",
|
||
|
|
"EPOCH": "2026-08-31T11:11:23.184384", "MEAN_MOTION": 15.0865888,
|
||
|
|
"ECCENTRICITY": 0.0002426, "INCLINATION": 28.4697,
|
||
|
|
"RA_OF_ASC_NODE": 102.1854, "ARG_OF_PERICENTER": 152.8462,
|
||
|
|
"MEAN_ANOMALY": 207.2795, "EPHEMERIS_TYPE": 0,
|
||
|
|
"CLASSIFICATION_TYPE": "U", "NORAD_CAT_ID": 20580,
|
||
|
|
"ELEMENT_SET_NO": 999, "REV_AT_EPOCH": 12345,
|
||
|
|
"BSTAR": 2.9e-5, "MEAN_MOTION_DOT": 0.0,
|
||
|
|
"MEAN_MOTION_DDOT": 0,
|
||
|
|
}
|
||
|
|
FIXTURE = [ISS, HST]
|
||
|
|
|
||
|
|
NOW = datetime(2026, 8, 31, 12, 0, 0, tzinfo=timezone.utc)
|
||
|
|
|
||
|
|
|
||
|
|
async def _get(path: str) -> httpx.Response:
|
||
|
|
transport = httpx.ASGITransport(app=app)
|
||
|
|
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
|
||
|
|
return await client.get(path)
|
||
|
|
|
||
|
|
|
||
|
|
def test_propagate_gp_shape():
|
||
|
|
rows = satellites.propagate_gp(FIXTURE, "stations", NOW)
|
||
|
|
assert len(rows) == 2
|
||
|
|
by_id = {r["id"]: r for r in rows}
|
||
|
|
assert set(by_id) == {"25544", "20580"}
|
||
|
|
iss = by_id["25544"]
|
||
|
|
assert iss["name"] == "ISS (ZARYA)"
|
||
|
|
assert iss["group"] == "stations"
|
||
|
|
# ISS is in LEO: ~400 km, |lat| <= inclination 51.63, lon in range.
|
||
|
|
assert 300 < iss["alt_km"] < 500
|
||
|
|
assert -51.7 <= iss["lat"] <= 51.7
|
||
|
|
assert -180 <= iss["lon"] <= 180
|
||
|
|
for key in ("id", "name", "lat", "lon", "alt_km", "group"):
|
||
|
|
assert key in iss
|
||
|
|
|
||
|
|
|
||
|
|
def test_propagate_gp_skips_malformed():
|
||
|
|
bad = [{"OBJECT_NAME": "x"}, None, 42, {"NORAD_CAT_ID": 1}]
|
||
|
|
assert satellites.propagate_gp(bad, "stations", NOW) == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_groups_defaults_and_validation():
|
||
|
|
assert satellites.parse_groups("stations,weather") == ["stations", "weather"]
|
||
|
|
assert satellites.parse_groups("weather,gps-ops") == ["weather", "gps-ops"]
|
||
|
|
# starlink is allowed only when explicitly requested
|
||
|
|
assert satellites.parse_groups("starlink") == ["starlink"]
|
||
|
|
assert satellites.parse_groups("stations,stations") == ["stations"]
|
||
|
|
for bad in ("", None, "debris", "stations,active", "stations, weather, active"):
|
||
|
|
try:
|
||
|
|
satellites.parse_groups(bad)
|
||
|
|
except ValueError:
|
||
|
|
pass
|
||
|
|
else:
|
||
|
|
raise AssertionError(f"expected ValueError for {bad!r}")
|
||
|
|
|
||
|
|
|
||
|
|
def test_overlay_catalog_has_satellites_stub():
|
||
|
|
entry = overlay_catalog()["satellites"]
|
||
|
|
assert entry["kind"] == "points"
|
||
|
|
assert entry["endpoint"] == "/api/satellites"
|
||
|
|
assert "CelesTrak" in entry["attribution"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_unknown_group_400():
|
||
|
|
resp = asyncio.run(_get("/api/satellites?groups=debris"))
|
||
|
|
assert resp.status_code == 400
|
||
|
|
|
||
|
|
|
||
|
|
def test_default_groups_ok_with_fake_fetch(monkeypatch):
|
||
|
|
async def fake(groups, bbox=None, limit=2000):
|
||
|
|
return {"satellites": [], "source": "celestrak",
|
||
|
|
"tle_epoch": None, "timestamp": "t"}
|
||
|
|
|
||
|
|
monkeypatch.setattr("main.fetch_satellites", fake)
|
||
|
|
resp = asyncio.run(_get("/api/satellites"))
|
||
|
|
assert resp.status_code == 200
|
||
|
|
body = resp.json()
|
||
|
|
assert body["source"] == "celestrak"
|
||
|
|
assert "max-age" in (resp.headers.get("cache-control") or "").lower()
|
||
|
|
|
||
|
|
|
||
|
|
def test_2h_cache_does_not_refetch(monkeypatch):
|
||
|
|
satellites._last_good.clear()
|
||
|
|
_cache.clear()
|
||
|
|
hits = {"n": 0}
|
||
|
|
|
||
|
|
class FakeResp:
|
||
|
|
def __init__(self, data):
|
||
|
|
self._data = data
|
||
|
|
|
||
|
|
def raise_for_status(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def json(self):
|
||
|
|
return self._data
|
||
|
|
|
||
|
|
class FakeClient:
|
||
|
|
def __init__(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
async def get(self, url, params=None, headers=None):
|
||
|
|
hits["n"] += 1
|
||
|
|
assert "celestrak.org/NORAD/elements/gp.php" in url
|
||
|
|
return FakeResp(FIXTURE)
|
||
|
|
|
||
|
|
monkeypatch.setattr("live_layers._http", FakeClient())
|
||
|
|
|
||
|
|
async def run():
|
||
|
|
p1 = await satellites.fetch_satellites(["stations"])
|
||
|
|
p2 = await satellites.fetch_satellites(["stations"])
|
||
|
|
return p1, p2
|
||
|
|
|
||
|
|
p1, p2 = asyncio.run(run())
|
||
|
|
assert len(p1["satellites"]) == 2
|
||
|
|
assert p1["tle_epoch"] == "2026-08-31T11:11:23.184384"
|
||
|
|
# Same element blob served from cache (no refetch), same ids/epochs.
|
||
|
|
assert [s["id"] for s in p2["satellites"]] == [s["id"] for s in p1["satellites"]]
|
||
|
|
assert p2["tle_epoch"] == p1["tle_epoch"]
|
||
|
|
assert hits["n"] == 1
|
||
|
|
_cache.clear()
|
||
|
|
satellites._last_good.clear()
|
||
|
|
|
||
|
|
|
||
|
|
def test_bbox_culls_satellites(monkeypatch):
|
||
|
|
"""bbox filtering in fetch_satellites, deterministic via fake propagation."""
|
||
|
|
|
||
|
|
async def fake_elements(group):
|
||
|
|
return [{"x": 1}], "2026-08-31T11:11:23.184384"
|
||
|
|
|
||
|
|
monkeypatch.setattr("satellites._group_elements", fake_elements)
|
||
|
|
|
||
|
|
def fake_propagate(elements, group, now):
|
||
|
|
return [
|
||
|
|
{"id": "a", "name": "A", "lat": 10.0, "lon": 20.0, "alt_km": 400.0, "group": group},
|
||
|
|
{"id": "b", "name": "B", "lat": 45.0, "lon": -70.0, "alt_km": 400.0, "group": group},
|
||
|
|
{"id": "c", "name": "C", "lat": -10.0, "lon": 30.0, "alt_km": 400.0, "group": group},
|
||
|
|
]
|
||
|
|
|
||
|
|
monkeypatch.setattr("satellites.propagate_gp", fake_propagate)
|
||
|
|
|
||
|
|
payload = asyncio.run(
|
||
|
|
satellites.fetch_satellites(["stations"], bbox="-80,0,-60,50")
|
||
|
|
)
|
||
|
|
ids = [s["id"] for s in payload["satellites"]]
|
||
|
|
assert ids == ["b"] # only (45, -70) falls inside the box
|
||
|
|
|
||
|
|
|
||
|
|
def test_bbox_culls_nothing_when_empty():
|
||
|
|
from satellites import fetch_satellites
|
||
|
|
|
||
|
|
# No bbox: all rows returned up to limit.
|
||
|
|
# (skip network; just sanity-check the arg is accepted by signature)
|
||
|
|
assert callable(fetch_satellites)
|
||
|
|
|
||
|
|
|
||
|
|
def test_satnogs_fallback_parser():
|
||
|
|
payload = [{
|
||
|
|
"tle0": "0 ISS (ZARYA)",
|
||
|
|
"tle1": "1 25544U 98067A 26243.85334329 .00004554 00000-0 90917-4 0 9992",
|
||
|
|
"tle2": "2 25544 51.6312 285.5873 0005057 94.2999 265.8567 15.48953200583481",
|
||
|
|
"norad_cat_id": 25544,
|
||
|
|
"updated": "2026-09-01T01:19:54.327653Z",
|
||
|
|
}]
|
||
|
|
rows, epoch = satellites.propagate_satnogs_tle(payload, "stations", NOW)
|
||
|
|
assert len(rows) == 1
|
||
|
|
row = rows[0]
|
||
|
|
assert row["id"] == "25544"
|
||
|
|
assert row["name"] == "ISS (ZARYA)"
|
||
|
|
assert row["group"] == "stations"
|
||
|
|
assert epoch == "2026-09-01T01:19:54.327653Z"
|
||
|
|
assert 300 < row["alt_km"] < 500
|