精简仓库结构并增强任务链路:归档双目可选后端、新增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:
keita
2026-10-21 14:48:41 +08:00
parent 53fdd3b13f
commit df91b1110e
159 changed files with 6121 additions and 1617 deletions
+184 -15
View File
@@ -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)-------------------------------------------------------
+110 -3
View File
@@ -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
+35 -1
View File
@@ -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