281 lines
10 KiB
Python
281 lines
10 KiB
Python
"""Cross-check the ApsisRS coverage geometry against spherical trigonometry.
|
|
|
|
Reads the quantities written by ``cargo run -p apsis-rs --features network
|
|
--example dump_coverage`` and recomputes each one from the spherical
|
|
triangle relations 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 formula shows up as a disagreement.
|
|
|
|
The three angles of the triangle formed by the satellite, the ground point,
|
|
and the centre of the Earth satisfy
|
|
|
|
elevation + nadir + central = 90 degrees
|
|
|
|
and the law of sines gives
|
|
|
|
sin(nadir) / body_radius = cos(elevation) / satellite_radius
|
|
|
|
from which the nadir angle, the central angle, and the slant range follow.
|
|
The footprint is the spherical cap the central angle cuts from the body, so
|
|
its area is a zone area and its fraction is that over the whole surface.
|
|
|
|
The script needs no network access.
|
|
|
|
Usage:
|
|
|
|
pixi run python validation/python/constellation/check_coverage.py [path]
|
|
|
|
Exit status is zero when every quantity agrees within tolerance and one
|
|
otherwise.
|
|
|
|
用球面三角交叉核对 ApsisRS 的覆盖几何。
|
|
|
|
读取由 ``cargo run -p apsis-rs --features network --example dump_coverage``
|
|
写出的量,并只用标准库与 numpy 从球面三角形关系逐个重算。要点在于 Python
|
|
侧是依据几何而非依据 Rust 代码写成的,故 Rust 公式中的错误会表现为不一致。
|
|
|
|
由卫星、地面点与地心构成的三角形的三个角满足
|
|
|
|
elevation + nadir + central = 90 degrees
|
|
|
|
正弦定理给出
|
|
|
|
sin(nadir) / body_radius = cos(elevation) / satellite_radius
|
|
|
|
由此得出天底角、中心角与斜距。覆盖区是中心角从天体上截出的球冠,故其面积
|
|
是带面积,其比例即该面积除以整个表面。
|
|
|
|
本脚本不需要网络访问。
|
|
|
|
用法:
|
|
|
|
pixi run python validation/python/constellation/check_coverage.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 form expressions. The tolerances are set to catch a
|
|
# transposed radius or a missing conversion, not to allow a loose match.
|
|
# 相符程度在浮点水平,因为两侧求的是相同的闭式表达式。这些容差是为捕获
|
|
# 半径写反或漏换算而设,并非为允许宽松匹配。
|
|
ANGLE_TOLERANCE_RAD = 1e-12
|
|
LENGTH_TOLERANCE_KM = 1e-9
|
|
AREA_RELATIVE_TOLERANCE = 1e-12
|
|
FRACTION_TOLERANCE = 1e-12
|
|
|
|
DEFAULT_INPUT = Path("validation/data/expected/rust_coverage.csv")
|
|
|
|
|
|
def reference_nadir(satellite_radius: float, body_radius: float, elevation: float) -> float:
|
|
"""Returns the nadir angle from the law of sines.
|
|
|
|
由正弦定理返回天底角。
|
|
"""
|
|
return math.asin(body_radius * math.cos(elevation) / satellite_radius)
|
|
|
|
|
|
def reference_central(satellite_radius: float, body_radius: float, elevation: float) -> float:
|
|
"""Returns the central angle from the angle sum.
|
|
|
|
由角和返回中心角。
|
|
"""
|
|
nadir = reference_nadir(satellite_radius, body_radius, elevation)
|
|
return max(0.0, math.pi / 2.0 - elevation - nadir)
|
|
|
|
|
|
def reference_range(satellite_radius: float, body_radius: float, elevation: float) -> float:
|
|
"""Returns the slant range from the law of cosines.
|
|
|
|
由余弦定理返回斜距。
|
|
"""
|
|
central = reference_central(satellite_radius, body_radius, elevation)
|
|
squared = (
|
|
satellite_radius**2
|
|
+ body_radius**2
|
|
- 2.0 * satellite_radius * body_radius * math.cos(central)
|
|
)
|
|
return math.sqrt(squared)
|
|
|
|
|
|
def reference_cap_area(central: float, body_radius: float) -> float:
|
|
"""Returns the area of a spherical cap of a given central angle.
|
|
|
|
A cap is a zone between the pole and a colatitude, so its area is
|
|
``2 pi R^2 (1 - cos(central))``. The footprint is not centred on a pole in
|
|
general, but a spherical cap is rotationally symmetric, so the same
|
|
expression applies.
|
|
|
|
返回给定中心角的球冠面积。
|
|
|
|
球冠是极点与某余纬之间的带,故其面积为 ``2 pi R^2 (1 - cos(central))``。
|
|
覆盖区通常并不以极点为中心,但球冠具有旋转对称性,故同一表达式适用。
|
|
"""
|
|
return 2.0 * math.pi * body_radius**2 * (1.0 - math.cos(central))
|
|
|
|
|
|
def read_blocks(path: Path):
|
|
"""Reads the two blocks of the file.
|
|
|
|
The first block is the case table with a header row; a blank line
|
|
separates it from the inverse relation block, which has its own header.
|
|
|
|
读取该文件的两个块。
|
|
|
|
第一块是带表头的用例表;一个空行把它与反解关系块分开,后者有自己的
|
|
表头。
|
|
"""
|
|
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 check_cases(rows: list[dict[str, str]]) -> tuple[bool, list[str]]:
|
|
"""Checks the case table and returns whether it passed and any failures.
|
|
|
|
检查用例表,返回是否通过以及任何失败项。
|
|
"""
|
|
failures: list[str] = []
|
|
compared = 0
|
|
worst = {
|
|
"nadir": 0.0,
|
|
"central": 0.0,
|
|
"range": 0.0,
|
|
"area": 0.0,
|
|
"fraction": 0.0,
|
|
}
|
|
|
|
for row in rows:
|
|
name = row["case"]
|
|
satellite_radius = float(row["satellite_radius_km"])
|
|
body_radius = float(row["body_radius_km"])
|
|
elevation = float(row["elevation_mask_rad"])
|
|
compared += 1
|
|
|
|
nadir = reference_nadir(satellite_radius, body_radius, elevation)
|
|
central = reference_central(satellite_radius, body_radius, elevation)
|
|
slant = reference_range(satellite_radius, body_radius, elevation)
|
|
area = reference_cap_area(central, body_radius)
|
|
fraction = area / (4.0 * math.pi * body_radius**2)
|
|
|
|
worst["nadir"] = max(worst["nadir"], abs(nadir - float(row["nadir_rad"])))
|
|
worst["central"] = max(worst["central"], abs(central - float(row["central_rad"])))
|
|
worst["range"] = max(worst["range"], abs(slant - float(row["slant_range_km"])))
|
|
rust_area = float(row["footprint_area_km2"])
|
|
worst["area"] = max(worst["area"], abs(area - rust_area) / rust_area)
|
|
worst["fraction"] = max(
|
|
worst["fraction"], abs(fraction - float(row["footprint_fraction"]))
|
|
)
|
|
|
|
if abs(nadir - float(row["nadir_rad"])) > ANGLE_TOLERANCE_RAD:
|
|
failures.append(f"{name}: nadir differs by {abs(nadir - float(row['nadir_rad']))}")
|
|
if abs(central - float(row["central_rad"])) > ANGLE_TOLERANCE_RAD:
|
|
failures.append(
|
|
f"{name}: central differs by {abs(central - float(row['central_rad']))}"
|
|
)
|
|
if abs(slant - float(row["slant_range_km"])) > LENGTH_TOLERANCE_KM:
|
|
failures.append(
|
|
f"{name}: range differs by {abs(slant - float(row['slant_range_km']))}"
|
|
)
|
|
if abs(area - rust_area) / rust_area > AREA_RELATIVE_TOLERANCE:
|
|
failures.append(f"{name}: area differs by {abs(area - rust_area) / rust_area}")
|
|
if abs(fraction - float(row["footprint_fraction"])) > FRACTION_TOLERANCE:
|
|
failures.append(
|
|
f"{name}: fraction differs by "
|
|
f"{abs(fraction - float(row['footprint_fraction']))}"
|
|
)
|
|
|
|
print(f"cases compared: {compared}")
|
|
print(f"worst nadir: {worst['nadir']:.6e} rad")
|
|
print(f"worst central: {worst['central']:.6e} rad")
|
|
print(f"worst range: {worst['range']:.6e} km")
|
|
print(f"worst area (rel): {worst['area']:.6e}")
|
|
print(f"worst fraction: {worst['fraction']:.6e}")
|
|
return (not failures, failures)
|
|
|
|
|
|
def check_inverse(rows: list[dict[str, str]]) -> tuple[bool, list[str]]:
|
|
"""Checks that the central angle and elevation relations invert.
|
|
|
|
检查中心角与仰角关系可互相反解。
|
|
"""
|
|
failures: list[str] = []
|
|
compared = 0
|
|
worst = 0.0
|
|
for row in rows:
|
|
if not row["elevation_rad"]:
|
|
continue
|
|
central_degrees = float(row["inverse_central_degrees"])
|
|
elevation = float(row["elevation_rad"])
|
|
round_trip = float(row["round_trip_central_degrees"])
|
|
compared += 1
|
|
|
|
# Recompute the round trip independently: from the elevation, the
|
|
# central angle follows from the angle sum.
|
|
# 独立重算往返:由仰角经角和得出中心角。
|
|
satellite_radius = 6_378.137 + 550.0
|
|
body_radius = 6_378.137
|
|
nadir = reference_nadir(satellite_radius, body_radius, elevation)
|
|
expected = max(0.0, math.pi / 2.0 - elevation - nadir)
|
|
error = abs(expected * 180.0 / math.pi - round_trip)
|
|
worst = max(worst, error)
|
|
if error > 1e-9:
|
|
failures.append(
|
|
f"central {central_degrees} deg: round trip differs by {error}"
|
|
)
|
|
|
|
print(f"inverse compared: {compared}")
|
|
print(f"worst round trip: {worst:.6e} deg")
|
|
return (not failures, failures)
|
|
|
|
|
|
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 network "
|
|
"--example dump_coverage > " + 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
|
|
|
|
cases = list(csv.DictReader(blocks[0]))
|
|
inverse = list(csv.DictReader(blocks[1]))
|
|
if not cases or not inverse:
|
|
print("input file has an empty block", file=sys.stderr)
|
|
return 2
|
|
|
|
cases_ok, case_failures = check_cases(cases)
|
|
inverse_ok, inverse_failures = check_inverse(inverse)
|
|
|
|
for failure in case_failures + inverse_failures:
|
|
print(f" {failure}")
|
|
|
|
if cases_ok and inverse_ok:
|
|
print("result: pass")
|
|
return 0
|
|
print("result: FAIL")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv))
|