实现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
This commit is contained in:
@@ -9,6 +9,8 @@ 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,
|
||||
@@ -391,3 +393,133 @@ def test_run_recovers_after_transient_failure():
|
||||
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)
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pod_bridge.config import (BridgeConfig, GuidanceConfig, PodSerialConfig,
|
||||
load_from_json)
|
||||
from pod_bridge.config import (BridgeConfig, GuidanceConfig, HeadingServoConfig,
|
||||
PodSerialConfig, load_from_json)
|
||||
from visual_track_control.config import NoDistanceAction, TrackingConfig
|
||||
|
||||
_DEFAULT_CONFIG = Path(__file__).resolve().parents[2] / "config" / "pod_bridge.json"
|
||||
|
||||
@@ -113,3 +114,59 @@ def test_out_of_range_raises(tmp_path, obj, field):
|
||||
|
||||
def test_pod_serial_default_port_is_placeholder():
|
||||
assert PodSerialConfig().port == ""
|
||||
|
||||
|
||||
# --- L1 航向伺服配置(方案 §4,决策 1/2/3)----------------------------------
|
||||
def test_empty_json_heading_servo_and_tracking_default(tmp_path):
|
||||
cfg = load_from_json(_write(tmp_path, {}))
|
||||
assert cfg.heading_servo == HeadingServoConfig()
|
||||
assert cfg.heading_servo.enabled is False # 默认关,保持 L2 行为
|
||||
assert cfg.tracking == TrackingConfig() # 缺 tracking 段 → 默认
|
||||
|
||||
|
||||
def test_heading_servo_loads():
|
||||
cfg = load_from_json(str(_DEFAULT_CONFIG))
|
||||
hs = cfg.heading_servo
|
||||
assert hs.enabled is True # 部署配置走 L1
|
||||
assert hs.beta_from_off_target is True # 无电机:脱靶量为主信号
|
||||
assert hs.beta_from_frame is False # 框架角当前无用
|
||||
assert hs.vertical_enabled is True # 决策3:无人机 L1 开垂直
|
||||
|
||||
|
||||
def test_heading_servo_partial_override(tmp_path):
|
||||
cfg = load_from_json(_write(tmp_path, {
|
||||
"heading_servo": {"beta_sign": -1, "beta_from_frame": True},
|
||||
}))
|
||||
assert cfg.heading_servo.beta_sign == -1
|
||||
assert cfg.heading_servo.beta_from_frame is True
|
||||
assert cfg.heading_servo.alpha_sign == 1 # 默认回退
|
||||
|
||||
|
||||
@pytest.mark.parametrize("obj,field", [
|
||||
({"heading_servo": {"beta_sign": 0}}, "heading_servo.beta_sign"), # 非 ±1
|
||||
({"heading_servo": {"alpha_sign": 2}}, "heading_servo.alpha_sign"),
|
||||
({"heading_servo": {"beta_sign": 1.0}}, "heading_servo.beta_sign"), # 非整数
|
||||
({"heading_servo": {"unknown": 1}}, "未知字段"),
|
||||
])
|
||||
def test_heading_servo_bad_sign_rejected(tmp_path, obj, field):
|
||||
with pytest.raises(ValueError, match=field.replace(".", r"\.")):
|
||||
load_from_json(_write(tmp_path, obj))
|
||||
|
||||
|
||||
def test_tracking_incomplete_section_raises(tmp_path):
|
||||
# tracking 段一旦提供即按 vtc 严格解析:缺分组报错
|
||||
with pytest.raises(ValueError, match="camera"):
|
||||
load_from_json(_write(tmp_path, {"tracking": {
|
||||
"control": {"frequency_hz": 20.0, "visual_stale_ms": 200,
|
||||
"distance_stale_ms": 500, "attitude_stale_ms": 300},
|
||||
}}))
|
||||
|
||||
|
||||
def test_shipped_tracking_l1_values():
|
||||
cfg = load_from_json(str(_DEFAULT_CONFIG))
|
||||
d = cfg.tracking.distance
|
||||
assert d.no_distance_action is NoDistanceAction.SLOW_APPROACH # 决策3:低速接近
|
||||
assert d.no_distance_approach_limit_mps == pytest.approx(0.2) # 极低限速
|
||||
assert cfg.tracking.vertical.vz_limit_mps == pytest.approx(0.5) # vz 压低
|
||||
assert cfg.tracking.heading.mode.value == "rate"
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""run_bridge_test.py —— 桥接闭环入口单元测试(干跑桩 + 打印 + 干跑循环,无硬件)。
|
||||
|
||||
重点守住 S2 干跑的安全属性:DryRunPx4 合成新鲜姿态让闭环能跑,但 send_* 只计数、
|
||||
**绝不下发 offboard**;串口未指定时报错退出而非静默。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from pod_bridge.config import BridgeConfig
|
||||
from pod_bridge.protocol import Telemetry
|
||||
from pod_bridge.run_bridge import DryRunPx4, _format_status, _run_dry
|
||||
|
||||
|
||||
class FakePod:
|
||||
def __init__(self, *frames):
|
||||
self.inbox = list(frames)
|
||||
self.sent = []
|
||||
self.closed = False
|
||||
|
||||
def poll(self):
|
||||
out, self.inbox = self.inbox, []
|
||||
return out
|
||||
|
||||
def send(self, data):
|
||||
self.sent.append(data)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
# --- DryRunPx4:合成姿态 + 不发 offboard ------------------------------------
|
||||
def test_dry_run_px4_state_is_fresh():
|
||||
px4 = DryRunPx4(now_ms_fn=lambda: 12345)
|
||||
st = px4.own_state()
|
||||
assert st.has_attitude and st.position_valid
|
||||
assert st.time_ms == 12345 and st.position_time_ms == 12345
|
||||
|
||||
|
||||
def test_dry_run_px4_never_sends_offboard():
|
||||
px4 = DryRunPx4()
|
||||
assert asyncio.run(px4.start_offboard()) is True
|
||||
assert asyncio.run(px4.send_velocity_heading(object())) is True
|
||||
assert asyncio.run(px4.send_brake_hover()) is True
|
||||
# 只计数,无任何硬件副作用
|
||||
assert px4.velocity_calls == 1 and px4.brake_calls == 1
|
||||
|
||||
|
||||
def test_format_status_shows_mode_and_angles():
|
||||
out = SimpleNamespace(mode=SimpleNamespace(name="VELOCITY_HEADING"),
|
||||
yaw_rate_dps=10.0, vx_body_mps=0.2, vz_mps=0.0)
|
||||
tel = (Telemetry(status1=0b00000100, off_target_x_deg=10.0,
|
||||
off_target_y_deg=-3.0, frame_yaw_deg=0.0), 1000)
|
||||
line = _format_status(1.0, out, tel, locked=True)
|
||||
assert "VELOCITY_HEADING" in line
|
||||
assert "+10.00" in line # off_target_x
|
||||
assert "locked=1" in line
|
||||
|
||||
|
||||
# --- 干跑循环 ---------------------------------------------------------------
|
||||
def test_run_dry_completes_and_downlinks_only(monkeypatch):
|
||||
# 注入假串口:不碰真硬件,验证干跑循环能跑通并正常收尾
|
||||
pod = FakePod(Telemetry(status1=0b00000100, off_target_x_deg=10.0))
|
||||
monkeypatch.setattr("pod_bridge.run_bridge.PodSerial", lambda *a, **k: pod)
|
||||
cfg = BridgeConfig()
|
||||
cfg.heading_servo.enabled = True
|
||||
cfg.tracking.control.frequency_hz = 200.0 # 加快节拍,缩短测试
|
||||
args = SimpleNamespace(port="/dev/fake", duration=0.05, print_hz=0.0)
|
||||
|
||||
rc = asyncio.run(_run_dry(cfg, args))
|
||||
assert rc == 0
|
||||
assert pod.closed # 收尾关闭串口
|
||||
|
||||
|
||||
def test_run_dry_requires_port(monkeypatch):
|
||||
monkeypatch.setattr("pod_bridge.run_bridge.PodSerial",
|
||||
lambda *a, **k: pytest.fail("无端口时不应打开串口"))
|
||||
cfg = BridgeConfig() # pod_serial.port 默认空
|
||||
args = SimpleNamespace(port=None, duration=0.0, print_hz=5.0)
|
||||
assert asyncio.run(_run_dry(cfg, args)) == 2
|
||||
Reference in New Issue
Block a user