Initial commit
This commit is contained in:
commit
ba4c2dd31c
20 changed files with 3195 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
|
||||||
74
README.md
Normal file
74
README.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# odm
|
||||||
|
|
||||||
|
Orbit Data Messages: read, write and convert orbital element sets.
|
||||||
|
|
||||||
|
Everything ODMS knows about orbital *formats*, with nothing it knows about
|
||||||
|
Django, HTTP or a database. Dependencies are `sgp4` and the standard library.
|
||||||
|
|
||||||
|
## Why it is a library
|
||||||
|
|
||||||
|
The formats used to live in the service that happened to serve them first —
|
||||||
|
TLE rendering in a Django app's `formats.py`, the element-set catalogue in
|
||||||
|
another app, the OPM/OEM builders in a third, and the canonical record itself
|
||||||
|
implicit in a model row. That worked while one service read the data. It stops
|
||||||
|
working the moment a second one does: a format two services implement separately
|
||||||
|
is a format they will eventually disagree about, and the disagreement surfaces as
|
||||||
|
a satellite whose OMM and TLE describe slightly different orbits.
|
||||||
|
|
||||||
|
So: the OMM is the record. TLE text, OMM XML, OMM KVN and Celestrak GP CSV are
|
||||||
|
*renderings* of it, and the CCSDS SANA element sets are conversions of the state
|
||||||
|
it propagates to. One definition each, here.
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```python
|
||||||
|
from odm import OmmRecord, parse_tle, registry
|
||||||
|
|
||||||
|
parsed = parse_tle(name, line1, line2)
|
||||||
|
record = OmmRecord(omm=parsed.omm, object_name=parsed.name, line1=line1, line2=line2)
|
||||||
|
|
||||||
|
xml = registry.get("omm_xml").write([record])
|
||||||
|
csv = registry.get("csv").write([record])
|
||||||
|
kep = registry.get("keplerian").write([record], backend=backend, at=when)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every writer takes the same arguments — `write(records, *, backend=None,
|
||||||
|
at=None)` — so a caller never has to know which kind it got. `backend` and `at`
|
||||||
|
matter only to the element-set formats, which propagate before converting.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | What |
|
||||||
|
|---|---|
|
||||||
|
| `records` | `OmmRecord`, `ParsedTLE`, `StateVector` — what readers produce and writers consume. |
|
||||||
|
| `tle` | TLE text in and out. Lossy by construction; see the module docstring. |
|
||||||
|
| `omm` | CCSDS 502.0-B-3 OMM, XML and KVN. The authoritative rendering. |
|
||||||
|
| `gp` | Celestrak GP CSV, in their exact column order. |
|
||||||
|
| `norad` | Catalog-ID normalisation, ALPHA-5, and the temp-ID fallback. |
|
||||||
|
| `orbits` | Mean-element derivations: period, apogee, perigee, altitudes. |
|
||||||
|
| `conversions` | State vector → one SANA element set. |
|
||||||
|
| `element_sets` | The SANA catalogue, and computing a set for an OMM. |
|
||||||
|
| `messages` | CCSDS OPM/OEM: build and parse. |
|
||||||
|
| `registry` | Every output format in one table. |
|
||||||
|
| `propagator` | The seam a propagation backend implements. |
|
||||||
|
|
||||||
|
## The propagator seam
|
||||||
|
|
||||||
|
`odm` does no orbital mechanics beyond mean-element algebra. Anything needing a
|
||||||
|
propagated state takes a `PropagatorBackend` — `element_sets.compute_element_set`,
|
||||||
|
`messages.build_opm`, `messages.build_oem`, the element-set writers.
|
||||||
|
|
||||||
|
The backend is always **passed in**, never looked up. A library that reached for
|
||||||
|
a service's configured default would only work inside that service, which is the
|
||||||
|
thing this package exists not to be. Implementations live in the `yksa_orbital`
|
||||||
|
Django app (pure-Python SGP4, and an HTTP client to the Orekit sidecar).
|
||||||
|
|
||||||
|
## What is deliberately not here
|
||||||
|
|
||||||
|
- **Storage.** ODMS holds elements in Django, `track` holds them elsewhere, the
|
||||||
|
sidecar holds none. The format code works on records; the service adapts.
|
||||||
|
- **Serving.** Content types and filenames are in `registry` because they are
|
||||||
|
properties of the format, but nothing here builds a response.
|
||||||
|
- **The simulation model.** Force models, atmospheres and propagator settings
|
||||||
|
belong to the Orekit sidecar (`services/orekit/config.py`), which is their one
|
||||||
|
definition for the same reason this package is the formats' one definition.
|
||||||
128
odm/__init__.py
Normal file
128
odm/__init__.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""Orbit Data Messages: read, write and convert element sets.
|
||||||
|
|
||||||
|
Everything ODMS knows about orbital *formats*, with nothing it knows about
|
||||||
|
Django, HTTP or a database. The OMM is the record; TLE text, OMM XML, OMM KVN
|
||||||
|
and Celestrak GP CSV are renderings of it, and the CCSDS SANA element sets are
|
||||||
|
conversions of the state it propagates to.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
from odm import OmmRecord, parse_tle, as_omm_xml, registry
|
||||||
|
|
||||||
|
record = OmmRecord(omm=parse_tle(name, l1, l2).omm)
|
||||||
|
xml = registry.get("omm_xml").write([record])
|
||||||
|
|
||||||
|
The package is standard library plus ``sgp4``. It is separate from the services
|
||||||
|
because more than one of them now reads ODMS data -- and a format that two
|
||||||
|
services implement separately is a format they will eventually disagree about.
|
||||||
|
|
||||||
|
Layout
|
||||||
|
------
|
||||||
|
|
||||||
|
``records`` the in-memory types every reader and writer works on
|
||||||
|
``tle`` TLE text in and out (lossy by construction -- see the module)
|
||||||
|
``omm`` CCSDS OMM, XML and KVN
|
||||||
|
``gp`` Celestrak's GP CSV
|
||||||
|
``norad`` catalog-ID normalisation, ALPHA-5 and the temp-ID scheme
|
||||||
|
``orbits`` mean-element derivations (period, apogee, perigee, ...)
|
||||||
|
``conversions`` state vector -> one SANA element set
|
||||||
|
``element_sets`` the SANA catalogue, and computing a set for an OMM
|
||||||
|
``messages`` CCSDS OPM/OEM: build and parse
|
||||||
|
``registry`` every output format in one table
|
||||||
|
``propagator`` the seam a propagation backend implements
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .element_sets import (
|
||||||
|
API_ELEMENT_SET_KEYS,
|
||||||
|
DISPLAY_SET_KEYS,
|
||||||
|
ELEMENT_SETS,
|
||||||
|
ElementSet,
|
||||||
|
compute_element_set,
|
||||||
|
display_payload,
|
||||||
|
get_set,
|
||||||
|
)
|
||||||
|
from .gp import GP_FIELDS, as_celestrak_csv
|
||||||
|
from .messages import (
|
||||||
|
MessageMeta,
|
||||||
|
ParsedMessage,
|
||||||
|
build_oem,
|
||||||
|
build_opm,
|
||||||
|
parse_message,
|
||||||
|
parsed_states_to_statevectors,
|
||||||
|
parsed_to_json,
|
||||||
|
)
|
||||||
|
from .norad import (
|
||||||
|
ALPHA5_LETTERS,
|
||||||
|
ALPHA5_MAX,
|
||||||
|
ALPHA5_MIN,
|
||||||
|
TEMP_NORAD_MAX,
|
||||||
|
TEMP_NORAD_MIN,
|
||||||
|
coerce_norad_to_int,
|
||||||
|
from_alpha5,
|
||||||
|
normalise_norad,
|
||||||
|
temp_tle_norad,
|
||||||
|
tle_catalog_field,
|
||||||
|
to_alpha5,
|
||||||
|
)
|
||||||
|
from .omm import (
|
||||||
|
CCSDS_OMM_VERSION,
|
||||||
|
OMM_DEFAULT_METADATA,
|
||||||
|
as_omm_kvn,
|
||||||
|
as_omm_xml,
|
||||||
|
format_ccsds_datetime,
|
||||||
|
omm_xml_to_records,
|
||||||
|
parse_omm_epoch,
|
||||||
|
)
|
||||||
|
from .propagator import BackendBusy, PropagationError, PropagatorBackend
|
||||||
|
from .records import OmmRecord, ParsedTLE, StateVector
|
||||||
|
from .tle import as_plaintext, as_txt_txt, indexed_name, parse_tle, tle_from_gp
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ALPHA5_LETTERS",
|
||||||
|
"ALPHA5_MAX",
|
||||||
|
"ALPHA5_MIN",
|
||||||
|
"API_ELEMENT_SET_KEYS",
|
||||||
|
"BackendBusy",
|
||||||
|
"CCSDS_OMM_VERSION",
|
||||||
|
"DISPLAY_SET_KEYS",
|
||||||
|
"ELEMENT_SETS",
|
||||||
|
"ElementSet",
|
||||||
|
"GP_FIELDS",
|
||||||
|
"MessageMeta",
|
||||||
|
"OMM_DEFAULT_METADATA",
|
||||||
|
"OmmRecord",
|
||||||
|
"ParsedMessage",
|
||||||
|
"ParsedTLE",
|
||||||
|
"PropagationError",
|
||||||
|
"PropagatorBackend",
|
||||||
|
"StateVector",
|
||||||
|
"TEMP_NORAD_MAX",
|
||||||
|
"TEMP_NORAD_MIN",
|
||||||
|
"as_celestrak_csv",
|
||||||
|
"as_omm_kvn",
|
||||||
|
"as_omm_xml",
|
||||||
|
"as_plaintext",
|
||||||
|
"as_txt_txt",
|
||||||
|
"build_oem",
|
||||||
|
"build_opm",
|
||||||
|
"coerce_norad_to_int",
|
||||||
|
"compute_element_set",
|
||||||
|
"display_payload",
|
||||||
|
"format_ccsds_datetime",
|
||||||
|
"from_alpha5",
|
||||||
|
"get_set",
|
||||||
|
"indexed_name",
|
||||||
|
"normalise_norad",
|
||||||
|
"omm_xml_to_records",
|
||||||
|
"parse_message",
|
||||||
|
"parse_omm_epoch",
|
||||||
|
"parse_tle",
|
||||||
|
"parsed_states_to_statevectors",
|
||||||
|
"parsed_to_json",
|
||||||
|
"temp_tle_norad",
|
||||||
|
"tle_catalog_field",
|
||||||
|
"tle_from_gp",
|
||||||
|
"to_alpha5",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
261
odm/conversions.py
Normal file
261
odm/conversions.py
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
"""Convert a propagated :class:`StateVector` (+ the source OMM) into the CCSDS
|
||||||
|
SANA orbital element sets this service exposes.
|
||||||
|
|
||||||
|
Definitions follow the SANA registry (https://sanaregistry.org/r/orbital_elements):
|
||||||
|
CARTPV, KEPLERIAN (osculating), KEPLERIANMEAN, KEPLERIANMEANSGP-4, EQUINOCTIAL,
|
||||||
|
GEODETIC, ADBARV. Every function returns a flat ``{COMPONENT_KEY: float}`` dict
|
||||||
|
whose keys line up with :data:`odm.element_sets.ELEMENT_SETS`.
|
||||||
|
|
||||||
|
Pure stdlib ``math`` (no numpy). The inertial frame is whatever the backend
|
||||||
|
emits (TEME for SGP4); a future Orekit backend can supply a rigorously-defined
|
||||||
|
frame without touching this module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from .orbits import MU_EARTH_KM3_S2 as MU
|
||||||
|
from .orbits import derive as derive_mean
|
||||||
|
|
||||||
|
from .records import StateVector
|
||||||
|
|
||||||
|
TWO_PI = 2.0 * math.pi
|
||||||
|
|
||||||
|
|
||||||
|
# --- small vector helpers ---------------------------------------------------
|
||||||
|
|
||||||
|
def _dot(a, b) -> float:
|
||||||
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _cross(a, b) -> tuple[float, float, float]:
|
||||||
|
return (
|
||||||
|
a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(a) -> float:
|
||||||
|
return math.sqrt(_dot(a, a))
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap360(deg: float) -> float:
|
||||||
|
return deg % 360.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- element-set conversions ------------------------------------------------
|
||||||
|
|
||||||
|
def to_cartpv(sv: StateVector, omm: dict | None = None) -> dict:
|
||||||
|
"""Cartesian position + velocity in the inertial (TEME) frame, km / km·s⁻¹."""
|
||||||
|
x, y, z = sv.r_km
|
||||||
|
xd, yd, zd = sv.v_kms
|
||||||
|
return {"X": x, "Y": y, "Z": z, "X_DOT": xd, "Y_DOT": yd, "Z_DOT": zd}
|
||||||
|
|
||||||
|
|
||||||
|
def to_keplerian(sv: StateVector, omm: dict | None = None) -> dict:
|
||||||
|
"""Osculating Keplerian elements from the inertial state (classic RV→COE)."""
|
||||||
|
r = sv.r_km
|
||||||
|
v = sv.v_kms
|
||||||
|
r_mag = _norm(r)
|
||||||
|
v_mag = _norm(v)
|
||||||
|
|
||||||
|
h = _cross(r, v)
|
||||||
|
h_mag = _norm(h)
|
||||||
|
n = _cross((0.0, 0.0, 1.0), h)
|
||||||
|
n_mag = _norm(n)
|
||||||
|
|
||||||
|
# eccentricity vector
|
||||||
|
rv = _dot(r, v)
|
||||||
|
e_vec = tuple(
|
||||||
|
((v_mag * v_mag - MU / r_mag) * r[i] - rv * v[i]) / MU for i in range(3)
|
||||||
|
)
|
||||||
|
ecc = _norm(e_vec)
|
||||||
|
|
||||||
|
energy = v_mag * v_mag / 2.0 - MU / r_mag
|
||||||
|
sma = -MU / (2.0 * energy) if abs(energy) > 1e-12 else float("inf")
|
||||||
|
|
||||||
|
inc = math.acos(_clamp(h[2] / h_mag, -1.0, 1.0)) if h_mag else 0.0
|
||||||
|
|
||||||
|
if n_mag > 1e-12:
|
||||||
|
raan = math.acos(_clamp(n[0] / n_mag, -1.0, 1.0))
|
||||||
|
if n[1] < 0.0:
|
||||||
|
raan = TWO_PI - raan
|
||||||
|
else: # equatorial: RAAN undefined, take 0
|
||||||
|
raan = 0.0
|
||||||
|
|
||||||
|
if n_mag > 1e-12 and ecc > 1e-12:
|
||||||
|
argp = math.acos(_clamp(_dot(n, e_vec) / (n_mag * ecc), -1.0, 1.0))
|
||||||
|
if e_vec[2] < 0.0:
|
||||||
|
argp = TWO_PI - argp
|
||||||
|
else:
|
||||||
|
argp = 0.0
|
||||||
|
|
||||||
|
if ecc > 1e-12:
|
||||||
|
ta = math.acos(_clamp(_dot(e_vec, r) / (ecc * r_mag), -1.0, 1.0))
|
||||||
|
if rv < 0.0:
|
||||||
|
ta = TWO_PI - ta
|
||||||
|
else: # circular: measure from ascending node (argument of latitude)
|
||||||
|
if n_mag > 1e-12:
|
||||||
|
ta = math.acos(_clamp(_dot(n, r) / (n_mag * r_mag), -1.0, 1.0))
|
||||||
|
if r[2] < 0.0:
|
||||||
|
ta = TWO_PI - ta
|
||||||
|
else:
|
||||||
|
ta = math.atan2(r[1], r[0]) % TWO_PI
|
||||||
|
|
||||||
|
return {
|
||||||
|
"SEMI_MAJOR_AXIS": sma,
|
||||||
|
"ECCENTRICITY": ecc,
|
||||||
|
"INCLINATION": math.degrees(inc),
|
||||||
|
"RA_OF_ASC_NODE": _wrap360(math.degrees(raan)),
|
||||||
|
"ARG_OF_PERICENTER": _wrap360(math.degrees(argp)),
|
||||||
|
"TRUE_ANOMALY": _wrap360(math.degrees(ta)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def to_equinoctial(sv: StateVector, omm: dict | None = None) -> dict:
|
||||||
|
"""Equinoctial elements (a, a_f, a_g, L, χ, ψ, f_r) from osculating Keplerian.
|
||||||
|
|
||||||
|
``f_r`` is the retrograde factor: +1 for direct orbits, −1 for retrograde
|
||||||
|
(i > 90°), chosen to keep the χ/ψ pair well-defined near the poles. ``L`` is
|
||||||
|
the mean longitude.
|
||||||
|
"""
|
||||||
|
kep = to_keplerian(sv)
|
||||||
|
a = kep["SEMI_MAJOR_AXIS"]
|
||||||
|
ecc = kep["ECCENTRICITY"]
|
||||||
|
inc = math.radians(kep["INCLINATION"])
|
||||||
|
raan = math.radians(kep["RA_OF_ASC_NODE"])
|
||||||
|
argp = math.radians(kep["ARG_OF_PERICENTER"])
|
||||||
|
ta = math.radians(kep["TRUE_ANOMALY"])
|
||||||
|
|
||||||
|
f_r = 1.0 if kep["INCLINATION"] <= 90.0 else -1.0
|
||||||
|
|
||||||
|
a_f = ecc * math.cos(argp + f_r * raan)
|
||||||
|
a_g = ecc * math.sin(argp + f_r * raan)
|
||||||
|
|
||||||
|
tan_half = math.tan(inc / 2.0)
|
||||||
|
factor = tan_half if f_r > 0 else (1.0 / tan_half if tan_half else 0.0)
|
||||||
|
chi = factor * math.sin(raan)
|
||||||
|
psi = factor * math.cos(raan)
|
||||||
|
|
||||||
|
# true anomaly -> mean anomaly for the mean longitude L
|
||||||
|
ecc_anom = 2.0 * math.atan2(
|
||||||
|
math.sqrt(max(1.0 - ecc, 0.0)) * math.sin(ta / 2.0),
|
||||||
|
math.sqrt(1.0 + ecc) * math.cos(ta / 2.0),
|
||||||
|
)
|
||||||
|
mean_anom = ecc_anom - ecc * math.sin(ecc_anom)
|
||||||
|
lon = mean_anom + argp + f_r * raan
|
||||||
|
|
||||||
|
return {
|
||||||
|
"SEMI_MAJOR_AXIS": a,
|
||||||
|
"A_F": a_f,
|
||||||
|
"A_G": a_g,
|
||||||
|
"L": _wrap360(math.degrees(lon)),
|
||||||
|
"CHI": chi,
|
||||||
|
"PSI": psi,
|
||||||
|
"F_R": f_r,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def to_geodetic(sv: StateVector, omm: dict | None = None) -> dict:
|
||||||
|
"""Earth-relative geodetic set (λ, Φ_GD, β, A, h, v_rel) from the ECEF view."""
|
||||||
|
if sv.geodetic is None or sv.ecef_km is None or sv.ecef_v_kms is None:
|
||||||
|
raise ValueError("backend did not supply an Earth-fixed state for GEODETIC")
|
||||||
|
lat, lon, alt = sv.geodetic
|
||||||
|
v_rel = _norm(sv.ecef_v_kms)
|
||||||
|
east, north, up = _enu(lat, lon, sv.ecef_v_kms)
|
||||||
|
azimuth = _wrap360(math.degrees(math.atan2(east, north)))
|
||||||
|
flight_path = math.degrees(math.asin(_clamp(up / v_rel, -1.0, 1.0))) if v_rel else 0.0
|
||||||
|
return {
|
||||||
|
"LON": lon,
|
||||||
|
"LAT": lat,
|
||||||
|
"FLIGHT_PATH_ANGLE": flight_path,
|
||||||
|
"AZIMUTH": azimuth,
|
||||||
|
"ALTITUDE": alt,
|
||||||
|
"V_REL": v_rel,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def to_adbarv(sv: StateVector, omm: dict | None = None) -> dict:
|
||||||
|
"""Inertial spherical set (α, δ, β, A, r, v) from the inertial state."""
|
||||||
|
r = sv.r_km
|
||||||
|
v = sv.v_kms
|
||||||
|
r_mag = _norm(r)
|
||||||
|
v_mag = _norm(v)
|
||||||
|
ra = _wrap360(math.degrees(math.atan2(r[1], r[0])))
|
||||||
|
dec = math.degrees(math.asin(_clamp(r[2] / r_mag, -1.0, 1.0))) if r_mag else 0.0
|
||||||
|
|
||||||
|
# Local frame about the radial direction (geocentric up = r̂).
|
||||||
|
up = tuple(c / r_mag for c in r) if r_mag else (0.0, 0.0, 1.0)
|
||||||
|
east = _unit(_cross((0.0, 0.0, 1.0), up)) or (1.0, 0.0, 0.0)
|
||||||
|
north = _cross(up, east)
|
||||||
|
v_e, v_n, v_u = _dot(v, east), _dot(v, north), _dot(v, up)
|
||||||
|
azimuth = _wrap360(math.degrees(math.atan2(v_e, v_n)))
|
||||||
|
flight_path = math.degrees(math.asin(_clamp(v_u / v_mag, -1.0, 1.0))) if v_mag else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"RA": ra,
|
||||||
|
"DEC": dec,
|
||||||
|
"FLIGHT_PATH_ANGLE": flight_path,
|
||||||
|
"AZIMUTH": azimuth,
|
||||||
|
"RADIUS": r_mag,
|
||||||
|
"SPEED": v_mag,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def to_keplerian_mean(sv: StateVector | None, omm: dict) -> dict:
|
||||||
|
"""Mean Keplerian (a, e, i, Ω, ω, M) from the OMM mean elements.
|
||||||
|
|
||||||
|
``a`` is derived from the mean motion via Kepler's third law (reusing
|
||||||
|
:func:`odm.orbits.derive`); the angles come straight from the
|
||||||
|
stored mean elements. No propagation needed.
|
||||||
|
"""
|
||||||
|
info = derive_mean(omm)
|
||||||
|
if info is None:
|
||||||
|
raise ValueError("OMM is missing mean elements required for KEPLERIANMEAN")
|
||||||
|
return {
|
||||||
|
"SEMI_MAJOR_AXIS": info.semi_major_axis_km,
|
||||||
|
"ECCENTRICITY": info.eccentricity,
|
||||||
|
"INCLINATION": info.inclination_deg,
|
||||||
|
"RA_OF_ASC_NODE": info.raan_deg,
|
||||||
|
"ARG_OF_PERICENTER": info.arg_perigee_deg,
|
||||||
|
"MEAN_ANOMALY": info.mean_anomaly_deg,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def to_keplerian_mean_sgp4(sv: StateVector | None, omm: dict) -> dict:
|
||||||
|
"""KEPLERIANMEANSGP-4: the mean Keplerian set plus the SGP4 B* drag term.
|
||||||
|
|
||||||
|
This mirrors what the stored TLE/OMM already carries; B* is reported as-is
|
||||||
|
(1/earth-radii) rather than converted to the registry's AGOM/BTERM (m²/kg),
|
||||||
|
which needs an atmosphere-density assumption we deliberately do not bake in.
|
||||||
|
"""
|
||||||
|
out = to_keplerian_mean(sv, omm)
|
||||||
|
out["BSTAR"] = float(omm.get("BSTAR") or 0.0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- internals --------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clamp(value: float, low: float, high: float) -> float:
|
||||||
|
return max(low, min(high, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(a):
|
||||||
|
mag = _norm(a)
|
||||||
|
if mag < 1e-15:
|
||||||
|
return None
|
||||||
|
return (a[0] / mag, a[1] / mag, a[2] / mag)
|
||||||
|
|
||||||
|
|
||||||
|
def _enu(lat_deg: float, lon_deg: float, vec) -> tuple[float, float, float]:
|
||||||
|
"""Project an ECEF vector onto the local East/North/Up frame at (lat, lon)."""
|
||||||
|
lat = math.radians(lat_deg)
|
||||||
|
lon = math.radians(lon_deg)
|
||||||
|
sin_lat, cos_lat = math.sin(lat), math.cos(lat)
|
||||||
|
sin_lon, cos_lon = math.sin(lon), math.cos(lon)
|
||||||
|
east = (-sin_lon, cos_lon, 0.0)
|
||||||
|
north = (-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat)
|
||||||
|
up = (cos_lat * cos_lon, cos_lat * sin_lon, sin_lat)
|
||||||
|
return _dot(vec, east), _dot(vec, north), _dot(vec, up)
|
||||||
190
odm/element_sets.py
Normal file
190
odm/element_sets.py
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"""Data-driven catalog of the CCSDS SANA element sets this service exposes.
|
||||||
|
|
||||||
|
One :class:`ElementSet` per registry entry is the single source of truth for
|
||||||
|
both the API serialization (CSV columns) and the on-page conversion tables:
|
||||||
|
component ``key`` names the machine/CSV column, ``symbol`` + ``unit`` label the
|
||||||
|
display. :func:`compute_element_set` propagates when a set needs a state vector
|
||||||
|
and dispatches to :mod:`odm.conversions`. The propagator is always passed in --
|
||||||
|
the catalogue has no opinion about which one, and a library that reached for a
|
||||||
|
service's configured default would be unusable from anywhere else.
|
||||||
|
|
||||||
|
Registry reference: https://sanaregistry.org/r/orbital_elements
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from . import conversions
|
||||||
|
from .propagator import PropagatorBackend
|
||||||
|
from .records import StateVector
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Component:
|
||||||
|
key: str # machine / CSV column name
|
||||||
|
symbol: str # display symbol
|
||||||
|
unit: str # display unit ("" for dimensionless)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ElementSet:
|
||||||
|
key: str # URL / format key, e.g. "keplerian"
|
||||||
|
name: str # SANA identifier, e.g. "KEPLERIAN"
|
||||||
|
status: str # "Assigned" / "Provisional"
|
||||||
|
frame: str # reference frame label
|
||||||
|
needs_state: bool # requires propagation to a state vector
|
||||||
|
components: tuple[Component, ...]
|
||||||
|
compute: Callable[[StateVector | None, dict], dict]
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _c(*items: tuple[str, str, str]) -> tuple[Component, ...]:
|
||||||
|
return tuple(Component(*i) for i in items)
|
||||||
|
|
||||||
|
|
||||||
|
ELEMENT_SETS: dict[str, ElementSet] = {
|
||||||
|
"cartpv": ElementSet(
|
||||||
|
key="cartpv", name="CARTPV", status="Assigned", frame="TEME",
|
||||||
|
needs_state=True, compute=conversions.to_cartpv,
|
||||||
|
components=_c(
|
||||||
|
("X", "X", "km"), ("Y", "Y", "km"), ("Z", "Z", "km"),
|
||||||
|
("X_DOT", "Ẋ", "km/s"), ("Y_DOT", "Ẏ", "km/s"), ("Z_DOT", "Ż", "km/s"),
|
||||||
|
),
|
||||||
|
description="Cartesian inertial position and velocity.",
|
||||||
|
),
|
||||||
|
"keplerian": ElementSet(
|
||||||
|
key="keplerian", name="KEPLERIAN", status="Assigned", frame="TEME",
|
||||||
|
needs_state=True, compute=conversions.to_keplerian,
|
||||||
|
components=_c(
|
||||||
|
("SEMI_MAJOR_AXIS", "a", "km"), ("ECCENTRICITY", "e", ""),
|
||||||
|
("INCLINATION", "i", "deg"), ("RA_OF_ASC_NODE", "Ω", "deg"),
|
||||||
|
("ARG_OF_PERICENTER", "ω", "deg"), ("TRUE_ANOMALY", "ν", "deg"),
|
||||||
|
),
|
||||||
|
description="Osculating Keplerian elements from the inertial state.",
|
||||||
|
),
|
||||||
|
"keplerianmean": ElementSet(
|
||||||
|
key="keplerianmean", name="KEPLERIANMEAN", status="Assigned", frame="TEME",
|
||||||
|
needs_state=False, compute=conversions.to_keplerian_mean,
|
||||||
|
components=_c(
|
||||||
|
("SEMI_MAJOR_AXIS", "a", "km"), ("ECCENTRICITY", "e", ""),
|
||||||
|
("INCLINATION", "i", "deg"), ("RA_OF_ASC_NODE", "Ω", "deg"),
|
||||||
|
("ARG_OF_PERICENTER", "ω", "deg"), ("MEAN_ANOMALY", "M", "deg"),
|
||||||
|
),
|
||||||
|
description="Mean Keplerian elements derived from the OMM mean motion.",
|
||||||
|
),
|
||||||
|
"keplerianmeansgp4": ElementSet(
|
||||||
|
key="keplerianmeansgp4", name="KEPLERIANMEANSGP-4", status="Provisional",
|
||||||
|
frame="TEME", needs_state=False, compute=conversions.to_keplerian_mean_sgp4,
|
||||||
|
components=_c(
|
||||||
|
("SEMI_MAJOR_AXIS", "a", "km"), ("ECCENTRICITY", "e", ""),
|
||||||
|
("INCLINATION", "i", "deg"), ("RA_OF_ASC_NODE", "Ω", "deg"),
|
||||||
|
("ARG_OF_PERICENTER", "ω", "deg"), ("MEAN_ANOMALY", "M", "deg"),
|
||||||
|
("BSTAR", "B*", "1/ER"),
|
||||||
|
),
|
||||||
|
description="SGP4 mean elements as stored (B* reported natively).",
|
||||||
|
),
|
||||||
|
"equinoctial": ElementSet(
|
||||||
|
key="equinoctial", name="EQUINOCTIAL", status="Assigned", frame="TEME",
|
||||||
|
needs_state=True, compute=conversions.to_equinoctial,
|
||||||
|
components=_c(
|
||||||
|
("SEMI_MAJOR_AXIS", "a", "km"), ("A_F", "a_f", ""), ("A_G", "a_g", ""),
|
||||||
|
("L", "L", "deg"), ("CHI", "χ", ""), ("PSI", "ψ", ""), ("F_R", "f_r", "±1"),
|
||||||
|
),
|
||||||
|
description="Equinoctial elements (retrograde factor f_r).",
|
||||||
|
),
|
||||||
|
"geodetic": ElementSet(
|
||||||
|
key="geodetic", name="GEODETIC", status="Assigned", frame="WGS84 ECEF",
|
||||||
|
needs_state=True, compute=conversions.to_geodetic,
|
||||||
|
components=_c(
|
||||||
|
("LON", "λ", "deg"), ("LAT", "Φ_GD", "deg"),
|
||||||
|
("FLIGHT_PATH_ANGLE", "β", "deg"), ("AZIMUTH", "A", "deg"),
|
||||||
|
("ALTITUDE", "h", "km"), ("V_REL", "v_rel", "km/s"),
|
||||||
|
),
|
||||||
|
description="Earth-relative geodetic sub-satellite state.",
|
||||||
|
),
|
||||||
|
"adbarv": ElementSet(
|
||||||
|
key="adbarv", name="ADBARV", status="Assigned", frame="TEME",
|
||||||
|
needs_state=True, compute=conversions.to_adbarv,
|
||||||
|
components=_c(
|
||||||
|
("RA", "α", "deg"), ("DEC", "δ", "deg"),
|
||||||
|
("FLIGHT_PATH_ANGLE", "β", "deg"), ("AZIMUTH", "A", "deg"),
|
||||||
|
("RADIUS", "r", "km"), ("SPEED", "v", "km/s"),
|
||||||
|
),
|
||||||
|
description="Inertial spherical position and velocity.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Element sets offered as downloadable API formats (KEPLERIANMEANSGP-4 is
|
||||||
|
#: already covered by the native tle/omm formats, so it stays display-only).
|
||||||
|
API_ELEMENT_SET_KEYS: tuple[str, ...] = (
|
||||||
|
"cartpv", "keplerian", "keplerianmean", "equinoctial", "geodetic", "adbarv",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_set(key: str) -> ElementSet:
|
||||||
|
try:
|
||||||
|
return ELEMENT_SETS[key]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"unknown element set {key!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
#: Element sets shown on the satellite detail "Conversions" tab (all of them).
|
||||||
|
DISPLAY_SET_KEYS: tuple[str, ...] = tuple(ELEMENT_SETS)
|
||||||
|
|
||||||
|
|
||||||
|
def display_payload(omm: dict, backend: PropagatorBackend, *,
|
||||||
|
at: datetime | None = None) -> dict:
|
||||||
|
"""Compute every display element set for one OMM in one pass.
|
||||||
|
|
||||||
|
Returns ``{"epoch": iso|None, "sets": [{key, name, status, frame,
|
||||||
|
description, components: [{key, symbol, unit, value}]}]}`` -- the single
|
||||||
|
shape used by both the detail-page initial render and the JSON endpoint the
|
||||||
|
"propagate to" control fetches. Sets that fail to compute are skipped.
|
||||||
|
"""
|
||||||
|
epoch = None
|
||||||
|
sets = []
|
||||||
|
for key in DISPLAY_SET_KEYS:
|
||||||
|
eset = ELEMENT_SETS[key]
|
||||||
|
try:
|
||||||
|
result = compute_element_set(key, omm, backend, at=at)
|
||||||
|
except Exception: # noqa: BLE001 -- a bad set shouldn't blank the page
|
||||||
|
continue
|
||||||
|
if epoch is None and result["epoch"] is not None:
|
||||||
|
epoch = result["epoch"]
|
||||||
|
values = result["values"]
|
||||||
|
sets.append({
|
||||||
|
"key": eset.key,
|
||||||
|
"name": eset.name,
|
||||||
|
"status": eset.status,
|
||||||
|
"frame": eset.frame,
|
||||||
|
"description": eset.description,
|
||||||
|
"components": [
|
||||||
|
{"key": c.key, "symbol": c.symbol, "unit": c.unit,
|
||||||
|
"value": values.get(c.key)}
|
||||||
|
for c in eset.components
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return {"epoch": epoch.isoformat() if epoch else None, "sets": sets}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_element_set(
|
||||||
|
key: str, omm: dict, backend: PropagatorBackend, *,
|
||||||
|
at: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Compute one element set's values from an OMM dict.
|
||||||
|
|
||||||
|
Returns ``{"epoch": datetime|None, "frame": str, "values": {COMPONENT: float}}``.
|
||||||
|
Sets flagged ``needs_state`` are propagated (at ``at`` or the OMM epoch);
|
||||||
|
mean sets read the OMM directly.
|
||||||
|
"""
|
||||||
|
eset = get_set(key)
|
||||||
|
state = None
|
||||||
|
epoch = None
|
||||||
|
if eset.needs_state:
|
||||||
|
state = backend.state_at(omm, at)
|
||||||
|
epoch = state.epoch
|
||||||
|
values = eset.compute(state, omm)
|
||||||
|
return {"epoch": epoch, "frame": eset.frame, "values": values}
|
||||||
76
odm/gp.py
Normal file
76
odm/gp.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Celestrak's GP CSV rendering.
|
||||||
|
|
||||||
|
Column set and order are Celestrak's, exactly, so the output drops into tooling
|
||||||
|
written against their ``FORMAT=csv`` queries without a translation step. Values
|
||||||
|
are formatted through :mod:`odm.omm` rather than restringified here, so a number
|
||||||
|
reads the same whichever rendering you asked for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .omm import format_omm_value
|
||||||
|
from .records import OmmRecord
|
||||||
|
|
||||||
|
GP_FIELDS = (
|
||||||
|
"OBJECT_NAME",
|
||||||
|
"OBJECT_ID",
|
||||||
|
"EPOCH",
|
||||||
|
"MEAN_MOTION",
|
||||||
|
"ECCENTRICITY",
|
||||||
|
"INCLINATION",
|
||||||
|
"RA_OF_ASC_NODE",
|
||||||
|
"ARG_OF_PERICENTER",
|
||||||
|
"MEAN_ANOMALY",
|
||||||
|
"EPHEMERIS_TYPE",
|
||||||
|
"CLASSIFICATION_TYPE",
|
||||||
|
"NORAD_CAT_ID",
|
||||||
|
"ELEMENT_SET_NO",
|
||||||
|
"REV_AT_EPOCH",
|
||||||
|
"BSTAR",
|
||||||
|
"MEAN_MOTION_DOT",
|
||||||
|
"MEAN_MOTION_DDOT",
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_celestrak_csv(records: Iterable["OmmRecord"]) -> str:
|
||||||
|
"""Render GP CSV in Celestrak's special-request column order.
|
||||||
|
|
||||||
|
Columns are :data:`GP_FIELDS` -- the exact field set and order Celestrak
|
||||||
|
emits for ``FORMAT=csv`` GP queries -- so the output drops straight into
|
||||||
|
tooling written against Celestrak's CSV. One row per element, read from the
|
||||||
|
record's canonical ``omm`` dict (falling back to the record's metadata for
|
||||||
|
OBJECT_NAME / OBJECT_ID).
|
||||||
|
"""
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf, lineterminator="\n")
|
||||||
|
writer.writerow(GP_FIELDS)
|
||||||
|
for rec in records:
|
||||||
|
omm = rec.omm or {}
|
||||||
|
row = []
|
||||||
|
for key in GP_FIELDS:
|
||||||
|
if key == "OBJECT_NAME":
|
||||||
|
value = omm.get(key) or rec.object_name
|
||||||
|
elif key == "OBJECT_ID":
|
||||||
|
value = omm.get(key) or rec.object_id
|
||||||
|
else:
|
||||||
|
value = omm.get(key)
|
||||||
|
row.append(_csv_cell(key, value))
|
||||||
|
writer.writerow(row)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def _csv_cell(key: str, value) -> str:
|
||||||
|
"""Stringify one GP field for CSV output.
|
||||||
|
|
||||||
|
EPOCH is passed through as-is (already an ISO string); other values reuse
|
||||||
|
:func:`format_omm_value` so numeric/enum formatting matches the XML/KVN
|
||||||
|
paths. Missing values render as an empty cell.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if key == "EPOCH":
|
||||||
|
return format_omm_value(key, value) if isinstance(value, datetime) else str(value)
|
||||||
|
return format_omm_value(key, value)
|
||||||
461
odm/messages.py
Normal file
461
odm/messages.py
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
"""CCSDS 502.0 OPM / OEM message build + parse (KVN and XML).
|
||||||
|
|
||||||
|
* **OPM** (Orbit Parameter Message) -- a single-epoch state: Cartesian state
|
||||||
|
vector + osculating Keplerian elements, generated by propagating an OMM.
|
||||||
|
* **OEM** (Orbit Ephemeris Message) -- a time-ordered run of Cartesian state
|
||||||
|
vectors over a span, generated from a backend ephemeris.
|
||||||
|
|
||||||
|
The same functions parse externally-provided messages (for the ingest path) into
|
||||||
|
a normalized dict. Datetime formatting reuses
|
||||||
|
:func:`odm.omm.format_ccsds_datetime` so generated messages match
|
||||||
|
the OMM path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from .omm import format_ccsds_datetime, parse_omm_epoch
|
||||||
|
|
||||||
|
from . import conversions
|
||||||
|
from .propagator import PropagatorBackend
|
||||||
|
from .records import StateVector
|
||||||
|
|
||||||
|
OPM_VERSION = "3.0"
|
||||||
|
OEM_VERSION = "3.0"
|
||||||
|
GM_EARTH = "398600.4418"
|
||||||
|
|
||||||
|
_STATE_KEYS = ("X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT")
|
||||||
|
_KEPLERIAN_KEYS = (
|
||||||
|
"SEMI_MAJOR_AXIS", "ECCENTRICITY", "INCLINATION",
|
||||||
|
"RA_OF_ASC_NODE", "ARG_OF_PERICENTER", "TRUE_ANOMALY",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Frames in which osculating Keplerian elements are meaningful (pseudo-inertial).
|
||||||
|
#: For an Earth-fixed frame (ITRF) we emit the Cartesian state only.
|
||||||
|
INERTIAL_FRAMES = frozenset({"TEME", "GCRF", "EME2000", "J2000", "TOD", "MOD"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageMeta:
|
||||||
|
object_name: str = ""
|
||||||
|
object_id: str = ""
|
||||||
|
originator: str = "YKSA"
|
||||||
|
center_name: str = "EARTH"
|
||||||
|
ref_frame: str = "TEME"
|
||||||
|
time_system: str = "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedMessage:
|
||||||
|
message_type: str # "OPM" | "OEM"
|
||||||
|
raw_format: str # "KVN" | "XML"
|
||||||
|
meta: MessageMeta
|
||||||
|
epoch: datetime | None = None # OPM state epoch
|
||||||
|
start_time: datetime | None = None # OEM span
|
||||||
|
stop_time: datetime | None = None
|
||||||
|
states: list[dict] = field(default_factory=list) # [{epoch, X, Y, Z, X_DOT...}]
|
||||||
|
creation_date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- number/datetime helpers -----------------------------------------------
|
||||||
|
|
||||||
|
def _num(value) -> str:
|
||||||
|
return repr(float(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# --- OPM build --------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_opm(
|
||||||
|
omm: dict,
|
||||||
|
meta: MessageMeta,
|
||||||
|
at: datetime | None = None,
|
||||||
|
*,
|
||||||
|
backend: PropagatorBackend,
|
||||||
|
fmt: str = "kvn",
|
||||||
|
ref_frame: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Propagate ``omm`` to a single-epoch OPM in ``ref_frame`` (default TEME).
|
||||||
|
|
||||||
|
A non-TEME ``ref_frame`` transforms the propagated state via the backend;
|
||||||
|
Keplerian elements are only emitted for pseudo-inertial frames.
|
||||||
|
"""
|
||||||
|
target = (ref_frame or meta.ref_frame or "TEME").upper()
|
||||||
|
sv = _express(backend.state_at(omm, at), target, backend)
|
||||||
|
meta = replace(meta, ref_frame=target)
|
||||||
|
state = conversions.to_cartpv(sv)
|
||||||
|
kep = conversions.to_keplerian(sv) if target in INERTIAL_FRAMES else None
|
||||||
|
if fmt == "xml":
|
||||||
|
return _opm_xml(meta, sv.epoch, state, kep)
|
||||||
|
return _opm_kvn(meta, sv.epoch, state, kep)
|
||||||
|
|
||||||
|
|
||||||
|
def _express(sv: StateVector, target: str, backend: PropagatorBackend) -> StateVector:
|
||||||
|
"""Return ``sv`` re-expressed in ``target`` (a no-op when already there)."""
|
||||||
|
if not target or target == sv.frame:
|
||||||
|
return sv
|
||||||
|
return backend.transform([sv], sv.frame, target)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _opm_kvn(meta, epoch, state, kep) -> str:
|
||||||
|
lines = [
|
||||||
|
f"CCSDS_OPM_VERS = {OPM_VERSION}",
|
||||||
|
f"CREATION_DATE = {format_ccsds_datetime(_now())}",
|
||||||
|
f"ORIGINATOR = {meta.originator}",
|
||||||
|
f"OBJECT_NAME = {meta.object_name}",
|
||||||
|
f"OBJECT_ID = {meta.object_id}",
|
||||||
|
f"CENTER_NAME = {meta.center_name}",
|
||||||
|
f"REF_FRAME = {meta.ref_frame}",
|
||||||
|
f"TIME_SYSTEM = {meta.time_system}",
|
||||||
|
f"EPOCH = {format_ccsds_datetime(epoch)}",
|
||||||
|
]
|
||||||
|
lines += [f"{k} = {_num(state[k])}" for k in _STATE_KEYS]
|
||||||
|
if kep is not None:
|
||||||
|
lines += [f"{k} = {_num(kep[k])}" for k in _KEPLERIAN_KEYS]
|
||||||
|
lines.append(f"GM = {GM_EARTH}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _opm_xml(meta, epoch, state, kep) -> str:
|
||||||
|
opm = ET.Element("opm", attrib={"id": "CCSDS_OPM_VERS", "version": OPM_VERSION})
|
||||||
|
header = ET.SubElement(opm, "header")
|
||||||
|
ET.SubElement(header, "CREATION_DATE").text = format_ccsds_datetime(_now())
|
||||||
|
ET.SubElement(header, "ORIGINATOR").text = meta.originator
|
||||||
|
body = ET.SubElement(opm, "body")
|
||||||
|
segment = ET.SubElement(body, "segment")
|
||||||
|
_append_metadata(segment, meta)
|
||||||
|
data = ET.SubElement(segment, "data")
|
||||||
|
sv_el = ET.SubElement(data, "stateVector")
|
||||||
|
ET.SubElement(sv_el, "EPOCH").text = format_ccsds_datetime(epoch)
|
||||||
|
for k in _STATE_KEYS:
|
||||||
|
ET.SubElement(sv_el, k).text = _num(state[k])
|
||||||
|
if kep is not None:
|
||||||
|
kep_el = ET.SubElement(data, "keplerianElements")
|
||||||
|
for k in _KEPLERIAN_KEYS:
|
||||||
|
ET.SubElement(kep_el, k).text = _num(kep[k])
|
||||||
|
ET.SubElement(kep_el, "GM").text = GM_EARTH
|
||||||
|
ET.indent(opm, space=" ")
|
||||||
|
return ET.tostring(opm, encoding="utf-8", xml_declaration=True).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# --- OEM build --------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_oem(
|
||||||
|
omm: dict,
|
||||||
|
meta: MessageMeta,
|
||||||
|
start: datetime,
|
||||||
|
stop: datetime,
|
||||||
|
step_s: float,
|
||||||
|
*,
|
||||||
|
backend: PropagatorBackend,
|
||||||
|
fmt: str = "kvn",
|
||||||
|
ref_frame: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Propagate ``omm`` to an OEM ephemeris in ``ref_frame`` (default TEME).
|
||||||
|
|
||||||
|
A non-TEME ``ref_frame`` batch-transforms the whole run through the backend
|
||||||
|
(one round-trip to the Orekit sidecar) before serialising.
|
||||||
|
"""
|
||||||
|
target = (ref_frame or meta.ref_frame or "TEME").upper()
|
||||||
|
states = backend.ephemeris(omm, start, stop, step_s)
|
||||||
|
if target != "TEME":
|
||||||
|
states = backend.transform(states, "TEME", target)
|
||||||
|
meta = replace(meta, ref_frame=target)
|
||||||
|
rows = [
|
||||||
|
{"epoch": sv.epoch, **conversions.to_cartpv(sv)} for sv in states
|
||||||
|
]
|
||||||
|
if fmt == "xml":
|
||||||
|
return _oem_xml(meta, start, stop, rows)
|
||||||
|
return _oem_kvn(meta, start, stop, rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _oem_kvn(meta, start, stop, rows) -> str:
|
||||||
|
lines = [
|
||||||
|
f"CCSDS_OEM_VERS = {OEM_VERSION}",
|
||||||
|
f"CREATION_DATE = {format_ccsds_datetime(_now())}",
|
||||||
|
f"ORIGINATOR = {meta.originator}",
|
||||||
|
"",
|
||||||
|
"META_START",
|
||||||
|
f"OBJECT_NAME = {meta.object_name}",
|
||||||
|
f"OBJECT_ID = {meta.object_id}",
|
||||||
|
f"CENTER_NAME = {meta.center_name}",
|
||||||
|
f"REF_FRAME = {meta.ref_frame}",
|
||||||
|
f"TIME_SYSTEM = {meta.time_system}",
|
||||||
|
f"START_TIME = {format_ccsds_datetime(start)}",
|
||||||
|
f"STOP_TIME = {format_ccsds_datetime(stop)}",
|
||||||
|
"META_STOP",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
cells = " ".join(_num(row[k]) for k in _STATE_KEYS)
|
||||||
|
lines.append(f"{format_ccsds_datetime(row['epoch'])} {cells}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _oem_xml(meta, start, stop, rows) -> str:
|
||||||
|
oem = ET.Element("oem", attrib={"id": "CCSDS_OEM_VERS", "version": OEM_VERSION})
|
||||||
|
header = ET.SubElement(oem, "header")
|
||||||
|
ET.SubElement(header, "CREATION_DATE").text = format_ccsds_datetime(_now())
|
||||||
|
ET.SubElement(header, "ORIGINATOR").text = meta.originator
|
||||||
|
body = ET.SubElement(oem, "body")
|
||||||
|
segment = ET.SubElement(body, "segment")
|
||||||
|
metadata = _append_metadata(segment, meta)
|
||||||
|
ET.SubElement(metadata, "START_TIME").text = format_ccsds_datetime(start)
|
||||||
|
ET.SubElement(metadata, "STOP_TIME").text = format_ccsds_datetime(stop)
|
||||||
|
data = ET.SubElement(segment, "data")
|
||||||
|
for row in rows:
|
||||||
|
sv_el = ET.SubElement(data, "stateVector")
|
||||||
|
ET.SubElement(sv_el, "EPOCH").text = format_ccsds_datetime(row["epoch"])
|
||||||
|
for k in _STATE_KEYS:
|
||||||
|
ET.SubElement(sv_el, k).text = _num(row[k])
|
||||||
|
ET.indent(oem, space=" ")
|
||||||
|
return ET.tostring(oem, encoding="utf-8", xml_declaration=True).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _append_metadata(segment: ET.Element, meta: MessageMeta) -> ET.Element:
|
||||||
|
metadata = ET.SubElement(segment, "metadata")
|
||||||
|
ET.SubElement(metadata, "OBJECT_NAME").text = meta.object_name
|
||||||
|
ET.SubElement(metadata, "OBJECT_ID").text = meta.object_id
|
||||||
|
ET.SubElement(metadata, "CENTER_NAME").text = meta.center_name
|
||||||
|
ET.SubElement(metadata, "REF_FRAME").text = meta.ref_frame
|
||||||
|
ET.SubElement(metadata, "TIME_SYSTEM").text = meta.time_system
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
# --- parse (ingest) ---------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_message(text: str) -> ParsedMessage:
|
||||||
|
"""Parse an OPM or OEM message (KVN or XML) into a :class:`ParsedMessage`."""
|
||||||
|
stripped = text.lstrip()
|
||||||
|
if stripped.startswith("<"):
|
||||||
|
return _parse_xml(stripped)
|
||||||
|
return _parse_kvn(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_kvn(text: str) -> ParsedMessage:
|
||||||
|
lines = text.splitlines()
|
||||||
|
kind = "OEM" if any("CCSDS_OEM_VERS" in ln for ln in lines) else "OPM"
|
||||||
|
meta = MessageMeta()
|
||||||
|
result = ParsedMessage(message_type=kind, raw_format="KVN", meta=meta)
|
||||||
|
|
||||||
|
in_meta = False
|
||||||
|
for raw in lines:
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("COMMENT"):
|
||||||
|
continue
|
||||||
|
if line in ("META_START", "META_STOP"):
|
||||||
|
in_meta = line == "META_START"
|
||||||
|
continue
|
||||||
|
if " = " in line:
|
||||||
|
key, value = (p.strip() for p in line.split("=", 1))
|
||||||
|
_assign_kv(result, key, value)
|
||||||
|
continue
|
||||||
|
# A non key/value line inside/after an OEM is an ephemeris row.
|
||||||
|
if kind == "OEM":
|
||||||
|
row = _parse_ephemeris_row(line)
|
||||||
|
if row:
|
||||||
|
result.states.append(row)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_kv(result: ParsedMessage, key: str, value: str) -> None:
|
||||||
|
meta = result.meta
|
||||||
|
if key == "ORIGINATOR":
|
||||||
|
meta.originator = value
|
||||||
|
elif key == "OBJECT_NAME":
|
||||||
|
meta.object_name = value
|
||||||
|
elif key == "OBJECT_ID":
|
||||||
|
meta.object_id = value
|
||||||
|
elif key == "CENTER_NAME":
|
||||||
|
meta.center_name = value
|
||||||
|
elif key == "REF_FRAME":
|
||||||
|
meta.ref_frame = value
|
||||||
|
elif key == "TIME_SYSTEM":
|
||||||
|
meta.time_system = value
|
||||||
|
elif key == "CREATION_DATE":
|
||||||
|
result.creation_date = parse_omm_epoch(value)
|
||||||
|
elif key == "EPOCH":
|
||||||
|
result.epoch = parse_omm_epoch(value)
|
||||||
|
result.states.append({"epoch": result.epoch})
|
||||||
|
elif key == "START_TIME":
|
||||||
|
result.start_time = parse_omm_epoch(value)
|
||||||
|
elif key == "STOP_TIME":
|
||||||
|
result.stop_time = parse_omm_epoch(value)
|
||||||
|
elif key in _STATE_KEYS and result.states:
|
||||||
|
result.states[-1][key] = _to_float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ephemeris_row(line: str) -> dict | None:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 7:
|
||||||
|
return None
|
||||||
|
epoch = parse_omm_epoch(parts[0])
|
||||||
|
if epoch is None:
|
||||||
|
return None
|
||||||
|
row = {"epoch": epoch}
|
||||||
|
for key, token in zip(_STATE_KEYS, parts[1:7]):
|
||||||
|
row[key] = _to_float(token)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_xml(text: str) -> ParsedMessage:
|
||||||
|
root = ET.fromstring(text)
|
||||||
|
kind = "OEM" if root.tag.lower().endswith("oem") else "OPM"
|
||||||
|
meta = MessageMeta()
|
||||||
|
result = ParsedMessage(message_type=kind, raw_format="XML", meta=meta)
|
||||||
|
|
||||||
|
header = root.find("header")
|
||||||
|
if header is not None:
|
||||||
|
meta.originator = _text(header, "ORIGINATOR", meta.originator)
|
||||||
|
result.creation_date = parse_omm_epoch(_text(header, "CREATION_DATE", ""))
|
||||||
|
|
||||||
|
segment = root.find("./body/segment")
|
||||||
|
if segment is not None:
|
||||||
|
md = segment.find("metadata")
|
||||||
|
if md is not None:
|
||||||
|
meta.object_name = _text(md, "OBJECT_NAME", "")
|
||||||
|
meta.object_id = _text(md, "OBJECT_ID", "")
|
||||||
|
meta.center_name = _text(md, "CENTER_NAME", meta.center_name)
|
||||||
|
meta.ref_frame = _text(md, "REF_FRAME", meta.ref_frame)
|
||||||
|
meta.time_system = _text(md, "TIME_SYSTEM", meta.time_system)
|
||||||
|
result.start_time = parse_omm_epoch(_text(md, "START_TIME", ""))
|
||||||
|
result.stop_time = parse_omm_epoch(_text(md, "STOP_TIME", ""))
|
||||||
|
data = segment.find("data")
|
||||||
|
if data is not None:
|
||||||
|
for sv_el in data.findall("stateVector"):
|
||||||
|
row = {"epoch": parse_omm_epoch(_text(sv_el, "EPOCH", ""))}
|
||||||
|
for k in _STATE_KEYS:
|
||||||
|
row[k] = _to_float(_text(sv_el, k, ""))
|
||||||
|
result.states.append(row)
|
||||||
|
if kind == "OPM" and result.states:
|
||||||
|
result.epoch = result.states[0]["epoch"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parsed_to_json(parsed: ParsedMessage) -> dict:
|
||||||
|
"""Flatten a :class:`ParsedMessage` into a JSON-serializable dict for storage."""
|
||||||
|
def _iso(dt):
|
||||||
|
return dt.isoformat() if dt else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message_type": parsed.message_type,
|
||||||
|
"raw_format": parsed.raw_format,
|
||||||
|
"originator": parsed.meta.originator,
|
||||||
|
"object_name": parsed.meta.object_name,
|
||||||
|
"object_id": parsed.meta.object_id,
|
||||||
|
"center_name": parsed.meta.center_name,
|
||||||
|
"ref_frame": parsed.meta.ref_frame,
|
||||||
|
"time_system": parsed.meta.time_system,
|
||||||
|
"creation_date": _iso(parsed.creation_date),
|
||||||
|
"epoch": _iso(parsed.epoch),
|
||||||
|
"start_time": _iso(parsed.start_time),
|
||||||
|
"stop_time": _iso(parsed.stop_time),
|
||||||
|
"states": [
|
||||||
|
{**{k: v for k, v in state.items() if k != "epoch"},
|
||||||
|
"epoch": _iso(state.get("epoch"))}
|
||||||
|
for state in parsed.states
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- re-express / derive from an ingested message ---------------------------
|
||||||
|
|
||||||
|
def parsed_states_to_statevectors(parsed: ParsedMessage) -> list[StateVector]:
|
||||||
|
"""Turn a parsed message's Cartesian rows into frame-tagged StateVectors.
|
||||||
|
|
||||||
|
Rows missing an epoch or any of the six state components are skipped (an OPM
|
||||||
|
header without its state vector, say). The frame comes from the message's
|
||||||
|
``REF_FRAME`` so downstream transforms know where the data starts.
|
||||||
|
"""
|
||||||
|
frame = (parsed.meta.ref_frame or "TEME").upper()
|
||||||
|
out: list[StateVector] = []
|
||||||
|
for s in parsed.states:
|
||||||
|
epoch = s.get("epoch")
|
||||||
|
if epoch is None or any(s.get(k) is None for k in _STATE_KEYS):
|
||||||
|
continue
|
||||||
|
out.append(StateVector(
|
||||||
|
epoch=epoch,
|
||||||
|
frame=frame,
|
||||||
|
r_km=(s["X"], s["Y"], s["Z"]),
|
||||||
|
v_kms=(s["X_DOT"], s["Y_DOT"], s["Z_DOT"]),
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def transform_message(
|
||||||
|
parsed: ParsedMessage,
|
||||||
|
target_frame: str,
|
||||||
|
*,
|
||||||
|
backend: PropagatorBackend,
|
||||||
|
fmt: str = "kvn",
|
||||||
|
) -> str:
|
||||||
|
"""Re-express an ingested OPM/OEM in ``target_frame`` and re-serialise it.
|
||||||
|
|
||||||
|
The source frame is the message's own ``REF_FRAME``; the states are pushed
|
||||||
|
through the backend's frame transform. OPM Keplerian elements are re-derived
|
||||||
|
only when the target is pseudo-inertial. This is the "source OPM/OEM in any
|
||||||
|
coordinate system" path -- the tracking service can publish in its native
|
||||||
|
frame and ODMS serves it in whatever the consumer wants.
|
||||||
|
"""
|
||||||
|
target = target_frame.upper()
|
||||||
|
source = (parsed.meta.ref_frame or "TEME").upper()
|
||||||
|
svs = parsed_states_to_statevectors(parsed)
|
||||||
|
if not svs:
|
||||||
|
raise ValueError("message has no complete Cartesian states to transform")
|
||||||
|
if target != source:
|
||||||
|
svs = backend.transform(svs, source, target)
|
||||||
|
meta = replace(parsed.meta, ref_frame=target)
|
||||||
|
|
||||||
|
if parsed.message_type == "OEM":
|
||||||
|
rows = [{"epoch": sv.epoch, **conversions.to_cartpv(sv)} for sv in svs]
|
||||||
|
start = parsed.start_time or svs[0].epoch
|
||||||
|
stop = parsed.stop_time or svs[-1].epoch
|
||||||
|
if fmt == "xml":
|
||||||
|
return _oem_xml(meta, start, stop, rows)
|
||||||
|
return _oem_kvn(meta, start, stop, rows)
|
||||||
|
|
||||||
|
sv = svs[0]
|
||||||
|
state = conversions.to_cartpv(sv)
|
||||||
|
kep = conversions.to_keplerian(sv) if target in INERTIAL_FRAMES else None
|
||||||
|
if fmt == "xml":
|
||||||
|
return _opm_xml(meta, sv.epoch, state, kep)
|
||||||
|
return _opm_kvn(meta, sv.epoch, state, kep)
|
||||||
|
|
||||||
|
|
||||||
|
def fit_tle_from_message(
|
||||||
|
parsed: ParsedMessage,
|
||||||
|
template_omm: dict,
|
||||||
|
*,
|
||||||
|
backend: PropagatorBackend,
|
||||||
|
) -> dict:
|
||||||
|
"""Fit a TLE to an ingested OEM's states via the backend orbit-fit.
|
||||||
|
|
||||||
|
``template_omm`` seeds the object identity (NORAD id / designator). Returns
|
||||||
|
the backend's ``{"line1", "line2", ...}``. Needs a backend with a real fit
|
||||||
|
(Orekit); the SGP4 backend refuses.
|
||||||
|
"""
|
||||||
|
svs = parsed_states_to_statevectors(parsed)
|
||||||
|
if len(svs) < 2:
|
||||||
|
raise ValueError("need at least two states to fit a TLE")
|
||||||
|
frame = (parsed.meta.ref_frame or "TEME").upper()
|
||||||
|
return backend.fit_tle(svs, frame, template_omm)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(el: ET.Element, tag: str, default: str) -> str:
|
||||||
|
child = el.find(tag)
|
||||||
|
if child is None or child.text is None:
|
||||||
|
return default
|
||||||
|
return child.text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value: str):
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
106
odm/norad.py
Normal file
106
odm/norad.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""NORAD catalog ID normalisation and TLE catalog-field rendering.
|
||||||
|
|
||||||
|
The canonical ID is stored as a string: legacy numeric IDs are zero-padded to
|
||||||
|
5 digits so ``"25544"`` stays comparable across sources, while Space-Track's
|
||||||
|
future alphanumeric IDs (e.g. ``"A1234"``) are preserved verbatim.
|
||||||
|
Two conventions are supported (see :func:`tle_catalog_field`):
|
||||||
|
|
||||||
|
* **temp** -- the fallback; any ID that does not fit five digits is
|
||||||
|
mapped into the **90000-99999** pseudo-range via :func:`temp_tle_norad`.
|
||||||
|
* **ALPHA-5** -- Space-Track's scheme for catalog numbers 100000-339999, where
|
||||||
|
the leading two digits are replaced by a letter. It is numeric-only and
|
||||||
|
*not* backward compatible with five-digit parsers, so it is an opt-in
|
||||||
|
rendering, never the default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# Reserved pseudo-range for synthetic numeric NORAD substitutes.
|
||||||
|
TEMP_NORAD_MIN = 90000
|
||||||
|
TEMP_NORAD_MAX = 99999
|
||||||
|
_TEMP_NORAD_SPAN = TEMP_NORAD_MAX - TEMP_NORAD_MIN + 1
|
||||||
|
|
||||||
|
# ALPHA-5: letters encode the leading "10".."33"; I and O are skipped to avoid
|
||||||
|
# confusion with 1 and 0.
|
||||||
|
ALPHA5_LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
|
ALPHA5_MIN = 100000
|
||||||
|
ALPHA5_MAX = 339999
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_norad(value: object) -> str | None:
|
||||||
|
"""Return the canonical string form of a NORAD catalog ID."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode("ascii", errors="replace")
|
||||||
|
s = str(value).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s.isdigit():
|
||||||
|
return s.zfill(5)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def temp_tle_norad(value: object) -> int:
|
||||||
|
"""Return a deterministic 5-digit pseudo-NORAD in [90000, 99999]."""
|
||||||
|
raw = "" if value is None else str(value).strip()
|
||||||
|
digest = hashlib.blake2b(raw.encode("utf-8"), digest_size=4).digest()
|
||||||
|
n = int.from_bytes(digest, "big")
|
||||||
|
return TEMP_NORAD_MIN + (n % _TEMP_NORAD_SPAN)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: object) -> int | None:
|
||||||
|
"""Parse a NORAD value to a plain int, or None if it is not numeric."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
s = str(value).strip()
|
||||||
|
return int(s) if s.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def to_alpha5(value: int) -> str:
|
||||||
|
"""Encode a numeric catalog ID in [100000, 339999] as a 5-char ALPHA-5 field."""
|
||||||
|
if not ALPHA5_MIN <= value <= ALPHA5_MAX:
|
||||||
|
raise ValueError(f"{value} outside ALPHA-5 range [{ALPHA5_MIN}, {ALPHA5_MAX}]")
|
||||||
|
return f"{ALPHA5_LETTERS[value // 10000 - 10]}{value % 10000:04d}"
|
||||||
|
|
||||||
|
|
||||||
|
def from_alpha5(field: str) -> int | None:
|
||||||
|
"""Decode a 5-char ALPHA-5 field back to its integer catalog number.
|
||||||
|
|
||||||
|
Returns None when ``field`` is not a valid ALPHA-5 token.
|
||||||
|
"""
|
||||||
|
field = field.strip()
|
||||||
|
if len(field) != 5 or not field[1:].isdigit():
|
||||||
|
return None
|
||||||
|
idx = ALPHA5_LETTERS.find(field[0].upper())
|
||||||
|
if idx < 0:
|
||||||
|
return None
|
||||||
|
return (idx + 10) * 10000 + int(field[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def tle_catalog_field(value: object, *, alpha5: bool = False) -> str:
|
||||||
|
"""Return the 5-character catalog field for a TLE line.
|
||||||
|
|
||||||
|
Numeric IDs <= 99999 render as zero-padded digits. With ``alpha5=True``,
|
||||||
|
numeric IDs in [100000, 339999] use the ALPHA-5 letter prefix. Anything
|
||||||
|
that cannot fit five characters (alphanumeric future IDs, out-of-range
|
||||||
|
numbers) falls back to a deterministic 9xxxx pseudo-ID.
|
||||||
|
"""
|
||||||
|
n = _as_int(value)
|
||||||
|
if n is not None and 0 <= n <= 99999:
|
||||||
|
return f"{n:05d}"
|
||||||
|
if alpha5 and n is not None and ALPHA5_MIN <= n <= ALPHA5_MAX:
|
||||||
|
return to_alpha5(n)
|
||||||
|
return f"{temp_tle_norad(value):05d}"
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_norad_to_int(value: object) -> int:
|
||||||
|
"""Best-effort int form for TLE rendering (legacy temp-ID scheme)."""
|
||||||
|
n = _as_int(value)
|
||||||
|
return n if n is not None else temp_tle_norad(value)
|
||||||
411
odm/omm.py
Normal file
411
odm/omm.py
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
"""CCSDS 502.0-B-3 OMM, in both XML and Keyword-Value Notation.
|
||||||
|
|
||||||
|
The authoritative rendering of an element set: full precision, real catalog IDs,
|
||||||
|
and the metadata (originator, theory, frame, time system) that says what the
|
||||||
|
numbers mean. The XML path handles both the single-document form and the
|
||||||
|
``<ndm>`` multi-document wrapper Space-Track ships archives in.
|
||||||
|
|
||||||
|
XML and KVN deliberately share their key groups and value formatting, so the two
|
||||||
|
cannot drift into disagreeing about the same record.
|
||||||
|
|
||||||
|
Reference: CCSDS 502.0-B-3 -- Orbit Data Messages (Blue Book).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from .records import OmmRecord
|
||||||
|
|
||||||
|
CCSDS_OMM_VERSION = "3.0"
|
||||||
|
|
||||||
|
CCSDS_NDM_SCHEMA = (
|
||||||
|
"https://sanaregistry.org/r/ndmxml_unqualified/" "ndmxml-3.0.0-master-3.0.xsd"
|
||||||
|
)
|
||||||
|
|
||||||
|
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
|
||||||
|
OMM_DEFAULT_METADATA = {
|
||||||
|
"CENTER_NAME": "EARTH",
|
||||||
|
"REF_FRAME": "TEME",
|
||||||
|
"TIME_SYSTEM": "UTC",
|
||||||
|
"MEAN_ELEMENT_THEORY": "SGP4",
|
||||||
|
}
|
||||||
|
|
||||||
|
# CCSDS-mandatory fields that go into <meanElements> for every OMM, regardless
|
||||||
|
# of theory. SEMI_MAJOR_AXIS is the spec's preferred form; we emit MEAN_MOTION
|
||||||
|
# because SGP4-derived data carries it natively and Celestrak's GP JSON uses
|
||||||
|
# the same.
|
||||||
|
_OMM_MEAN_ELEMENT_KEYS = (
|
||||||
|
"EPOCH",
|
||||||
|
"MEAN_MOTION",
|
||||||
|
"ECCENTRICITY",
|
||||||
|
"INCLINATION",
|
||||||
|
"RA_OF_ASC_NODE",
|
||||||
|
"ARG_OF_PERICENTER",
|
||||||
|
"MEAN_ANOMALY",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Emitted as <tleParameters> when MEAN_ELEMENT_THEORY is an SGP* variant.
|
||||||
|
_OMM_TLE_PARAM_KEYS = (
|
||||||
|
"EPHEMERIS_TYPE",
|
||||||
|
"CLASSIFICATION_TYPE",
|
||||||
|
"NORAD_CAT_ID",
|
||||||
|
"ELEMENT_SET_NO",
|
||||||
|
"REV_AT_EPOCH",
|
||||||
|
"BSTAR",
|
||||||
|
"MEAN_MOTION_DOT",
|
||||||
|
"MEAN_MOTION_DDOT",
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_omm_xml(
|
||||||
|
records: Iterable["OmmRecord"],
|
||||||
|
*,
|
||||||
|
originator: str = "YKSA",
|
||||||
|
creation_date: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Render CCSDS 502.0-B-3 OMM XML wrapped in an ``<ndm>`` container."""
|
||||||
|
creation = creation_date or datetime.now(timezone.utc)
|
||||||
|
ndm = ET.Element(
|
||||||
|
"ndm",
|
||||||
|
attrib={
|
||||||
|
f"{{{XSI_NS}}}noNamespaceSchemaLocation": CCSDS_NDM_SCHEMA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# ElementTree only registers the xmlns:xsi prefix if we pre-register it.
|
||||||
|
ET.register_namespace("xsi", XSI_NS)
|
||||||
|
|
||||||
|
materialised = list(records)
|
||||||
|
for rec in materialised:
|
||||||
|
if rec.omm_xml:
|
||||||
|
try:
|
||||||
|
ndm.append(_parse_omm_chunk(rec.omm_xml))
|
||||||
|
continue
|
||||||
|
except ET.ParseError:
|
||||||
|
pass # fall through to synthesis
|
||||||
|
ndm.append(_synthesise_omm_document(rec, originator, creation))
|
||||||
|
|
||||||
|
if len(materialised) == 1 and not materialised[0].omm_xml:
|
||||||
|
only = ndm[0]
|
||||||
|
ET.indent(only, space=" ", level=0)
|
||||||
|
return ET.tostring(only, encoding="utf-8", xml_declaration=True).decode("utf-8")
|
||||||
|
|
||||||
|
ET.indent(ndm, space=" ", level=0)
|
||||||
|
return ET.tostring(ndm, encoding="utf-8", xml_declaration=True).decode("utf-8")
|
||||||
|
|
||||||
|
def omm_xml_to_records(payload: str | bytes) -> Iterator["OmmRecord"]:
|
||||||
|
"""Parse CCSDS OMM XML and yield one record per ``<omm>`` element.
|
||||||
|
|
||||||
|
Stray ``xsi:`` references without an ``xmlns:xsi`` declaration (as in
|
||||||
|
Space-Track samples) are auto-repaired before parsing.
|
||||||
|
"""
|
||||||
|
text = _xml_payload_to_str(payload)
|
||||||
|
root = _parse_omm_root(text)
|
||||||
|
|
||||||
|
if root.tag == "ndm":
|
||||||
|
omm_iter = list(root.findall("omm"))
|
||||||
|
elif root.tag == "omm":
|
||||||
|
omm_iter = [root]
|
||||||
|
else:
|
||||||
|
# Unwrap one level of unknown container -- some vendors emit <root>.
|
||||||
|
omm_iter = list(root.findall("omm"))
|
||||||
|
if not omm_iter:
|
||||||
|
raise ValueError(f"expected <ndm> or <omm> root, got <{root.tag}>")
|
||||||
|
|
||||||
|
for omm_el in omm_iter:
|
||||||
|
# Header is per-OMM in the multi-document case; share it across
|
||||||
|
# whatever segments the OMM contains (usually exactly one).
|
||||||
|
header_ctx = _read_omm_header(omm_el)
|
||||||
|
body = omm_el.find("body")
|
||||||
|
if body is None:
|
||||||
|
continue
|
||||||
|
segments = list(body.findall("segment"))
|
||||||
|
if not segments:
|
||||||
|
continue
|
||||||
|
for segment in segments:
|
||||||
|
rec = _segment_to_record(segment, header_ctx)
|
||||||
|
if len(segments) == 1:
|
||||||
|
rec.omm_xml = _serialise_omm_element(omm_el)
|
||||||
|
yield rec
|
||||||
|
|
||||||
|
def as_omm_kvn(
|
||||||
|
records: Iterable["OmmRecord"],
|
||||||
|
*,
|
||||||
|
originator: str = "YKSA",
|
||||||
|
creation_date: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Render CCSDS 502.0-B-3 OMM in Keyword-Value Notation (KVN).
|
||||||
|
|
||||||
|
Flat ``KEYWORD = value`` layout (as Space-Track's ``/format/kvn`` emits),
|
||||||
|
reusing the same key groups and value formatting as the XML path so the two
|
||||||
|
representations stay in lock-step. Multiple records are separated by a blank
|
||||||
|
line; each begins with its own ``CCSDS_OMM_VERS`` line.
|
||||||
|
"""
|
||||||
|
creation = creation_date or datetime.now(timezone.utc)
|
||||||
|
blocks: list[str] = []
|
||||||
|
for rec in records:
|
||||||
|
lines = [f"CCSDS_OMM_VERS = {CCSDS_OMM_VERSION}"]
|
||||||
|
hdr_date = rec.creation_date or creation
|
||||||
|
lines.append(f"CREATION_DATE = {format_ccsds_datetime(hdr_date)}")
|
||||||
|
lines.append(f"ORIGINATOR = {rec.originator or originator}")
|
||||||
|
|
||||||
|
lines.append(f"OBJECT_NAME = {rec.object_name or rec.omm.get('OBJECT_NAME', '')}")
|
||||||
|
lines.append(f"OBJECT_ID = {rec.object_id or rec.omm.get('OBJECT_ID', '')}")
|
||||||
|
lines.append(f"CENTER_NAME = {rec.center_name}")
|
||||||
|
lines.append(f"REF_FRAME = {rec.ref_frame}")
|
||||||
|
lines.append(f"TIME_SYSTEM = {rec.time_system}")
|
||||||
|
lines.append(f"MEAN_ELEMENT_THEORY = {rec.mean_element_theory}")
|
||||||
|
|
||||||
|
for key in _OMM_MEAN_ELEMENT_KEYS:
|
||||||
|
value = rec.omm.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
lines.append(f"{key} = {format_omm_value(key, value)}")
|
||||||
|
|
||||||
|
if rec.is_sgp_variant:
|
||||||
|
for key in _OMM_TLE_PARAM_KEYS:
|
||||||
|
value = rec.omm.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
lines.append(f"{key} = {format_omm_value(key, value)}")
|
||||||
|
|
||||||
|
blocks.append("\n".join(lines))
|
||||||
|
return "\n\n".join(blocks) + "\n"
|
||||||
|
|
||||||
|
def _synthesise_omm_document(
|
||||||
|
rec: OmmRecord,
|
||||||
|
originator: str,
|
||||||
|
creation_date: datetime,
|
||||||
|
) -> ET.Element:
|
||||||
|
"""Build one full ``<omm>`` element from an :class:`OmmRecord`.
|
||||||
|
|
||||||
|
Used when the record has no upstream XML to splice in (i.e. it came from
|
||||||
|
a TLE / JSON / internal source).
|
||||||
|
"""
|
||||||
|
omm = ET.Element(
|
||||||
|
"omm", attrib={"id": "CCSDS_OMM_VERS", "version": CCSDS_OMM_VERSION}
|
||||||
|
)
|
||||||
|
|
||||||
|
header = ET.SubElement(omm, "header")
|
||||||
|
hdr_date = rec.creation_date or creation_date
|
||||||
|
ET.SubElement(header, "CREATION_DATE").text = format_ccsds_datetime(hdr_date)
|
||||||
|
ET.SubElement(header, "ORIGINATOR").text = rec.originator or originator
|
||||||
|
|
||||||
|
body = ET.SubElement(omm, "body")
|
||||||
|
body.append(_omm_segment(rec))
|
||||||
|
return omm
|
||||||
|
|
||||||
|
def _omm_segment(rec: OmmRecord) -> ET.Element:
|
||||||
|
segment = ET.Element("segment")
|
||||||
|
metadata = ET.SubElement(segment, "metadata")
|
||||||
|
ET.SubElement(metadata, "OBJECT_NAME").text = rec.object_name or rec.omm.get(
|
||||||
|
"OBJECT_NAME", ""
|
||||||
|
)
|
||||||
|
ET.SubElement(metadata, "OBJECT_ID").text = rec.object_id or rec.omm.get(
|
||||||
|
"OBJECT_ID", ""
|
||||||
|
)
|
||||||
|
ET.SubElement(metadata, "CENTER_NAME").text = rec.center_name
|
||||||
|
ET.SubElement(metadata, "REF_FRAME").text = rec.ref_frame
|
||||||
|
ET.SubElement(metadata, "TIME_SYSTEM").text = rec.time_system
|
||||||
|
ET.SubElement(metadata, "MEAN_ELEMENT_THEORY").text = rec.mean_element_theory
|
||||||
|
|
||||||
|
data = ET.SubElement(segment, "data")
|
||||||
|
mean = ET.SubElement(data, "meanElements")
|
||||||
|
for key in _OMM_MEAN_ELEMENT_KEYS:
|
||||||
|
value = rec.omm.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
ET.SubElement(mean, key).text = format_omm_value(key, value)
|
||||||
|
|
||||||
|
if rec.is_sgp_variant and any(
|
||||||
|
rec.omm.get(k) is not None for k in _OMM_TLE_PARAM_KEYS
|
||||||
|
):
|
||||||
|
tle_params = ET.SubElement(data, "tleParameters")
|
||||||
|
for key in _OMM_TLE_PARAM_KEYS:
|
||||||
|
value = rec.omm.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
ET.SubElement(tle_params, key).text = format_omm_value(key, value)
|
||||||
|
|
||||||
|
return segment
|
||||||
|
|
||||||
|
def _segment_to_record(
|
||||||
|
segment: ET.Element,
|
||||||
|
header_ctx: dict,
|
||||||
|
) -> OmmRecord:
|
||||||
|
md = segment.find("metadata")
|
||||||
|
md_text = (
|
||||||
|
{child.tag: (child.text or "").strip() for child in md}
|
||||||
|
if md is not None
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
omm_dict: dict = {}
|
||||||
|
data = segment.find("data")
|
||||||
|
for block in () if data is None else ("meanElements", "tleParameters"):
|
||||||
|
node = data.find(block)
|
||||||
|
if node is None:
|
||||||
|
continue
|
||||||
|
for child in node:
|
||||||
|
omm_dict[child.tag] = coerce_omm_value(
|
||||||
|
child.tag, (child.text or "").strip()
|
||||||
|
)
|
||||||
|
# <userDefinedParameters> are ignored
|
||||||
|
|
||||||
|
omm_dict.setdefault("OBJECT_NAME", md_text.get("OBJECT_NAME", ""))
|
||||||
|
omm_dict.setdefault("OBJECT_ID", md_text.get("OBJECT_ID", ""))
|
||||||
|
|
||||||
|
return OmmRecord(
|
||||||
|
omm=omm_dict,
|
||||||
|
object_name=md_text.get("OBJECT_NAME", ""),
|
||||||
|
object_id=md_text.get("OBJECT_ID", ""),
|
||||||
|
originator=header_ctx.get("ORIGINATOR", "UNKNOWN"),
|
||||||
|
center_name=md_text.get("CENTER_NAME", OMM_DEFAULT_METADATA["CENTER_NAME"]),
|
||||||
|
ref_frame=md_text.get("REF_FRAME", OMM_DEFAULT_METADATA["REF_FRAME"]),
|
||||||
|
time_system=md_text.get("TIME_SYSTEM", OMM_DEFAULT_METADATA["TIME_SYSTEM"]),
|
||||||
|
mean_element_theory=md_text.get(
|
||||||
|
"MEAN_ELEMENT_THEORY",
|
||||||
|
OMM_DEFAULT_METADATA["MEAN_ELEMENT_THEORY"],
|
||||||
|
),
|
||||||
|
creation_date=header_ctx.get("CREATION_DATE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_omm_header(omm_el: ET.Element) -> dict:
|
||||||
|
"""Extract ORIGINATOR / CREATION_DATE / COMMENT from an OMM header."""
|
||||||
|
header = omm_el.find("header")
|
||||||
|
if header is None:
|
||||||
|
return {}
|
||||||
|
out: dict = {}
|
||||||
|
for child in header:
|
||||||
|
text = (child.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if child.tag == "CREATION_DATE":
|
||||||
|
out["CREATION_DATE"] = _parse_ccsds_datetime(text)
|
||||||
|
else:
|
||||||
|
out[child.tag] = text
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _parse_ccsds_datetime(text: str) -> datetime:
|
||||||
|
"""Lenient parser -- Space-Track ships microsecond precision, no offset."""
|
||||||
|
t = text.strip()
|
||||||
|
if t.endswith("Z"):
|
||||||
|
t = t[:-1]
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(t)
|
||||||
|
except ValueError:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
return (
|
||||||
|
dt.replace(tzinfo=timezone.utc)
|
||||||
|
if dt.tzinfo is None
|
||||||
|
else dt.astimezone(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_omm_epoch(value: object) -> datetime | None:
|
||||||
|
"""Parse an ``OMM["EPOCH"]`` value into an aware UTC datetime.
|
||||||
|
|
||||||
|
Accepts ``datetime`` instances, ISO-8601 strings (with or without Z), and
|
||||||
|
naive timestamps (assumed UTC). Returns ``None`` on anything that can't be parsed.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.endswith("Z"):
|
||||||
|
text = text[:-1] + "+00:00"
|
||||||
|
if "+" not in text and text.count("-") <= 2:
|
||||||
|
text = text + "+00:00"
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Real-world Space-Track archive XML uses xsi: prefix without declaring its
|
||||||
|
# namespace. We auto-repair before parsing rather than fail-hard.
|
||||||
|
_NS_DECL_INJECTION = re.compile(
|
||||||
|
r"(<(?:ndm|omm)\b)([^>]*xsi:[^>]*>)",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _xml_payload_to_str(payload: str | bytes) -> str:
|
||||||
|
if isinstance(payload, bytes):
|
||||||
|
return payload.decode("utf-8", errors="replace")
|
||||||
|
return str(payload)
|
||||||
|
|
||||||
|
def _parse_omm_root(text: str) -> ET.Element:
|
||||||
|
"""Parse a Space-Track-style payload, repairing missing xsi declarations."""
|
||||||
|
candidates = [text]
|
||||||
|
if "xmlns:xsi" not in text and "xsi:" in text:
|
||||||
|
candidates.append(_inject_xsi_namespace(text))
|
||||||
|
last_exc: ET.ParseError | None = None
|
||||||
|
for variant in candidates:
|
||||||
|
try:
|
||||||
|
return ET.fromstring(variant)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
assert last_exc is not None # pragma: no cover
|
||||||
|
raise last_exc
|
||||||
|
|
||||||
|
def _inject_xsi_namespace(text: str) -> str:
|
||||||
|
"""Add ``xmlns:xsi=...`` to the first ``<ndm>`` / ``<omm>`` tag that uses xsi:."""
|
||||||
|
|
||||||
|
def _patch(match: re.Match) -> str:
|
||||||
|
return f'{match.group(1)} xmlns:xsi="{XSI_NS}"{match.group(2)}'
|
||||||
|
|
||||||
|
return _NS_DECL_INJECTION.sub(_patch, text, count=1)
|
||||||
|
|
||||||
|
def _parse_omm_chunk(chunk: str) -> ET.Element:
|
||||||
|
"""Parse a single stored ``<omm>...</omm>`` element back into a tree."""
|
||||||
|
return _parse_omm_root(chunk)
|
||||||
|
|
||||||
|
def _serialise_omm_element(omm_el: ET.Element) -> str:
|
||||||
|
"""Render a single ``<omm>`` element back to its byte form for storage."""
|
||||||
|
return str(ET.tostring(omm_el, encoding="unicode"))
|
||||||
|
|
||||||
|
def format_omm_value(key: str, value) -> str:
|
||||||
|
if key == "EPOCH":
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return format_ccsds_datetime(value)
|
||||||
|
return str(value)
|
||||||
|
if key == "NORAD_CAT_ID":
|
||||||
|
return str(value)
|
||||||
|
if key in ("EPHEMERIS_TYPE", "ELEMENT_SET_NO", "REV_AT_EPOCH"):
|
||||||
|
return str(int(value))
|
||||||
|
if key == "CLASSIFICATION_TYPE":
|
||||||
|
return str(value)[:1] or "U"
|
||||||
|
if isinstance(value, float):
|
||||||
|
# Default float repr; OMM is forgiving about precision.
|
||||||
|
return repr(value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def coerce_omm_value(key: str, text: str):
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if key == "EPOCH":
|
||||||
|
return text
|
||||||
|
if key == "NORAD_CAT_ID":
|
||||||
|
return text
|
||||||
|
if key in ("EPHEMERIS_TYPE", "ELEMENT_SET_NO", "REV_AT_EPOCH"):
|
||||||
|
try:
|
||||||
|
return int(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if key == "CLASSIFICATION_TYPE":
|
||||||
|
return text[:1]
|
||||||
|
try:
|
||||||
|
return float(text)
|
||||||
|
except ValueError:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def format_ccsds_datetime(dt: datetime) -> str:
|
||||||
|
"""ISO 8601 without offset suffix, milliseconds, UTC (per CCSDS convention)."""
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return dt.isoformat(timespec="milliseconds")
|
||||||
81
odm/orbits.py
Normal file
81
odm/orbits.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Derive human-readable orbital quantities from an OMM dict.
|
||||||
|
|
||||||
|
Implements the standard SGP4-era conventions:
|
||||||
|
|
||||||
|
* mu_earth (GM) = 398 600.4418 km^3/s^2
|
||||||
|
* Earth's mean radius = 6 378.135 km
|
||||||
|
* Mean motion in rev/day -> orbital period in minutes
|
||||||
|
* Semi-major axis from Kepler's third law on the mean motion
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
MU_EARTH_KM3_S2 = 398_600.4418
|
||||||
|
R_EARTH_KM = 6_378.135
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OrbitalInfo:
|
||||||
|
"""Derived human-readable orbital quantities."""
|
||||||
|
|
||||||
|
mean_motion_rev_per_day: float
|
||||||
|
period_minutes: float
|
||||||
|
semi_major_axis_km: float
|
||||||
|
eccentricity: float
|
||||||
|
inclination_deg: float
|
||||||
|
raan_deg: float
|
||||||
|
arg_perigee_deg: float
|
||||||
|
mean_anomaly_deg: float
|
||||||
|
perigee_alt_km: float
|
||||||
|
apogee_alt_km: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def period_hms(self) -> str:
|
||||||
|
"""Return the orbital period as ``HH:MM:SS``."""
|
||||||
|
total_seconds = int(round(self.period_minutes * 60))
|
||||||
|
h, rem = divmod(total_seconds, 3600)
|
||||||
|
m, s = divmod(rem, 60)
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def derive(omm: dict) -> OrbitalInfo | None:
|
||||||
|
"""Build :class:`OrbitalInfo` from an OMM mean-element dict.
|
||||||
|
|
||||||
|
Returns ``None`` when the dict is missing one of the fields required to
|
||||||
|
derive both SMA and perigee/apogee.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mean_motion = float(omm["MEAN_MOTION"])
|
||||||
|
ecc = float(omm["ECCENTRICITY"])
|
||||||
|
inc = float(omm["INCLINATION"])
|
||||||
|
raan = float(omm["RA_OF_ASC_NODE"])
|
||||||
|
argp = float(omm["ARG_OF_PERICENTER"])
|
||||||
|
ma = float(omm["MEAN_ANOMALY"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if mean_motion <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
n_rad_per_sec = 2.0 * math.pi * mean_motion / 86_400.0
|
||||||
|
semi_major_axis_km = (MU_EARTH_KM3_S2 / (n_rad_per_sec ** 2)) ** (1.0 / 3.0)
|
||||||
|
|
||||||
|
period_minutes = 1440.0 / mean_motion
|
||||||
|
perigee_alt_km = semi_major_axis_km * (1.0 - ecc) - R_EARTH_KM
|
||||||
|
apogee_alt_km = semi_major_axis_km * (1.0 + ecc) - R_EARTH_KM
|
||||||
|
|
||||||
|
return OrbitalInfo(
|
||||||
|
mean_motion_rev_per_day=mean_motion,
|
||||||
|
period_minutes=period_minutes,
|
||||||
|
semi_major_axis_km=semi_major_axis_km,
|
||||||
|
eccentricity=ecc,
|
||||||
|
inclination_deg=inc,
|
||||||
|
raan_deg=raan,
|
||||||
|
arg_perigee_deg=argp,
|
||||||
|
mean_anomaly_deg=ma,
|
||||||
|
perigee_alt_km=perigee_alt_km,
|
||||||
|
apogee_alt_km=apogee_alt_km,
|
||||||
|
)
|
||||||
158
odm/propagator.py
Normal file
158
odm/propagator.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""The propagator seam: what a propagation backend must be able to do.
|
||||||
|
|
||||||
|
An interface, not an implementation. Everything in this package that needs a
|
||||||
|
state vector -- the element-set conversions, the OPM/OEM builders -- takes one of
|
||||||
|
these rather than reaching for a particular propagator, which is what lets the
|
||||||
|
same code run against the pure-Python SGP4 path and against a rigorous
|
||||||
|
flight-dynamics engine without knowing which it got.
|
||||||
|
|
||||||
|
Implementations live in the service that owns the deployment (see the
|
||||||
|
``yksa_orbital`` Django app). Nothing here does any orbital mechanics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .records import StateVector
|
||||||
|
|
||||||
|
|
||||||
|
class PropagationError(Exception):
|
||||||
|
"""Raised when a backend cannot produce a state for the requested time."""
|
||||||
|
|
||||||
|
|
||||||
|
class BackendBusy(PropagationError):
|
||||||
|
"""The backend refused the work because it is at capacity, not because the
|
||||||
|
request was wrong.
|
||||||
|
|
||||||
|
A subclass so existing ``except PropagationError`` handlers keep working,
|
||||||
|
but callers that can wait -- the Celery decay tasks -- should catch this
|
||||||
|
first and retry rather than recording a failed forecast. Nothing about the
|
||||||
|
request needs to change for it to succeed later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class PropagatorBackend(abc.ABC):
|
||||||
|
"""Turns a canonical OMM mean-element dict into state vectors."""
|
||||||
|
|
||||||
|
#: Short backend identifier stored on generated artifacts (e.g. ``"sgp4"``).
|
||||||
|
name: str = "abstract"
|
||||||
|
|
||||||
|
#: Reference frames this backend can express a Cartesian state in, offered
|
||||||
|
#: by the detail page's coordinate-system dropdown. The pure-Python backend
|
||||||
|
#: only knows TEME + a GMST Earth-fixed frame; Orekit adds rigorous frames.
|
||||||
|
display_frames: tuple[str, ...] = ("TEME",)
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def state_at(self, omm: dict, at: datetime | None = None) -> StateVector:
|
||||||
|
"""Return the state at ``at`` (aware UTC), or at the OMM epoch if ``None``."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def ephemeris(
|
||||||
|
self, omm: dict, start: datetime, stop: datetime, step_s: float,
|
||||||
|
) -> list[StateVector]:
|
||||||
|
"""Return states from ``start`` to ``stop`` inclusive, every ``step_s`` seconds."""
|
||||||
|
|
||||||
|
def transform(
|
||||||
|
self, states: list[StateVector], frame_in: str, frame_out: str,
|
||||||
|
) -> list[StateVector]:
|
||||||
|
"""Re-express Cartesian ``states`` from ``frame_in`` to ``frame_out``.
|
||||||
|
|
||||||
|
Returns new :class:`StateVector` instances whose ``r_km`` / ``v_kms``
|
||||||
|
are in ``frame_out`` (and whose ``frame`` label is ``frame_out``). The
|
||||||
|
default handles the identity case only; backends override to add real
|
||||||
|
frame math (SGP4: TEME↔ITRF via GMST; Orekit: the full rigorous set).
|
||||||
|
"""
|
||||||
|
if frame_in == frame_out:
|
||||||
|
return list(states)
|
||||||
|
raise PropagationError(
|
||||||
|
f"{self.name} backend cannot transform {frame_in!r}->{frame_out!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def fit_tle(
|
||||||
|
self, states: list[StateVector], frame: str, template_omm: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Fit a TLE to a run of Cartesian states (an OEM ephemeris).
|
||||||
|
|
||||||
|
``template_omm`` seeds the fit with the object's identity (NORAD id,
|
||||||
|
designator). Returns ``{"line1", "line2", ...}``. Only backends with a
|
||||||
|
real orbit-fit (Orekit) implement this; the default refuses.
|
||||||
|
"""
|
||||||
|
raise PropagationError(f"{self.name} backend cannot fit TLEs from ephemerides")
|
||||||
|
|
||||||
|
def decay(self, omm: dict, spacecraft: dict, **options) -> dict:
|
||||||
|
"""Propagate to re-entry under drag and return the decay trajectory.
|
||||||
|
|
||||||
|
``spacecraft`` carries the drag/SRP properties resolved by
|
||||||
|
:mod:`yksa_tle.predictions.ballistic` (a ballistic coefficient, or mass +
|
||||||
|
areas + coefficients). ``options`` are forwarded to the engine
|
||||||
|
(``decay_altitude_km``, ``strengths``, ``max_years``, ...).
|
||||||
|
|
||||||
|
Returns ``{"runs": [{"strength", "decayed", "decay_epoch", "decay_days",
|
||||||
|
"points": [...]}, ...], ...}``. Only backends with a numerical
|
||||||
|
propagator and an atmosphere model (Orekit) can answer this; SGP4's
|
||||||
|
analytical theory has no drag integration to run, so the default
|
||||||
|
refuses rather than returning a number nobody should trust.
|
||||||
|
"""
|
||||||
|
raise PropagationError(
|
||||||
|
f"{self.name} backend cannot predict decay; use the orekit backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
def fit_drag(self, elements: list[dict], **options) -> dict:
|
||||||
|
"""Fit a ballistic coefficient from an observed element history.
|
||||||
|
|
||||||
|
``elements`` is a chronological list of stored OMM dicts. Returns
|
||||||
|
``{"ballistic_coefficient", "adot_m_per_s", "r_squared", ...}``.
|
||||||
|
|
||||||
|
Needs an atmosphere model to convert the observed decay rate into a
|
||||||
|
coefficient, so like :meth:`decay` this is Orekit-only.
|
||||||
|
"""
|
||||||
|
raise PropagationError(
|
||||||
|
f"{self.name} backend cannot fit drag; use the orekit backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
def space_weather(
|
||||||
|
self, start: datetime, stop: datetime, **options,
|
||||||
|
) -> dict:
|
||||||
|
"""Observed daily F10.7 / Ap between two dates.
|
||||||
|
|
||||||
|
Returns ``{"times": [...], "f107": [...], "ap": [...]}``. Reads a space-
|
||||||
|
weather provider, which lives in the sidecar, so like :meth:`fit_drag`
|
||||||
|
this is Orekit-only.
|
||||||
|
"""
|
||||||
|
raise PropagationError(
|
||||||
|
f"{self.name} backend cannot read space weather; use the orekit backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
def state_in_frames(
|
||||||
|
self, omm: dict, at: datetime | None = None, frames=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return the state expressed in one or more reference frames.
|
||||||
|
|
||||||
|
Shape::
|
||||||
|
|
||||||
|
{"epoch", "element_epoch", "frames": [...],
|
||||||
|
"states": {frame: {"r_km": [...], "v_kms": [...]}}, "geodetic": [...]}
|
||||||
|
|
||||||
|
The default implementation derives everything from :meth:`state_at`
|
||||||
|
(TEME, plus an Earth-fixed ``ITRF`` view when the backend fills ECEF).
|
||||||
|
Backends with real frame transforms (Orekit) override this.
|
||||||
|
"""
|
||||||
|
sv = self.state_at(omm, at)
|
||||||
|
available: dict[str, dict] = {
|
||||||
|
"TEME": {"r_km": list(sv.r_km), "v_kms": list(sv.v_kms)},
|
||||||
|
}
|
||||||
|
if sv.ecef_km is not None:
|
||||||
|
available["ITRF"] = {
|
||||||
|
"r_km": list(sv.ecef_km),
|
||||||
|
"v_kms": list(sv.ecef_v_kms) if sv.ecef_v_kms is not None else None,
|
||||||
|
}
|
||||||
|
requested = list(frames) if frames else list(self.display_frames)
|
||||||
|
states = {f: available[f] for f in requested if f in available}
|
||||||
|
return {
|
||||||
|
"epoch": sv.epoch.isoformat() if sv.epoch else None,
|
||||||
|
"element_epoch": sv.element_epoch.isoformat() if sv.element_epoch else None,
|
||||||
|
"frames": list(states),
|
||||||
|
"states": states,
|
||||||
|
"geodetic": list(sv.geodetic) if sv.geodetic else None,
|
||||||
|
}
|
||||||
92
odm/records.py
Normal file
92
odm/records.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""The in-memory representations every format reads into and writes out of.
|
||||||
|
|
||||||
|
Nothing here knows about a file, a wire protocol or a database row. A reader
|
||||||
|
produces one of these; a writer consumes one. That is the whole point of the
|
||||||
|
package: the OMM is the record, and TLE text, OMM XML, KVN and GP CSV are four
|
||||||
|
renderings of it -- so a new rendering is one writer, not another parallel idea
|
||||||
|
of what an element set is.
|
||||||
|
|
||||||
|
Deliberately not the service's storage model. ODMS holds these rows in Django,
|
||||||
|
`track` holds them somewhere else, and the sidecar holds none at all; the format
|
||||||
|
code has to work for all three, so it works on records and the service adapts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OmmRecord:
|
||||||
|
"""One element set, with the metadata that says what its numbers mean.
|
||||||
|
|
||||||
|
``omm`` is the canonical mean-element dict under CCSDS field names
|
||||||
|
(``EPOCH``, ``MEAN_MOTION``, ``ECCENTRICITY``, ... plus the SGP-only
|
||||||
|
``tleParameters`` fields). Everything else is provenance: who produced it,
|
||||||
|
under which theory, in which frame and time system -- which is why an OMM
|
||||||
|
can be read back exactly and a TLE cannot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
omm: dict
|
||||||
|
object_name: str = ""
|
||||||
|
object_id: str = ""
|
||||||
|
originator: str = "UNKNOWN"
|
||||||
|
center_name: str = "EARTH"
|
||||||
|
ref_frame: str = "TEME"
|
||||||
|
time_system: str = "UTC"
|
||||||
|
mean_element_theory: str = "SGP4"
|
||||||
|
creation_date: datetime | None = None
|
||||||
|
|
||||||
|
omm_xml: str = ""
|
||||||
|
"""Original ``<omm>...</omm>`` chunk from upstream, if available.
|
||||||
|
|
||||||
|
Kept so a record ingested as XML is republished byte-identically rather than
|
||||||
|
re-synthesised: an upstream document is evidence, and re-rendering it loses
|
||||||
|
whatever the producer said that we do not model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
line1: str = ""
|
||||||
|
line2: str = ""
|
||||||
|
"""The TLE lines as issued, when the record came with them.
|
||||||
|
|
||||||
|
A rendering, not data -- but the *issued* one, so it is preserved rather
|
||||||
|
than regenerated. Regeneration is only correct when it produces exactly
|
||||||
|
these, and for alphanumeric catalog IDs it cannot (see :mod:`odm.norad`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_sgp_variant(self) -> bool:
|
||||||
|
return self.mean_element_theory.upper().startswith(("SGP", "SDP"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedTLE:
|
||||||
|
"""In-memory representation produced by :func:`odm.tle.parse_tle`."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
line1: str
|
||||||
|
line2: str
|
||||||
|
omm: dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StateVector:
|
||||||
|
"""A single propagated state, frame-agnostic at the seam.
|
||||||
|
|
||||||
|
``r_km`` / ``v_kms`` are the inertial (TEME for SGP4) position and velocity
|
||||||
|
and are always populated. ``ecef_km`` / ``ecef_v_kms`` and ``geodetic``
|
||||||
|
(lat_deg, lon_deg, alt_km) are Earth-fixed convenience views a backend may
|
||||||
|
fill; conversions that need them should tolerate ``None`` from backends that
|
||||||
|
do not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
epoch: datetime
|
||||||
|
frame: str
|
||||||
|
r_km: tuple[float, float, float]
|
||||||
|
v_kms: tuple[float, float, float]
|
||||||
|
ecef_km: tuple[float, float, float] | None = None
|
||||||
|
ecef_v_kms: tuple[float, float, float] | None = None
|
||||||
|
geodetic: tuple[float, float, float] | None = None
|
||||||
|
element_epoch: datetime | None = None
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
224
odm/registry.py
Normal file
224
odm/registry.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
"""Every output format in one table, keyed by the name the API uses.
|
||||||
|
|
||||||
|
This is the answer to "what formats does ODMS emit?" -- a question that used to
|
||||||
|
need reading a Django renderer module, a schema Literal, an element-set catalogue
|
||||||
|
and a controller. A format is a key, the HTTP facts about it (content type,
|
||||||
|
filename, encoding), and a writer over :class:`~odms.records.OmmRecord`. Adding
|
||||||
|
one is a `Format` entry; nothing else has to learn about it.
|
||||||
|
|
||||||
|
Writers all take the same arguments so a caller never has to know which kind it
|
||||||
|
got::
|
||||||
|
|
||||||
|
write(records, *, backend=None, at=None) -> str
|
||||||
|
|
||||||
|
``backend`` and ``at`` matter only to the element-set formats, which propagate
|
||||||
|
before converting. The rest ignore them.
|
||||||
|
|
||||||
|
``json`` is registered but has no writer: it is the API's own serialisation of
|
||||||
|
its storage rows, which is a service concern rather than a format. It appears
|
||||||
|
here so the format list is complete and validation has one source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
from . import element_sets
|
||||||
|
from .gp import as_celestrak_csv
|
||||||
|
from .omm import as_omm_kvn, as_omm_xml, parse_omm_epoch
|
||||||
|
from .records import OmmRecord
|
||||||
|
from .tle import as_plaintext, indexed_name, tle_from_gp
|
||||||
|
|
||||||
|
DEFAULT_ORIGINATOR = "YKSA"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Format:
|
||||||
|
"""One addressable output format."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
content_type: str
|
||||||
|
filename: str
|
||||||
|
encoding: str = "utf-8"
|
||||||
|
#: ``None`` for formats the service serialises itself (``json``).
|
||||||
|
write: Callable[..., str] | None = None
|
||||||
|
#: True when the writer propagates, and so needs a ``backend``.
|
||||||
|
needs_backend: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# --- TLE text ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def labelled_rows(records: Iterable[OmmRecord], *,
|
||||||
|
alpha5: bool = False) -> list[tuple[str, str, str]]:
|
||||||
|
"""``(name, line1, line2)`` per record, with duplicates numbered.
|
||||||
|
|
||||||
|
A range query returns many elements for one object, and three-line text has
|
||||||
|
nowhere to put an epoch -- so identical name lines would make the records
|
||||||
|
indistinguishable in a file. Repeats get a ``[NN]`` suffix; a single record
|
||||||
|
per object is left alone.
|
||||||
|
"""
|
||||||
|
records = list(records)
|
||||||
|
totals: dict[str, int] = {}
|
||||||
|
for record in records:
|
||||||
|
key = _identity(record)
|
||||||
|
totals[key] = totals.get(key, 0) + 1
|
||||||
|
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
rows: list[tuple[str, str, str]] = []
|
||||||
|
for record in records:
|
||||||
|
key = _identity(record)
|
||||||
|
seen[key] = seen.get(key, 0) + 1
|
||||||
|
total = totals[key]
|
||||||
|
name = indexed_name(
|
||||||
|
record.object_name, index=seen[key] if total > 1 else None, total=total,
|
||||||
|
)
|
||||||
|
line1, line2 = tle_lines(record, alpha5=alpha5)
|
||||||
|
rows.append((name, line1, line2))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def tle_lines(record: OmmRecord, *, alpha5: bool = False) -> tuple[str, str]:
|
||||||
|
"""The record's TLE lines: as issued, or re-rendered from the OMM.
|
||||||
|
|
||||||
|
ALPHA-5 always re-renders. The issued lines use the temp-ID scheme, so a
|
||||||
|
numeric catalog number at or above 100000 only carries its letter prefix if
|
||||||
|
we build the field ourselves. Falls back to the issued lines when the OMM
|
||||||
|
cannot be rendered -- which is the normal case for an object whose ID has no
|
||||||
|
five-character form at all.
|
||||||
|
"""
|
||||||
|
if alpha5 and (record.omm or {}).get("NORAD_CAT_ID") is not None:
|
||||||
|
try:
|
||||||
|
_, line1, line2 = tle_from_gp(record.omm, alpha5=True)
|
||||||
|
return line1, line2
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if record.line1 and record.line2:
|
||||||
|
return record.line1, record.line2
|
||||||
|
_, line1, line2 = tle_from_gp(record.omm)
|
||||||
|
return line1, line2
|
||||||
|
|
||||||
|
|
||||||
|
def _identity(record: OmmRecord) -> str:
|
||||||
|
return record.object_id or record.object_name or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _write_tle(records, **_kw) -> str:
|
||||||
|
return "".join(as_plaintext(*row) for row in labelled_rows(records))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_tle_alpha5(records, **_kw) -> str:
|
||||||
|
return "".join(as_plaintext(*row) for row in labelled_rows(records, alpha5=True))
|
||||||
|
|
||||||
|
|
||||||
|
# --- OMM and GP -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _write_omm_xml(records, **_kw) -> str:
|
||||||
|
return as_omm_xml(records, originator=DEFAULT_ORIGINATOR)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_omm_kvn(records, **_kw) -> str:
|
||||||
|
return as_omm_kvn(records, originator=DEFAULT_ORIGINATOR)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_gp_csv(records, **_kw) -> str:
|
||||||
|
return as_celestrak_csv(records)
|
||||||
|
|
||||||
|
|
||||||
|
# --- SANA element sets ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _element_set_writer(set_key: str) -> Callable[..., str]:
|
||||||
|
"""A CSV writer for one SANA element set.
|
||||||
|
|
||||||
|
Columns are ``OBJECT_NAME, NORAD_CAT_ID, EPOCH, FRAME`` plus the set's own
|
||||||
|
components. Each record is propagated to ``at`` (or its own epoch) and
|
||||||
|
converted. A record that cannot be propagated is skipped rather than failing
|
||||||
|
the batch: one manoeuvring object must not cost a caller the other 199.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def write(records, *, backend=None, at: datetime | None = None, **_kw) -> str:
|
||||||
|
eset = element_sets.get_set(set_key)
|
||||||
|
component_keys = [c.key for c in eset.components]
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf, lineterminator="\n")
|
||||||
|
writer.writerow(
|
||||||
|
["OBJECT_NAME", "NORAD_CAT_ID", "EPOCH", "FRAME", *component_keys]
|
||||||
|
)
|
||||||
|
for record in records:
|
||||||
|
try:
|
||||||
|
result = element_sets.compute_element_set(
|
||||||
|
set_key, record.omm, backend, at=at,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 -- one bad record must not fail the batch
|
||||||
|
continue
|
||||||
|
omm = record.omm or {}
|
||||||
|
epoch = result["epoch"] or parse_omm_epoch(omm.get("EPOCH"))
|
||||||
|
values = result["values"]
|
||||||
|
writer.writerow([
|
||||||
|
record.object_name or omm.get("OBJECT_NAME") or "",
|
||||||
|
omm.get("NORAD_CAT_ID") or "",
|
||||||
|
epoch.isoformat() if epoch else "",
|
||||||
|
result["frame"],
|
||||||
|
*[_number(values.get(k)) for k in component_keys],
|
||||||
|
])
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
return write
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value) -> str:
|
||||||
|
return "" if value is None else repr(float(value))
|
||||||
|
|
||||||
|
|
||||||
|
# --- the table --------------------------------------------------------------
|
||||||
|
|
||||||
|
FORMATS: dict[str, Format] = {
|
||||||
|
"json": Format(
|
||||||
|
key="json", content_type="application/json", filename="yksa-tle.json",
|
||||||
|
),
|
||||||
|
"tle": Format(
|
||||||
|
key="tle", content_type="text/plain", filename="yksa-tle.tle",
|
||||||
|
encoding="ascii", write=_write_tle,
|
||||||
|
),
|
||||||
|
"tle_alpha5": Format(
|
||||||
|
key="tle_alpha5", content_type="text/plain", filename="yksa-tle-alpha5.tle",
|
||||||
|
encoding="ascii", write=_write_tle_alpha5,
|
||||||
|
),
|
||||||
|
"omm_xml": Format(
|
||||||
|
key="omm_xml", content_type="application/xml", filename="yksa-omm.xml",
|
||||||
|
write=_write_omm_xml,
|
||||||
|
),
|
||||||
|
"kvn": Format(
|
||||||
|
key="kvn", content_type="text/plain", filename="yksa-omm.kvn",
|
||||||
|
write=_write_omm_kvn,
|
||||||
|
),
|
||||||
|
"csv": Format(
|
||||||
|
key="csv", content_type="text/csv", filename="yksa-gp.csv",
|
||||||
|
write=_write_gp_csv,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Registered from the element-set catalogue itself, so the two cannot drift into
|
||||||
|
# offering different sets.
|
||||||
|
for _key in element_sets.API_ELEMENT_SET_KEYS:
|
||||||
|
FORMATS[_key] = Format(
|
||||||
|
key=_key, content_type="text/csv", filename=f"yksa-{_key}.csv",
|
||||||
|
write=_element_set_writer(_key), needs_backend=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get(key: str) -> Format:
|
||||||
|
try:
|
||||||
|
return FORMATS[key]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"unknown format {key!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def keys() -> tuple[str, ...]:
|
||||||
|
return tuple(FORMATS)
|
||||||
232
odm/tle.py
Normal file
232
odm/tle.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
"""TLE text: reading three lines in, rendering three lines out.
|
||||||
|
|
||||||
|
The TLE is a *derived* rendering, not the record. Line 1 has narrow fixed-width
|
||||||
|
fields that lose precision on MEAN_MOTION_DOT, MEAN_MOTION_DDOT and BSTAR
|
||||||
|
against the OMM they came from, and its five-character catalog field cannot hold
|
||||||
|
a modern alphanumeric ID at all (see :mod:`odm.norad`). Round-tripping through
|
||||||
|
here is lossy by construction; round-tripping through :mod:`odm.omm` is not.
|
||||||
|
|
||||||
|
Reference for the line layout: https://celestrak.org/columns/v04n03/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from sgp4.api import Satrec
|
||||||
|
|
||||||
|
from .norad import tle_catalog_field
|
||||||
|
from .records import ParsedTLE
|
||||||
|
|
||||||
|
_SGP4_NDOT_TO_CELESTRAK = (1440.0**2) / (2.0 * math.pi)
|
||||||
|
|
||||||
|
_SGP4_NDDOT_TO_CELESTRAK = (1440.0**3) / (2.0 * math.pi)
|
||||||
|
|
||||||
|
def parse_tle(line0: str, line1: str, line2: str) -> ParsedTLE:
|
||||||
|
"""Parse a 3-line TLE block and return a :class:`ParsedTLE` with OMM dict."""
|
||||||
|
line0 = (line0 or "").strip().lstrip("0 ").strip()
|
||||||
|
line1 = line1.rstrip()
|
||||||
|
line2 = line2.rstrip()
|
||||||
|
if not (line1.startswith("1 ") and line2.startswith("2 ")):
|
||||||
|
raise ValueError("TLE lines must start with '1 ' and '2 '")
|
||||||
|
if len(line1) < 69 or len(line2) < 69:
|
||||||
|
raise ValueError("TLE lines must be 69 characters long")
|
||||||
|
|
||||||
|
sat = Satrec.twoline2rv(line1, line2)
|
||||||
|
epoch = _epoch_from_satrec(sat)
|
||||||
|
omm_dict: dict = {
|
||||||
|
"OBJECT_NAME": line0,
|
||||||
|
"OBJECT_ID": _intl_designator(line1),
|
||||||
|
"EPOCH": epoch.isoformat().replace("+00:00", ""),
|
||||||
|
"MEAN_MOTION": _no_kozai_to_rev_per_day(sat.no_kozai),
|
||||||
|
"ECCENTRICITY": sat.ecco,
|
||||||
|
"INCLINATION": _rad2deg(sat.inclo),
|
||||||
|
"RA_OF_ASC_NODE": _rad2deg(sat.nodeo),
|
||||||
|
"ARG_OF_PERICENTER": _rad2deg(sat.argpo),
|
||||||
|
"MEAN_ANOMALY": _rad2deg(sat.mo),
|
||||||
|
"EPHEMERIS_TYPE": int(line1[62]) if line1[62].isdigit() else 0,
|
||||||
|
"CLASSIFICATION_TYPE": line1[7] or "U",
|
||||||
|
"NORAD_CAT_ID": sat.satnum,
|
||||||
|
"ELEMENT_SET_NO": int(line1[64:68].strip() or 0),
|
||||||
|
"REV_AT_EPOCH": int(line2[63:68].strip() or 0),
|
||||||
|
"BSTAR": sat.bstar,
|
||||||
|
"MEAN_MOTION_DOT": sat.ndot * _SGP4_NDOT_TO_CELESTRAK,
|
||||||
|
"MEAN_MOTION_DDOT": sat.nddot * _SGP4_NDDOT_TO_CELESTRAK,
|
||||||
|
}
|
||||||
|
return ParsedTLE(name=line0, line1=line1, line2=line2, omm=omm_dict)
|
||||||
|
|
||||||
|
def tle_from_gp(gp: dict, *, alpha5: bool = False) -> tuple[str, str, str]:
|
||||||
|
"""Render a 3-line TLE block from a GP JSON dict.
|
||||||
|
|
||||||
|
The 5-char catalog field follows :func:`tle_catalog_field`: numeric IDs
|
||||||
|
render as digits, ``alpha5=True`` enables the ALPHA-5 letter prefix for
|
||||||
|
100000-339999, and anything else falls back to a deterministic 9xxxx
|
||||||
|
pseudo-ID (backwards compatible with Orbitron and similar tools).
|
||||||
|
"""
|
||||||
|
cat = tle_catalog_field(gp["NORAD_CAT_ID"], alpha5=alpha5)
|
||||||
|
classification = (gp.get("CLASSIFICATION_TYPE") or "U")[:1]
|
||||||
|
intl = (gp.get("OBJECT_ID") or "").strip()
|
||||||
|
intl_l1 = _format_intl_for_tle(intl)
|
||||||
|
epoch_yy, epoch_day = _epoch_to_yyddd(gp["EPOCH"])
|
||||||
|
n_dot = float(gp.get("MEAN_MOTION_DOT") or 0.0)
|
||||||
|
n_ddot = float(gp.get("MEAN_MOTION_DDOT") or 0.0)
|
||||||
|
bstar = float(gp.get("BSTAR") or 0.0)
|
||||||
|
ephem_type = int(gp.get("EPHEMERIS_TYPE") or 0)
|
||||||
|
elset = int(gp.get("ELEMENT_SET_NO") or 0)
|
||||||
|
|
||||||
|
line1_body = (
|
||||||
|
f"1 {cat}{classification} {intl_l1:<8s} "
|
||||||
|
f"{epoch_yy:02d}{epoch_day:012.8f} "
|
||||||
|
f"{_format_signed_decimal(n_dot)} "
|
||||||
|
f"{_format_assumed_exponent(n_ddot)} "
|
||||||
|
f"{_format_assumed_exponent(bstar)} "
|
||||||
|
f"{ephem_type:1d} {elset:4d}"
|
||||||
|
)
|
||||||
|
line1 = _with_checksum(line1_body)
|
||||||
|
|
||||||
|
incl = float(gp["INCLINATION"])
|
||||||
|
node = float(gp["RA_OF_ASC_NODE"])
|
||||||
|
ecc = float(gp["ECCENTRICITY"])
|
||||||
|
argp = float(gp["ARG_OF_PERICENTER"])
|
||||||
|
ma = float(gp["MEAN_ANOMALY"])
|
||||||
|
mm = float(gp["MEAN_MOTION"])
|
||||||
|
rev = int(gp.get("REV_AT_EPOCH") or 0)
|
||||||
|
|
||||||
|
line2_body = (
|
||||||
|
f"2 {cat} "
|
||||||
|
f"{incl:8.4f} {node:8.4f} "
|
||||||
|
f"{int(round(ecc * 1e7)):07d} "
|
||||||
|
f"{argp:8.4f} {ma:8.4f} "
|
||||||
|
f"{mm:11.8f}{rev:5d}"
|
||||||
|
)
|
||||||
|
line2 = _with_checksum(line2_body)
|
||||||
|
|
||||||
|
name = (gp.get("OBJECT_NAME") or "").strip()
|
||||||
|
return name, line1, line2
|
||||||
|
|
||||||
|
def as_plaintext(name: str, line1: str, line2: str) -> str:
|
||||||
|
"""Three-line block with a trailing newline."""
|
||||||
|
return f"{name}\n{line1}\n{line2}\n"
|
||||||
|
|
||||||
|
def as_txt_txt(records: Iterable[tuple[str, str, str]]) -> str:
|
||||||
|
"""txt-style multi-satellite text file (CRLF line endings, name pad 24)."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for name, line1, line2 in records:
|
||||||
|
padded = (name or "")[:24].ljust(24)
|
||||||
|
parts.append(padded)
|
||||||
|
parts.append(line1)
|
||||||
|
parts.append(line2)
|
||||||
|
return "\r\n".join(parts) + "\r\n"
|
||||||
|
|
||||||
|
def indexed_name(base: str, *, index: int | None, total: int) -> str:
|
||||||
|
"""Compose a TLE-name-line label for batch exports.
|
||||||
|
|
||||||
|
When *total* > 1 (the same satellite appears more than once in the
|
||||||
|
output set), a ``[NN]`` suffix is appended so consumers can tell the
|
||||||
|
records apart in plaintext / .txt downloads.
|
||||||
|
"""
|
||||||
|
base = (base or "").strip()
|
||||||
|
if not base:
|
||||||
|
base = "UNKNOWN"
|
||||||
|
if total <= 1 or index is None:
|
||||||
|
return base[:24]
|
||||||
|
suffix = f" [{index:02d}]"
|
||||||
|
# Reserve room for the suffix; clip the base if needed so the total
|
||||||
|
# never exceeds 24 chars.
|
||||||
|
budget = 24 - len(suffix)
|
||||||
|
return f"{base[:budget].rstrip()}{suffix}"
|
||||||
|
|
||||||
|
def _epoch_from_satrec(sat: Satrec) -> datetime:
|
||||||
|
"""Reconstruct a UTC datetime from a Satrec's epochyr/epochdays."""
|
||||||
|
year = sat.epochyr
|
||||||
|
if year < 57:
|
||||||
|
year += 2000
|
||||||
|
elif year < 100:
|
||||||
|
year += 1900
|
||||||
|
day_of_year = sat.epochdays # 1-based, fractional
|
||||||
|
return datetime(year, 1, 1, tzinfo=timezone.utc) + timedelta(days=day_of_year - 1)
|
||||||
|
|
||||||
|
def _intl_designator(line1: str) -> str:
|
||||||
|
raw = line1[9:17].rstrip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
yy = raw[:2]
|
||||||
|
rest = raw[2:].strip()
|
||||||
|
if not yy.isdigit():
|
||||||
|
return raw
|
||||||
|
year = int(yy)
|
||||||
|
century = 2000 if year < 57 else 1900
|
||||||
|
return f"{century + year:04d}-{rest}"
|
||||||
|
|
||||||
|
def _format_intl_for_tle(intl: str) -> str:
|
||||||
|
"""Convert "2024-001A" back to TLE line-1 format "24001A"."""
|
||||||
|
if not intl:
|
||||||
|
return ""
|
||||||
|
if "-" in intl:
|
||||||
|
year, rest = intl.split("-", 1)
|
||||||
|
try:
|
||||||
|
return f"{int(year) % 100:02d}{rest}"
|
||||||
|
except ValueError:
|
||||||
|
return intl
|
||||||
|
return intl
|
||||||
|
|
||||||
|
def _epoch_to_yyddd(epoch_iso: str) -> tuple[int, float]:
|
||||||
|
iso = epoch_iso
|
||||||
|
if iso.endswith("Z"):
|
||||||
|
iso = iso[:-1] + "+00:00"
|
||||||
|
if "+" not in iso and iso.count("-") <= 2:
|
||||||
|
iso = iso + "+00:00"
|
||||||
|
dt = datetime.fromisoformat(iso)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
dt_utc = dt.astimezone(timezone.utc)
|
||||||
|
start = datetime(dt_utc.year, 1, 1, tzinfo=timezone.utc)
|
||||||
|
day_of_year = (dt_utc - start).total_seconds() / 86400.0 + 1.0
|
||||||
|
return dt_utc.year % 100, day_of_year
|
||||||
|
|
||||||
|
def _rad2deg(value: float) -> float:
|
||||||
|
return math.degrees(value)
|
||||||
|
|
||||||
|
def _no_kozai_to_rev_per_day(no_kozai: float) -> float:
|
||||||
|
"""Convert sgp4's stored mean motion (rad/min) to revolutions/day."""
|
||||||
|
return no_kozai * 1440.0 / (2.0 * math.pi)
|
||||||
|
|
||||||
|
def _format_signed_decimal(value: float) -> str:
|
||||||
|
"""Format like '-.00002182' / ' .00002182' (10 chars, no leading zero)."""
|
||||||
|
sign = "-" if value < 0 else " "
|
||||||
|
body = f"{abs(value):.8f}" # "0.00002182"
|
||||||
|
# Drop the leading "0" to get ".00002182".
|
||||||
|
if body.startswith("0."):
|
||||||
|
body = body[1:]
|
||||||
|
return f"{sign}{body}"
|
||||||
|
|
||||||
|
def _format_assumed_exponent(value: float) -> str:
|
||||||
|
"""Format like ' 00000-0' / '-12345-3' (8 chars, assumed leading decimal).
|
||||||
|
|
||||||
|
Encodes value = 0.MANTISSA * 10**EXP. Mantissa is 5 digits.
|
||||||
|
"""
|
||||||
|
if value == 0.0:
|
||||||
|
return " 00000-0"
|
||||||
|
sign = "-" if value < 0 else " "
|
||||||
|
v = abs(value)
|
||||||
|
exp = int(math.floor(math.log10(v))) + 1 # mantissa in [0.1, 1.0)
|
||||||
|
mantissa = v / (10.0**exp)
|
||||||
|
mant_digits = int(round(mantissa * 1e5))
|
||||||
|
if mant_digits >= 100000:
|
||||||
|
mant_digits //= 10
|
||||||
|
exp += 1
|
||||||
|
exp_sign = "-" if exp < 0 else "+"
|
||||||
|
return f"{sign}{mant_digits:05d}{exp_sign}{abs(exp):d}"
|
||||||
|
|
||||||
|
def _with_checksum(body: str) -> str:
|
||||||
|
"""Pad to 68 chars and append the standard mod-10 checksum digit."""
|
||||||
|
body = body[:68].ljust(68)
|
||||||
|
total = 0
|
||||||
|
for ch in body:
|
||||||
|
if ch.isdigit():
|
||||||
|
total += int(ch)
|
||||||
|
elif ch == "-":
|
||||||
|
total += 1
|
||||||
|
return f"{body}{total % 10}"
|
||||||
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "odm"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Orbit Data Messages: read, write and convert orbital element sets"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
# sgp4 only, and only for reading TLE text (Satrec does the unpacking) and for
|
||||||
|
# the mean-element derivations. No Django, no HTTP, no framework: every service
|
||||||
|
# that touches ODMS data has to be able to install this.
|
||||||
|
dependencies = ["sgp4>=2.20"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["odm*"]
|
||||||
98
tests/test_alpha5.py
Normal file
98
tests/test_alpha5.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Tests for the ALPHA-5 NORAD catalog-number encoding."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sgp4.api import Satrec
|
||||||
|
|
||||||
|
from odm import (
|
||||||
|
ALPHA5_MAX,
|
||||||
|
ALPHA5_MIN,
|
||||||
|
from_alpha5,
|
||||||
|
tle_catalog_field,
|
||||||
|
to_alpha5,
|
||||||
|
)
|
||||||
|
from odm import parse_tle, tle_from_gp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value,expected", [
|
||||||
|
(100000, "A0000"),
|
||||||
|
(108493, "A8493"),
|
||||||
|
(180000, "J0000"), # skips I
|
||||||
|
(234567, "P4567"),
|
||||||
|
(270354, "T0354"),
|
||||||
|
(339999, "Z9999"),
|
||||||
|
])
|
||||||
|
def test_to_alpha5_known_values(value, expected):
|
||||||
|
assert to_alpha5(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_alpha5_skips_i_and_o():
|
||||||
|
encoded = "".join(to_alpha5(v * 10000)[0] for v in range(10, 34))
|
||||||
|
assert "I" not in encoded and "O" not in encoded
|
||||||
|
assert len(set(encoded)) == 24 # all distinct
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [ALPHA5_MIN, 123456, 234567, ALPHA5_MAX])
|
||||||
|
def test_alpha5_round_trips(value):
|
||||||
|
assert from_alpha5(to_alpha5(value)) == value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [99999, ALPHA5_MAX + 1, 0, -1])
|
||||||
|
def test_to_alpha5_rejects_out_of_range(value):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
to_alpha5(value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["", "ABCDE", "A123", "I0000", "O0000", "12345"])
|
||||||
|
def test_from_alpha5_rejects_invalid(field):
|
||||||
|
assert from_alpha5(field) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tle_catalog_field_plain_numeric_unchanged():
|
||||||
|
assert tle_catalog_field("25544", alpha5=True) == "25544"
|
||||||
|
assert tle_catalog_field("25544", alpha5=False) == "25544"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tle_catalog_field_alpha5_vs_temp():
|
||||||
|
assert tle_catalog_field("234567", alpha5=True) == "P4567"
|
||||||
|
temp = tle_catalog_field("234567", alpha5=False)
|
||||||
|
assert temp.isdigit() and 90000 <= int(temp) <= 99999
|
||||||
|
|
||||||
|
|
||||||
|
def test_tle_catalog_field_alphanumeric_always_pseudo():
|
||||||
|
for flag in (True, False):
|
||||||
|
field = tle_catalog_field("A1234", alpha5=flag)
|
||||||
|
assert field.isdigit() and 90000 <= int(field) <= 99999
|
||||||
|
|
||||||
|
|
||||||
|
def test_tle_from_gp_alpha5_round_trips_through_sgp4():
|
||||||
|
iss = parse_tle(
|
||||||
|
"ISS (ZARYA)",
|
||||||
|
"1 25544U 98067A 24070.50000000 .00012345 00000-0 22000-3 0 9991",
|
||||||
|
"2 25544 51.6400 200.0000 0001234 90.0000 270.0000 15.50000000400000",
|
||||||
|
)
|
||||||
|
gp = dict(iss.omm)
|
||||||
|
gp["NORAD_CAT_ID"] = "234567"
|
||||||
|
_, line1, line2 = tle_from_gp(gp, alpha5=True)
|
||||||
|
|
||||||
|
assert len(line1) == 69 and len(line2) == 69
|
||||||
|
assert line1[2:7] == "P4567"
|
||||||
|
assert line1[2:7] == line2[2:7]
|
||||||
|
# sgp4 must decode the ALPHA-5 field back to the real catalog number.
|
||||||
|
sat = Satrec.twoline2rv(line1, line2)
|
||||||
|
assert sat.satnum == 234567
|
||||||
|
|
||||||
|
|
||||||
|
def test_tle_from_gp_defaults_to_temp_id():
|
||||||
|
"""Without alpha5, a 6-digit numeric ID must not overflow the 5-char field."""
|
||||||
|
iss = parse_tle(
|
||||||
|
"ISS (ZARYA)",
|
||||||
|
"1 25544U 98067A 24070.50000000 .00012345 00000-0 22000-3 0 9991",
|
||||||
|
"2 25544 51.6400 200.0000 0001234 90.0000 270.0000 15.50000000400000",
|
||||||
|
)
|
||||||
|
gp = dict(iss.omm)
|
||||||
|
gp["NORAD_CAT_ID"] = "234567"
|
||||||
|
_, line1, line2 = tle_from_gp(gp)
|
||||||
|
assert len(line1) == 69 and line1[2:7].isdigit()
|
||||||
|
assert 90000 <= int(line1[2:7]) <= 99999
|
||||||
66
tests/test_export_formats.py
Normal file
66
tests/test_export_formats.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Tests for the CSV and KVN export renderers added for the ODMS service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from odm import GP_FIELDS, OmmRecord, as_celestrak_csv, as_omm_kvn
|
||||||
|
|
||||||
|
|
||||||
|
def _record() -> OmmRecord:
|
||||||
|
omm = {
|
||||||
|
"OBJECT_NAME": "ISS (ZARYA)",
|
||||||
|
"OBJECT_ID": "1998-067A",
|
||||||
|
"EPOCH": "2026-05-13T22:14:00",
|
||||||
|
"MEAN_MOTION": 15.498,
|
||||||
|
"ECCENTRICITY": 0.000284,
|
||||||
|
"INCLINATION": 51.64,
|
||||||
|
"RA_OF_ASC_NODE": 20.23,
|
||||||
|
"ARG_OF_PERICENTER": 74.12,
|
||||||
|
"MEAN_ANOMALY": 10.2,
|
||||||
|
"EPHEMERIS_TYPE": 0,
|
||||||
|
"CLASSIFICATION_TYPE": "U",
|
||||||
|
"NORAD_CAT_ID": "25544",
|
||||||
|
"ELEMENT_SET_NO": 999,
|
||||||
|
"REV_AT_EPOCH": 100,
|
||||||
|
"BSTAR": 0.00012345,
|
||||||
|
"MEAN_MOTION_DOT": 7.2e-05,
|
||||||
|
"MEAN_MOTION_DDOT": 0.0,
|
||||||
|
}
|
||||||
|
return OmmRecord(omm=omm, object_name="ISS (ZARYA)", object_id="1998-067A", originator="YKSA")
|
||||||
|
|
||||||
|
|
||||||
|
def test_celestrak_csv_header_matches_gp_field_order():
|
||||||
|
text = as_celestrak_csv([_record()])
|
||||||
|
rows = list(csv.reader(io.StringIO(text)))
|
||||||
|
assert tuple(rows[0]) == GP_FIELDS
|
||||||
|
# One data row, NORAD in the right column.
|
||||||
|
assert rows[1][GP_FIELDS.index("NORAD_CAT_ID")] == "25544"
|
||||||
|
assert rows[1][GP_FIELDS.index("OBJECT_NAME")] == "ISS (ZARYA)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_celestrak_csv_multiple_records():
|
||||||
|
text = as_celestrak_csv([_record(), _record()])
|
||||||
|
rows = list(csv.reader(io.StringIO(text)))
|
||||||
|
assert len(rows) == 3 # header + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_omm_kvn_has_version_and_key_value_lines():
|
||||||
|
text = as_omm_kvn([_record()], originator="YKSA")
|
||||||
|
assert text.startswith("CCSDS_OMM_VERS = 3.0")
|
||||||
|
kv = dict(
|
||||||
|
line.split(" = ", 1)
|
||||||
|
for line in text.splitlines()
|
||||||
|
if " = " in line
|
||||||
|
)
|
||||||
|
assert kv["ORIGINATOR"] == "YKSA"
|
||||||
|
assert kv["NORAD_CAT_ID"] == "25544"
|
||||||
|
assert kv["MEAN_ELEMENT_THEORY"] == "SGP4"
|
||||||
|
assert kv["INCLINATION"] == "51.64"
|
||||||
|
|
||||||
|
|
||||||
|
def test_omm_kvn_separates_records_with_blank_line():
|
||||||
|
text = as_omm_kvn([_record(), _record()])
|
||||||
|
assert text.count("CCSDS_OMM_VERS = 3.0") == 2
|
||||||
|
assert "\n\n" in text
|
||||||
288
tests/test_formats.py
Normal file
288
tests/test_formats.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
"""TLE / OMM format conversion tests.
|
||||||
|
|
||||||
|
Round-trip checks: ``parse_tle -> tle_from_gp`` should reproduce the underlying
|
||||||
|
orbit (compared via sgp4) to within float tolerance. OMM XML rendering must
|
||||||
|
emit every CCSDS-mandatory field, and the parser must handle the real-world
|
||||||
|
``<ndm>`` wrapper Space-Track ships in its archive exports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sgp4.api import Satrec
|
||||||
|
|
||||||
|
from odm import (
|
||||||
|
OmmRecord,
|
||||||
|
as_omm_xml,
|
||||||
|
as_txt_txt,
|
||||||
|
as_plaintext,
|
||||||
|
omm_xml_to_records,
|
||||||
|
parse_tle,
|
||||||
|
tle_from_gp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ISS = (
|
||||||
|
"ISS (ZARYA)",
|
||||||
|
"1 25544U 98067A 24070.50000000 .00012345 00000-0 22000-3 0 9991",
|
||||||
|
"2 25544 51.6400 200.0000 0001234 90.0000 270.0000 15.50000000400000",
|
||||||
|
)
|
||||||
|
|
||||||
|
NOAA15 = (
|
||||||
|
"NOAA 15",
|
||||||
|
"1 25338U 98030A 24070.20000000 .00000050 00000-0 31000-4 0 9990",
|
||||||
|
"2 25338 98.7000 100.0000 0010000 10.0000 350.0000 14.26000000900000",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Real-world Space-Track archive sample (verbatim from user-supplied issue),
|
||||||
|
# trimmed to two records to keep the test fast.
|
||||||
|
SPACETRACK_ARCHIVE_XML = """\
|
||||||
|
<ndm xsi:noNamespaceSchemaLocation="https://sanaregistry.org/r/ndmxml_unqualified/ndmxml-3.0.0-master-3.0.xsd">
|
||||||
|
<omm id="CCSDS_OMM_VERS" version="3.0">
|
||||||
|
<header>
|
||||||
|
<COMMENT>GENERATED VIA SPACE-TRACK.ORG API</COMMENT>
|
||||||
|
<CREATION_DATE>2026-01-08T05:20:27</CREATION_DATE>
|
||||||
|
<ORIGINATOR>18 SPCS</ORIGINATOR>
|
||||||
|
</header>
|
||||||
|
<body>
|
||||||
|
<segment>
|
||||||
|
<metadata>
|
||||||
|
<OBJECT_NAME>TBA - TO BE ASSIGNED</OBJECT_NAME>
|
||||||
|
<OBJECT_ID>2025-313AU</OBJECT_ID>
|
||||||
|
<CENTER_NAME>EARTH</CENTER_NAME>
|
||||||
|
<REF_FRAME>TEME</REF_FRAME>
|
||||||
|
<TIME_SYSTEM>UTC</TIME_SYSTEM>
|
||||||
|
<MEAN_ELEMENT_THEORY>SGP4</MEAN_ELEMENT_THEORY>
|
||||||
|
</metadata>
|
||||||
|
<data>
|
||||||
|
<meanElements>
|
||||||
|
<EPOCH>2026-01-06T22:50:56.049504</EPOCH>
|
||||||
|
<MEAN_MOTION>15.21286088</MEAN_MOTION>
|
||||||
|
<ECCENTRICITY>0.00095778</ECCENTRICITY>
|
||||||
|
<INCLINATION>97.4119</INCLINATION>
|
||||||
|
<RA_OF_ASC_NODE>84.2715</RA_OF_ASC_NODE>
|
||||||
|
<ARG_OF_PERICENTER>195.9225</ARG_OF_PERICENTER>
|
||||||
|
<MEAN_ANOMALY>164.1711</MEAN_ANOMALY>
|
||||||
|
</meanElements>
|
||||||
|
<tleParameters>
|
||||||
|
<EPHEMERIS_TYPE>0</EPHEMERIS_TYPE>
|
||||||
|
<CLASSIFICATION_TYPE>U</CLASSIFICATION_TYPE>
|
||||||
|
<NORAD_CAT_ID>67290</NORAD_CAT_ID>
|
||||||
|
<ELEMENT_SET_NO>999</ELEMENT_SET_NO>
|
||||||
|
<REV_AT_EPOCH>143</REV_AT_EPOCH>
|
||||||
|
<BSTAR>0.00053078883000</BSTAR>
|
||||||
|
<MEAN_MOTION_DOT>0.00011823</MEAN_MOTION_DOT>
|
||||||
|
<MEAN_MOTION_DDOT>0.0000000000000</MEAN_MOTION_DDOT>
|
||||||
|
</tleParameters>
|
||||||
|
<userDefinedParameters>
|
||||||
|
<USER_DEFINED parameter="SEMIMAJOR_AXIS">6880.097</USER_DEFINED>
|
||||||
|
<USER_DEFINED parameter="PERIOD">94.657</USER_DEFINED>
|
||||||
|
<USER_DEFINED parameter="APOAPSIS">508.552</USER_DEFINED>
|
||||||
|
<USER_DEFINED parameter="PERIAPSIS">495.373</USER_DEFINED>
|
||||||
|
<USER_DEFINED parameter="OBJECT_TYPE">UNKNOWN</USER_DEFINED>
|
||||||
|
<USER_DEFINED parameter="RCS_SIZE"/>
|
||||||
|
<USER_DEFINED parameter="COUNTRY_CODE"/>
|
||||||
|
<USER_DEFINED parameter="LAUNCH_DATE"/>
|
||||||
|
<USER_DEFINED parameter="SITE"/>
|
||||||
|
<USER_DEFINED parameter="DECAY_DATE"/>
|
||||||
|
<USER_DEFINED parameter="FILE">4962752</USER_DEFINED>
|
||||||
|
<USER_DEFINED parameter="GP_ID">308346717</USER_DEFINED>
|
||||||
|
</userDefinedParameters>
|
||||||
|
</data>
|
||||||
|
</segment>
|
||||||
|
</body>
|
||||||
|
</omm>
|
||||||
|
<omm id="CCSDS_OMM_VERS" version="3.0">
|
||||||
|
<header>
|
||||||
|
<COMMENT>GENERATED VIA SPACE-TRACK.ORG API</COMMENT>
|
||||||
|
<CREATION_DATE>2026-01-08T15:46:24</CREATION_DATE>
|
||||||
|
<ORIGINATOR>18 SPCS</ORIGINATOR>
|
||||||
|
</header>
|
||||||
|
<body>
|
||||||
|
<segment>
|
||||||
|
<metadata>
|
||||||
|
<OBJECT_NAME>TBA - TO BE ASSIGNED</OBJECT_NAME>
|
||||||
|
<OBJECT_ID>2025-313AU</OBJECT_ID>
|
||||||
|
<CENTER_NAME>EARTH</CENTER_NAME>
|
||||||
|
<REF_FRAME>TEME</REF_FRAME>
|
||||||
|
<TIME_SYSTEM>UTC</TIME_SYSTEM>
|
||||||
|
<MEAN_ELEMENT_THEORY>SGP4</MEAN_ELEMENT_THEORY>
|
||||||
|
</metadata>
|
||||||
|
<data>
|
||||||
|
<meanElements>
|
||||||
|
<EPOCH>2026-01-08T11:09:24.647040</EPOCH>
|
||||||
|
<MEAN_MOTION>15.21321390</MEAN_MOTION>
|
||||||
|
<ECCENTRICITY>0.00096980</ECCENTRICITY>
|
||||||
|
<INCLINATION>97.4116</INCLINATION>
|
||||||
|
<RA_OF_ASC_NODE>85.7604</RA_OF_ASC_NODE>
|
||||||
|
<ARG_OF_PERICENTER>190.1005</ARG_OF_PERICENTER>
|
||||||
|
<MEAN_ANOMALY>170.0038</MEAN_ANOMALY>
|
||||||
|
</meanElements>
|
||||||
|
<tleParameters>
|
||||||
|
<EPHEMERIS_TYPE>0</EPHEMERIS_TYPE>
|
||||||
|
<CLASSIFICATION_TYPE>U</CLASSIFICATION_TYPE>
|
||||||
|
<NORAD_CAT_ID>67290</NORAD_CAT_ID>
|
||||||
|
<ELEMENT_SET_NO>999</ELEMENT_SET_NO>
|
||||||
|
<REV_AT_EPOCH>166</REV_AT_EPOCH>
|
||||||
|
<BSTAR>0.00055242000000</BSTAR>
|
||||||
|
<MEAN_MOTION_DOT>0.00012321</MEAN_MOTION_DOT>
|
||||||
|
<MEAN_MOTION_DDOT>0.0000000000000</MEAN_MOTION_DDOT>
|
||||||
|
</tleParameters>
|
||||||
|
</data>
|
||||||
|
</segment>
|
||||||
|
</body>
|
||||||
|
</omm>
|
||||||
|
</ndm>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,l1,l2", [ISS, NOAA15])
|
||||||
|
def test_parse_tle_yields_expected_omm(name, l1, l2):
|
||||||
|
parsed = parse_tle(name, l1, l2)
|
||||||
|
assert parsed.omm["NORAD_CAT_ID"] == int(l1[2:7])
|
||||||
|
assert math.isclose(parsed.omm["INCLINATION"], float(l2[8:16]), abs_tol=1e-4)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,l1,l2", [ISS, NOAA15])
|
||||||
|
def test_round_trip_preserves_orbit(name, l1, l2):
|
||||||
|
parsed = parse_tle(name, l1, l2)
|
||||||
|
name_out, l1_out, l2_out = tle_from_gp(parsed.omm)
|
||||||
|
assert l1_out.startswith("1 ")
|
||||||
|
assert l2_out.startswith("2 ")
|
||||||
|
assert len(l1_out) == 69
|
||||||
|
assert len(l2_out) == 69
|
||||||
|
sat_a = Satrec.twoline2rv(l1, l2)
|
||||||
|
sat_b = Satrec.twoline2rv(l1_out, l2_out)
|
||||||
|
assert sat_a.satnum == sat_b.satnum
|
||||||
|
assert math.isclose(sat_a.inclo, sat_b.inclo, abs_tol=1e-5)
|
||||||
|
assert math.isclose(sat_a.nodeo, sat_b.nodeo, abs_tol=1e-5)
|
||||||
|
assert math.isclose(sat_a.ecco, sat_b.ecco, abs_tol=1e-7)
|
||||||
|
assert math.isclose(sat_a.argpo, sat_b.argpo, abs_tol=1e-5)
|
||||||
|
assert math.isclose(sat_a.mo, sat_b.mo, abs_tol=1e-5)
|
||||||
|
assert math.isclose(sat_a.no_kozai, sat_b.no_kozai, abs_tol=1e-7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_txt_txt_format():
|
||||||
|
name, l1, l2 = ISS
|
||||||
|
parsed = parse_tle(name, l1, l2)
|
||||||
|
out = as_txt_txt([(parsed.name, parsed.line1, parsed.line2)])
|
||||||
|
lines = out.split("\r\n")
|
||||||
|
assert lines[0] == parsed.name[:24].ljust(24)
|
||||||
|
assert lines[1] == parsed.line1
|
||||||
|
assert lines[2] == parsed.line2
|
||||||
|
assert lines[3] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_plaintext_format_has_trailing_newline():
|
||||||
|
out = as_plaintext(*ISS)
|
||||||
|
assert out.endswith("\n")
|
||||||
|
assert out.count("\n") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_omm_xml_contains_all_mandatory_fields():
|
||||||
|
parsed = parse_tle(*ISS)
|
||||||
|
rec = OmmRecord(
|
||||||
|
omm=parsed.omm,
|
||||||
|
object_name=parsed.name,
|
||||||
|
object_id=parsed.omm["OBJECT_ID"],
|
||||||
|
originator="YKSA",
|
||||||
|
mean_element_theory="SGP4",
|
||||||
|
)
|
||||||
|
xml = as_omm_xml([rec], originator="YKSA")
|
||||||
|
|
||||||
|
assert xml.startswith("<?xml")
|
||||||
|
assert "<omm" in xml and 'id="CCSDS_OMM_VERS"' in xml and 'version="3.0"' in xml
|
||||||
|
|
||||||
|
assert "<CREATION_DATE>" in xml
|
||||||
|
assert "<ORIGINATOR>YKSA</ORIGINATOR>" in xml
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"OBJECT_NAME", "OBJECT_ID", "CENTER_NAME", "REF_FRAME",
|
||||||
|
"TIME_SYSTEM", "MEAN_ELEMENT_THEORY",
|
||||||
|
):
|
||||||
|
assert f"<{field}>" in xml, f"missing mandatory metadata field {field}"
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"EPOCH", "MEAN_MOTION", "ECCENTRICITY", "INCLINATION",
|
||||||
|
"RA_OF_ASC_NODE", "ARG_OF_PERICENTER", "MEAN_ANOMALY",
|
||||||
|
):
|
||||||
|
assert f"<{field}>" in xml, f"missing mandatory meanElements field {field}"
|
||||||
|
|
||||||
|
assert "<tleParameters>" in xml
|
||||||
|
for field in ("NORAD_CAT_ID", "BSTAR", "MEAN_MOTION_DOT"):
|
||||||
|
assert f"<{field}>" in xml
|
||||||
|
|
||||||
|
|
||||||
|
def test_omm_xml_round_trip_preserves_orbit():
|
||||||
|
parsed = parse_tle(*ISS)
|
||||||
|
rec = OmmRecord(
|
||||||
|
omm=parsed.omm,
|
||||||
|
object_name=parsed.name,
|
||||||
|
object_id=parsed.omm["OBJECT_ID"],
|
||||||
|
mean_element_theory="SGP4",
|
||||||
|
)
|
||||||
|
xml = as_omm_xml([rec])
|
||||||
|
parsed_back = list(omm_xml_to_records(xml))
|
||||||
|
assert len(parsed_back) == 1
|
||||||
|
rt = parsed_back[0]
|
||||||
|
assert rt.object_name == parsed.name
|
||||||
|
assert rt.mean_element_theory == "SGP4"
|
||||||
|
assert rt.ref_frame == "TEME"
|
||||||
|
assert math.isclose(rt.omm["INCLINATION"], parsed.omm["INCLINATION"], abs_tol=1e-9)
|
||||||
|
assert math.isclose(rt.omm["MEAN_MOTION"], parsed.omm["MEAN_MOTION"], abs_tol=1e-12)
|
||||||
|
# NORAD_CAT_ID round-trips through OMM XML as a string (to accommodate the
|
||||||
|
# future alphanumeric Space-Track catalog IDs).
|
||||||
|
assert str(rt.omm["NORAD_CAT_ID"]) == str(parsed.omm["NORAD_CAT_ID"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_omm_xml_omits_tle_params_for_non_sgp_theories():
|
||||||
|
parsed = parse_tle(*ISS)
|
||||||
|
rec = OmmRecord(
|
||||||
|
omm=parsed.omm,
|
||||||
|
object_name=parsed.name,
|
||||||
|
object_id=parsed.omm["OBJECT_ID"],
|
||||||
|
mean_element_theory="DSST",
|
||||||
|
)
|
||||||
|
xml = as_omm_xml([rec])
|
||||||
|
assert "<tleParameters>" not in xml
|
||||||
|
assert "<MEAN_ELEMENT_THEORY>DSST</MEAN_ELEMENT_THEORY>" in xml
|
||||||
|
|
||||||
|
|
||||||
|
def test_omm_xml_parses_spacetrack_archive_format():
|
||||||
|
"""The Space-Track archive ships <ndm>-wrapped multi-document OMM with an
|
||||||
|
undeclared xsi: prefix and a vendor-specific <userDefinedParameters> block.
|
||||||
|
All three must be tolerated without losing structured data.
|
||||||
|
"""
|
||||||
|
records = list(omm_xml_to_records(SPACETRACK_ARCHIVE_XML))
|
||||||
|
assert len(records) == 2
|
||||||
|
|
||||||
|
a, b = records
|
||||||
|
# First record's precise numeric fields must survive verbatim -- this is
|
||||||
|
# the exact reason we treat OMM as canonical instead of round-tripping
|
||||||
|
# through TLE line 1.
|
||||||
|
assert a.omm["NORAD_CAT_ID"] == "67290"
|
||||||
|
assert math.isclose(a.omm["MEAN_MOTION_DOT"], 0.00011823, abs_tol=1e-12)
|
||||||
|
assert math.isclose(a.omm["BSTAR"], 0.00053078883, abs_tol=1e-14)
|
||||||
|
assert math.isclose(a.omm["MEAN_MOTION"], 15.21286088, abs_tol=1e-12)
|
||||||
|
|
||||||
|
# Second record carries its own creation date -- header context must be
|
||||||
|
# per-OMM, not shared across the whole <ndm>.
|
||||||
|
assert b.omm["NORAD_CAT_ID"] == "67290"
|
||||||
|
assert math.isclose(b.omm["BSTAR"], 0.00055242, abs_tol=1e-14)
|
||||||
|
|
||||||
|
# Metadata from the segment must be threaded through correctly.
|
||||||
|
assert a.mean_element_theory == "SGP4"
|
||||||
|
assert a.ref_frame == "TEME"
|
||||||
|
assert a.originator == "18 SPCS"
|
||||||
|
assert a.object_id == "2025-313AU"
|
||||||
|
|
||||||
|
# The original <omm> chunk must be preserved verbatim so we can hand it
|
||||||
|
# back to API consumers unchanged. The userDefinedParameters block must
|
||||||
|
# still be present in the stored XML even though we discard it from the
|
||||||
|
# structured dict.
|
||||||
|
assert "<omm" in a.omm_xml and "</omm>" in a.omm_xml
|
||||||
|
assert "userDefinedParameters" in a.omm_xml
|
||||||
|
assert "SEMIMAJOR_AXIS" in a.omm_xml
|
||||||
24
tests/test_norad.py
Normal file
24
tests/test_norad.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Tests for the NORAD ID normaliser."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from odm import normalise_norad
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
(None, None),
|
||||||
|
("", None),
|
||||||
|
(" ", None),
|
||||||
|
(25544, "25544"),
|
||||||
|
("25544", "25544"),
|
||||||
|
(" 25544 ", "25544"),
|
||||||
|
("123", "00123"), # zero-padded to 5
|
||||||
|
(b"42", "00042"), # bytes accepted, zero-padded
|
||||||
|
("A1234", "A1234"), # alphanumeric preserved verbatim
|
||||||
|
("1998-067A", "1998-067A"), # hyphenated preserved verbatim
|
||||||
|
("12345678", "12345678"), # >5 digits left intact
|
||||||
|
])
|
||||||
|
def test_normalise_norad(raw, expected):
|
||||||
|
assert normalise_norad(raw) == expected
|
||||||
53
tests/test_temp_norad.py
Normal file
53
tests/test_temp_norad.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""Tests for the alphanumeric -> numeric TLE fallback."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from odm import (
|
||||||
|
TEMP_NORAD_MAX,
|
||||||
|
TEMP_NORAD_MIN,
|
||||||
|
coerce_norad_to_int,
|
||||||
|
temp_tle_norad,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temp_norad_stays_in_reserved_range():
|
||||||
|
for ident in ("A1234", "ZZ-9999", "1998-067A", "X", ""):
|
||||||
|
n = temp_tle_norad(ident)
|
||||||
|
assert TEMP_NORAD_MIN <= n <= TEMP_NORAD_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def test_temp_norad_is_deterministic():
|
||||||
|
assert temp_tle_norad("A1234") == temp_tle_norad("A1234")
|
||||||
|
assert temp_tle_norad("A1234") != temp_tle_norad("A1235")
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_norad_to_int_passes_through_numerics():
|
||||||
|
assert coerce_norad_to_int(25544) == 25544
|
||||||
|
assert coerce_norad_to_int("25544") == 25544
|
||||||
|
assert coerce_norad_to_int(" 25544 ") == 25544
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_norad_to_int_substitutes_for_alphanumeric():
|
||||||
|
n = coerce_norad_to_int("A1234")
|
||||||
|
assert TEMP_NORAD_MIN <= n <= TEMP_NORAD_MAX
|
||||||
|
# Stable: second call returns the same number.
|
||||||
|
assert coerce_norad_to_int("A1234") == n
|
||||||
|
|
||||||
|
|
||||||
|
def test_tle_from_gp_renders_for_alphanumeric_norad():
|
||||||
|
"""The TLE rendering must succeed even when NORAD_CAT_ID is non-numeric."""
|
||||||
|
from odm import parse_tle, tle_from_gp
|
||||||
|
|
||||||
|
iss = parse_tle(
|
||||||
|
"ISS (ZARYA)",
|
||||||
|
"1 25544U 98067A 24070.50000000 .00012345 00000-0 22000-3 0 9991",
|
||||||
|
"2 25544 51.6400 200.0000 0001234 90.0000 270.0000 15.50000000400000",
|
||||||
|
)
|
||||||
|
gp = dict(iss.omm)
|
||||||
|
gp["NORAD_CAT_ID"] = "A1234" # alphanumeric future ID
|
||||||
|
_, line1, line2 = tle_from_gp(gp)
|
||||||
|
assert line1.startswith("1 ") and line2.startswith("2 ")
|
||||||
|
# The 5-digit slot at chars 2-7 must contain the synthetic 9xxxx number.
|
||||||
|
assert line1[2:7].isdigit()
|
||||||
|
assert 90000 <= int(line1[2:7]) <= 99999
|
||||||
|
assert line1[2:7] == line2[2:7] # consistent across lines
|
||||||
Loading…
Add table
Add a link
Reference in a new issue