Files
code-test-drone/utils/tests/pod_bridge/sim_fc_test.py
T
keita e1dbf081ab 新增sim_fc.py: 模拟飞控喂表3验证吊舱解算(第1阶段)
- 伪造载机姿态/位置/时戳持续喂表3,单进程收发一体观测表2-2/表4
- 回答Q1(无激光是否仍解算表4)/Q2(表4实际频率)/Q3(丢失后行为)
- 自动判读「目标贴地」假设: 表4相对高度是否约等于喂入的载机高度
- 退出码0/1/2/3便于脚本串测; --json落逐帧采样供阶段2聚类分析
- 默认参数已敲定: lat=39.9042 lon=116.4074 rel-alt=50 rate=20Hz
- 纯软单测21条(注入fake serial), 全量回归372 passed
2026-09-29 14:39:26 +08:00

270 lines
9.5 KiB
Python

"""sim_fc_test.py —— 模拟飞控喂表3 工具的纯软单测(注入 fake serial,无硬件)。
覆盖:
* Feeder:静态姿态 / 时戳冻结 / yaw 匀速推进 / 地速位移;
* offset_m: East/North 换算方向;
* Collector:遥测与表4 计数、锁定跳变、贴地假设、回环帧识别;
* stddev / ptpm 统计;
* verdict 四种判读分支;
* main() 端到端(假串口回回放表4)→ 退出码 0。
"""
import sys
import types
import pytest
from pod_bridge.protocol import (CarrierAttitude, TargetGeo, Telemetry,
encode_attitude, encode_target)
from pod_bridge.sim_fc import (EXIT_LINK_ERROR, EXIT_NO_TARGET_FRAME, EXIT_OK,
EXIT_ZERO_GEO, Collector, Feeder, main, offset_m,
ptpm, stddev, verdict)
_LAT, _LON = 39.9042, 116.4074
class _FakeSerialInstance:
"""pyserial.Serial 的最小替身:可预设 RX 字节,记录 TX 写入。"""
def __init__(self, **kwargs):
self.kwargs = kwargs
self._buf = b""
self.written = []
self.closed = False
def seed_rx(self, data: bytes) -> None:
self._buf += data
@property
def in_waiting(self):
return len(self._buf)
def read(self, n):
data, self._buf = self._buf[:n], self._buf[n:]
return data
def write(self, data):
self.written.append(bytes(data))
return len(data)
def close(self):
self.closed = True
@pytest.fixture
def fake_serial(monkeypatch):
"""注入 serial 模块并返回**共享实例**——PodSerial 内部 Serial(...) 必须拿到
同一个对象,否则外部 seed_rx 预置的字节 main() 读不到。"""
mod = types.ModuleType("serial")
mod.EIGHTBITS = 8
mod.PARITY_NONE = "N"
mod.STOPBITS_ONE = 1
shared = _FakeSerialInstance(port="FAKE")
mod.Serial = lambda **kwargs: shared
monkeypatch.setitem(sys.modules, "serial", mod)
return shared
def _locked_telemetry(locked: bool) -> Telemetry:
status1 = (1 << 2) if locked else 0
return Telemetry(status1=status1)
# --- Feeder -----------------------------------------------------------------
def test_feeder_static_defaults():
f = Feeder(_LAT, _LON, abs_alt_m=50.0, rel_alt_m=50.0)
frame = f.build(0.0)
assert isinstance(frame, CarrierAttitude)
assert (frame.latitude_deg, frame.longitude_deg) == (_LAT, _LON)
assert (frame.roll_deg, frame.pitch_deg, frame.yaw_deg) == (0.0, 0.0, 0.0)
assert (frame.abs_alt_m, frame.rel_alt_m) == (50.0, 50.0)
assert 0 <= frame.centisecond <= 99
def test_feeder_freeze_time_keeps_stamp():
f = Feeder(_LAT, _LON, 50.0, 50.0, freeze_time=True)
assert f.build(0.0).second == f.build(30.0).second
def test_feeder_yaw_sweep_wraps():
f = Feeder(_LAT, _LON, 50.0, 50.0, yaw_deg=350.0, yaw_rate_dps=20.0)
assert abs(f.build(0.0).yaw_deg - 350.0) < 1e-9
assert abs(f.build(1.0).yaw_deg - 10.0) < 1e-9 # 360 → 回绕到 10°
def test_feeder_ground_speed_moves_position_north():
f = Feeder(_LAT, _LON, 50.0, 50.0, yaw_deg=0.0, ground_speed_mps=10.0)
later = f.build(10.0) # 向北 100m
north_m = (later.latitude_deg - _LAT) * 111320.0
assert 95.0 < north_m < 105.0
assert abs((later.longitude_deg - _LON) * 111320.0) < 1.0
# --- 几何 / 统计 ------------------------------------------------------------
def test_offset_m_directions():
east, north = offset_m(0.0, 0.0, 0.0, 0.0001)
assert north == pytest.approx(0.0, abs=1e-6)
assert east > 10.0
east2, north2 = offset_m(0.0, 0.0, 0.0001, 0.0)
assert east2 == pytest.approx(0.0, abs=1e-6)
assert north2 > 10.0
def test_stddev_and_ptpm():
assert stddev([1.0]) == 0.0
assert stddev([]) == 0.0
assert stddev([2.0, 2.0, 2.0]) == 0.0
assert stddev([1.0, 3.0]) == pytest.approx(1.0)
assert ptpm([]) == 0.0
assert ptpm([1.0, 5.0, 3.0]) == pytest.approx(4.0)
# --- Collector --------------------------------------------------------------
def test_collector_counts_and_lock_events():
c = Collector(_LAT, _LON)
for t, lk in ((0.1, False), (0.2, True), (0.3, True), (0.4, False)):
c.update(_locked_telemetry(lk), t)
assert c.n_tele == 4
assert c.n_lock_events == 2 # False→True, True→False
assert c.first_lock_s == pytest.approx(0.2)
assert c.n_tgt == 0
def test_collector_target_stats_and_first_frame():
c = Collector(_LAT, _LON)
assert c.has_valid_geo is False
c.update(TargetGeo(latitude_deg=_LAT, longitude_deg=_LON), 1.0)
c.update(TargetGeo(latitude_deg=_LAT + 0.0001, longitude_deg=_LON), 2.0)
assert c.has_valid_geo is True
assert c.first_tgt_s == pytest.approx(1.0)
assert c.last_tgt_s == pytest.approx(2.0)
north = c.records[-1]["north_m"]
assert north == pytest.approx(11.132, abs=0.5)
def test_collector_flags_loopback_and_unknown():
c = Collector(_LAT, _LON)
c.update(CarrierAttitude(), 0.1)
c.update(object(), 0.2)
assert c.n_loopback == 1
assert c.n_other == 1
def test_collector_summary_includes_geo_validity():
c = Collector(_LAT, _LON)
assert c.summary(1.0, 20, 50.0, 50.0)["has_valid_geo"] is False
c.update(TargetGeo(latitude_deg=_LAT, longitude_deg=_LON, rel_alt_m=50.0), 0.5)
summary = c.summary(1.0, 20, 50.0, 50.0)
assert summary["has_valid_geo"] is True
assert summary["target_hz"] == pytest.approx(1.0)
assert summary["target_stale_s"] == pytest.approx(0.5)
# --- verdict ----------------------------------------------------------------
def _summary(**over):
base = {
"elapsed_s": 10.0, "target_frames": 0, "target_hz": 0.0,
"first_target_s": None, "first_lock_s": None, "has_valid_geo": False,
"loopback_frames": 0, "east_mean_m": 0.0, "north_mean_m": 0.0,
"spread_east_m": 0.0, "spread_north_m": 0.0,
"rel_alt_mean_m": 0.0, "ref_rel_alt_m": 50.0,
}
base.update(over)
return base
def test_verdict_no_target_frame():
code, lines = verdict(_summary(), None)
assert code == EXIT_NO_TARGET_FRAME
assert any("Q1 失败" in ln for ln in lines)
def test_verdict_zero_geo():
code, lines = verdict(_summary(target_frames=100), None)
assert code == EXIT_ZERO_GEO
assert any("有帧无解算" in ln for ln in lines)
def test_verdict_ok_and_ground_plane_warning():
code, lines = verdict(_summary(target_frames=100, target_hz=5.5,
has_valid_geo=True, first_target_s=3.4,
first_lock_s=3.0, rel_alt_mean_m=50.0), None)
assert code == EXIT_OK
assert any("Q1 通过" in ln for ln in lines)
assert any("目标在地面" in ln for ln in lines) # 表4 相对高度 ≈ 喂入值
def test_verdict_link_error_takes_priority():
code, lines = verdict(_summary(target_frames=100, has_valid_geo=True),
OSError(5, "Input/output error"))
assert code == EXIT_LINK_ERROR
assert any("链路中断" in ln for ln in lines)
def test_verdict_flags_loopback():
code, lines = verdict(_summary(target_frames=1, has_valid_geo=True,
loopback_frames=7), None)
assert code == EXIT_OK
assert any("回环" in ln for ln in lines)
# --- main() 端到端(假串口回环) ---------------------------------------------
def test_main_end_to_end_reports_target(fake_serial, tmp_path, capsys):
inst = fake_serial
target = TargetGeo(latitude_deg=_LAT + 0.0002, longitude_deg=_LON,
abs_alt_m=50.0, rel_alt_m=50.0)
inst.seed_rx(encode_target(target))
code = main(["FAKE", "--seconds", "0.05", "--rate", "60",
"--rel-alt", "50", "--quiet", "--json", str(tmp_path / "r.json")])
out = capsys.readouterr().out
assert code == EXIT_OK
assert inst.closed is True
assert len(inst.written) >= 1 # 至少发过一帧 表3
assert inst.written[0].startswith(b"\xeb\x91") # 同步字 EB 91
assert "Q1 通过" in out
assert (tmp_path / "r.json").exists()
def test_main_no_target_returns_error(fake_serial, capsys):
fake_serial # RX 为空
code = main(["FAKE", "--seconds", "0.05", "--rate", "60", "--quiet"])
assert code == EXIT_NO_TARGET_FRAME
def test_main_rejects_bad_rate(fake_serial, capsys):
code = main(["FAKE", "--rate", "100", "--quiet"])
out = capsys.readouterr().out
assert code == EXIT_LINK_ERROR
assert "1~60Hz" in out
def test_main_auto_lock_requires_detect(fake_serial, capsys):
code = main(["FAKE", "--auto-lock", "--quiet"])
out = capsys.readouterr().out
assert code == EXIT_LINK_ERROR
assert "--auto-lock 需配合" in out
def test_main_sends_detect_sequence(fake_serial, capsys):
inst = fake_serial
main(["FAKE", "--seconds", "0.02", "--rate", "60", "--quiet",
"--detect", "drone", "--auto-lock", "--motor-on"])
codes = [w[2] for w in inst.written]
assert 0x27 in codes # 开电机
assert 0x07 in codes # 目标识别开
assert 0x0F in codes # 自动锁定
assert 0x10 in codes # 识别转跟踪
def test_feeder_frame_is_encodable():
"""Feeder 产物必须能通过 protocol 编码/解码往返(防止字段对不上规格)。"""
from pod_bridge.protocol import decode_attitude
f = Feeder(_LAT, _LON, 50.0, 50.0, roll_deg=1.5, pitch_deg=-2.0, yaw_deg=30.0)
frame = f.build(0.0)
back = decode_attitude(encode_attitude(frame))
assert back.latitude_deg == pytest.approx(frame.latitude_deg, abs=1e-7)
assert back.rel_alt_m == pytest.approx(50.0, abs=0.2) # ALT_SCALE=0.2m
assert back.yaw_deg == pytest.approx(30.0, abs=0.01)