168 lines
5.4 KiB
Python
168 lines
5.4 KiB
Python
"""Cross-check the ApsisRS SGP4 propagator against the reference library.
|
|
|
|
Reads the states written by ``cargo run -p apsis-rs --features orbit
|
|
--example dump_orbit``, propagates the same element sets with the ``sgp4``
|
|
reference implementation, and reports the largest position and velocity
|
|
difference for each element set.
|
|
|
|
The script needs no network access.
|
|
|
|
Usage:
|
|
|
|
pixi run python validation/python/orbit/check_sgp4.py [path]
|
|
|
|
Exit status is zero when every state agrees within tolerance and one
|
|
otherwise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from sgp4.earth_gravity import wgs72
|
|
from sgp4.model import Satrec, WGS72
|
|
|
|
# Tolerances for the cross-check.
|
|
#
|
|
# These are the limits the workspace commits to for propagated position and
|
|
# velocity. They are far above the observed agreement, which is at the
|
|
# floating point level, and far below the accuracy of the SGP4 theory
|
|
# itself, which is on the order of a kilometer per day of propagation.
|
|
POSITION_TOLERANCE_KM = 1e-3
|
|
VELOCITY_TOLERANCE_KMS = 1e-6
|
|
|
|
DEFAULT_INPUT = Path("validation/data/expected/rust_sgp4.csv")
|
|
|
|
# The element sets, in the same order as the Rust example. They are the
|
|
# historical test cases distributed with the reference implementation.
|
|
ELEMENT_SETS = [
|
|
(
|
|
"1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927",
|
|
"2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537",
|
|
),
|
|
(
|
|
"1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753",
|
|
"2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667",
|
|
),
|
|
(
|
|
"1 27651U 03004A 06185.99374200 .00000109 00000-0 19909-4 0 445",
|
|
"2 27651 7.1743 182.9443 7867251 269.0386 18.6601 1.03461646 9884",
|
|
),
|
|
(
|
|
"1 09880U 77021A 06174.85710247 .00000090 00000-0 10000-3 0 7322",
|
|
"2 09880 64.1526 313.1296 6546925 103.4683 305.3980 2.00754276 13047",
|
|
),
|
|
]
|
|
|
|
|
|
def read_rows(path: Path) -> list[dict[str, str]]:
|
|
"""Reads the Rust output rows."""
|
|
with path.open(newline="") as handle:
|
|
reader = csv.DictReader(handle)
|
|
return [row for row in reader if row.get("set")]
|
|
|
|
|
|
def reference_state(satellite: Satrec, minutes: float):
|
|
"""Returns the reference position and velocity, or None on error."""
|
|
error, position, velocity = satellite.sgp4_tsince(minutes)
|
|
if error != 0:
|
|
return None
|
|
return position, velocity
|
|
|
|
|
|
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 orbit "
|
|
"--example dump_orbit > " + 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
|
|
|
|
satellites = [
|
|
Satrec.twoline2rv(first, second, WGS72) for first, second in ELEMENT_SETS
|
|
]
|
|
|
|
worst_position = 0.0
|
|
worst_position_at = None
|
|
worst_velocity = 0.0
|
|
worst_velocity_at = None
|
|
compared = 0
|
|
rust_errors = 0
|
|
reference_errors = 0
|
|
mismatched_errors = 0
|
|
|
|
for row in rows:
|
|
index = int(row["set"])
|
|
minutes = float(row["minutes"])
|
|
rust_error = row["error"] != "0"
|
|
reference = reference_state(satellites[index], minutes)
|
|
|
|
if rust_error and reference is None:
|
|
# Both sides reject the instant, which is agreement.
|
|
continue
|
|
if rust_error or reference is None:
|
|
mismatched_errors += 1
|
|
if rust_error:
|
|
rust_errors += 1
|
|
else:
|
|
reference_errors += 1
|
|
continue
|
|
|
|
position, velocity = reference
|
|
rust_position = (float(row["x_km"]), float(row["y_km"]), float(row["z_km"]))
|
|
rust_velocity = (
|
|
float(row["vx_kms"]),
|
|
float(row["vy_kms"]),
|
|
float(row["vz_kms"]),
|
|
)
|
|
position_error = max(abs(a - b) for a, b in zip(rust_position, position))
|
|
velocity_error = max(abs(a - b) for a, b in zip(rust_velocity, velocity))
|
|
|
|
if position_error > worst_position:
|
|
worst_position = position_error
|
|
worst_position_at = (index, minutes)
|
|
if velocity_error > worst_velocity:
|
|
worst_velocity = velocity_error
|
|
worst_velocity_at = (index, minutes)
|
|
compared += 1
|
|
|
|
print(f"states compared: {compared}")
|
|
print(f"rust only errors: {rust_errors}")
|
|
print(f"reference errors: {reference_errors}")
|
|
print(f"mismatched errors: {mismatched_errors}")
|
|
print(
|
|
f"worst position: {worst_position:.6e} km at (set, minutes) {worst_position_at}"
|
|
)
|
|
print(
|
|
f"worst velocity: {worst_velocity:.6e} km/s at (set, minutes) {worst_velocity_at}"
|
|
)
|
|
print(f"position tolerance: {POSITION_TOLERANCE_KM:.6e} km")
|
|
print(f"velocity tolerance: {VELOCITY_TOLERANCE_KMS:.6e} km/s")
|
|
|
|
if mismatched_errors > 0:
|
|
print("result: FAIL (error handling disagrees)")
|
|
return 1
|
|
if (
|
|
compared > 0
|
|
and worst_position <= POSITION_TOLERANCE_KM
|
|
and worst_velocity <= VELOCITY_TOLERANCE_KMS
|
|
):
|
|
print("result: pass")
|
|
return 0
|
|
print("result: FAIL")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv))
|