精简仓库结构并增强任务链路:归档双目可选后端、新增LOS判据与自定义返航
归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。 目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。 自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。 任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。 入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。 新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
This commit is contained in:
@@ -6,6 +6,7 @@ start/stop 生命周期与停机刹车、run() 主循环异常护栏与故障恢
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from dataclasses import replace
|
||||
|
||||
@@ -29,6 +30,7 @@ class FakePx4:
|
||||
self.offboard_start_ok = True
|
||||
self.velocity_calls = []
|
||||
self.brake_calls = []
|
||||
self.yaw_calls = []
|
||||
|
||||
def own_state(self):
|
||||
return replace(self._state)
|
||||
@@ -54,6 +56,10 @@ class FakePx4:
|
||||
self.brake_calls.append(time_boot_ms)
|
||||
return True
|
||||
|
||||
async def send_yaw_rate(self, yaw_rate_dps):
|
||||
self.yaw_calls.append(yaw_rate_dps)
|
||||
return True
|
||||
|
||||
|
||||
class FakePod:
|
||||
def __init__(self):
|
||||
@@ -159,6 +165,116 @@ def test_attitude_downlink_throttled():
|
||||
assert len(pod.sent) == 2
|
||||
|
||||
|
||||
def test_control_law_throttled_to_own_frequency():
|
||||
"""生效律按自身频率推进:L2 配置 10Hz,外部 20Hz 调用时控制律只跑 10 次/秒。
|
||||
|
||||
链路维持(表3 姿态下发)不受此节流影响——否则吊舱解算会断流。
|
||||
"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
cfg = BridgeConfig()
|
||||
cfg.heading_servo.enabled = False # L2 地理引导(10Hz)
|
||||
cfg.guidance.frequency_hz = 10.0
|
||||
cfg.link.attitude_downlink_hz = 20.0
|
||||
cfg.ground_inject.enabled = True
|
||||
ground = GroundTarget(latitude_deg=0.01, longitude_deg=0.0, time_ms=1000)
|
||||
b = PodBridge(cfg, px4, pod, now_ms_fn=lambda: 1000,
|
||||
ground_target_fn=lambda: ground)
|
||||
|
||||
# 统计控制律实际推进次数(包装 update,不改变行为)
|
||||
law_updates = {"n": 0}
|
||||
real_update = b._guidance.update
|
||||
|
||||
def counting_update(inp, now):
|
||||
law_updates["n"] += 1
|
||||
return real_update(inp, now)
|
||||
|
||||
b._guidance.update = counting_update
|
||||
|
||||
ticks = 10 # 20Hz 调用 10 拍 = 500ms
|
||||
|
||||
async def scenario():
|
||||
for i in range(ticks):
|
||||
await b.step(1000 + i * 50)
|
||||
asyncio.run(scenario())
|
||||
|
||||
# 10Hz 下 500ms 只应推进 5 拍控制律;其余 5 拍重发上一拍输出保心跳
|
||||
assert law_updates["n"] == 5
|
||||
assert len(px4.velocity_calls) == ticks # offboard 设定点每拍都在下发
|
||||
assert len(pod.sent) == ticks # 表3 姿态下发不受控制律节流影响
|
||||
|
||||
|
||||
def test_control_law_throttle_resets_on_law_switch():
|
||||
"""切律后立即出第一拍(不被上一拍的节流压住)。"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
cfg = BridgeConfig()
|
||||
cfg.guidance.frequency_hz = 10.0
|
||||
b = _bridge(px4, pod, cfg)
|
||||
|
||||
async def scenario():
|
||||
await b.step(1000)
|
||||
b.set_heading_servo_enabled(True)
|
||||
await b.step(1010) # 切律后 10ms 即应推进
|
||||
asyncio.run(scenario())
|
||||
assert b._last_law_ms == 1010
|
||||
|
||||
|
||||
def test_stop_is_idempotent():
|
||||
"""重复 stop() 只真正停机一次(真机在环实测:RTL 与收尾各调一次)。"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
b = _bridge(px4, pod)
|
||||
|
||||
async def scenario():
|
||||
await b.start()
|
||||
await b.stop()
|
||||
await b.stop() # 第二次应为 no-op
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert pod.closed is True
|
||||
assert px4.offboard_stopped is True
|
||||
assert len(px4.brake_calls) == 1 # 刹车只发一次
|
||||
|
||||
|
||||
def test_start_after_stop_reenables_stop():
|
||||
"""停机后重新 start() 可再次生效(标志位复位)。"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
b = _bridge(px4, pod)
|
||||
|
||||
async def scenario():
|
||||
await b.start()
|
||||
await b.stop()
|
||||
assert b._stopped is True
|
||||
await b.start()
|
||||
assert b._stopped is False
|
||||
await b.stop()
|
||||
asyncio.run(scenario())
|
||||
assert len(px4.brake_calls) == 2 # 两次停机各发一次刹车
|
||||
|
||||
|
||||
def test_landing_stop_keeps_telemetry_and_pod_open():
|
||||
"""降落前只退 offboard:遥测与串口保留,供落地判定与高度曲线。"""
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
b = _bridge(px4, pod)
|
||||
|
||||
async def scenario():
|
||||
await b.start()
|
||||
await b.landing_stop()
|
||||
assert b._landing_stopped is True
|
||||
assert b._stopped is False # 未全停
|
||||
assert px4.offboard_stopped is True
|
||||
assert px4.telemetry_stopped is False # 遥测仍在
|
||||
assert pod.closed is False # 串口仍在
|
||||
await b.stop() # 收尾才全停
|
||||
asyncio.run(scenario())
|
||||
assert px4.telemetry_stopped is True
|
||||
assert pod.closed is True
|
||||
assert len(px4.brake_calls) == 2 # 两次停机各发一次刹车
|
||||
|
||||
|
||||
# --- 吊舱帧入站缓存 --------------------------------------------------------
|
||||
def test_target_frame_updates_latest_and_flips_to_velocity():
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
@@ -423,6 +539,12 @@ def _locked_off_target(x=0.0, y=0.0, frame_yaw=0.0, frame_pitch=0.0, laser=50.0)
|
||||
frame_yaw_deg=frame_yaw, frame_pitch_deg=frame_pitch)
|
||||
|
||||
|
||||
def _set_pose(px4, now_ms: int, yaw_deg: float):
|
||||
"""更新假飞控姿态与时间戳(保证控制律的姿态超龄判定通过)。"""
|
||||
px4._state = replace(px4._state, yaw_rad=math.radians(yaw_deg),
|
||||
time_ms=now_ms, position_time_ms=now_ms)
|
||||
|
||||
|
||||
def test_l1_off_target_beta_drives_yaw_rate():
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
pod = FakePod()
|
||||
@@ -503,26 +625,73 @@ def test_l1_no_telemetry_degrades_and_keeps_heartbeat():
|
||||
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))
|
||||
def test_l1_los_consistent_keeps_tracking():
|
||||
"""机头右转多少、脱靶量就左移多少(目标在世界系静止)→ 判一致,正常跟踪。"""
|
||||
clock = {"ms": 1_000_000}
|
||||
px4 = FakePx4(_valid_state(clock["ms"]))
|
||||
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 == []
|
||||
b = PodBridge(_l1_config(), px4, pod, now_ms_fn=lambda: clock["ms"])
|
||||
|
||||
async def scenario():
|
||||
for i in range(80): # 4s,20°/s 右转、脱靶量 −20°/s
|
||||
clock["ms"] += 50
|
||||
yaw = 20.0 * (i + 1) * 0.05
|
||||
beta = 10.0 - 20.0 * (i + 1) * 0.05
|
||||
_set_pose(px4, clock["ms"], yaw)
|
||||
pod.queue(_locked_off_target(x=beta))
|
||||
out = await b.step(clock["ms"])
|
||||
return out
|
||||
|
||||
def test_l1_require_vision_passes_with_source():
|
||||
px4 = FakePx4(_valid_state(1000))
|
||||
out = asyncio.run(scenario())
|
||||
assert b._los_consistent is True
|
||||
assert out.mode.name == "VELOCITY_HEADING"
|
||||
assert b.pod_locked(clock["ms"]) is True
|
||||
|
||||
|
||||
def test_l1_los_frozen_angle_blocks_tracking():
|
||||
"""机头转了 60° 而脱靶量几乎不变 → 角度冻结/幽灵目标 → 判丢(刹车)。"""
|
||||
clock = {"ms": 1_000_000}
|
||||
px4 = FakePx4(_valid_state(clock["ms"]))
|
||||
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))
|
||||
b = PodBridge(_l1_config(), px4, pod, now_ms_fn=lambda: clock["ms"])
|
||||
|
||||
async def scenario():
|
||||
out = None
|
||||
for i in range(80): # 4s,右转 20°/s,脱靶量恒定 10°
|
||||
clock["ms"] += 50
|
||||
yaw = 20.0 * (i + 1) * 0.05
|
||||
_set_pose(px4, clock["ms"], yaw)
|
||||
pod.queue(_locked_off_target(x=10.0))
|
||||
out = await b.step(clock["ms"])
|
||||
return out
|
||||
|
||||
out = asyncio.run(scenario())
|
||||
assert b._los_consistent is False
|
||||
assert out.mode.name == "BRAKE_HOVER" # 控制律被喂 tracked=False
|
||||
assert b.pod_locked(clock["ms"]) is False # 状态机侧也判为丢目标
|
||||
|
||||
|
||||
def test_l1_los_disabled_falls_back_to_bit2():
|
||||
"""关掉一致性判据 → 退回"只信 bit2"(角度冻结也不再判丢)。"""
|
||||
clock = {"ms": 1_000_000}
|
||||
px4 = FakePx4(_valid_state(clock["ms"]))
|
||||
pod = FakePod()
|
||||
b = PodBridge(_l1_config(los_consistency_enabled=False), px4, pod,
|
||||
now_ms_fn=lambda: clock["ms"])
|
||||
|
||||
async def scenario():
|
||||
out = None
|
||||
for i in range(80):
|
||||
clock["ms"] += 50
|
||||
yaw = 20.0 * (i + 1) * 0.05
|
||||
_set_pose(px4, clock["ms"], yaw)
|
||||
pod.queue(_locked_off_target(x=10.0))
|
||||
out = await b.step(clock["ms"])
|
||||
return out
|
||||
|
||||
out = asyncio.run(scenario())
|
||||
assert b._los_consistent is True
|
||||
assert out.mode.name == "VELOCITY_HEADING"
|
||||
assert out.yaw_rate_dps == pytest.approx(10.0, abs=1e-9)
|
||||
|
||||
|
||||
# --- 地面坐标注入(A2)-------------------------------------------------------
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""ground_link_test.py —— 地面坐标注入通道单元测试。
|
||||
"""ground_link_test.py —— 地面站链路单元测试。
|
||||
|
||||
覆盖:parse_payload 解析(合法/缺字段/非法 JSON/非对象/类型错误)、
|
||||
is_valid 新鲜度判定(未注入/新鲜/超龄边界)。
|
||||
parse_message 双通道(坐标 + {"cmd"} 命令)、命令队列 drain、
|
||||
is_valid 新鲜度判定(未注入/新鲜/超龄边界)、run() 真 UDP 回环。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pod_bridge.ground_link import GroundInjectConfig, GroundLink, GroundTarget
|
||||
from pod_bridge.ground_link import (GroundCommand, GroundInjectConfig,
|
||||
GroundLink, GroundTarget)
|
||||
|
||||
|
||||
# --- parse_payload ----------------------------------------------------------
|
||||
@@ -92,3 +94,108 @@ def test_latest_returns_injected_target():
|
||||
_inject(link, 1000)
|
||||
assert link.latest().latitude_deg == pytest.approx(1.0)
|
||||
assert link.latest().time_ms == 1000
|
||||
|
||||
|
||||
# --- parse_message(坐标 + 命令双通道)---------------------------------------
|
||||
def test_parse_message_command():
|
||||
m = GroundLink.parse_message(b'{"cmd": "rtl"}')
|
||||
assert isinstance(m, GroundCommand)
|
||||
assert m.name == "rtl"
|
||||
|
||||
|
||||
def test_parse_message_command_normalized():
|
||||
m = GroundLink.parse_message(b'{"cmd": " RTL "}')
|
||||
assert isinstance(m, GroundCommand)
|
||||
assert m.name == "rtl"
|
||||
|
||||
|
||||
def test_parse_message_cmd_takes_precedence_over_coords():
|
||||
m = GroundLink.parse_message(b'{"cmd": "emergency", "lat": 1.0, "lon": 2.0}')
|
||||
assert isinstance(m, GroundCommand)
|
||||
assert m.name == "emergency"
|
||||
|
||||
|
||||
def test_parse_message_coords_still_work():
|
||||
m = GroundLink.parse_message(b'{"lat": 1.0, "lon": 2.0}')
|
||||
assert isinstance(m, GroundTarget)
|
||||
assert m.latitude_deg == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_parse_message_empty_cmd_falls_back_to_coords():
|
||||
m = GroundLink.parse_message(b'{"cmd": "", "lat": 1.0, "lon": 2.0}')
|
||||
assert isinstance(m, GroundTarget)
|
||||
|
||||
|
||||
def test_parse_message_non_string_cmd_falls_back_to_coords():
|
||||
m = GroundLink.parse_message(b'{"cmd": 5, "lat": 1.0, "lon": 2.0}')
|
||||
assert isinstance(m, GroundTarget)
|
||||
|
||||
|
||||
def test_parse_message_invalid_returns_none():
|
||||
assert GroundLink.parse_message(b"not json") is None
|
||||
|
||||
|
||||
# --- drain_commands ---------------------------------------------------------
|
||||
def test_drain_commands_empty():
|
||||
assert _link().drain_commands() == []
|
||||
|
||||
|
||||
def test_drain_commands_fifo_and_clear():
|
||||
link = _link()
|
||||
link._commands.append("rtl")
|
||||
link._commands.append("pod_center")
|
||||
assert link.drain_commands() == ["rtl", "pod_center"]
|
||||
assert link.drain_commands() == [] # 取后清空
|
||||
|
||||
|
||||
def test_commands_queue_drops_oldest_when_full():
|
||||
link = _link()
|
||||
for i in range(20):
|
||||
link._commands.append(f"c{i}")
|
||||
cmds = link.drain_commands()
|
||||
assert len(cmds) == 16 # maxlen=16
|
||||
assert cmds[0] == "c4" and cmds[-1] == "c19"
|
||||
|
||||
|
||||
# --- run() 真 UDP 回环(坐标 + 命令)-----------------------------------------
|
||||
def test_run_receives_coords_and_commands():
|
||||
import asyncio
|
||||
import socket as socket_mod
|
||||
|
||||
probe = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_DGRAM)
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
probe.close()
|
||||
|
||||
link = GroundLink(GroundInjectConfig(host="127.0.0.1", port=port),
|
||||
now_ms_fn=lambda: 1234)
|
||||
addr = ("127.0.0.1", port)
|
||||
|
||||
async def scenario():
|
||||
stop = asyncio.Event()
|
||||
task = asyncio.ensure_future(link.run(stop))
|
||||
udp = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_DGRAM)
|
||||
try:
|
||||
for _ in range(200): # 重发防 bind 竞态
|
||||
udp.sendto(b'{"lat": 1.5, "lon": 2.5}', addr)
|
||||
await asyncio.sleep(0.01)
|
||||
if link.latest().time_ms == 1234:
|
||||
break
|
||||
udp.sendto(b'{"cmd": "rtl"}', addr)
|
||||
for _ in range(100):
|
||||
if link._commands:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
udp.close()
|
||||
stop.set()
|
||||
task.cancel() # 收包循环阻塞在 sock_recvfrom,须 cancel 唤醒(同 SITL 入口)
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert link.latest().latitude_deg == pytest.approx(1.5)
|
||||
assert link.latest().time_ms == 1234
|
||||
assert link.drain_commands() == ["rtl"]
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""los_consistency_test.py —— 吊舱角度运动学一致性判据的纯逻辑测试。
|
||||
|
||||
背景:本吊舱无电机、相机刚性固连机体,机头转 Δψ 时真实目标脱靶量必变 ≈−Δψ。
|
||||
判据 `residual = Δβ + Δψ`:超限且激励足够 → 判不一致(角度冻结/幽灵目标)。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pod_bridge.los_consistency import (LosConsistencyConfig,
|
||||
LosConsistencyMonitor, wrap_deg_180)
|
||||
|
||||
|
||||
def _mon(cfg=None):
|
||||
return LosConsistencyMonitor(cfg or LosConsistencyConfig())
|
||||
|
||||
|
||||
def _drive(mon, n, dt_ms, yaw0=0.0, beta0=0.0, yaw_rate=0.0, beta_rate=0.0,
|
||||
t0=0):
|
||||
"""按固定角速度喂 n 拍,返回最后一次状态。"""
|
||||
status = None
|
||||
for i in range(n):
|
||||
t = t0 + (i + 1) * dt_ms
|
||||
yaw = yaw0 + yaw_rate * (i + 1) * dt_ms / 1000.0
|
||||
beta = beta0 + beta_rate * (i + 1) * dt_ms / 1000.0
|
||||
status = mon.update(t, yaw, beta)
|
||||
return status
|
||||
|
||||
|
||||
# --- 条件开关 ---------------------------------------------------------------
|
||||
def test_disabled_always_consistent():
|
||||
mon = _mon(LosConsistencyConfig(enabled=False))
|
||||
status = _drive(mon, 100, 50, yaw_rate=30.0, beta_rate=0.0)
|
||||
assert status.consistent is True
|
||||
assert status.excited is False
|
||||
|
||||
|
||||
def test_no_history_is_not_excited():
|
||||
mon = _mon()
|
||||
status = mon.update(1000, 0.0, 10.0)
|
||||
assert status.consistent is True
|
||||
assert status.excited is False
|
||||
|
||||
|
||||
def test_insufficient_span_is_not_excited():
|
||||
"""窗口未积累到 3/4 之前不做判断(避免一开始就误判丢目标)。"""
|
||||
mon = _mon(LosConsistencyConfig(window_ms=3000))
|
||||
status = _drive(mon, 20, 50, yaw_rate=40.0, beta_rate=0.0) # 仅 1s
|
||||
assert status.excited is False
|
||||
assert status.consistent is True
|
||||
|
||||
|
||||
# --- 一致:机头转、脱靶量反向跟随 -------------------------------------------
|
||||
def test_consistent_rotation_is_ok():
|
||||
"""右转 20°/s 且 β 以 −20°/s 变化 → 残差≈0。"""
|
||||
mon = _mon()
|
||||
status = _drive(mon, 80, 50, yaw_rate=20.0, beta_rate=-20.0) # 4s
|
||||
assert status.excited is True
|
||||
assert status.consistent is True
|
||||
assert status.residual_deg == pytest.approx(0.0, abs=0.5)
|
||||
|
||||
|
||||
def test_consistent_left_rotation_is_ok():
|
||||
"""左转(负角速度)且 β 正向跟随 → 同样一致(符号对称)。"""
|
||||
mon = _mon()
|
||||
status = _drive(mon, 80, 50, yaw_rate=-20.0, beta_rate=20.0)
|
||||
assert status.excited is True
|
||||
assert status.consistent is True
|
||||
|
||||
|
||||
def test_target_motion_within_residual_tolerance_is_ok():
|
||||
"""目标自身在动(β 变化与 −Δψ 不完全相等)但残差在容差内 → 仍判一致。"""
|
||||
mon = _mon(LosConsistencyConfig(max_residual_deg=8.0))
|
||||
# 4s 内 Δψ=80°,β 只反向变 76° → 残差 4°(< 8°)
|
||||
status = _drive(mon, 80, 50, yaw_rate=20.0, beta_rate=-19.0)
|
||||
assert status.excited is True
|
||||
assert status.consistent is True
|
||||
|
||||
|
||||
# --- 不一致:角度冻结/幽灵目标 ---------------------------------------------
|
||||
def test_frozen_beta_while_yawing_is_inconsistent():
|
||||
"""机头转 80° 而脱靶量纹丝不动 → 角度冻结 → 判不一致。
|
||||
|
||||
比较窗口为 3s(默认),故 4s 数据里只比较最后 3s:Δψ = 20°/s × 3s = 60°。
|
||||
"""
|
||||
mon = _mon()
|
||||
status = _drive(mon, 80, 50, yaw_rate=20.0, beta_rate=0.0)
|
||||
assert status.excited is True
|
||||
assert status.consistent is False
|
||||
assert status.frozen is True
|
||||
assert status.residual_deg == pytest.approx(60.0, abs=2.0) # 窗口内 Δψ
|
||||
|
||||
|
||||
def test_small_yaw_change_does_not_judge_even_if_residual_big():
|
||||
"""转得不够多(< 激励门槛)时不判不一致 —— 避免误杀。"""
|
||||
mon = _mon(LosConsistencyConfig(min_yaw_change_deg=20.0))
|
||||
# 窗口内只转 8°,虽然 β 没动(残差 8°)但未达激励门槛
|
||||
status = _drive(mon, 80, 50, yaw_rate=2.0, beta_rate=0.0)
|
||||
assert status.excited is False
|
||||
assert status.consistent is True
|
||||
|
||||
|
||||
# --- 历史管理 ---------------------------------------------------------------
|
||||
def test_reset_clears_history():
|
||||
mon = _mon()
|
||||
_drive(mon, 80, 50, yaw_rate=20.0, beta_rate=0.0)
|
||||
assert mon._samples # 有历史
|
||||
mon.reset()
|
||||
assert not mon._samples
|
||||
status = mon.update(9999, 0.0, 0.0)
|
||||
assert status.excited is False
|
||||
|
||||
|
||||
def test_old_samples_dropped():
|
||||
"""超出历史保留期的样本被丢弃,不会永久污染判定。"""
|
||||
mon = _mon(LosConsistencyConfig(window_ms=1000, history_factor=2.0))
|
||||
_drive(mon, 40, 50, yaw_rate=50.0, beta_rate=0.0) # 2s
|
||||
# 再喂一拍"很久之后",旧样本应被清掉
|
||||
status = mon.update(60_000, 0.0, 0.0)
|
||||
assert status.excited is False
|
||||
|
||||
|
||||
# --- 角度回绕 ---------------------------------------------------------------
|
||||
@pytest.mark.parametrize("deg,expect", [
|
||||
(0.0, 0.0), (179.0, 179.0), (181.0, -179.0),
|
||||
(-181.0, 179.0), (359.0, -1.0),
|
||||
])
|
||||
def test_wrap_deg_180(deg, expect):
|
||||
assert wrap_deg_180(deg) == pytest.approx(expect)
|
||||
|
||||
|
||||
def test_wrap_around_north_is_consistent():
|
||||
"""航向跨过 ±180 与 β 同步变化时,不应因回绕误判。"""
|
||||
mon = _mon()
|
||||
# 从 yaw=170 转到 190(即 -170),β 从 10 到 -10
|
||||
for i in range(80):
|
||||
t = (i + 1) * 50
|
||||
yaw = 170.0 + 20.0 * (i + 1) * 0.05
|
||||
beta = 10.0 - 20.0 * (i + 1) * 0.05
|
||||
status = mon.update(t, wrap_deg_180(yaw), beta)
|
||||
assert status.excited is True
|
||||
assert status.consistent is True
|
||||
@@ -43,11 +43,13 @@ class FakeOffboard:
|
||||
class FakeTelemetry:
|
||||
"""各遥测流:先吐预设消息,再挂起(由 stop_telemetry 取消)。"""
|
||||
|
||||
def __init__(self, position=None, attitude=None, gps=None, velocity=None):
|
||||
def __init__(self, position=None, attitude=None, gps=None, velocity=None,
|
||||
status=None):
|
||||
self._position = position or []
|
||||
self._attitude = attitude or []
|
||||
self._gps = gps or []
|
||||
self._velocity = velocity or []
|
||||
self._status = status or []
|
||||
|
||||
async def _stream(self, msgs):
|
||||
for m in msgs:
|
||||
@@ -66,6 +68,9 @@ class FakeTelemetry:
|
||||
def velocity_ned(self):
|
||||
return self._stream(self._velocity)
|
||||
|
||||
def status_text(self):
|
||||
return self._stream(self._status)
|
||||
|
||||
|
||||
class FakeDrone:
|
||||
def __init__(self, telemetry=None):
|
||||
@@ -197,6 +202,35 @@ def test_start_telemetry_populates_cache_then_stop():
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
# --- 飞控状态文本(STATUSTEXT)----------------------------------------------
|
||||
class Sev(Enum):
|
||||
INFO = 1
|
||||
WARNING = 2
|
||||
|
||||
|
||||
def test_update_fc_status_and_take_messages():
|
||||
gw = Px4Gateway(FakeDrone())
|
||||
gw.update_fc_status(SimpleNamespace(type=Sev.WARNING, text="Low battery"))
|
||||
gw.update_fc_status(SimpleNamespace(type="INFO", text="Takeoff detected"))
|
||||
assert gw.take_fc_messages() == [("WARNING", "Low battery"),
|
||||
("INFO", "Takeoff detected")]
|
||||
assert gw.take_fc_messages() == [] # 取后清空
|
||||
|
||||
|
||||
def test_status_text_subscription_captures_messages():
|
||||
telemetry = FakeTelemetry(status=[
|
||||
SimpleNamespace(type=Sev.WARNING, text="EKF variance")])
|
||||
gw = Px4Gateway(FakeDrone(telemetry))
|
||||
|
||||
async def scenario():
|
||||
await gw.start_telemetry()
|
||||
await asyncio.sleep(0.05)
|
||||
assert gw.take_fc_messages() == [("WARNING", "EKF variance")]
|
||||
await gw.stop_telemetry()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_default_own_state_is_invalid():
|
||||
s = OwnState()
|
||||
assert s.has_position is False and s.position_valid is False
|
||||
|
||||
Reference in New Issue
Block a user