Initial commit
This commit is contained in:
commit
c63670f035
22 changed files with 1128 additions and 0 deletions
155
.gitignore
vendored
Normal file
155
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
|
||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|
||||||
|
### Python ###
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
pytestdebug.log
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
doc/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
pythonenv*
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# profiling data
|
||||||
|
.prof
|
||||||
|
|
||||||
|
# Db and static files
|
||||||
|
*.sqlite3
|
||||||
|
/media
|
||||||
|
/static
|
||||||
|
/postgres
|
||||||
|
/EXAMPLE_*
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
docker-compose.override
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||||
37
README.md
Normal file
37
README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# yksa-orbital
|
||||||
|
|
||||||
|
Propagation for YKSA Django services. See [yksa_orbital/README.md](yksa_orbital/README.md)
|
||||||
|
for the API, the backends and the sidecar contract — that document moved here
|
||||||
|
with the code and is the reference.
|
||||||
|
|
||||||
|
```
|
||||||
|
yksa-orbital @ git+https://git.intra.yksa.space/web/yksa-orbital.git@v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
INSTALLED_APPS = [..., "yksa_orbital", ...]
|
||||||
|
ORBITAL_PROPAGATOR_BACKEND = "sgp4" # or "orekit"
|
||||||
|
OREKIT_SERVICE_URL = "http://orekit:5000"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Extracted from the `tle` repo, where it was already the right shape: a backend
|
||||||
|
registry, a wire format and a config reader, with no Django models. It is used
|
||||||
|
by `odms` today.
|
||||||
|
|
||||||
|
`ops` and `tdas` still have their own propagation code
|
||||||
|
(`ops/yksa_ops/orbits/propagate.py`, `tdas/yksa_tdas/location/propagate.py`) and
|
||||||
|
should move onto this package next. Two things are missing before they can:
|
||||||
|
|
||||||
|
- **`ops`** needs a `passes(station, satellite, window)` entry point. Its pass
|
||||||
|
prediction currently uses Skyfield, which this package does not wrap.
|
||||||
|
- **`tdas`** needs the sub-satellite point and ECI/ECEF state vectors it computes
|
||||||
|
itself. Those the `sgp4` backend already provides, so that port is the
|
||||||
|
smaller of the two.
|
||||||
|
|
||||||
|
Until both are done the estate still describes the same satellite through three
|
||||||
|
code paths, which is the reason this package exists.
|
||||||
|
|
||||||
|
The Orekit sidecar itself stays in `tle/services/orekit` — it is a container, not
|
||||||
|
a Python package, and moving it is a deployment change rather than an extraction.
|
||||||
24
pyproject.toml
Normal file
24
pyproject.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "yksa-orbital"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Propagation backends for YKSA Django services: sgp4 in-process, Orekit over HTTP"
|
||||||
|
readme = "yksa_orbital/README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
license = { text = "Proprietary" }
|
||||||
|
# Django only for the app registry and settings; no models, no migrations.
|
||||||
|
dependencies = [
|
||||||
|
"Django>=5.2",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"odm>=1.0",
|
||||||
|
"sgp4>=2.23",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = ["pytest>=8.0"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["yksa_orbital*"]
|
||||||
86
yksa_orbital/README.md
Normal file
86
yksa_orbital/README.md
Normal 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
26
yksa_orbital/__init__.py
Normal 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"]
|
||||||
BIN
yksa_orbital/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
yksa_orbital/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
yksa_orbital/__pycache__/apps.cpython-311.pyc
Normal file
BIN
yksa_orbital/__pycache__/apps.cpython-311.pyc
Normal file
Binary file not shown.
BIN
yksa_orbital/__pycache__/model_config.cpython-311.pyc
Normal file
BIN
yksa_orbital/__pycache__/model_config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
yksa_orbital/__pycache__/registry.cpython-311.pyc
Normal file
BIN
yksa_orbital/__pycache__/registry.cpython-311.pyc
Normal file
Binary file not shown.
BIN
yksa_orbital/__pycache__/wire.cpython-311.pyc
Normal file
BIN
yksa_orbital/__pycache__/wire.cpython-311.pyc
Normal file
Binary file not shown.
7
yksa_orbital/apps.py
Normal file
7
yksa_orbital/apps.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class OrbitalConfig(AppConfig):
|
||||||
|
name = "yksa_orbital"
|
||||||
|
label = "yksa_orbital"
|
||||||
|
verbose_name = "Orbital propagation"
|
||||||
BIN
yksa_orbital/backends/__pycache__/orekit.cpython-311.pyc
Normal file
BIN
yksa_orbital/backends/__pycache__/orekit.cpython-311.pyc
Normal file
Binary file not shown.
BIN
yksa_orbital/backends/__pycache__/sgp4.cpython-311.pyc
Normal file
BIN
yksa_orbital/backends/__pycache__/sgp4.cpython-311.pyc
Normal file
Binary file not shown.
276
yksa_orbital/backends/orekit.py
Normal file
276
yksa_orbital/backends/orekit.py
Normal 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,
|
||||||
|
)
|
||||||
217
yksa_orbital/backends/sgp4.py
Normal file
217
yksa_orbital/backends/sgp4.py
Normal 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
|
||||||
108
yksa_orbital/model_config.py
Normal file
108
yksa_orbital/model_config.py
Normal 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
52
yksa_orbital/registry.py
Normal 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]
|
||||||
0
yksa_orbital/tests/__init__.py
Normal file
0
yksa_orbital/tests/__init__.py
Normal file
BIN
yksa_orbital/tests/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
yksa_orbital/tests/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
yksa_orbital/tests/__pycache__/test_model_config.cpython-311.pyc
Normal file
BIN
yksa_orbital/tests/__pycache__/test_model_config.cpython-311.pyc
Normal file
Binary file not shown.
74
yksa_orbital/tests/test_model_config.py
Normal file
74
yksa_orbital/tests/test_model_config.py
Normal 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
66
yksa_orbital/wire.py
Normal 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]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue