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

86
yksa_orbital/README.md Normal file
View file

@ -0,0 +1,86 @@
# yksa_orbital
Propagation for Django services: a backend registry, and the client to the
Orekit sidecar.
```python
from yksa_orbital import get_backend
state = get_backend().state_at(omm, at)
weather = get_backend("orekit").space_weather(start, stop)
```
## Why it is its own app
ODMS and `track.tmtc.yksa.space` both propagate, both against the same sidecar.
The alternative to sharing this is two HTTP clients that drift apart on timeouts,
on 503 handling, and on what they put in a request — and since the sidecar's
whole purpose is that two services fly the *same* model, two clients that send
different things quietly defeat it.
## Install
Add `"yksa_orbital"` to `INSTALLED_APPS` and set:
```
ORBITAL_PROPAGATOR_BACKEND=sgp4 # or "orekit"
OREKIT_SERVICE_URL=http://orekit:5000
```
It depends on `odm` and on `httpx`. Nothing else — in particular, nothing
from the host service.
## Backends
| Name | What it can do |
|---|---|
| `sgp4` | Pure Python. TEME plus a GMST Earth-fixed frame. Analytical theory only: **no drag integration**, so it refuses decay, drag fitting and space weather rather than returning a number nobody should trust. |
| `orekit` | HTTP client to the Orekit sidecar (`tle/services/orekit`). Rigorous frames, numerical and semi-analytical propagation, TLE fitting, drag fitting, decay forecasts, solar-activity ensembles, space weather. |
### Name the backend when you need the sidecar
`get_backend()` returns the deployment's default, which is `sgp4`. Anything
needing physics `sgp4` does not have must ask for `"orekit"` explicitly.
This is not hypothetical. A view that forgot to, and swallowed the resulting
`PropagationError`, rendered an empty space-weather chart for weeks with nothing
logging a complaint. If your call needs the sidecar, say so.
## Capacity
The sidecar keeps a worker free for the interactive queries a page load waits on,
and turns heavy work away rather than queueing it. A 503 arrives here as
`BackendBusy` — a subclass of `PropagationError`, so existing handlers still
work, but a Celery task should catch it **first** and retry. Nothing about the
request needs to change for it to succeed later; recording it as a failed
forecast is wrong.
## The model config
`yksa_orbital.model_config` reads the sidecar's `GET /config` — the force model,
propagator, decay altitude, drag-fit thresholds and weather sources it is
actually flying — with a cache and a vendored fallback.
Use it for values a page genuinely needs locally: the ensemble's display floor,
the decay altitude in a caption. Do **not** use it to build a request. Omitting a
parameter already gets the sidecar's value; echoing it back only adds a way for
the two to disagree.
```python
from yksa_orbital.model_config import model_config, setting
floor_km = setting("ensemble", "display_floor_km", 150.0)
```
`setting()` applies this deployment's `DECAY_*` override when there is one, so a
caption describes the run on screen rather than the model in general.
## Layout
| File | What |
|---|---|
| `registry.py` | `get_backend()`, and `register()` for a service with its own propagator. |
| `backends/sgp4.py` | The pure-Python backend. |
| `backends/orekit.py` | The sidecar HTTP client. |
| `wire.py` | Stored OMM dict → the CCSDS message the sidecar consumes. The only place that conversion happens. |
| `model_config.py` | Cached access to the sidecar's model definition. |

26
yksa_orbital/__init__.py Normal file
View file

@ -0,0 +1,26 @@
"""Propagation for Django services: a backend registry and the Orekit client.
The public surface is :func:`get_backend`. Everything that needs a state vector
asks for a backend by name (or lets ``settings.ORBITAL_PROPAGATOR_BACKEND``
decide) and talks to the :class:`odm.PropagatorBackend` seam, so no call site
knows or cares which propagator answered.
Two backends ship:
``sgp4`` pure Python, no service dependency. TEME plus a GMST Earth-fixed
frame, and analytical theory only -- it has no drag integration, so
it refuses decay work rather than returning a number nobody should
trust.
``orekit`` an HTTP client to the Orekit sidecar (``services/orekit``): rigorous
frames, numerical propagation, drag fitting, decay forecasts and the
solar-activity ensemble.
Reusable on purpose. ODMS and ``track.tmtc.yksa.space`` both propagate, both
against the same sidecar, and the alternative to sharing this app is two HTTP
clients that drift apart on timeouts, on 503 handling, and on what they send.
Add it to ``INSTALLED_APPS`` and set ``OREKIT_SERVICE_URL``.
"""
from .registry import BACKENDS, get_backend, register
__all__ = ["BACKENDS", "get_backend", "register"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
yksa_orbital/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class OrbitalConfig(AppConfig):
name = "yksa_orbital"
label = "yksa_orbital"
verbose_name = "Orbital propagation"

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

View file

@ -0,0 +1,108 @@
"""What simulation model the sidecar is flying, asked rather than assumed.
The model -- force model, propagator, decay altitude, drag-fit thresholds,
space-weather sources -- is defined once, in the sidecar's ``config.py``, because
more than one service flies it and two forecasts are only comparable if the model
behind them is the same. This is how a Django service reads that definition
instead of keeping a second copy that drifts.
Use it for the values a page or a plot genuinely needs locally: the ensemble's
display floor, the drag-fit window count, the decay altitude in a caption. Do
*not* use it to build a request -- omitting a parameter already gets the
sidecar's value, and echoing it back would only add a way for the two to
disagree.
Cached, because it changes on deploy and not otherwise, and because a page that
wants one number out of it must not pay a round trip. Falls back to a vendored
copy of the defaults when the sidecar is unreachable, so a caption never takes a
page down; the fallback is flagged so a caller can tell.
"""
from __future__ import annotations
import logging
from django.conf import settings
from django.core.cache import cache
from odm import PropagationError
from .registry import get_backend
logger = logging.getLogger(__name__)
CACHE_KEY = "yksa_orbital:model_config"
CACHE_TTL_S = 3600
#: A failure is cached too, briefly. Without it a sidecar that is down costs a
#: fresh connection attempt on every page render -- and against an unresolvable
#: hostname that is a DNS timeout each time, which is how a caption took a page
#: down rather than the other way round.
FAILURE_TTL_S = 60
#: The sidecar's defaults as of this release. Only ever used when the sidecar
#: cannot be reached, and only for values that are read for display -- so a stale
#: entry here shows a slightly wrong caption rather than flying a wrong model.
#: Keep it in step with ``services/orekit/config.py`` when a default changes.
FALLBACK = {
"force_model": {
"gravity_degree": 4, "gravity_order": 4,
"srp": True, "third_bodies": True, "tesseral": False,
"atmosphere": "NRLMSISE00",
},
"decay": {
"method": "auto", "strengths": ["AVERAGE"], "altitude_km": 105.0,
"max_years": 25.0, "dsst_handover_altitude_km": 180.0,
},
"drag_fit": {"window_days": 90, "min_elements": 30, "windows": 5},
"ensemble": {
"realizations": 30, "percentiles": [10, 25, 50, 75, 90],
"display_floor_km": 150.0,
},
"unavailable": True,
}
def model_config(*, refresh: bool = False) -> dict:
"""The sidecar's resolved model. Never raises."""
if not refresh:
cached = cache.get(CACHE_KEY)
if cached is not None:
return cached
try:
config = get_backend("orekit").model_config()
except (PropagationError, ValueError) as exc:
logger.warning("could not read the sidecar's model config: %s", exc)
cache.set(CACHE_KEY, FALLBACK, FAILURE_TTL_S)
return FALLBACK
cache.set(CACHE_KEY, config, CACHE_TTL_S)
return config
def setting(section: str, key: str, default=None):
"""One value out of the model, with the local override applied if there is one.
Django's ``DECAY_*`` settings are overrides: when one is set, this deployment
really is flying something other than the sidecar's model, and a caption
reading the sidecar's value would be describing a different run than the one
on screen.
"""
override = getattr(settings, _OVERRIDES.get((section, key), ""), None)
if override not in (None, ""):
return override
return model_config().get(section, {}).get(key, default)
#: (section, key) -> the Django setting that overrides it. Mirrors
#: ``yksa_tle.predictions.service._DECAY_OVERRIDES``.
_OVERRIDES = {
("decay", "altitude_km"): "DECAY_ALTITUDE_KM",
("decay", "strengths"): "DECAY_STRENGTHS",
("decay", "method"): "DECAY_METHOD",
("decay", "max_years"): "DECAY_MAX_YEARS",
("force_model", "gravity_degree"): "DECAY_GRAVITY_DEGREE",
("force_model", "gravity_order"): "DECAY_GRAVITY_ORDER",
("force_model", "srp"): "DECAY_SRP",
("force_model", "third_bodies"): "DECAY_THIRD_BODIES",
("drag_fit", "windows"): "DECAY_DRAG_FIT_WINDOWS",
("ensemble", "realizations"): "DECAY_ENSEMBLE_REALIZATIONS",
}

52
yksa_orbital/registry.py Normal file
View file

@ -0,0 +1,52 @@
"""Which propagator answers, and one instance of it per process.
``get_backend()`` with no argument returns the deployment's configured default;
with a name it returns that one specifically. Both matter. The default is what
the request path should use, and the explicit name is what a caller needing
physics the default cannot do -- space weather, drag fitting, decay -- must pass.
That second case is not hypothetical. The default is ``sgp4``, which raises on
any of those; a view that forgot to name ``"orekit"`` and swallowed the error
rendered an empty chart for weeks without anything logging a complaint. If your
call needs the sidecar, say so.
"""
from __future__ import annotations
from django.conf import settings
from odm import PropagatorBackend
from .backends.orekit import OrekitBackend
from .backends.sgp4 import Sgp4Backend
BACKENDS: dict[str, type[PropagatorBackend]] = {
"sgp4": Sgp4Backend,
"orekit": OrekitBackend,
}
#: One instance per name. Backends are stateless apart from an HTTP client, so
#: sharing them saves a connection pool rather than risking shared state.
_INSTANCES: dict[str, PropagatorBackend] = {}
DEFAULT_BACKEND = "sgp4"
def register(name: str, backend: type[PropagatorBackend]) -> None:
"""Add a backend under ``name``. For a service with its own propagator."""
BACKENDS[name] = backend
_INSTANCES.pop(name, None)
def get_backend(name: str | None = None) -> PropagatorBackend:
key = name or getattr(settings, "ORBITAL_PROPAGATOR_BACKEND", DEFAULT_BACKEND)
try:
cls = BACKENDS[key]
except KeyError as exc:
raise ValueError(
f"unknown propagation backend {key!r}; "
f"expected one of {', '.join(sorted(BACKENDS))}"
) from exc
if key not in _INSTANCES:
_INSTANCES[key] = cls()
return _INSTANCES[key]

View file

View file

@ -0,0 +1,74 @@
from __future__ import annotations
import pytest
from django.core.cache import cache
from odm import PropagationError
from yksa_orbital import model_config as mc
@pytest.fixture(autouse=True)
def _clear_cache():
cache.delete(mc.CACHE_KEY)
yield
cache.delete(mc.CACHE_KEY)
class _Backend:
def __init__(self, result=None, error=None):
self.result = result
self.error = error
self.calls = 0
def model_config(self):
self.calls += 1
if self.error:
raise self.error
return self.result
def test_the_model_is_read_once_and_cached(monkeypatch):
backend = _Backend({"decay": {"altitude_km": 105.0}})
monkeypatch.setattr(mc, "get_backend", lambda name=None: backend)
assert mc.model_config()["decay"]["altitude_km"] == 105.0
mc.model_config()
assert backend.calls == 1
def test_an_unreachable_sidecar_falls_back_without_raising(monkeypatch):
backend = _Backend(error=PropagationError("unreachable"))
monkeypatch.setattr(mc, "get_backend", lambda name=None: backend)
assert mc.model_config()["unavailable"] is True
def test_the_failure_is_cached_too(monkeypatch):
"""Otherwise a sidecar that is down costs a connection attempt on every page
render -- and against an unresolvable hostname each one is a DNS timeout, so
a caption takes the page down instead of degrading."""
backend = _Backend(error=PropagationError("unreachable"))
monkeypatch.setattr(mc, "get_backend", lambda name=None: backend)
mc.model_config()
mc.model_config()
mc.model_config()
assert backend.calls == 1
def test_a_local_override_wins_over_the_sidecar(monkeypatch, settings):
"""When this deployment overrides a knob it really is flying something else,
and a caption reading the sidecar's value would describe a different run."""
backend = _Backend({"decay": {"altitude_km": 105.0}})
monkeypatch.setattr(mc, "get_backend", lambda name=None: backend)
settings.DECAY_ALTITUDE_KM = 120.0
assert mc.setting("decay", "altitude_km") == 120.0
def test_an_unset_override_reads_the_sidecar(monkeypatch, settings):
backend = _Backend({"decay": {"altitude_km": 105.0}})
monkeypatch.setattr(mc, "get_backend", lambda name=None: backend)
settings.DECAY_ALTITUDE_KM = None
assert mc.setting("decay", "altitude_km") == 105.0

66
yksa_orbital/wire.py Normal file
View file

@ -0,0 +1,66 @@
"""Render a stored OMM dict as the CCSDS message the Orekit sidecar consumes.
The sidecar takes CCSDS messages, not TLE lines, so nothing on this path
truncates an epoch to eight digits or drops a mass. This module is the single
place that turns ODMS's stored GP dict into one.
"""
from __future__ import annotations
from odm import OmmRecord, PropagationError, as_omm_kvn
#: CCSDS defines MEAN_MOTION_DOT as the *first derivative* of the mean motion,
#: while TLE line 1 carries half of it -- and Space-Track's OMM output copies
#: the TLE field across unchanged, so that is the convention in our stored data.
#: Orekit reads the spec. Doubling here keeps the element set Orekit reconstructs
#: identical to the one we hold, and is confined to this wire format: the OMM we
#: publish to users stays byte-compatible with Space-Track's.
_NDOT_TLE_TO_CCSDS = 2.0
#: Orekit's OMM parser rejects an SGP4 message missing any of these, and
#: upstream feeds do sometimes omit the bookkeeping ones. They do not affect
#: propagation; the values only have to be present and well-formed.
_SGP4_DEFAULTS = {
"EPHEMERIS_TYPE": 0,
"CLASSIFICATION_TYPE": "U",
"ELEMENT_SET_NO": 999,
"REV_AT_EPOCH": 1,
"BSTAR": 0.0,
"MEAN_MOTION_DOT": 0.0,
"MEAN_MOTION_DDOT": 0.0,
}
_REQUIRED = ("EPOCH", "MEAN_MOTION", "ECCENTRICITY", "INCLINATION")
def omm_message(omm: dict, *, object_name: str = "") -> str:
"""One stored OMM dict as CCSDS KVN, ready to POST to the sidecar."""
if not isinstance(omm, dict):
raise PropagationError(f"expected an OMM dict, got {type(omm).__name__}")
missing = [key for key in _REQUIRED if omm.get(key) in (None, "")]
if missing:
raise PropagationError(
f"OMM is missing {', '.join(missing)}; cannot build a CCSDS message"
)
fields = dict(omm)
theory = str(fields.get("MEAN_ELEMENT_THEORY") or "SGP4")
if theory.upper().startswith(("SGP", "SDP")):
for key, default in _SGP4_DEFAULTS.items():
if fields.get(key) in (None, ""):
fields[key] = default
fields["MEAN_MOTION_DOT"] = (
float(fields["MEAN_MOTION_DOT"]) * _NDOT_TLE_TO_CCSDS
)
return as_omm_kvn([OmmRecord(
omm=fields,
object_name=object_name or str(fields.get("OBJECT_NAME") or ""),
object_id=str(fields.get("OBJECT_ID") or ""),
originator="ODMS",
mean_element_theory=theory,
)])
def omm_messages(omms: list[dict]) -> list[str]:
return [omm_message(omm) for omm in omms]