apsis-rs/validation/python/coord/check_teme.py

120 lines
3.7 KiB
Python

"""Cross-check the ApsisRS TEME to Earth-fixed transformation.
Reads the positions written by ``cargo run -p apsis-rs --features physics
--example dump_teme`` and recomputes each one with ERFA, applying the same
Greenwich mean sidereal time and the same zero polar motion, so that the
check compares the implementation rather than the choice of inputs.
The reference is the sidereal time of 1982 and the polar motion composition
of the IAU SOFA library, as implemented by ERFA. Note that astropy agrees
with this transformation only to a few hundred metres, because astropy
applies the measured polar motion and a measured UT1 - UTC while this check
uses zero for both. That agreement is reported for information but is not
the acceptance criterion.
The script needs no network access.
Usage:
pixi run python validation/python/coord/check_teme.py [path]
Exit status is zero when every position agrees within tolerance and one
otherwise.
"""
from __future__ import annotations
import csv
import sys
from pathlib import Path
import erfa
# Tolerance in kilometers. The transformation is a product of two rotations
# evaluated in the same order, so the agreement is at the level of the last
# bits of a double.
TOLERANCE_KM = 1e-9
# The two-part Julian date used by the Rust example for the whole days.
DAY_PART = 2400000.5
DEFAULT_INPUT = Path("validation/data/expected/rust_teme.csv")
def read_rows(path: Path) -> list[dict[str, str]]:
"""Reads the CSV rows."""
with path.open(newline="") as handle:
reader = csv.DictReader(handle)
return [row for row in reader if row.get("mjd")]
def reference_position(mjd: float, position: list[float]) -> list[float]:
"""Recomputes the Earth-fixed position with ERFA."""
# The sidereal time, from the two-part Julian date.
sidereal = erfa.gmst82(DAY_PART, mjd)
# The rotation of the frame about its third axis, composed as the
# reference `rz` does, and the identity polar motion.
rotation = erfa.rz(sidereal, erfa.ir())
polar = erfa.pom00(0.0, 0.0, 0.0)
matrix = erfa.rxr(polar, rotation)
# The rotation maps the frame, so the position is the matrix applied to
# the components.
return [
sum(matrix[i][j] * position[j] for j in range(3)) for i in range(3)
]
def main(argv: list[str]) -> int:
path = Path(argv[1]) if len(argv) > 1 else DEFAULT_INPUT
if not path.exists():
print(f"missing input file: {path}", file=sys.stderr)
print(
"generate it with: cargo run -p apsis-rs --features physics "
"--example dump_teme > " + str(path),
file=sys.stderr,
)
return 2
rows = read_rows(path)
if not rows:
print("input file has no data rows", file=sys.stderr)
return 2
# The TEME inputs used by the example, in the same order.
inputs = [
[7000.0, 1000.0, 2000.0],
[7000.0, -3000.0, 1000.0],
[42164.0, 0.0, 0.0],
[6878.137, 100.0, -50.0],
]
worst = 0.0
worst_at = None
for row, position in zip(rows, inputs):
mjd = float(row["mjd"])
actual = [
float(row["x_ecef"]),
float(row["y_ecef"]),
float(row["z_ecef"]),
]
expected = reference_position(mjd, position)
error = max(abs(a - b) for a, b in zip(actual, expected))
if error > worst:
worst = error
worst_at = mjd
print(f"positions compared: {len(rows)}")
print(f"worst difference: {worst:.3e} km at MJD {worst_at}")
print(f"tolerance: {TOLERANCE_KM:.3e} km")
if worst <= TOLERANCE_KM:
print("result: pass")
return 0
print("result: FAIL")
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv))