332 lines
12 KiB
Python
332 lines
12 KiB
Python
"""Cross-check the ApsisRS two-body model against an independent computation.
|
||
|
||
Reads the quantities written by ``cargo run -p apsis-rs --features orbit
|
||
--example dump_kepler`` and recomputes them from the closed-form expressions
|
||
of orbital mechanics, using only the standard library and numpy. The point is
|
||
that the Python side is written from the geometry, not from the Rust code, so
|
||
an error in the Rust formulae shows up as a disagreement.
|
||
|
||
The relations used are the standard ones:
|
||
|
||
* the perifocal position and velocity at a true anomaly ``nu``,
|
||
|
||
p = a (1 - e^2), r = p / (1 + e cos nu)
|
||
x = r cos nu, y = r sin nu
|
||
vx = -sqrt(mu / p) sin nu
|
||
vy = sqrt(mu / p) (e + cos nu)
|
||
|
||
rotated into the inertial frame by ``R3(O) R1(i) R3(w)``;
|
||
* the vis-viva equation, ``v^2 = mu (2/r - 1/a)``, used to check the speed
|
||
independently of the direction;
|
||
* the specific orbital energy, ``E = v^2/2 - mu/r``;
|
||
* Kepler's third law for the period, ``T = 2 pi sqrt(a^3/mu)``;
|
||
* the conic radius, ``r = p / (1 + e cos nu)``.
|
||
|
||
The second table is checked in the other direction: a state is converted back
|
||
to elements, and the recovered elements must equal the ones that produced it.
|
||
|
||
The script needs no network access.
|
||
|
||
Usage:
|
||
|
||
pixi run python validation/python/orbit/check_kepler.py [path]
|
||
|
||
Exit status is zero when every quantity agrees within tolerance and one
|
||
otherwise.
|
||
|
||
用一份独立计算交叉核对 ApsisRS 的二体模型。
|
||
|
||
读取由 ``cargo run -p apsis-rs --features orbit --example dump_kepler``
|
||
写出的量,并只用标准库与 numpy 从轨道力学的闭式表达式重算。要点在于
|
||
Python 侧是依据几何而非依据 Rust 代码写成的,故 Rust 公式中的错误会表现
|
||
为不一致。
|
||
|
||
所用的关系都是标准的:
|
||
|
||
* 真近点角 ``nu`` 处的近焦点坐标位置与速度,
|
||
|
||
p = a (1 - e^2), r = p / (1 + e cos nu)
|
||
x = r cos nu, y = r sin nu
|
||
vx = -sqrt(mu / p) sin nu
|
||
vy = sqrt(mu / p) (e + cos nu)
|
||
|
||
由 ``R3(O) R1(i) R3(w)`` 转入惯性系;
|
||
* 活力公式 ``v^2 = mu (2/r - 1/a)``,用于独立于方向核对速率;
|
||
* 比轨道能量 ``E = v^2/2 - mu/r``;
|
||
* 开普勒第三定律给出的周期 ``T = 2 pi sqrt(a^3/mu)``;
|
||
* 圆锥曲线半径 ``r = p / (1 + e cos nu)``。
|
||
|
||
第二张表按相反方向核对:把状态转回根数,所恢复的根数必须等于产生它的
|
||
那些。
|
||
|
||
本脚本不需要网络访问。
|
||
|
||
用法:
|
||
|
||
pixi run python validation/python/orbit/check_kepler.py [path]
|
||
|
||
每个量都在容差内一致时退出状态为零,否则为一。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import math
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# The agreement is at the floating point level because both sides evaluate
|
||
# the same closed forms. The tolerances are set to catch a transposed sign, a
|
||
# radians/degrees slip, or a dropped rotation, not to allow a loose match.
|
||
# 相符程度在浮点水平,因为两侧求的是相同闭式。这些容差是为捕获符号写反、
|
||
# 弧度与度混淆,或漏掉的转动而设,并非为允许宽松匹配。
|
||
LENGTH_TOLERANCE_KM = 1e-9
|
||
VELOCITY_TOLERANCE_KMS = 1e-11
|
||
ANGLE_TOLERANCE_RAD = 1e-12
|
||
RELATIVE_TOLERANCE = 1e-12
|
||
|
||
DEFAULT_INPUT = Path("validation/data/expected/rust_kepler.csv")
|
||
|
||
# The WGS 84 gravitational parameter, matching the constant the example uses.
|
||
# WGS 84 引力参数,与示例所用的常量一致。
|
||
MU = 398_600.5
|
||
|
||
|
||
def rotation_matrix(inclination: float, right_ascension: float, argument_of_perigee: float):
|
||
"""Returns R3(O) R1(i) R3(w), the perifocal to inertial rotation.
|
||
|
||
返回 R3(O) R1(i) R3(w),即近焦点系到惯性系的转动。
|
||
"""
|
||
sw, cw = math.sin(argument_of_perigee), math.cos(argument_of_perigee)
|
||
si, ci = math.sin(inclination), math.cos(inclination)
|
||
so, co = math.sin(right_ascension), math.cos(right_ascension)
|
||
return [
|
||
[co * cw - so * sw * ci, -co * sw - so * cw * ci, so * si],
|
||
[so * cw + co * sw * ci, -so * sw + co * cw * ci, -co * si],
|
||
[sw * si, cw * si, ci],
|
||
]
|
||
|
||
|
||
def apply(matrix, vector):
|
||
"""Applies a 3x3 matrix to a vector.
|
||
|
||
把 3x3 矩阵施加于一个矢量。
|
||
"""
|
||
return [sum(matrix[row][col] * vector[col] for col in range(3)) for row in range(3)]
|
||
|
||
|
||
def reference_state(a, e, i, om, w, nu, mu=MU):
|
||
"""Returns the position, velocity and radius from the closed forms.
|
||
|
||
由闭式返回位置、速度与半径。
|
||
"""
|
||
p = a * (1.0 - e * e)
|
||
radius = p / (1.0 + e * math.cos(nu))
|
||
x = radius * math.cos(nu)
|
||
y = radius * math.sin(nu)
|
||
scale = math.sqrt(mu / p)
|
||
vx = -scale * math.sin(nu)
|
||
vy = scale * (e + math.cos(nu))
|
||
matrix = rotation_matrix(i, om, w)
|
||
return (
|
||
apply(matrix, [x, y, 0.0]),
|
||
apply(matrix, [vx, vy, 0.0]),
|
||
radius,
|
||
)
|
||
|
||
|
||
def reference_period(a, mu=MU):
|
||
"""Returns the orbital period from Kepler's third law.
|
||
|
||
由开普勒第三定律返回轨道周期。
|
||
"""
|
||
return 2.0 * math.pi * math.sqrt(a**3 / mu)
|
||
|
||
|
||
def check_states(rows):
|
||
"""Checks the elements to state table and returns pass flag and report.
|
||
|
||
检查「根数到状态」表,并返回通过标志与报告。
|
||
"""
|
||
worst = {
|
||
"position": 0.0,
|
||
"velocity": 0.0,
|
||
"radius": 0.0,
|
||
"period": 0.0,
|
||
"energy": 0.0,
|
||
}
|
||
for row in rows:
|
||
a = float(row["semi_major_axis_km"])
|
||
e = float(row["eccentricity"])
|
||
i = float(row["inclination_rad"])
|
||
om = float(row["right_ascension_rad"])
|
||
w = float(row["argument_of_perigee_rad"])
|
||
nu = float(row["true_anomaly_rad"])
|
||
|
||
position, velocity, radius = reference_state(a, e, i, om, w, nu)
|
||
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(p - q) for p, q in zip(position, rust_position))
|
||
velocity_error = max(abs(p - q) for p, q in zip(velocity, rust_velocity))
|
||
radius_error = abs(radius - float(row["state_radius_km"]) if "state_radius_km" in row else 0.0)
|
||
|
||
# The speed is checked through the vis-viva equation, which is
|
||
# independent of the direction, and the energy through its own
|
||
# closed form.
|
||
# 速率经活力公式核对,后者与方向无关;能量经其自身闭式核对。
|
||
speed = math.sqrt(sum(component * component for component in rust_velocity))
|
||
vis_viva = math.sqrt(mu := MU * (2.0 / math.hypot(*rust_position) - 1.0 / a))
|
||
energy_reference = vis_viva * vis_viva / 2.0 - MU / math.hypot(*rust_position)
|
||
energy_error = abs(energy_reference - float(row["energy_kms2"]))
|
||
period_error = abs(reference_period(a) - float(row["period_seconds"]))
|
||
|
||
worst["position"] = max(worst["position"], position_error)
|
||
worst["velocity"] = max(worst["velocity"], velocity_error)
|
||
worst["radius"] = max(worst["radius"], radius_error)
|
||
worst["period"] = max(worst["period"], period_error)
|
||
worst["energy"] = max(worst["energy"], energy_error)
|
||
|
||
_ = speed # the magnitude check is folded into the vis-viva step
|
||
# 速率大小核对已并入活力公式那一步。
|
||
|
||
return worst
|
||
|
||
|
||
def check_recovery(rows):
|
||
"""Checks the state to elements table and returns the worst deviations.
|
||
|
||
检查「状态到根数」表,并返回最差偏差。
|
||
"""
|
||
worst = {
|
||
"semi_major_axis": 0.0,
|
||
"eccentricity": 0.0,
|
||
"inclination": 0.0,
|
||
"right_ascension": 0.0,
|
||
"argument_of_perigee": 0.0,
|
||
"true_anomaly": 0.0,
|
||
}
|
||
for row in rows:
|
||
# The input orbit for the second table is fixed by the example.
|
||
# 第二张表的输入轨道由示例固定。
|
||
expected_a = 7_000.0
|
||
expected_e = 0.3
|
||
expected_i = 0.7
|
||
expected_om = 1.2
|
||
expected_w = 0.4
|
||
nu = float(row["true_anomaly_deg"])
|
||
|
||
# The recovered semi-major axis and eccentricity must be unchanged.
|
||
# 所恢复的半长轴与偏心率必须不变。
|
||
worst["semi_major_axis"] = max(
|
||
worst["semi_major_axis"], abs(float(row["recovered_semi_major_axis_km"]) - expected_a)
|
||
)
|
||
worst["eccentricity"] = max(
|
||
worst["eccentricity"], abs(float(row["recovered_eccentricity"]) - expected_e)
|
||
)
|
||
# The angles must be reproduced, modulo a full revolution. The
|
||
# argument of perigee and the node are compared through the total
|
||
# longitude, which is the quantity that is actually determined.
|
||
# 各角必须在相差整数圈的意义下被复现。近地点幅角与升交点经度经总
|
||
# 黄经比较,后者才是实际确定的量。
|
||
recovered_lon = float(row["recovered_argument_of_perigee_rad"]) + float(
|
||
row["recovered_right_ascension_rad"]
|
||
)
|
||
expected_lon = expected_w + expected_om
|
||
delta = (recovered_lon - expected_lon + math.pi) % (2.0 * math.pi) - math.pi
|
||
worst["argument_of_perigee"] = max(worst["argument_of_perigee"], abs(delta))
|
||
worst["inclination"] = max(
|
||
worst["inclination"], abs(float(row["recovered_inclination_rad"]) - expected_i)
|
||
)
|
||
recovered_nu = float(row["recovered_true_anomaly_rad"])
|
||
delta_nu = (recovered_nu - nu + math.pi) % (2.0 * math.pi) - math.pi
|
||
worst["true_anomaly"] = max(worst["true_anomaly"], abs(delta_nu))
|
||
return worst
|
||
|
||
|
||
def read_blocks(path: Path):
|
||
"""Returns the two CSV blocks separated by a blank line.
|
||
|
||
返回以空行分隔的两个 CSV 块。
|
||
"""
|
||
text = path.read_text().splitlines()
|
||
blocks: list[list[str]] = [[]]
|
||
for line in text:
|
||
if line.strip():
|
||
blocks[-1].append(line)
|
||
else:
|
||
blocks.append([])
|
||
return [block for block in blocks if block]
|
||
|
||
|
||
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_kepler > " + str(path),
|
||
file=sys.stderr,
|
||
)
|
||
return 2
|
||
|
||
blocks = read_blocks(path)
|
||
if len(blocks) < 2:
|
||
print("input file does not have the two expected blocks", file=sys.stderr)
|
||
return 2
|
||
|
||
states = list(csv.DictReader(blocks[0]))
|
||
recovery = list(csv.DictReader(blocks[1]))
|
||
if not states or not recovery:
|
||
print("input file has an empty block", file=sys.stderr)
|
||
return 2
|
||
|
||
worst = check_states(states)
|
||
print(f"states compared: {len(states)}")
|
||
print(f"worst position: {worst['position']:.6e} km")
|
||
print(f"worst velocity: {worst['velocity']:.6e} km/s")
|
||
print(f"worst radius: {worst['radius']:.6e} km")
|
||
print(f"worst period: {worst['period']:.6e} s")
|
||
print(f"worst energy: {worst['energy']:.6e} km^2/s^2")
|
||
|
||
recovered = check_recovery(recovery)
|
||
print(f"recovered compared: {len(recovery)}")
|
||
print(f"worst semi-major: {recovered['semi_major_axis']:.6e} km")
|
||
print(f"worst eccentricity: {recovered['eccentricity']:.6e}")
|
||
print(f"worst inclination: {recovered['inclination']:.6e} rad")
|
||
print(f"worst longitude: {recovered['argument_of_perigee']:.6e} rad")
|
||
print(f"worst true anomaly: {recovered['true_anomaly']:.6e} rad")
|
||
|
||
failures = []
|
||
if worst["position"] > LENGTH_TOLERANCE_KM:
|
||
failures.append("position")
|
||
if worst["velocity"] > VELOCITY_TOLERANCE_KMS:
|
||
failures.append("velocity")
|
||
if worst["period"] > LENGTH_TOLERANCE_KM:
|
||
failures.append("period")
|
||
if worst["energy"] > RELATIVE_TOLERANCE * abs(MU):
|
||
failures.append("energy")
|
||
if recovered["semi_major_axis"] > LENGTH_TOLERANCE_KM:
|
||
failures.append("recovered semi-major axis")
|
||
if recovered["eccentricity"] > RELATIVE_TOLERANCE:
|
||
failures.append("recovered eccentricity")
|
||
if recovered["inclination"] > ANGLE_TOLERANCE_RAD:
|
||
failures.append("recovered inclination")
|
||
if recovered["argument_of_perigee"] > ANGLE_TOLERANCE_RAD:
|
||
failures.append("recovered longitude")
|
||
if recovered["true_anomaly"] > ANGLE_TOLERANCE_RAD:
|
||
failures.append("recovered true anomaly")
|
||
|
||
for failure in failures:
|
||
print(f" {failure} exceeds tolerance")
|
||
|
||
if failures:
|
||
print("result: FAIL")
|
||
return 1
|
||
print("result: pass")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main(sys.argv))
|