Files
code-test-drone/utils/tests/pod_bridge/bridge_test.py
T
keita 5a944af4f5 实现L1航向伺服(路线A1): 吊舱角度直入控制律 + 桥接胶水 + 干跑入口
- visual_track_control: ControlSnapshot 加可选 los_beta_deg/los_alpha_deg,
  第④步角度直入优先否则回退像素路径(向后兼容); config 拆出 load_from_dict 供内嵌复用
- pod_bridge: BridgeConfig 内嵌 TrackingConfig + 新 HeadingServoConfig;
  bridge._build_snapshot 合成 β/α(姿态来自PX4/角度来自吊舱,信号双路适配无电机),
  step 按 heading_servo.enabled 切 L1/L2, DEGRADED_HOLD 补发刹车保 offboard 心跳;
  run() 循环频率按生效律取(修 L1 频率错配)
- run_bridge.py: 闭环 CLI 入口, --dry-run(S2 只接吊舱不发offboard) 与实飞优雅停机
- 配置落 L1 首飞值(slow_approach/0.2mps、垂直压低、无电机仅用脱靶量)
- 新增测试 29 条(vtc5+config9+bridgeL1 10+runner5); 全量 401 passed
2026-10-09 12:52:14 +08:00

526 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""bridge_test.py —— PodBridge 闭环主循环单元测试(FakePx4 + FakePod,无硬件)。
覆盖:单周期 step(有/无目标、位置无效、位置时间戳超龄)、姿态下发节流与帧内容、
吊舱帧入站缓存、锁定门控(表2-2 bit2 + 遥测超龄)、触发式控制指令编码、
start/stop 生命周期与停机刹车、run() 主循环异常护栏与故障恢复。
"""
import asyncio
import time
from dataclasses import replace
import pytest
from pod_bridge.bridge import PodBridge
from pod_bridge.config import BridgeConfig, load_from_json
from pod_bridge.protocol import (ControlCode, ControlCommand, TargetGeo,
Telemetry, decode_attitude, decode_control)
from pod_bridge.px4_gateway import OwnState
class FakePx4:
def __init__(self, state=None):
self._state = state or OwnState()
self.telemetry_started = False
self.telemetry_stopped = False
self.offboard_started = False
self.offboard_stopped = False
self.offboard_start_ok = True
self.velocity_calls = []
self.brake_calls = []
def own_state(self):
return replace(self._state)
async def start_telemetry(self):
self.telemetry_started = True
async def stop_telemetry(self):
self.telemetry_stopped = True
async def start_offboard(self):
self.offboard_started = True
return self.offboard_start_ok
async def stop_offboard(self):
self.offboard_stopped = True
async def send_velocity_heading(self, out, time_boot_ms=None):
self.velocity_calls.append((out, time_boot_ms))
return True
async def send_brake_hover(self, time_boot_ms=None):
self.brake_calls.append(time_boot_ms)
return True
class FakePod:
def __init__(self):
self.inbox = []
self.sent = []
self.closed = False
def queue(self, *frames):
self.inbox.extend(frames)
def poll(self):
out, self.inbox = self.inbox, []
return out
def send(self, data):
self.sent.append(data)
def close(self):
self.closed = True
def _valid_state(now_ms, lat=0.0, lon=0.0, yaw_rad=0.0, gs=0.0):
return OwnState(latitude_deg=lat, longitude_deg=lon, yaw_rad=yaw_rad,
ground_speed_mps=gs, fix_type=3, num_satellites=12,
has_position=True, has_attitude=True, position_valid=True,
time_ms=now_ms, position_time_ms=now_ms)
def _locked_telemetry(laser=50.0):
"""状态字节1 bit2=1(锁定)的遥测帧;默认带有效激光(默认闸门要求激光有效)。"""
return Telemetry(status1=0b00000100, laser_range_m=laser)
def _bridge(px4, pod, cfg=None):
return PodBridge(cfg or BridgeConfig(), px4, pod, now_ms_fn=lambda: 0)
# --- step:有目标 → 速度指令 ------------------------------------------------
def test_step_with_target_commands_velocity():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(TargetGeo(latitude_deg=0.001, longitude_deg=0.0), # 正北 ~111m
_locked_telemetry())
b = _bridge(px4, pod)
out = asyncio.run(b.step(1000))
assert out.mode.name == "VELOCITY_HEADING"
assert len(px4.velocity_calls) == 1
assert px4.brake_calls == []
def test_step_without_target_brakes():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
b = _bridge(px4, pod)
out = asyncio.run(b.step(1000))
assert out.mode.name == "BRAKE_HOVER"
assert len(px4.brake_calls) == 1
assert px4.velocity_calls == []
def test_step_invalid_position_brakes_even_with_target():
state = replace(_valid_state(1000), position_valid=False, fix_type=0)
px4 = FakePx4(state)
pod = FakePod()
pod.queue(TargetGeo(latitude_deg=0.001))
b = _bridge(px4, pod)
out = asyncio.run(b.step(1000))
assert out.mode.name == "BRAKE_HOVER"
# --- 姿态下发:首拍即发 + 节流 + 帧内容 ------------------------------------
def test_attitude_downlinked_on_first_step_with_own_state():
px4 = FakePx4(_valid_state(1000, lat=31.5, lon=121.5, yaw_rad=1.57, gs=4.0))
pod = FakePod()
b = _bridge(px4, pod)
asyncio.run(b.step(1000))
assert len(pod.sent) == 1
att = decode_attitude(pod.sent[0])
assert abs(att.latitude_deg - 31.5) < 1e-6
assert abs(att.longitude_deg - 121.5) < 1e-6
assert abs(att.yaw_deg - 89.95) < 0.02 # 1.57 rad ≈ 89.95°
assert att.ground_speed_mps == 4.0
assert att.year >= 2026 and 1 <= att.month <= 12
def test_attitude_downlink_throttled():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
cfg = BridgeConfig()
cfg.link.attitude_downlink_hz = 20.0 # 周期 50ms
b = _bridge(px4, pod, cfg)
async def scenario():
await b.step(1000) # 发(首拍)
await b.step(1010) # 10ms < 50ms,跳过
await b.step(1040) # 40ms < 50ms,跳过
await b.step(1060) # 60ms ≥ 50ms,发
asyncio.run(scenario())
assert len(pod.sent) == 2
# --- 吊舱帧入站缓存 --------------------------------------------------------
def test_target_frame_updates_latest_and_flips_to_velocity():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
b = _bridge(px4, pod)
asyncio.run(b.step(1000)) # 无目标 → 刹车
assert b.latest_target is None
pod.queue(TargetGeo(latitude_deg=0.001), _locked_telemetry())
asyncio.run(b.step(1100)) # 收到目标且锁定 → 速度
assert b.latest_target is not None
assert len(px4.velocity_calls) == 1
def test_telemetry_frame_cached():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(Telemetry(laser_range_m=88.8))
b = _bridge(px4, pod)
asyncio.run(b.step(1000))
assert b.latest_telemetry is not None
assert b.latest_telemetry[0].laser_range_m == 88.8
# --- 触发式控制指令 --------------------------------------------------------
def test_send_control_encodes_frame():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
b = _bridge(px4, pod)
b.send_control(ControlCommand(code=ControlCode.CENTER))
assert len(pod.sent) == 1
assert decode_control(pod.sent[0]).code == ControlCode.CENTER
# --- 生命周期 --------------------------------------------------------------
def test_start_wires_telemetry_and_offboard():
px4 = FakePx4(_valid_state(0))
b = _bridge(px4, FakePod())
assert asyncio.run(b.start()) is True
assert px4.telemetry_started and px4.offboard_started
def test_start_returns_false_when_offboard_fails():
px4 = FakePx4(_valid_state(0))
px4.offboard_start_ok = False
b = _bridge(px4, FakePod())
assert asyncio.run(b.start()) is False
def test_stop_brakes_and_tears_down():
px4 = FakePx4(_valid_state(0))
pod = FakePod()
b = _bridge(px4, pod)
async def scenario():
await b.start()
await b.stop()
asyncio.run(scenario())
assert len(px4.brake_calls) >= 1 # 停机前刹车
assert px4.offboard_stopped and px4.telemetry_stopped
assert pod.closed
# --- 缺陷①回归:position 时间戳超龄 -------------------------------------------
def test_step_brakes_when_position_timestamp_stale():
"""time_ms 被姿态流刷得很新,但 position_time_ms 超龄 → 必须刹车。"""
state = replace(_valid_state(1000), time_ms=100000, position_time_ms=1000)
px4 = FakePx4(state)
pod = FakePod()
pod.queue(TargetGeo(latitude_deg=0.001), _locked_telemetry())
b = _bridge(px4, pod)
out = asyncio.run(b.step(100000)) # position 已 99s 未更新
assert out.mode.name == "BRAKE_HOVER"
assert px4.velocity_calls == []
# --- 缺陷②:锁定门控 -----------------------------------------------------------
def test_target_without_lock_bit_brakes():
"""吊舱在搜索状态(bit2=0),即使收到表4 坐标也必须刹车。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(TargetGeo(latitude_deg=0.001), Telemetry(status1=0)) # 未锁定
b = _bridge(px4, pod)
out = asyncio.run(b.step(1000))
assert out.mode.name == "BRAKE_HOVER"
assert px4.velocity_calls == []
def test_target_without_any_telemetry_brakes():
"""只收到表4、从未收到表2-2 → 锁定状态未知,按未锁定处理。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(TargetGeo(latitude_deg=0.001))
b = _bridge(px4, pod)
out = asyncio.run(b.step(1000))
assert out.mode.name == "BRAKE_HOVER"
def test_target_with_stale_telemetry_brakes():
"""锁定遥测超龄(> link.telemetry_timeout_ms)→ 目标视为无效。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
cfg = BridgeConfig()
cfg.link.telemetry_timeout_ms = 500
b = PodBridge(cfg, px4, pod, now_ms_fn=lambda: 0)
async def scenario():
pod.queue(_locked_telemetry(), TargetGeo(latitude_deg=0.001))
await b.step(1000) # 遥测新鲜,但此刻若无目标则刹车
pod.queue(TargetGeo(latitude_deg=0.001))
out1 = await b.step(1200) # 遥测 200ms 前,仍新鲜 → 速度
out2 = await b.step(1600) # 遥测 600ms 前,超龄 → 刹车
return out1, out2
out1, out2 = asyncio.run(scenario())
assert out1.mode.name == "VELOCITY_HEADING"
assert out2.mode.name == "BRAKE_HOVER"
def test_lock_lost_midflight_falls_back_to_brake():
"""先锁定引导,随后遥测变为搜索状态 → 立即回落刹车。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
b = _bridge(px4, pod)
async def scenario():
pod.queue(TargetGeo(latitude_deg=0.001), _locked_telemetry())
out1 = await b.step(1000)
pod.queue(Telemetry(status1=0)) # 丢失锁定
out2 = await b.step(1100)
return out1, out2
out1, out2 = asyncio.run(scenario())
assert out1.mode.name == "VELOCITY_HEADING"
assert out2.mode.name == "BRAKE_HOVER"
# --- 激光有效性门控(锁定位置位粘滞时的兜底)-----------------------------------
def test_laser_invalid_blocks_guidance_when_required():
"""默认 require_laser_valid=True:锁定位置位但激光无效 → 目标无效 → 刹车。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(TargetGeo(latitude_deg=0.001), _locked_telemetry(laser=None))
b = _bridge(px4, pod)
out = asyncio.run(b.step(1000))
assert out.mode.name == "BRAKE_HOVER"
assert px4.velocity_calls == []
def test_laser_invalid_allowed_when_gate_disabled():
"""本设备无激光(厂商确认)→ require_laser_valid=False 时照常引导。"""
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(TargetGeo(latitude_deg=0.001), _locked_telemetry(laser=None))
cfg = BridgeConfig()
cfg.link.require_laser_valid = False
b = _bridge(px4, pod, cfg)
out = asyncio.run(b.step(1000))
assert out.mode.name == "VELOCITY_HEADING"
def test_deployed_config_disables_laser_gate():
"""部署配置回归:本机无激光,pod_bridge.json 必须关掉激光闸门。"""
import os
path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "config", "pod_bridge.json")
cfg = load_from_json(path)
assert cfg.link.require_laser_valid is False
# --- 缺陷③:run() 异常护栏 -----------------------------------------------------
class FlakyPod(FakePod):
"""前 fail_times 次 poll 抛串口异常,之后恢复正常。"""
def __init__(self, fail_times):
super().__init__()
self.fail_times = fail_times
def poll(self):
if self.fail_times > 0:
self.fail_times -= 1
raise OSError("serial device disconnected")
return super().poll()
def _run_bridge(px4, pod, hz=200.0):
cfg = BridgeConfig()
cfg.guidance.frequency_hz = hz # 加快节拍,缩短测试时间
return PodBridge(cfg, px4, pod) # 用真实单调时钟
def test_run_survives_poll_exceptions_and_keeps_heartbeat():
"""串口持续异常:任务不死、每周期补发刹车保心跳、stop_event 正常退出。"""
now = int(time.monotonic() * 1000)
px4 = FakePx4(_valid_state(now))
pod = FlakyPod(fail_times=10**9) # 永远失败
b = _run_bridge(px4, pod)
async def scenario():
stop = asyncio.Event()
task = asyncio.ensure_future(b.run(stop))
await asyncio.sleep(0.2)
stop.set()
await asyncio.wait_for(task, timeout=2.0)
asyncio.run(scenario())
assert len(px4.brake_calls) > 0 # 异常期间持续补发刹车
assert pod.fail_times > 0 # 循环确实在反复尝试(未提前退出)
def test_run_recovers_after_transient_failure():
"""串口短暂异常后恢复:异常期刹车,恢复后照常引导。"""
now = int(time.monotonic() * 1000)
px4 = FakePx4(_valid_state(now))
pod = FlakyPod(fail_times=3)
pod.queue(TargetGeo(latitude_deg=0.001), _locked_telemetry())
b = _run_bridge(px4, pod)
async def scenario():
stop = asyncio.Event()
task = asyncio.ensure_future(b.run(stop))
await asyncio.sleep(0.3)
stop.set()
await asyncio.wait_for(task, timeout=2.0)
asyncio.run(scenario())
assert len(px4.brake_calls) >= 3 # 3 次异常各补一发刹车
assert len(px4.velocity_calls) >= 1 # 恢复后进入正常引导
assert b._step_failures == 0 # 计数已复位
# --- L1 航向伺服(方案 §2,heading_servo.enabled=True)-----------------------
def _l1_config(**hs_overrides):
"""L1 配置:控制律限幅近似透明,前向置 0,隔离验证 β→yaw_rate / α→vz 数学。"""
cfg = BridgeConfig()
cfg.heading_servo.enabled = True
for k, v in hs_overrides.items():
setattr(cfg.heading_servo, k, v)
t = cfg.tracking
t.heading.gain = 1.0
t.heading.yaw_rate_limit_dps = 30.0
t.heading.yaw_slew_limit_dps2 = 100000.0
t.vertical.vz_gain_mps_per_deg = 0.1
t.vertical.vz_limit_mps = 2.0
t.accel_limit.ax_mps2 = 1000.0
t.accel_limit.ay_mps2 = 1000.0
t.accel_limit.az_mps2 = 1000.0
t.distance.no_distance_approach_limit_mps = 0.0 # L1 前向隔离为 0
return cfg
def _locked_off_target(x=0.0, y=0.0, frame_yaw=0.0, frame_pitch=0.0, laser=50.0):
"""bit2=锁定 + 有效激光,带指定脱靶量/框架角的遥测帧。"""
return Telemetry(status1=0b00000100, laser_range_m=laser,
off_target_x_deg=x, off_target_y_deg=y,
frame_yaw_deg=frame_yaw, frame_pitch_deg=frame_pitch)
def test_l1_off_target_beta_drives_yaw_rate():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(_locked_off_target(x=10.0)) # 目标偏右 10° → 右转正
b = _bridge(px4, pod, _l1_config())
out = asyncio.run(b.step(1000))
assert out.mode.name == "VELOCITY_HEADING"
assert out.yaw_rate_dps == pytest.approx(10.0, abs=1e-9)
assert len(px4.velocity_calls) == 1
def test_l1_beta_sign_flips_direction():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(_locked_off_target(x=10.0))
b = _bridge(px4, pod, _l1_config(beta_sign=-1)) # 台架标定若接反,配置翻符号
out = asyncio.run(b.step(1000))
assert out.yaw_rate_dps == pytest.approx(-10.0, abs=1e-9)
def test_l1_frame_angle_ignored_when_disabled():
# 无电机:beta_from_frame=False → frame_yaw 不贡献,仅脱靶量生效
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(_locked_off_target(x=5.0, frame_yaw=99.0))
b = _bridge(px4, pod, _l1_config())
out = asyncio.run(b.step(1000))
assert out.yaw_rate_dps == pytest.approx(5.0, abs=1e-9)
def test_l1_frame_angle_added_when_enabled():
# 装电机后:beta_from_frame=True → β = frame_yaw + off_target_x
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(_locked_off_target(x=5.0, frame_yaw=3.0))
b = _bridge(px4, pod, _l1_config(beta_from_frame=True))
out = asyncio.run(b.step(1000))
assert out.yaw_rate_dps == pytest.approx(8.0, abs=1e-9)
def test_l1_vertical_enabled_drives_vz():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(_locked_off_target(y=20.0)) # α=20° × gain 0.1 = 2.0(上限 2.0)
b = _bridge(px4, pod, _l1_config())
out = asyncio.run(b.step(1000))
assert out.vz_mps == pytest.approx(2.0, abs=1e-9)
def test_l1_vertical_disabled_zeroes_vz():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(_locked_off_target(y=20.0))
b = _bridge(px4, pod, _l1_config(vertical_enabled=False))
out = asyncio.run(b.step(1000))
assert out.vz_mps == 0.0
def test_l1_not_locked_brakes():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(Telemetry(status1=0, off_target_x_deg=10.0)) # 搜索态
b = _bridge(px4, pod, _l1_config())
out = asyncio.run(b.step(1000))
assert out.mode.name == "BRAKE_HOVER"
assert px4.velocity_calls == []
assert len(px4.brake_calls) == 1
def test_l1_no_telemetry_degrades_and_keeps_heartbeat():
# 无表2-2 → track_time_ms=0 → 控制律判视觉超龄 → DEGRADED_HOLD;仍补发刹车保心跳
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
b = _bridge(px4, pod, _l1_config())
out = asyncio.run(b.step(1000))
assert out.mode.name == "DEGRADED_HOLD"
assert px4.velocity_calls == []
assert len(px4.brake_calls) == 1
def test_l1_require_vision_blocks_without_source():
# §2.3 第二判据:require_vision=True 但未接视觉源 → fail-safe 视为无目标 → 刹车
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(_locked_off_target(x=10.0))
b = _bridge(px4, pod, _l1_config(require_vision=True))
out = asyncio.run(b.step(1000))
assert out.mode.name == "BRAKE_HOVER"
assert px4.velocity_calls == []
def test_l1_require_vision_passes_with_source():
px4 = FakePx4(_valid_state(1000))
pod = FakePod()
pod.queue(_locked_off_target(x=10.0))
b = PodBridge(_l1_config(require_vision=True), px4, pod,
now_ms_fn=lambda: 0, vision_target_fn=lambda: True)
out = asyncio.run(b.step(1000))
assert out.mode.name == "VELOCITY_HEADING"
assert out.yaw_rate_dps == pytest.approx(10.0, abs=1e-9)