Initial commit

This commit is contained in:
ThePetrovich 2026-08-18 22:01:53 +08:00
commit 5fb00f30d1
22 changed files with 1128 additions and 0 deletions

Binary file not shown.

View file

@ -0,0 +1,276 @@
"""Orekit propagation backend -- HTTP client to the sidecar service.
Implements the same :class:`~odm.PropagatorBackend` seam as
:class:`~yksa_orbital.backends.sgp4.Sgp4Backend`, but delegates the math to the
Orekit sidecar (``services/orekit``) over HTTP. The stored OMM goes over the wire
as a CCSDS message (see :mod:`yksa_orbital.wire`) and the JSON response maps back
into a frame-agnostic :class:`~odm.StateVector`. Set
``settings.ORBITAL_PROPAGATOR_BACKEND = "orekit"`` (and ``OREKIT_SERVICE_URL``)
to make it the default -- nothing downstream changes.
Requests carry only what this deployment means to override. The model itself
lives in the sidecar's ``config.py`` and comes back through :meth:`model_config`;
restating it here would be a second definition of the same thing.
"""
from __future__ import annotations
from datetime import datetime, timezone
import httpx
from django.conf import settings
from odm import (
BackendBusy,
PropagationError,
PropagatorBackend,
StateVector,
parse_omm_epoch as _parse_iso,
)
from ..wire import omm_message, omm_messages
#: Frames every state_at/ephemeris call needs: TEME is the seam's source of
#: truth, ITRF supplies the Earth-fixed (ECEF) view.
_CORE_FRAMES = ("TEME", "ITRF")
#: Frames offered by the "show in frame" dropdown when this backend is active.
DROPDOWN_FRAMES = ("TEME", "GCRF", "EME2000", "TOD", "ITRF")
#: Route -> setting holding its timeout. A decay run is a minutes-long numerical
#: propagation and an ensemble repeats it per realization, so neither can share
#: the seconds-long budget the state/ephemeris queries use.
_TIMEOUTS = {
"/decay": ("OREKIT_DECAY_TIMEOUT_S", 900),
"/decay_ensemble": ("OREKIT_ENSEMBLE_TIMEOUT_S", 7200),
# A download plus a file reload -- longer than the seconds-long state budget,
# shorter than a propagation.
"/refresh_space_weather": ("OREKIT_REFRESH_TIMEOUT_S", 120),
# Read on a page render for a caption. Fail fast and let the caller fall
# back rather than holding the response open.
"/config": ("OREKIT_CONFIG_TIMEOUT_S", 2),
}
def _service_url() -> str:
url = getattr(settings, "OREKIT_SERVICE_URL", "")
if not url:
raise PropagationError("OREKIT_SERVICE_URL is not configured")
return url.rstrip("/")
def _timeout(path: str) -> float:
name, default = _TIMEOUTS.get(path, ("OREKIT_TIMEOUT_S", 30))
return float(getattr(settings, name, default))
def _iso(dt: datetime | None) -> str | None:
if dt is None:
return None
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _tuple3(seq) -> tuple[float, float, float]:
return (float(seq[0]), float(seq[1]), float(seq[2]))
def _cartesian(states) -> list[dict]:
return [
{"epoch": _iso(sv.epoch), "r_km": list(sv.r_km), "v_kms": list(sv.v_kms)}
for sv in states
]
class OrekitBackend(PropagatorBackend):
name = "orekit"
display_frames = DROPDOWN_FRAMES
def model_config(self) -> dict:
"""The simulation model the sidecar is flying (its ``GET /config``).
Read it rather than restating it: the model is defined in one place so
that two services' forecasts are comparable, and a client that keeps its
own copy has just made a second definition. See
:mod:`yksa_orbital.model_config` for the cached accessor callers want.
"""
return self._get("/config")
def _get(self, path: str) -> dict:
try:
resp = httpx.get(_service_url() + path, timeout=_timeout(path))
except httpx.HTTPError as exc:
raise PropagationError(f"Orekit sidecar unreachable: {exc}") from exc
if resp.status_code >= 400:
raise PropagationError(f"Orekit sidecar error {resp.status_code}")
return resp.json()
def _post(self, path: str, payload: dict) -> dict:
try:
resp = httpx.post(
_service_url() + path, json=payload, timeout=_timeout(path),
)
except httpx.HTTPError as exc:
raise PropagationError(f"Orekit sidecar unreachable: {exc}") from exc
if resp.status_code >= 400:
try:
detail = resp.json().get("error", resp.text)
except Exception: # noqa: BLE001
detail = resp.text
if resp.status_code == 503:
# The sidecar keeps a worker free for interactive queries and
# turns heavy work away rather than queueing it. Distinct from a
# 400 so the caller retries instead of storing a failed run.
raise BackendBusy(detail)
raise PropagationError(f"Orekit sidecar error {resp.status_code}: {detail}")
return resp.json()
def _run(self, path: str, omm: dict, spacecraft: dict, options: dict) -> dict:
"""Shared payload assembly for the decay and ensemble routes."""
payload = {"message": omm_message(omm)}
for source in (spacecraft, options):
payload.update({k: v for k, v in source.items() if v is not None})
at = payload.get("at")
if isinstance(at, datetime):
payload["at"] = _iso(at)
return self._post(path, payload)
def state_at(self, omm: dict, at: datetime | None = None) -> StateVector:
return _state_from_response(self._post("/state", {
"message": omm_message(omm),
"at": _iso(at),
"frames": list(_CORE_FRAMES),
}))
def ephemeris(
self, omm: dict, start: datetime, stop: datetime, step_s: float,
) -> list[StateVector]:
data = self._post("/ephemeris", {
"message": omm_message(omm),
"start": _iso(start), "stop": _iso(stop),
"step_s": float(step_s), "frame": "TEME",
})
element_epoch = _parse_iso(data.get("element_epoch"))
return [_ephemeris_point(p, element_epoch) for p in data.get("states", [])]
def transform(self, states, frame_in, frame_out):
frame_in = frame_in.upper()
frame_out = frame_out.upper()
if frame_in == frame_out:
return list(states)
data = self._post("/transform", {
"frame_in": frame_in,
"frame_out": frame_out,
"states": _cartesian(states),
})
return [
StateVector(
epoch=_parse_iso(res.get("epoch")) or src.epoch,
frame=frame_out,
r_km=_tuple3(res["r_km"]),
v_kms=_tuple3(res["v_kms"]),
element_epoch=src.element_epoch,
)
for src, res in zip(states, data.get("states", []))
]
def fit_tle(self, states, frame, template_omm):
return self._post("/fit_tle", {
"states": _cartesian(states),
"frame": frame.upper(),
"template_message": omm_message(template_omm),
})
def decay(self, omm: dict, spacecraft: dict, **options) -> dict:
"""Propagate to re-entry (see ``services/orekit/decay.py``).
Minutes-long, so it is only ever called from Celery.
"""
return self._run("/decay", omm, spacecraft, options)
def decay_ensemble(self, omm: dict, spacecraft: dict, **options) -> dict:
"""P10/P50/P90 lifetimes over resampled solar cycles.
This is :meth:`decay` repeated once per realization, so it costs tens of
minutes. Never call it from a request path.
"""
return self._run("/decay_ensemble", omm, spacecraft, options)
def refresh_space_weather(self, *, force: bool = True) -> dict:
"""Pull the current CSSI (and, if configured, MSAFE) file over the deployed
one on the sidecar. Returns the sidecar's status dict; a failed download
is reported there, not raised, so a scheduled refresh never errors on a
transient network problem.
"""
return self._post("/refresh_space_weather", {"force": bool(force)})
def fit_drag(self, elements: list[dict], **options) -> dict:
"""Fit a ballistic coefficient from an element history.
Cheap next to :meth:`decay` -- a linear fit plus one orbit of density
evaluations -- so it uses the ordinary timeout.
"""
payload = {"messages": omm_messages(elements)}
payload.update({k: v for k, v in options.items() if v is not None})
return self._post("/fit_drag", payload)
def space_weather(
self, start: datetime, stop: datetime, **options,
) -> dict:
"""Observed daily F10.7 / Ap between two dates.
As cheap as :meth:`fit_drag` -- a provider lookup per day, no
propagation -- so it uses the ordinary timeout.
"""
payload = {"start": _iso(start), "stop": _iso(stop)}
payload.update({k: v for k, v in options.items() if v is not None})
return self._post("/space_weather", payload)
def state_in_frames(
self, omm: dict, at: datetime | None = None, frames=None,
) -> dict:
"""Multi-frame state for the coordinate-system dropdown.
The sidecar's ``/state`` payload already matches the
:meth:`PropagatorBackend.state_in_frames` contract, so nothing
downstream special-cases Orekit.
"""
return self._post("/state", {
"message": omm_message(omm),
"at": _iso(at),
"frames": list(frames or DROPDOWN_FRAMES),
})
def _state_from_response(data: dict) -> StateVector:
states = data.get("states", {})
teme = states.get("TEME")
if not teme:
raise PropagationError("Orekit response missing the TEME state")
itrf = states.get("ITRF") or {}
geodetic = data.get("geodetic")
return StateVector(
epoch=_parse_iso(data.get("epoch")),
frame="TEME",
r_km=_tuple3(teme["r_km"]),
v_kms=_tuple3(teme["v_kms"]),
ecef_km=_tuple3(itrf["r_km"]) if itrf.get("r_km") else None,
ecef_v_kms=_tuple3(itrf["v_kms"]) if itrf.get("v_kms") else None,
geodetic=_tuple3(geodetic) if geodetic else None,
element_epoch=_parse_iso(data.get("element_epoch")),
warnings=list(data.get("warnings") or []),
)
def _ephemeris_point(point: dict, element_epoch: datetime | None) -> StateVector:
ecef = point.get("ecef_km")
ecef_v = point.get("ecef_v_kms")
geodetic = point.get("geodetic")
return StateVector(
epoch=_parse_iso(point.get("epoch")),
frame="TEME",
r_km=_tuple3(point["r_km"]),
v_kms=_tuple3(point["v_kms"]),
ecef_km=_tuple3(ecef) if ecef else None,
ecef_v_kms=_tuple3(ecef_v) if ecef_v else None,
geodetic=_tuple3(geodetic) if geodetic else None,
element_epoch=element_epoch,
)

View file

@ -0,0 +1,217 @@
"""SGP4 propagation backend.
Ported from the sibling ``yksa_tdas`` service's ``location/propagate.py`` (pure
``sgp4`` + stdlib ``math``, no numpy). Builds a ``Satrec`` straight from the
stored canonical OMM dict via :func:`sgp4.omm.initialize` -- no TLE-line
round-trip -- propagates to the requested instant, and fills the TEME state plus
Earth-fixed (ECEF) and WGS84 geodetic views on the :class:`StateVector`.
The TEME->ECEF rotation uses GMST only (no polar motion / nutation); a future
Orekit backend will provide rigorous frames. That approximation is fine for the
sub-km display accuracy this service targets.
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta, timezone
from sgp4 import omm as sgp4_omm
from sgp4.api import SGP4_ERRORS, Satrec, jday
from sgp4.propagation import gstime
from odm import PropagationError, PropagatorBackend, StateVector, parse_omm_epoch
WGS84_A_KM = 6378.137
WGS84_F = 1.0 / 298.257223563
WGS84_E2 = WGS84_F * (2.0 - WGS84_F)
EARTH_ROT_RAD_S = 7.292115e-5
#: Frames the pure-Python backend can transform between (GMST rotation only).
_SGP4_FRAMES = ("TEME", "ITRF")
class Sgp4Backend(PropagatorBackend):
name = "sgp4"
# SGP4 natively yields TEME; the GMST rotation gives an Earth-fixed view.
display_frames = ("TEME", "ITRF")
def transform(self, states, frame_in, frame_out):
frame_in = frame_in.upper()
frame_out = frame_out.upper()
if frame_in == frame_out:
return list(states)
if frame_in not in _SGP4_FRAMES or frame_out not in _SGP4_FRAMES:
raise PropagationError(
f"sgp4 backend only transforms between {_SGP4_FRAMES}; "
f"got {frame_in!r}->{frame_out!r} (use the orekit backend for more)"
)
out = []
for sv in states:
if sv.epoch is None:
raise PropagationError("state needs an epoch to rotate frames")
jd_ut1 = _jd_of(sv.epoch)
if frame_in == "TEME": # TEME -> ITRF
r, v = _teme_to_ecef(sv.r_km, sv.v_kms, jd_ut1)
else: # ITRF -> TEME
r, v = _ecef_to_teme(sv.r_km, sv.v_kms, jd_ut1)
out.append(_replace_frame(sv, frame_out, r, v))
return out
def state_at(self, omm: dict, at: datetime | None = None) -> StateVector:
sat = _satrec_from_omm(omm)
element_epoch = _epoch_of(sat)
when = at if at is not None else element_epoch
if when is None:
raise PropagationError("OMM has no usable epoch and no target time given")
return _state(sat, when, element_epoch)
def ephemeris(
self, omm: dict, start: datetime, stop: datetime, step_s: float,
) -> list[StateVector]:
if step_s <= 0:
raise PropagationError("step must be positive")
if stop < start:
raise PropagationError("stop must be on or after start")
sat = _satrec_from_omm(omm)
element_epoch = _epoch_of(sat)
out: list[StateVector] = []
t = start.astimezone(timezone.utc)
stop = stop.astimezone(timezone.utc)
step = timedelta(seconds=step_s)
# Guard against runaway loops; callers cap this via OEM_MAX_POINTS.
while t <= stop + timedelta(microseconds=1):
out.append(_state(sat, t, element_epoch))
t += step
return out
def _satrec_from_omm(omm: dict) -> Satrec:
sat = Satrec()
sgp4_omm.initialize(sat, _normalise_for_sgp4(omm))
return sat
def _normalise_for_sgp4(omm: dict) -> dict:
"""Return a copy whose EPOCH matches sgp4.omm's strict ``...%S.%f`` parser.
Stored epochs occasionally lack fractional seconds (or carry a trailing
``Z``); sgp4.omm.initialize only accepts ``%Y-%m-%dT%H:%M:%S.%f``.
"""
fields = dict(omm)
epoch = fields.get("EPOCH")
dt = parse_omm_epoch(epoch)
if dt is not None:
fields["EPOCH"] = dt.astimezone(timezone.utc).replace(tzinfo=None).strftime(
"%Y-%m-%dT%H:%M:%S.%f"
)
return fields
def _epoch_of(sat: Satrec) -> datetime | None:
try:
jd = sat.jdsatepoch + sat.jdsatepochF
unix = (jd - 2440587.5) * 86400.0
return datetime.fromtimestamp(unix, tz=timezone.utc)
except Exception: # noqa: BLE001 -- defensive; a bad epoch just yields None
return None
def _state(sat: Satrec, when: datetime, element_epoch: datetime | None) -> StateVector:
when = when.astimezone(timezone.utc)
jd, fr = jday(
when.year, when.month, when.day,
when.hour, when.minute, when.second + when.microsecond / 1e6,
)
err, r, v = sat.sgp4(jd, fr)
if err != 0:
raise PropagationError(SGP4_ERRORS.get(err, f"sgp4 error {err}"))
r_ecef, v_ecef = _teme_to_ecef(r, v, jd + fr)
geodetic = _ecef_to_geodetic(r_ecef)
return StateVector(
epoch=when,
frame="TEME",
r_km=(r[0], r[1], r[2]),
v_kms=(v[0], v[1], v[2]),
ecef_km=r_ecef,
ecef_v_kms=v_ecef,
geodetic=geodetic,
element_epoch=element_epoch,
)
def _jd_of(when: datetime) -> float:
"""Full Julian date (UT1≈UTC) for a UTC datetime."""
when = when.astimezone(timezone.utc)
jd, fr = jday(
when.year, when.month, when.day,
when.hour, when.minute, when.second + when.microsecond / 1e6,
)
return jd + fr
def _replace_frame(sv: StateVector, frame: str, r, v) -> StateVector:
"""Copy ``sv`` with a new frame label and position/velocity.
When the target is the Earth-fixed frame, mirror the rotated state into the
ECEF convenience fields so geodetic-dependent conversions keep working.
"""
ecef_km = r if frame == "ITRF" else sv.ecef_km
ecef_v_kms = v if frame == "ITRF" else sv.ecef_v_kms
return StateVector(
epoch=sv.epoch,
frame=frame,
r_km=tuple(r),
v_kms=tuple(v),
ecef_km=ecef_km,
ecef_v_kms=ecef_v_kms,
geodetic=sv.geodetic,
element_epoch=sv.element_epoch,
warnings=list(sv.warnings),
)
def _teme_to_ecef(r, v, jd_ut1):
"""Rotate TEME position/velocity to Earth-fixed (ECEF) via GMST."""
theta = gstime(jd_ut1)
cos, sin = math.cos(theta), math.sin(theta)
x = cos * r[0] + sin * r[1]
y = -sin * r[0] + cos * r[1]
z = r[2]
vx = cos * v[0] + sin * v[1] + EARTH_ROT_RAD_S * y
vy = -sin * v[0] + cos * v[1] - EARTH_ROT_RAD_S * x
vz = v[2]
return (x, y, z), (vx, vy, vz)
def _ecef_to_teme(r, v, jd_ut1):
"""Inverse of :func:`_teme_to_ecef`: Earth-fixed (ECEF) -> TEME via GMST."""
theta = gstime(jd_ut1)
cos, sin = math.cos(theta), math.sin(theta)
x = cos * r[0] - sin * r[1]
y = sin * r[0] + cos * r[1]
z = r[2]
# Undo the Earth-rotation term, then the rotation, to recover TEME velocity.
a = v[0] - EARTH_ROT_RAD_S * r[1]
b = v[1] + EARTH_ROT_RAD_S * r[0]
vx = cos * a - sin * b
vy = sin * a + cos * b
vz = v[2]
return (x, y, z), (vx, vy, vz)
def _ecef_to_geodetic(r) -> tuple[float, float, float]:
"""ECEF (km) -> WGS84 geodetic latitude/longitude (deg) and altitude (km)."""
x, y, z = r
lon = math.atan2(y, x)
p = math.hypot(x, y)
lat = math.atan2(z, p * (1.0 - WGS84_E2))
alt = 0.0
for _ in range(8):
sin_lat = math.sin(lat)
n = WGS84_A_KM / math.sqrt(1.0 - WGS84_E2 * sin_lat * sin_lat)
alt = p / math.cos(lat) - n
lat = math.atan2(z, p * (1.0 - WGS84_E2 * n / (n + alt)))
return math.degrees(lat), math.degrees(lon), alt