Files
code-test-drone/utils/tests/pod_bridge/config_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

173 lines
6.9 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.
"""config_test.py —— 桥接配置加载与校验单元测试。"""
import json
from pathlib import Path
import pytest
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"
def _write(tmp_path, obj) -> str:
p = tmp_path / "cfg.json"
p.write_text(json.dumps(obj), encoding="utf-8")
return str(p)
def test_shipped_default_config_loads():
cfg = load_from_json(str(_DEFAULT_CONFIG))
assert cfg.pod_serial.baudrate == 115200
assert cfg.px4.connection_string == "udp://:14540"
assert cfg.link.attitude_downlink_hz == pytest.approx(20.0)
assert cfg.guidance.max_speed_mps == pytest.approx(5.0)
assert cfg.guidance.stop_radius_m == pytest.approx(5.0)
def test_empty_json_uses_all_defaults(tmp_path):
cfg = load_from_json(_write(tmp_path, {}))
default = BridgeConfig()
assert cfg.pod_serial == default.pod_serial
assert cfg.px4 == default.px4
assert cfg.link == default.link
assert cfg.guidance == default.guidance
def test_partial_override_keeps_other_defaults(tmp_path):
cfg = load_from_json(_write(tmp_path, {
"pod_serial": {"port": "COM3"},
"guidance": {"max_speed_mps": 8.0},
}))
assert cfg.pod_serial.port == "COM3"
assert cfg.pod_serial.baudrate == 115200 # 默认回退
assert cfg.guidance.max_speed_mps == 8.0
assert cfg.guidance.stop_radius_m == GuidanceConfig().stop_radius_m # 默认回退
def test_full_override_roundtrip(tmp_path):
obj = {
"pod_serial": {"port": "/dev/ttyUSB0", "baudrate": 115200},
"px4": {"connection_string": "serial:///dev/ttyS1:57600"},
"link": {"attitude_downlink_hz": 30.0, "telemetry_timeout_ms": 800},
"guidance": {"frequency_hz": 20.0, "stop_radius_m": 3.0,
"max_speed_mps": 7.5, "speed_kp": 0.8, "yaw_kp": 1.5},
}
cfg = load_from_json(_write(tmp_path, obj))
assert cfg.px4.connection_string == "serial:///dev/ttyS1:57600"
assert cfg.link.attitude_downlink_hz == 30.0
assert cfg.link.telemetry_timeout_ms == 800
assert cfg.guidance.frequency_hz == 20.0
assert cfg.guidance.yaw_kp == 1.5
# --- 校验错误 ---------------------------------------------------------------
def test_missing_file_raises():
with pytest.raises(ValueError, match="打开失败"):
load_from_json(str(_DEFAULT_CONFIG.parent / "no_such_file.json"))
def test_non_object_root_raises(tmp_path):
p = tmp_path / "bad.json"
p.write_text("[1, 2, 3]", encoding="utf-8")
with pytest.raises(ValueError, match="根节点必须为对象"):
load_from_json(str(p))
def test_malformed_json_raises(tmp_path):
p = tmp_path / "bad.json"
p.write_text("{not json", encoding="utf-8")
with pytest.raises(ValueError, match="解析失败"):
load_from_json(str(p))
def test_wrong_type_raises_with_field_name(tmp_path):
with pytest.raises(ValueError, match="guidance.max_speed_mps"):
load_from_json(_write(tmp_path, {"guidance": {"max_speed_mps": "fast"}}))
def test_unknown_key_rejected(tmp_path):
with pytest.raises(ValueError, match="未知字段"):
load_from_json(_write(tmp_path, {"guidance": {"max_speed": 5.0}}))
def test_empty_connection_string_raises(tmp_path):
with pytest.raises(ValueError, match="px4.connection_string"):
load_from_json(_write(tmp_path, {"px4": {"connection_string": ""}}))
@pytest.mark.parametrize("obj,field", [
({"link": {"attitude_downlink_hz": 100.0}}, "link.attitude_downlink_hz"), # >60
({"link": {"attitude_downlink_hz": 0.5}}, "link.attitude_downlink_hz"), # <1
({"guidance": {"max_speed_mps": -1.0}}, "guidance.max_speed_mps"), # 非正
({"guidance": {"stop_radius_m": -2.0}}, "guidance.stop_radius_m"), # 负
({"guidance": {"speed_derivative_filter_coef": 1.0}}, # 上界开
"guidance.speed_derivative_filter_coef"),
({"pod_serial": {"baudrate": 0}}, "pod_serial.baudrate"), # 非正
])
def test_out_of_range_raises(tmp_path, obj, field):
with pytest.raises(ValueError, match=field.replace(".", r"\.")):
load_from_json(_write(tmp_path, obj))
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"