apsis-rs/validation/python/link/check_link.py

146 lines
4.8 KiB
Python

"""Cross-check the ApsisRS link budget against the link equation.
Reads the scenarios written by ``cargo run -p apsis-rs --features rf
--example dump_link`` and recomputes each figure from the link equation in
plain Python, then reports the largest difference in each column.
The reference here is the link equation itself rather than a library, because
the equation is closed form and short enough to transcribe without
ambiguity. The constants are taken from the 2019 SI definitions, so they
match the values the Rust side uses.
The script needs no network access.
Usage:
pixi run python validation/python/link/check_link.py [path]
Exit status is zero when every figure agrees within tolerance and one
otherwise.
用链路方程交叉核对 ApsisRS 的链路预算。
读取由 ``cargo run -p apsis-rs --features rf --example dump_link`` 写出的
场景,并用纯 Python 从链路方程逐个重算每个数字,然后报告每列的最大差异。
此处的参考是链路方程本身而非某个库,因为该方程是闭式的且短到可以无歧义地
抄录。各常量取自 2019 年 SI 定义,故与 Rust 侧所用的值一致。
本脚本不需要网络访问。
用法:
pixi run python validation/python/link/check_link.py [path]
每个数字都在容差内一致时退出状态为零,否则为一。
"""
from __future__ import annotations
import csv
import math
import sys
from pathlib import Path
# Defining constants, exact in the SI.
# 定义常量,在 SI 中精确。
BOLTZMANN = 1.380649e-23
SPEED_OF_LIGHT = 299792458.0
# Tolerance in decibels. The two sides evaluate the same expressions with
# double precision, so the only difference should be rounding.
# 容差,单位为分贝。两侧以双精度求相同表达式,故唯一差异应是舍入。
TOLERANCE_DB = 1e-9
DEFAULT_INPUT = Path("validation/data/expected/rust_link.csv")
def read_rows(path: Path) -> list[dict[str, str]]:
"""Reads the CSV rows.
读取 CSV 各行。
"""
with path.open(newline="") as handle:
reader = csv.DictReader(handle)
return [row for row in reader if row.get("transmit_dbw")]
def reference(row: dict[str, str]) -> dict[str, float]:
"""Recomputes a budget from the link equation.
由链路方程重算一份预算。
"""
transmit_dbw = float(row["transmit_dbw"])
transmit_dbi = float(row["transmit_dbi"])
transmit_loss = float(row["transmit_loss_db"])
receive_dbi = float(row["receive_dbi"])
receive_loss = float(row["receive_loss_db"])
temperature = float(row["temperature_k"])
bandwidth = float(row["bandwidth_hz"])
distance = float(row["distance_m"])
frequency = float(row["frequency_hz"])
eirp_dbw = transmit_dbw + transmit_dbi - transmit_loss
wavelength = SPEED_OF_LIGHT / frequency
path_loss_db = 20.0 * math.log10(4.0 * math.pi * distance / wavelength)
carrier_dbw = eirp_dbw - path_loss_db + receive_dbi - receive_loss
noise_density_dbw = 10.0 * math.log10(BOLTZMANN * temperature)
cn0_db = carrier_dbw - noise_density_dbw
cn_db = cn0_db - 10.0 * math.log10(bandwidth)
# The example reports the energy per bit ratio at a data rate equal to
# the bandwidth.
# 示例在数据速率等于带宽时报告每比特能量比。
ebn0_db = cn0_db - 10.0 * math.log10(bandwidth)
return {
"eirp_dbw": eirp_dbw,
"carrier_dbw": carrier_dbw,
"cn0_db": cn0_db,
"cn_db": cn_db,
"ebn0_db": ebn0_db,
}
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 rf "
"--example dump_link > " + 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
columns = ["eirp_dbw", "carrier_dbw", "cn0_db", "cn_db", "ebn0_db"]
worst = {column: 0.0 for column in columns}
worst_at = {column: None for column in columns}
for row in rows:
expected = reference(row)
for column in columns:
difference = abs(float(row[column]) - expected[column])
if difference > worst[column]:
worst[column] = difference
worst_at[column] = row["distance_m"]
print(f"scenarios compared: {len(rows)}")
for column in columns:
print(f"{column:>12}: worst {worst[column]:.3e} dB at distance {worst_at[column]} m")
print(f"{'tolerance':>12}: {TOLERANCE_DB:.3e} dB")
if all(value <= TOLERANCE_DB for value in worst.values()):
print("result: pass")
return 0
print("result: FAIL")
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv))