From 5fb00f30d191edd78675c215d8a67d38739a691c Mon Sep 17 00:00:00 2001 From: ThePetrovich Date: Tue, 18 Aug 2026 22:01:53 +0800 Subject: [PATCH] Initial commit --- .gitignore | 155 ++++++++++ README.md | 37 +++ pyproject.toml | 24 ++ yksa_orbital/README.md | 86 ++++++ yksa_orbital/__init__.py | 26 ++ .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 1441 bytes yksa_orbital/__pycache__/apps.cpython-311.pyc | Bin 0 -> 532 bytes .../__pycache__/model_config.cpython-311.pyc | Bin 0 -> 4632 bytes .../__pycache__/registry.cpython-311.pyc | Bin 0 -> 2686 bytes yksa_orbital/__pycache__/wire.cpython-311.pyc | Bin 0 -> 3480 bytes yksa_orbital/apps.py | 7 + .../__pycache__/orekit.cpython-311.pyc | Bin 0 -> 17280 bytes .../backends/__pycache__/sgp4.cpython-311.pyc | Bin 0 -> 12055 bytes yksa_orbital/backends/orekit.py | 276 ++++++++++++++++++ yksa_orbital/backends/sgp4.py | 217 ++++++++++++++ yksa_orbital/model_config.py | 108 +++++++ yksa_orbital/registry.py | 52 ++++ yksa_orbital/tests/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 161 bytes .../test_model_config.cpython-311.pyc | Bin 0 -> 5367 bytes yksa_orbital/tests/test_model_config.py | 74 +++++ yksa_orbital/wire.py | 66 +++++ 22 files changed, 1128 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 yksa_orbital/README.md create mode 100644 yksa_orbital/__init__.py create mode 100644 yksa_orbital/__pycache__/__init__.cpython-311.pyc create mode 100644 yksa_orbital/__pycache__/apps.cpython-311.pyc create mode 100644 yksa_orbital/__pycache__/model_config.cpython-311.pyc create mode 100644 yksa_orbital/__pycache__/registry.cpython-311.pyc create mode 100644 yksa_orbital/__pycache__/wire.cpython-311.pyc create mode 100644 yksa_orbital/apps.py create mode 100644 yksa_orbital/backends/__pycache__/orekit.cpython-311.pyc create mode 100644 yksa_orbital/backends/__pycache__/sgp4.cpython-311.pyc create mode 100644 yksa_orbital/backends/orekit.py create mode 100644 yksa_orbital/backends/sgp4.py create mode 100644 yksa_orbital/model_config.py create mode 100644 yksa_orbital/registry.py create mode 100644 yksa_orbital/tests/__init__.py create mode 100644 yksa_orbital/tests/__pycache__/__init__.cpython-311.pyc create mode 100644 yksa_orbital/tests/__pycache__/test_model_config.cpython-311.pyc create mode 100644 yksa_orbital/tests/test_model_config.py create mode 100644 yksa_orbital/wire.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e70718 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..717b59a --- /dev/null +++ b/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8584282 --- /dev/null +++ b/pyproject.toml @@ -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*"] diff --git a/yksa_orbital/README.md b/yksa_orbital/README.md new file mode 100644 index 0000000..1ed8d71 --- /dev/null +++ b/yksa_orbital/README.md @@ -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. | diff --git a/yksa_orbital/__init__.py b/yksa_orbital/__init__.py new file mode 100644 index 0000000..6a6921a --- /dev/null +++ b/yksa_orbital/__init__.py @@ -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"] diff --git a/yksa_orbital/__pycache__/__init__.cpython-311.pyc b/yksa_orbital/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2862aa81c8d2dd1b4bed27defa11901caf7dcd8d GIT binary patch literal 1441 zcmZ`(!H(lZ5VeymJ2LE%d!5imnivo)hX}2d*@>3X&P<{taEOrXj=SRY#_qPd+6lhF zS8zpK5FdlI=Q$y9;(#>L-u6VblW3U(9joIicUM=v_o{z;@+1m){PD|Aum9-@9m?kO#8!Yq)$QyYrym6DtlgwWboeaLQ;{ z8w;1Oh0YBS+MUXfh9F=ivMp)}7IQ_;HXzt=8-$6)t#XhR3bl))Xu-a!UKJ_>s_j~2 z2#R31u5~s{b9BiuQX0c}hqiGWrE`uX9B4#Id=@$35O$b3VR!-N`$AJ zYJrX*O$nXjI22E27gvkXbuyhzrlXgm#blOTj9$DN-(IF^Br#JG`-j{`!GWD=ix_5w zAR4AdmhlM;V=oRhrwoKB&w&hR1DPlaAmuRHYO^Qc95P{%V82n>2C5^J>=v5#Xi<7l z_U42j0c}(@j3Q1tuf9xE;8E2UVaiw-eeMrAA`cQP1|47T`2xnm zy3Js%en1)dAg8gm49rsr%;ER-}gt#Uj7 z9O9eBVtN$OiIk(Vh$&-);i+f&AjnToTl+(>DmT{D^dRLH0%9oZ5^Y=li5Hgm`Jv>s zY9+%~TNLY_|B2OfRQ7{_WC~#iB6Dh|Tmz^H%T<9)?F?%Y{L09B8Y@E>!{qX2-o{B& zXIVwDD_s^hTM{u<+!9>$%DDGxVY<8>aT6*{uGJ)Yi64tUu<6@P6b`LsqIT$UqK&YJ z{^2GQRpoolvDiGE(V-{V;hRfZwRXIOMdMR-szg&e#)yq)pMAk<(6ZoAhojiJ_q+H= zCXqE5Mg{qEh_u^H!gAO7bqxH~&sD`;=~AKX1o5^1s|IeBekyS&@-MHSdbJoy#eg-P;z z@RrTsUCfwnMUM2nhj?J@N;y%qMshNeL}}%cB(9owXJ3~_)&)MdAMtjd8a?Ofz8i+& iedpt_cOUpE4#KbA1ke8O`7`?bP5b=#*#E4*ng0d(6x7E6 literal 0 HcmV?d00001 diff --git a/yksa_orbital/__pycache__/apps.cpython-311.pyc b/yksa_orbital/__pycache__/apps.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01030965f76ec0a8a8be6f5d26d247e16eb3d9e1 GIT binary patch literal 532 zcmZut%}T>S5T4DC)HYG1Ckuk!j9{NY1W^%D>cLwmEZc4jZPVPaa+ zi{LTr)sweMJ@w>nk_w`;J2T(6Gdr_8&$e9#DX))r-HG(?iY&w|z^pF87${H_Kp(jX zfd(f)wR@nthn{qR7pb|p8Z|D#F+UV_kXSz8{E&rL9lu>T*xbp|BJiwJAfiwS1=*=@B5Zt{q6;*&v#g$-v(lrRoS-@{mxG43-u z3>3C7z8;dGh#1NXGaxN55Y~+swOH(B*CsGi&ztXX((V!8i<_$HZqYO&ix)Hr=W(5n zA~mec7!t%A(j-4Ct*j?UsZ~pk)9Q9|u2!_N$Zt5hsL(D6eO6b+A~njqoUKrnxGK@S qw3q*6KU$MiO)x%_W~w8EQrLR?y{Ah$a;EUtJe+>2)31L*S+j2xhKI)h literal 0 HcmV?d00001 diff --git a/yksa_orbital/__pycache__/model_config.cpython-311.pyc b/yksa_orbital/__pycache__/model_config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90295bb514a8ec4b567bdcb627e360963a7adf56 GIT binary patch literal 4632 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 literal 0 HcmV?d00001 diff --git a/yksa_orbital/__pycache__/registry.cpython-311.pyc b/yksa_orbital/__pycache__/registry.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3d847f4b0f5e11794bba4c730bf54164eb476e9 GIT binary patch literal 2686 zcmZ`*O>7%Q6rTOD?=Car zx*Me^t%@iJq+Y55ZPlI%sN#q?bKr>NNIgU=MT*1$aWg6h)Dv%ZZFi%J(fI9qGqdl_ z``*v``&`aIFn+&#>D;*ig#M61Z)h>2JX`|i26_(>R7ON0s-ra2vZ_d4bF_wD)+1hb zQjK&u4Sb5EoqJ$^| zJ6<4{U*OpC+&R0}WR$@AoY{250V8_b#ID%@!^w9|5nYD+)UxMn%XFNefDe0Mt6>U3 zSpm;R`XfEq=D1Zig&}P+`bm>=xVQ<2dEIL|1UEU29h3*?1>dnPuohd5U&SVtM!;E@ zQewL`!>M&li;F^9W!lCIiO>hZXkms>K>;D zz=88NZGi+f<~&vdy~2xqtX6k=jLzF)Cj?B!nYX}KuLY5a0Ab8y27`!F864BawBd^Y zTXmB~y1+>~4>&=ny{3QzfvAI**zsyL`2_5|hVPiRD+>7d91grDGbEf5m@$OhmXH8W z9B|=Y!T1Z(73GyM8)0e++jBXH8BQYzCBNx(KTi+GDfgEWzmz)_`O{sMT4n&Go~VYp z#4Jq5UN7tEX0}*5a%_eT z!ev5TrctPY_HdwE7>}O%A2VS(u8vSIL8)0#kNu$o2Mt4ccp8`+Xi*XUErb@4EN+oF ze>cHDKDY=i!T`94!(EE$-eBH)XJKwJI-PtrqZGvK2sXvh%h~YFX5_ zfGlgdiDpqKB*vGaook*zk z1V(E%Ko%7IAk4%9Wo#QLn2?%w16e|ATgN|p=j!aG(yBi6dFgWL+P-hLEbGrc(4TGh z=0iA$N{?dI*ijaZmj(hOdefY)WqB4j? zx+g(Ul&AZ`Mb&+G0*Oq20qsKU&_zv1+ucFbMdhYSG@{>4XzA@jcqNA2k+gFU?s~s} z-}TZ>bX#A)vZ!5F3A(6#sj|+5n#$*h_;iJdiD!zAewx>4AyfAZu2FGN>3XS(or%7X8Kj+zQ znC2b>txQ%@_vTDotb)Uo*A~R`T%UzW`Z6E5=aUn7;nP z?HAj-4*mAh^2qdqk?Ex)9W|ZWygIb)8vm+sRlBPFyPDnHL3%1dR)mB%8(}sdki1grBF0Ah8?ut}o5F!gg?Lt_0==&?*MzS- ztyGvSi!e*hnQqN1$WMD5G|eN8r~<>`ZmZ#ixC=A*dxXMVe^qv9CJQEx>u{x#_z-{r z*<#qZB(f_+(fJXvrz9_9_b79);gP08-(XW9%KG4c0s%x7rK4sQy@UE>3@HRs04 literal 0 HcmV?d00001 diff --git a/yksa_orbital/__pycache__/wire.cpython-311.pyc b/yksa_orbital/__pycache__/wire.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5cdee4f6cacfde50b0e3765af7b9467cb38bdf97 GIT binary patch literal 3480 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 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, + ) diff --git a/yksa_orbital/backends/sgp4.py b/yksa_orbital/backends/sgp4.py new file mode 100644 index 0000000..c5730b5 --- /dev/null +++ b/yksa_orbital/backends/sgp4.py @@ -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 diff --git a/yksa_orbital/model_config.py b/yksa_orbital/model_config.py new file mode 100644 index 0000000..e4b927b --- /dev/null +++ b/yksa_orbital/model_config.py @@ -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", +} diff --git a/yksa_orbital/registry.py b/yksa_orbital/registry.py new file mode 100644 index 0000000..b14bd35 --- /dev/null +++ b/yksa_orbital/registry.py @@ -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] diff --git a/yksa_orbital/tests/__init__.py b/yksa_orbital/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yksa_orbital/tests/__pycache__/__init__.cpython-311.pyc b/yksa_orbital/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fbd9df8b300386d1a6d194b3c33b02cbd39f2a35 GIT binary patch literal 161 zcmZ3^%ge<81ZNjCW`XF(AOZ#$p^VRLK*n^26oz01O-8?!3`I;p{%4TnFO!1A{ VO))=^_`uA_$oPQ)Miemv#Q?2uCs6LuFe!sJK%Pogo z{6m?Y`|fvUcD^&;na}q--?=|_cD55pf4%d&+0`~e{(*%?QbUIB{0W#-x@C#F}gK5ho*U377W?3iDE|vv)h;;!y%#^ES=+IaA@emC( z({Wti^exx%CZU|noB0_I%%PtNcix9S5OuW58c5njUj?J6-IdhtO?<~xD=7=k%EZ?+c!DtkG?)lz3 zNR8$-8J%AUI*h!{O<~A|6o)_}Zmf42$iLtzlP}e_&d?y<>}9-)_3o4=t;NlN@CPz@{=~ndTBK0b8vy3R|5FGRAA55qH>RK3Zky zg(9F#cV3htmgo{OfD6--0;OoD7`8n8^+?{#_UELRo(dYQ$Wh3*4uweUD0YiN8r_7>*>YU zEqKBei6#giqj;<}7$5_y--0v9Gm7dv4El_mw7fGE;SO{Moh+1W^0sp?la&`?73 z!b>V6)f%Fr4=Oxt4Al}TU>AELjhe9`KOKg4&??h$t9_;YL-TeQzH3_7%9(9K+Xiag zy&t_$>FBR@J+bneO6G{v5|(;W?xNwCMQ#{DyMdr9*~oVo#;;4J9kz(?fp|Fw0ooGT zTQ0Z2C6(kbFy2WZ<>=W~63M=85-06y@~LgoNHu#tafy!M^$>{f^kC@WL zC+6fPd&*DAd-NcZD7j5%)rK&ZYVI#>A~P8z8PA&w;8tzl@=J^xbH#tk9^<+3K%3^i zF%{ZkZ252zoXhU?_HVLB}INo@42?Shed=YR=&>L6|h_wbuC`TTmhj+`Se*9qb5+|VJAa=eD1g3H? z+q;&&GxF!2%CVD`zEhjo)79+h@}-*E^>?*zOYQrEvi4?W_+oS3R41zHL`9wWQtMoK zbyMrFYW-{1tJ<@7idF69&y!o)Sf!Egsj20+Hr1Z0+EY<`YFhWo*xLCmZJ-j*t*$0Q z`(I$5d(rMk_=J;u58^*n_OXD-)qt=l`MA3E#1eH2Q1`I_oJ&3JVsjm8nk*pMg!)to z_68B(K&)1B03`VtbIRr$H1uvyL;nW>sQ3YNXa=Qt4k{Ke%Y8Q;qiG1kv(Q9@QNUwD zT7z-Z@@L$VZwS-!EN8kEpF6WXCqMW7X`~Ljd%bRc-hazoNagC*KEpS@0R( zA@ufhz6XFFxai0D6}aEX;qP(3DxA}q#jUr%`H7pB$8}S$15Nj5OkcM=ow+w1J?}!` z28|HIIy{eIuMQrK7w3H)Sbl?x1s%d(-VTw9E+A&&LLV8?!FlQs3|o%p1Fs{VJ=ea0 z0MutdBzk8Ztq7!W9L&@&PF~f0tH=S^Cv?x10%2TpUJmM;bKHZeaXDHzHf7Z8g7_aP-Lh#O}F}3ImJ% ztWJhXEv7ek|DnZ?VI)-8`#lgavHxU9SKp%;SH`HknOh13JE>Mz&(cfOfN{eV3s`o85(u^8*Flz!g(*p}leWdx>oeQ|^r2Po zdD~qmat90m&@aF{BYNi?cS^T>FadDW3yA1~y#ROwBo{e!1diJtKM}$jts{aqh8)6UB;V|saEO`(x8t5D!zXE0L985KuCIiVvthU9S}ek}C(hKzfR zhMsWlIG!vS7&F!9+6wF*$6|U-7_aRd~xE!`L~VpSFXK&?dKQa zX8ER!n|xT3jf5D6u0aOkC@gznsDyi}>-AV^_yMLW#-Qg<;OE&u&|FerE)N|t)s7u6 zUxphghE8WboL>9wrZ!U5ApF|WUa7=$crbl;Mv;<6vCkpI$U_-LEcTa?PmkZ@U1#&tml{vXr$3nsjN zV3Zf63)Z_RQU2Kyo=A9;knx&4QKX$n#rNEc;qDaPq|XUlYrKYe{{^z0pp@3g)0KGs zf^?SUQzOq*;`s}rSK_%whAZ*Bog&Jia_{osnp&X)n{=Q`2jKEekHY1f9{oB&l) 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]