329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""Cross-check the ApsisRS two-line element parser against an independent reader.
|
||
|
||
Reads the element fields written by ``cargo run -p apsis-rs --features orbit
|
||
--example dump_tle`` and reparses the same element sets with a column indexed
|
||
reader written from the NORAD format, then compares every field. The
|
||
checksum of each line is recomputed from its definition, so a field placed in
|
||
the wrong column or a sign convention that was dropped shows up as a
|
||
disagreement.
|
||
|
||
The format is positional, with the first character of a line at column zero:
|
||
|
||
* line 1: satellite number in columns 2 to 6, classification in column 7,
|
||
international designator in columns 9 to 16, epoch year and fraction of a
|
||
day in columns 18 to 32, the first derivative of the mean motion divided
|
||
by two in columns 33 to 43, the second derivative divided by six in columns
|
||
44 to 52, the drag term in columns 53 to 61, the element set number in
|
||
columns 62 to 67, and the checksum in column 68;
|
||
* line 2: inclination in columns 8 to 16, right ascension in columns 17 to
|
||
25, eccentricity in columns 26 to 33 as an implied leading decimal,
|
||
argument of perigee in columns 34 to 42, mean anomaly in columns 43 to 51,
|
||
mean motion in columns 52 to 63, the revolution number in columns 63 to 68,
|
||
and the checksum in column 68.
|
||
|
||
A dash counts as one and every digit counts as its own value, summed over the
|
||
first sixty eight columns and reduced modulo ten.
|
||
|
||
The script needs no network access.
|
||
|
||
Usage:
|
||
|
||
pixi run python validation/python/orbit/check_tle.py [path]
|
||
|
||
Exit status is zero when every field agrees and one otherwise.
|
||
|
||
用一份独立的读取器交叉核对 ApsisRS 的两行根数解析器。
|
||
|
||
读取由 ``cargo run -p apsis-rs --features orbit --example dump_tle`` 写出的
|
||
根数字段,并用一个依据 NORAD 格式写成的按列索引读取器重新解析同一批根数
|
||
集,然后比较每个字段。每一行的校验和都从其定义重算,故字段放错列或符号
|
||
约定被丢弃会表现为不一致。
|
||
|
||
该格式是按位置的,行的第一个字符在零列:
|
||
|
||
* 第 1 行:卫星编号在第 2 至 6 列,密级在第 7 列,国际编号在第 9 至 16
|
||
列,历元年与一天中的小数部分在第 18 至 32 列,平运动的一阶导数除以二
|
||
在第 33 至 43 列,二阶导数除以六在第 44 至 52 列,阻力项在第 53 至 61
|
||
列,根数集编号在第 62 至 67 列,校验和在第 68 列;
|
||
* 第 2 行:倾角在第 8 至 16 列,赤经在第 17 至 25 列,偏心率在第 26 至 33
|
||
列以隐含的前导小数点表示,近地点幅角在第 34 至 42 列,平近点角在第 43
|
||
至 51 列,平运动在第 52 至 63 列,圈数在第 63 至 68 列,校验和在第 68
|
||
列。
|
||
|
||
连字符计为一,每个数字计为其自身值,在前六十八列上求和并对十取模。
|
||
|
||
本脚本不需要网络访问。
|
||
|
||
用法:
|
||
|
||
pixi run python validation/python/orbit/check_tle.py [path]
|
||
|
||
每个字段都一致时退出状态为零,否则为一。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# The reader works on the same characters the parser saw, so the two must
|
||
# agree to the last digit. The tolerance is therefore exact, not
|
||
# approximate, and only guards the exponent fields against a difference in
|
||
# how the two languages round a very small value.
|
||
# 该读取器处理的是解析器所见的相同字符,故二者必须精确到最后一位数字。
|
||
# 因此该容差是精确的而非近似的,只是为防护指数字段在两种语言对极小值舍入
|
||
# 方式上的差异。
|
||
ABSOLUTE_TOLERANCE = 1e-12
|
||
EXPONENT_TOLERANCE = 1e-18
|
||
|
||
DEFAULT_INPUT = Path("validation/data/expected/rust_tle.csv")
|
||
|
||
# The element sets, in the order the example writes them. The lines are the
|
||
# same ones the example parses.
|
||
# 各根数集,按示例写出它们的次序。这些行就是示例所解析的那些。
|
||
ELEMENT_SETS = {
|
||
"vanguard_1": (
|
||
"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",
|
||
),
|
||
"iss_2008": (
|
||
"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",
|
||
),
|
||
"teme_94_deep": (
|
||
"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",
|
||
),
|
||
"lrs_4_deep": (
|
||
"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",
|
||
),
|
||
"circular_implied_zero": (
|
||
"1 20500U 90001A 90001.00000000 .00000000 00000-0 00000-0 0 00",
|
||
"2 20500 82.0000 100.0000 0000000 90.0000 90.0000 14.90000000 13",
|
||
),
|
||
"retrograde": (
|
||
"1 20600U 90002B 12345.50000000 -.00001234 00000-0 12345-6 0 9991",
|
||
"2 20600 120.0000 200.0000 0010000 30.0000 330.0000 15.00000000 12341",
|
||
),
|
||
"two_digit_year_1999": (
|
||
"1 10000U 99001A 99365.00000000 .00000000 00000-0 00000-0 0 05",
|
||
"2 10000 45.0000 10.0000 0001000 0.0000 0.0000 14.00000000 09",
|
||
),
|
||
"with_name_line": (
|
||
"1 12345U 80001C 20001.00000000 .00010000 00000-0 00000-0 0 9998",
|
||
"2 12345 60.0000 30.0000 0002000 100.0000 200.0000 12.00000000 12344",
|
||
),
|
||
}
|
||
|
||
|
||
def checksum(line: str) -> int:
|
||
"""Returns the modulo ten checksum of a line from its definition.
|
||
|
||
由其定义返回某行对十取模的校验和。
|
||
"""
|
||
total = 0
|
||
for character in line[:68]:
|
||
if character == "-":
|
||
total += 1
|
||
elif character.isdigit():
|
||
total += int(character)
|
||
return total % 10
|
||
|
||
|
||
def field(line: str, start: int, end: int) -> str:
|
||
"""Returns a slice of a line.
|
||
|
||
返回某行的一个切片。
|
||
"""
|
||
return line[start:end]
|
||
|
||
|
||
def parse_exponential(text: str) -> float:
|
||
"""Reads a NORAD assumed decimal point exponential.
|
||
|
||
The second derivative of the mean motion and the drag term are stored
|
||
with the decimal point assumed before the first digit and the exponent
|
||
written without an ``E``: ``-11606-4`` is ``-0.11606e-4``. A leading
|
||
``+`` or ``-`` before the mantissa is the sign of the value, and the sign
|
||
before the exponent is the sign of the exponent.
|
||
|
||
读取 NORAD 的隐含小数点指数表示。
|
||
|
||
平运动的二阶导数与阻力项在存储时,小数点隐含在第一位数字之前,指数不写
|
||
``E``:``-11606-4`` 即 ``-0.11606e-4``。尾数之前的 ``+`` 或 ``-`` 是值
|
||
的符号,指数之前的符号是指数的符号。
|
||
"""
|
||
text = text.strip()
|
||
if not text:
|
||
return 0.0
|
||
# The exponent is introduced by the last sign, once a mantissa digit is
|
||
# in hand, so scan from the end.
|
||
# 指数由最后一个符号引入(此时已有尾数数字在手),故从末尾扫描。
|
||
index = len(text) - 1
|
||
while index > 0 and text[index] not in "+-":
|
||
index -= 1
|
||
mantissa = text[:index].strip()
|
||
exponent = text[index:]
|
||
if mantissa.startswith("-"):
|
||
sign = -1.0
|
||
mantissa = mantissa[1:]
|
||
elif mantissa.startswith("+"):
|
||
sign = 1.0
|
||
mantissa = mantissa[1:]
|
||
else:
|
||
sign = 1.0
|
||
if not mantissa:
|
||
mantissa = "0"
|
||
if not exponent:
|
||
return 0.0
|
||
return sign * float("0." + mantissa) * 10.0 ** int(exponent)
|
||
|
||
|
||
def parse_line_one(line: str) -> dict:
|
||
"""Parses the first line by column index.
|
||
|
||
按列索引解析第一行。
|
||
"""
|
||
epoch = field(line, 18, 32).strip()
|
||
year = int(epoch[:2])
|
||
fraction = float(epoch[2:])
|
||
# The two digit year expands with the catalogue convention: 57 to 99 is
|
||
# the twentieth century and 00 to 56 the twenty first.
|
||
# 两位年份按目录约定展开:57 至 99 是二十世纪,00 至 56 是二十一世纪。
|
||
century = 1900 if year >= 57 else 2000
|
||
return {
|
||
"satellite_number": int(field(line, 2, 7)),
|
||
"classification": field(line, 7, 8).strip(),
|
||
"international_designator": field(line, 9, 17).strip(),
|
||
"epoch_year": century + year,
|
||
"epoch_fraction": fraction,
|
||
"mean_motion_dot": float(field(line, 33, 43)),
|
||
"mean_motion_double_dot": parse_exponential(field(line, 44, 52)),
|
||
"bstar": parse_exponential(field(line, 53, 61)),
|
||
"element_set_number": int(field(line, 65, 68)),
|
||
"checksum": checksum(line),
|
||
}
|
||
|
||
|
||
def parse_line_two(line: str) -> dict:
|
||
"""Parses the second line by column index.
|
||
|
||
按列索引解析第二行。
|
||
"""
|
||
# The eccentricity is stored as a leading decimal: the column holds the
|
||
# fractional digits and the decimal point is implied before the first.
|
||
# 偏心率以隐含的前导小数点存储:该列保存小数位,小数点隐含在第一位之前。
|
||
eccentricity = float("0." + field(line, 26, 33).strip())
|
||
return {
|
||
"inclination_deg": float(field(line, 8, 16)),
|
||
"right_ascension_deg": float(field(line, 17, 25)),
|
||
"eccentricity": eccentricity,
|
||
"argument_of_perigee_deg": float(field(line, 34, 42)),
|
||
"mean_anomaly_deg": float(field(line, 43, 51)),
|
||
"mean_motion": float(field(line, 52, 63)),
|
||
"revolution_number": int(field(line, 63, 68)),
|
||
"checksum": checksum(line),
|
||
}
|
||
|
||
|
||
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_tle > " + str(path),
|
||
file=sys.stderr,
|
||
)
|
||
return 2
|
||
|
||
with path.open(newline="") as handle:
|
||
rows = list(csv.DictReader(handle))
|
||
if not rows:
|
||
print("input file has no data rows", file=sys.stderr)
|
||
return 2
|
||
|
||
failures: list[str] = []
|
||
compared = 0
|
||
|
||
for row in rows:
|
||
name = row["name"]
|
||
if name not in ELEMENT_SETS:
|
||
failures.append(f"{name}: not in the reference set")
|
||
continue
|
||
first, second = ELEMENT_SETS[name]
|
||
one = parse_line_one(first)
|
||
two = parse_line_two(second)
|
||
|
||
def check(field_name: str, expected, actual, tolerance=ABSOLUTE_TOLERANCE) -> None:
|
||
if isinstance(expected, float):
|
||
if abs(expected - actual) > tolerance:
|
||
failures.append(f"{name}.{field_name}: {actual} against {expected}")
|
||
elif expected != actual:
|
||
failures.append(f"{name}.{field_name}: {actual!r} against {expected!r}")
|
||
|
||
check("satellite_number", one["satellite_number"], int(row["satellite_number"]))
|
||
check("classification", one["classification"], row["classification"])
|
||
check(
|
||
"international_designator",
|
||
one["international_designator"],
|
||
row["international_designator"],
|
||
)
|
||
check("epoch_year", one["epoch_year"], int(row["epoch_year"]))
|
||
check(
|
||
"mean_motion_dot",
|
||
one["mean_motion_dot"],
|
||
float(row["e_mean_motion_dot"]),
|
||
EXPONENT_TOLERANCE,
|
||
)
|
||
check(
|
||
"mean_motion_double_dot",
|
||
one["mean_motion_double_dot"],
|
||
float(row["e_mean_motion_double_dot"]),
|
||
EXPONENT_TOLERANCE,
|
||
)
|
||
check("bstar", one["bstar"], float(row["bstar"]), EXPONENT_TOLERANCE)
|
||
check(
|
||
"element_set_number",
|
||
one["element_set_number"],
|
||
int(row["element_set_number"]),
|
||
)
|
||
check("inclination_deg", two["inclination_deg"], float(row["inclination_deg"]))
|
||
check(
|
||
"right_ascension_deg",
|
||
two["right_ascension_deg"],
|
||
float(row["right_ascension_deg"]),
|
||
)
|
||
check("eccentricity", two["eccentricity"], float(row["eccentricity"]))
|
||
check(
|
||
"argument_of_perigee_deg",
|
||
two["argument_of_perigee_deg"],
|
||
float(row["argument_of_perigee_deg"]),
|
||
)
|
||
check("mean_anomaly_deg", two["mean_anomaly_deg"], float(row["mean_anomaly_deg"]))
|
||
check("mean_motion", two["mean_motion"], float(row["mean_motion"]))
|
||
check(
|
||
"revolution_number",
|
||
two["revolution_number"],
|
||
int(row["revolution_number"]),
|
||
)
|
||
check("line1_checksum", one["checksum"], int(row["line1_checksum"]))
|
||
check("line2_checksum", two["checksum"], int(row["line2_checksum"]))
|
||
compared += 1
|
||
|
||
print(f"element sets compared: {compared}")
|
||
print(f"fields per set: 17")
|
||
for failure in failures:
|
||
print(f" {failure}")
|
||
if failures:
|
||
print("result: FAIL")
|
||
return 1
|
||
print("result: pass")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main(sys.argv))
|