first commit
This commit is contained in:
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
"""frame_transform_test.py —— rotate_body_to_ned 单元测试。
|
||||
|
||||
移植自 reference-c++/visual_track_control/tests/frame_transform_test.cpp。
|
||||
覆盖(物理追踪思路 §2.3,A4):零航向恒等、朝东时机体前向 → 东向、
|
||||
垂直分量不变、模长守恒。
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from visual_track_control.frame_transform import rotate_body_to_ned
|
||||
|
||||
|
||||
def test_zero_yaw_is_identity():
|
||||
north, east, down = rotate_body_to_ned(1.0, 0.5, -2.0, 0.0)
|
||||
assert north == pytest.approx(1.0, abs=1e-12) # 北 = 前
|
||||
assert east == pytest.approx(0.5, abs=1e-12) # 东 = 右
|
||||
assert down == pytest.approx(-2.0, abs=1e-12)
|
||||
|
||||
|
||||
def test_yaw_east_90_maps_forward_to_east():
|
||||
north, east, _ = rotate_body_to_ned(2.0, 0.0, 0.0, math.pi / 2.0) # 机头朝东
|
||||
assert north == pytest.approx(0.0, abs=1e-9) # 北向 ≈ 0
|
||||
assert east == pytest.approx(2.0, abs=1e-9) # 东向 = 前向速度
|
||||
|
||||
|
||||
def test_right_is_east_when_heading_north():
|
||||
north, east, _ = rotate_body_to_ned(0.0, 1.0, 0.0, 0.0)
|
||||
assert north == pytest.approx(0.0, abs=1e-12)
|
||||
assert east == pytest.approx(1.0, abs=1e-12)
|
||||
|
||||
|
||||
def test_down_component_unchanged():
|
||||
_, _, down = rotate_body_to_ned(1.0, 1.0, 3.5, math.pi / 3.0)
|
||||
assert down == 3.5
|
||||
|
||||
|
||||
def test_preserves_horizontal_norm():
|
||||
# 旋转为正交变换:水平合速度不变(限幅约束不被破坏的前提)
|
||||
north, east, _ = rotate_body_to_ned(3.0, -4.0, 1.0, 0.7)
|
||||
assert math.hypot(north, east) == pytest.approx(math.hypot(3.0, -4.0),
|
||||
abs=1e-9)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""line_of_sight_test.py —— PixelToAngle 单元测试。
|
||||
|
||||
移植自 reference-c++/visual_track_control/tests/line_of_sight_test.cpp。
|
||||
覆盖(物理追踪思路 §4.1/§5.1,C2):零偏移、画面边缘偏移 = 半视场角、
|
||||
奇对称、单调性、与线性近似的偏离(全程针孔模型)、构造期非法参数抛异常。
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from visual_track_control.line_of_sight import PixelToAngle
|
||||
|
||||
|
||||
def test_zero_offset_gives_zero_angle():
|
||||
conv = PixelToAngle(1000.0, math.radians(90.0))
|
||||
assert conv.convert(0.0) == 0.0
|
||||
|
||||
|
||||
def test_half_size_offset_gives_half_fov():
|
||||
# 针孔模型:偏移 = size/2 时 angle = atan(tan(θ/2)) = θ/2
|
||||
conv = PixelToAngle(1000.0, math.radians(90.0))
|
||||
assert conv.convert(500.0) == pytest.approx(math.radians(45.0), abs=1e-12)
|
||||
assert conv.convert(-500.0) == pytest.approx(-math.radians(45.0), abs=1e-12)
|
||||
|
||||
|
||||
def test_odd_symmetry():
|
||||
conv = PixelToAngle(1920.0, math.radians(60.0))
|
||||
for offset in (1.0, 100.0, 730.5):
|
||||
assert conv.convert(-offset) == pytest.approx(-conv.convert(offset),
|
||||
abs=1e-15)
|
||||
|
||||
|
||||
def test_monotonic_increasing():
|
||||
conv = PixelToAngle(1080.0, math.radians(35.0))
|
||||
prev = conv.convert(-600.0)
|
||||
for x in range(-500, 601, 100):
|
||||
cur = conv.convert(float(x))
|
||||
assert cur > prev
|
||||
prev = cur
|
||||
|
||||
|
||||
def test_deviates_from_linear_approximation():
|
||||
# C2:全程针孔模型。θ=90°、偏移比例 0.25 处线性近似误差约 4°,
|
||||
# 针孔结果必须与之明显偏离(证明未走线性支路)。
|
||||
conv = PixelToAngle(1000.0, math.radians(90.0))
|
||||
pinhole_deg = math.degrees(conv.convert(250.0)) # atan(250·2·tan45°/1000)
|
||||
linear_deg = 250.0 * (90.0 / 1000.0) # θ·offset/size = 22.5°
|
||||
assert pinhole_deg == pytest.approx(math.degrees(math.atan(0.5)), abs=1e-9)
|
||||
assert abs(pinhole_deg - linear_deg) > 2.0
|
||||
|
||||
|
||||
def test_constructor_rejects_invalid_args():
|
||||
with pytest.raises(ValueError):
|
||||
PixelToAngle(0.0, 1.0)
|
||||
with pytest.raises(ValueError):
|
||||
PixelToAngle(-10.0, 1.0)
|
||||
with pytest.raises(ValueError):
|
||||
PixelToAngle(1000.0, 0.0)
|
||||
with pytest.raises(ValueError):
|
||||
PixelToAngle(1000.0, math.pi)
|
||||
with pytest.raises(ValueError):
|
||||
PixelToAngle(1000.0, math.pi + 0.1)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""mavsdk_gateway_test.py —— MavsdkCommandGateway 单元测试(mock offboard)。
|
||||
|
||||
移植自 reference-c++/visual_track_control/tests/mavlink_command_gateway_test.cpp。
|
||||
C++ 版校验 MAVLink ID 84 线格式字节流;Python 版采用 MAVSDK 语义映射,
|
||||
改为以 FakeOffboard 捕获设定点对象,校验三分支映射(物理追踪思路 §2.2、
|
||||
A2 掩码三分支的语义等价):
|
||||
|
||||
角速率非零 → 机体速度设定点 + 非零 yawspeed;
|
||||
航向角非零(角速率为零)→ NED 速度设定点 + 绝对 yaw;
|
||||
两者均零 → 机体速度设定点 + 零 yawspeed(保持航向,A2 回归语义);
|
||||
刹车 → 全零机体速度设定点。
|
||||
传输失败(异常)→ 返回 False 且不向外抛。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from visual_track_control.mavsdk_gateway import MavsdkCommandGateway
|
||||
from visual_track_control.types import ControlOutput
|
||||
|
||||
|
||||
class FakeOffboard:
|
||||
"""捕获式 offboard mock:记录每次调用的设定点,可注入失败。"""
|
||||
|
||||
def __init__(self):
|
||||
self.body_setpoints = []
|
||||
self.ned_setpoints = []
|
||||
self.attempts = 0
|
||||
self.start_count = 0
|
||||
self.stop_count = 0
|
||||
self.succeed = True
|
||||
|
||||
async def set_velocity_body(self, setpoint):
|
||||
self.attempts += 1 # 与 C++ CaptureTransport 一致:失败也计入发送次数
|
||||
if not self.succeed:
|
||||
raise RuntimeError("mock transport failure")
|
||||
self.body_setpoints.append(setpoint)
|
||||
|
||||
async def set_velocity_ned(self, setpoint):
|
||||
self.attempts += 1
|
||||
if not self.succeed:
|
||||
raise RuntimeError("mock transport failure")
|
||||
self.ned_setpoints.append(setpoint)
|
||||
|
||||
async def start(self):
|
||||
if not self.succeed:
|
||||
raise RuntimeError("mock start failure")
|
||||
self.start_count += 1
|
||||
|
||||
async def stop(self):
|
||||
self.stop_count += 1
|
||||
|
||||
@property
|
||||
def send_count(self):
|
||||
return self.attempts
|
||||
|
||||
|
||||
class FakeDrone:
|
||||
def __init__(self):
|
||||
self.offboard = FakeOffboard()
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def make_gateway():
|
||||
drone = FakeDrone()
|
||||
return MavsdkCommandGateway(drone), drone.offboard
|
||||
|
||||
|
||||
def test_start_sends_initial_setpoint_then_starts():
|
||||
gateway, offboard = make_gateway()
|
||||
assert run(gateway.start())
|
||||
assert offboard.start_count == 1
|
||||
# PX4 要求先流式发送首条设定点(全零)再请求启动
|
||||
assert len(offboard.body_setpoints) == 1
|
||||
sp = offboard.body_setpoints[0]
|
||||
assert sp.forward_m_s == 0.0
|
||||
assert sp.right_m_s == 0.0
|
||||
assert sp.down_m_s == 0.0
|
||||
assert sp.yawspeed_deg_s == 0.0
|
||||
|
||||
|
||||
def test_start_failure_returns_false():
|
||||
gateway, offboard = make_gateway()
|
||||
offboard.succeed = False
|
||||
assert not run(gateway.start())
|
||||
|
||||
|
||||
def _make_velocity_heading_output():
|
||||
from visual_track_control.types import ControlMode
|
||||
out = ControlOutput()
|
||||
out.mode = ControlMode.VELOCITY_HEADING
|
||||
return out
|
||||
|
||||
|
||||
def test_yaw_rate_branch_uses_body_frame_with_yawspeed():
|
||||
from visual_track_control.types import ControlMode
|
||||
gateway, offboard = make_gateway()
|
||||
out = _make_velocity_heading_output()
|
||||
out.vx_body_mps = 1.5
|
||||
out.vz_mps = 0.25
|
||||
out.yaw_rate_dps = 5.0
|
||||
assert run(gateway.send_velocity_heading(out, 12345))
|
||||
assert offboard.send_count == 1
|
||||
assert len(offboard.body_setpoints) == 1
|
||||
sp = offboard.body_setpoints[0]
|
||||
assert sp.forward_m_s == pytest.approx(1.5)
|
||||
assert sp.right_m_s == 0.0
|
||||
assert sp.down_m_s == pytest.approx(0.25)
|
||||
assert sp.yawspeed_deg_s == pytest.approx(5.0)
|
||||
assert offboard.ned_setpoints == []
|
||||
|
||||
|
||||
def test_yaw_only_branch_uses_ned_frame_with_absolute_yaw():
|
||||
# 航向角非零且角速率为零 → 航向掩码语义:NED 速度 + 绝对航向
|
||||
gateway, offboard = make_gateway()
|
||||
out = _make_velocity_heading_output()
|
||||
out.vx_mps = 2.0
|
||||
out.vy_mps = -1.0
|
||||
out.vz_mps = 0.5
|
||||
out.yaw_deg = 10.0
|
||||
out.yaw_rate_dps = 0.0
|
||||
assert run(gateway.send_velocity_heading(out, 1))
|
||||
assert offboard.send_count == 1
|
||||
assert len(offboard.ned_setpoints) == 1
|
||||
sp = offboard.ned_setpoints[0]
|
||||
assert sp.north_m_s == pytest.approx(2.0)
|
||||
assert sp.east_m_s == pytest.approx(-1.0)
|
||||
assert sp.down_m_s == pytest.approx(0.5)
|
||||
assert sp.yaw_deg == pytest.approx(10.0)
|
||||
assert offboard.body_setpoints == []
|
||||
|
||||
|
||||
def test_both_zero_keeps_heading_via_body_zero_yawspeed():
|
||||
# A2 回归:两者均零时机体速度设定点配零角速率 = 保持当前航向,
|
||||
# 不得误发绝对航向 0(正北)。
|
||||
gateway, offboard = make_gateway()
|
||||
out = _make_velocity_heading_output()
|
||||
out.vx_body_mps = 1.0
|
||||
out.yaw_rate_dps = 0.0
|
||||
out.yaw_deg = 0.0
|
||||
assert run(gateway.send_velocity_heading(out, 1))
|
||||
assert len(offboard.body_setpoints) == 1
|
||||
sp = offboard.body_setpoints[0]
|
||||
assert sp.forward_m_s == pytest.approx(1.0)
|
||||
assert sp.yawspeed_deg_s == 0.0
|
||||
assert offboard.ned_setpoints == [] # 不得走绝对航向支路
|
||||
|
||||
|
||||
def test_brake_hover_sends_zero_velocity_body():
|
||||
gateway, offboard = make_gateway()
|
||||
assert run(gateway.send_brake_hover(777))
|
||||
assert offboard.send_count == 1
|
||||
sp = offboard.body_setpoints[0]
|
||||
assert sp.forward_m_s == 0.0
|
||||
assert sp.right_m_s == 0.0
|
||||
assert sp.down_m_s == 0.0
|
||||
assert sp.yawspeed_deg_s == 0.0
|
||||
|
||||
|
||||
def test_transport_failure_propagates_as_false():
|
||||
gateway, offboard = make_gateway()
|
||||
offboard.succeed = False
|
||||
out = _make_velocity_heading_output()
|
||||
assert not run(gateway.send_velocity_heading(out, 1))
|
||||
assert not run(gateway.send_brake_hover(1))
|
||||
assert offboard.send_count == 2
|
||||
|
||||
|
||||
def test_stop_calls_offboard_stop():
|
||||
gateway, offboard = make_gateway()
|
||||
run(gateway.stop())
|
||||
assert offboard.stop_count == 1
|
||||
@@ -0,0 +1,103 @@
|
||||
"""pid_controller_test.py —— PidController 单元测试。
|
||||
|
||||
移植自 reference-c++/visual_track_control/tests/pid_controller_test.cpp。
|
||||
覆盖(物理追踪思路 §6.2/§6.4):位置式 P/I/D 三项、积分钳位、输出限幅、
|
||||
异常节拍保护、anti-windup 条件积分(C1 修复回归)、ResetIntegral / Reset。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from visual_track_control.pid_controller import PidController, PidParams
|
||||
|
||||
|
||||
def make_params(kp, ki, kd, integral_limit, output_limit, filter_coef=0.0):
|
||||
return PidParams(kp=kp, ki=ki, kd=kd, integral_limit=integral_limit,
|
||||
derivative_filter_coef=filter_coef, output_limit=output_limit)
|
||||
|
||||
|
||||
def test_proportional_only():
|
||||
pid = PidController(make_params(2.0, 0.0, 0.0, 0.0, 0.0))
|
||||
assert pid.update(3.0, 0.1) == 6.0
|
||||
assert pid.update(-1.5, 0.1) == -3.0
|
||||
|
||||
|
||||
def test_integral_accumulates():
|
||||
pid = PidController(make_params(0.0, 1.0, 0.0, 0.0, 0.0))
|
||||
# 两拍 error=2、dt=0.5:积分累计 1.0 → 2.0
|
||||
assert pid.update(2.0, 0.5) == 1.0
|
||||
assert pid.update(2.0, 0.5) == 2.0
|
||||
|
||||
|
||||
def test_integral_clamped_to_limit():
|
||||
pid = PidController(make_params(0.0, 1.0, 0.0, integral_limit=1.0, output_limit=0.0))
|
||||
for _ in range(5):
|
||||
pid.update(10.0, 1.0)
|
||||
# 积分候选值被钳位在 1.0 → 输出恒为 1.0
|
||||
assert pid.last_output == 1.0
|
||||
|
||||
|
||||
def test_output_clamped_to_limit():
|
||||
pid = PidController(make_params(10.0, 0.0, 0.0, 0.0, output_limit=3.0))
|
||||
assert pid.update(1.0, 0.1) == 3.0
|
||||
assert pid.update(-1.0, 0.1) == -3.0
|
||||
|
||||
|
||||
def test_anti_windup_freeze_while_saturated_same_direction():
|
||||
# kp=0、ki=2、output_limit=5:error=+1 持续饱和时积分冻结,
|
||||
# 输出恒钉在 +5(10 拍累计也不会让积分继续增长)。
|
||||
pid = PidController(make_params(0.0, 2.0, 0.0, 0.0, 5.0))
|
||||
for _ in range(10):
|
||||
assert pid.update(10.0, 1.0) == 5.0
|
||||
|
||||
|
||||
def test_anti_windup_unwinds_after_error_flips():
|
||||
# C1 回归:误差反号后积分必须立即回卷(旧判据"积分与输出同向"会
|
||||
# 冻结回卷、输出一直钉在 +5)。
|
||||
# kp=0、ki=2、limit=5:+10 饱和 3 拍、积分冻结在 0;
|
||||
# error=-1 首拍 → 输出 2×(-1) = -2(立即回卷);
|
||||
# 持续负误差使输出到达负饱和 -5(积分冻结在 -2,输出 = clamp(-6))。
|
||||
pid = PidController(make_params(0.0, 2.0, 0.0, 0.0, 5.0))
|
||||
for _ in range(3):
|
||||
assert pid.update(10.0, 1.0) == 5.0
|
||||
assert pid.update(-1.0, 1.0) == -2.0 # 首拍立即回卷
|
||||
out = -2.0
|
||||
for _ in range(11):
|
||||
out = pid.update(-1.0, 1.0)
|
||||
assert out == pytest.approx(-5.0, abs=1e-9) # 旧判据此处仍为 +5.0
|
||||
|
||||
|
||||
def test_derivative_first_step_is_zero():
|
||||
pid = PidController(make_params(0.0, 0.0, 1.0, 0.0, 0.0))
|
||||
# 首拍无微分历史:微分项为 0
|
||||
assert pid.update(5.0, 1.0) == 0.0
|
||||
|
||||
|
||||
def test_derivative_with_low_pass_filter():
|
||||
pid = PidController(make_params(0.0, 0.0, 1.0, 0.0, 0.0, filter_coef=0.5))
|
||||
pid.update(0.0, 1.0) # 建立微分历史(导数 0)
|
||||
# 第二拍原始导数 1.0,低通后 0.5×0 + 0.5×1.0 = 0.5
|
||||
assert pid.update(1.0, 1.0) == 0.5
|
||||
|
||||
|
||||
def test_zero_or_negative_dt_holds_last_output():
|
||||
pid = PidController(make_params(1.0, 0.0, 0.0, 0.0, 0.0))
|
||||
assert pid.update(2.0, 0.1) == 2.0
|
||||
assert pid.update(99.0, 0.0) == 2.0
|
||||
assert pid.update(99.0, -1.0) == 2.0
|
||||
|
||||
|
||||
def test_reset_integral_clears_only_integral():
|
||||
pid = PidController(make_params(0.0, 1.0, 1.0, 0.0, 0.0))
|
||||
pid.update(2.0, 1.0) # 积分 = 2
|
||||
pid.reset_integral()
|
||||
# 积分清零、微分历史保留:输出应为 kd×(0−2)/1 = −2
|
||||
assert pid.update(0.0, 1.0) == -2.0
|
||||
|
||||
|
||||
def test_reset_clears_everything():
|
||||
pid = PidController(make_params(1.0, 1.0, 1.0, 0.0, 0.0))
|
||||
pid.update(5.0, 1.0)
|
||||
pid.reset()
|
||||
assert pid.last_output == 0.0
|
||||
# 全清后等同全新控制器:首拍无微分历史
|
||||
assert pid.update(3.0, 1.0) == 3.0 + 3.0 # kp·e + ki·∫e
|
||||
@@ -0,0 +1,68 @@
|
||||
"""rate_limiter_test.py —— Clamp / SlewRateLimiter 单元测试。
|
||||
|
||||
移植自 reference-c++/visual_track_control/tests/rate_limiter_test.cpp。
|
||||
覆盖(物理追踪思路 §8、状态机设计 S6):静态限幅、变化率限幅步进、
|
||||
首条指令从零渐变、异常节拍保护、Reset 归零。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from visual_track_control.rate_limiter import SlewRateLimiter, clamp
|
||||
|
||||
|
||||
def test_clamp_clamps_value():
|
||||
assert clamp(5.0, -2.0, 2.0) == 2.0
|
||||
assert clamp(-5.0, -2.0, 2.0) == -2.0
|
||||
assert clamp(1.5, -2.0, 2.0) == 1.5
|
||||
# 边界值:落在区间内
|
||||
assert clamp(2.0, -2.0, 2.0) == 2.0
|
||||
assert clamp(-2.0, -2.0, 2.0) == -2.0
|
||||
|
||||
|
||||
def test_first_command_ramps_from_zero():
|
||||
# S6:初始输出为 0,首条大指令只能按变化率渐变
|
||||
lim = SlewRateLimiter(2.0)
|
||||
assert lim.limit(10.0, 0.5) == 1.0 # 0 + 2×0.5
|
||||
assert lim.limit(10.0, 0.5) == 2.0 # 继续渐变
|
||||
assert lim.limit(10.0, 10.0) == 10.0 # 步长足够 → 到达目标
|
||||
|
||||
|
||||
def test_tracks_target_within_step():
|
||||
lim = SlewRateLimiter(10.0)
|
||||
# 步长 10×1=10,目标 3 在步长内 → 直接到达
|
||||
assert lim.limit(3.0, 1.0) == 3.0
|
||||
assert lim.limit(3.0, 1.0) == 3.0 # 已在目标:保持
|
||||
|
||||
|
||||
def test_negative_direction():
|
||||
lim = SlewRateLimiter(2.0)
|
||||
assert lim.limit(-10.0, 0.5) == -1.0
|
||||
assert lim.limit(-10.0, 0.5) == -2.0
|
||||
|
||||
|
||||
def test_zero_or_negative_dt_holds():
|
||||
lim = SlewRateLimiter(2.0)
|
||||
lim.limit(10.0, 1.0) # 输出 2.0
|
||||
assert lim.limit(10.0, 0.0) == 2.0
|
||||
assert lim.limit(10.0, -1.0) == 2.0
|
||||
|
||||
|
||||
def test_zero_rate_limit_freezes_output():
|
||||
lim = SlewRateLimiter(0.0)
|
||||
assert lim.limit(5.0, 1.0) == 0.0
|
||||
|
||||
|
||||
def test_reset_to_value():
|
||||
lim = SlewRateLimiter(2.0)
|
||||
lim.limit(10.0, 1.0)
|
||||
lim.reset()
|
||||
assert lim.last_output == 0.0
|
||||
lim.reset(3.5)
|
||||
assert lim.last_output == 3.5
|
||||
# 复位后从新值继续渐变
|
||||
assert lim.limit(10.0, 1.0) == 5.5 # 3.5 + 2×1
|
||||
|
||||
|
||||
def test_custom_initial_value():
|
||||
lim = SlewRateLimiter(1.0, initial=5.0)
|
||||
assert lim.limit(10.0, 1.0) == 6.0
|
||||
@@ -0,0 +1,32 @@
|
||||
"""staleness_checker_test.py —— is_stale 单元测试。
|
||||
|
||||
移植自 reference-c++/visual_track_control/tests/staleness_checker_test.cpp。
|
||||
覆盖(物理追踪思路 §7.1):从未取样判超龄、有效期内新鲜、超出有效期超龄、
|
||||
边界恰好在有效期上(不算超龄)、时间戳不一致判超龄(C4)。
|
||||
"""
|
||||
|
||||
from visual_track_control.staleness_checker import is_stale
|
||||
|
||||
|
||||
def test_never_sampled_is_stale():
|
||||
assert is_stale(0, 1000, 200)
|
||||
assert is_stale(-5, 1000, 200)
|
||||
|
||||
|
||||
def test_within_validity_is_fresh():
|
||||
assert not is_stale(900, 1000, 200)
|
||||
assert not is_stale(1000, 1000, 200) # 刚取样
|
||||
|
||||
|
||||
def test_beyond_validity_is_stale():
|
||||
assert is_stale(799, 1000, 200) # 201 > 200
|
||||
|
||||
|
||||
def test_exactly_at_boundary_is_fresh():
|
||||
# 超龄判定为严格大于:恰好等于有效期仍算新鲜
|
||||
assert not is_stale(800, 1000, 200)
|
||||
|
||||
|
||||
def test_inconsistent_timestamp_is_stale():
|
||||
# C4:now < sample 时间戳不一致 → 年龄不可信 → 判超龄(宁降级不轻信)
|
||||
assert is_stale(2000, 1000, 200)
|
||||
@@ -0,0 +1,338 @@
|
||||
"""tracking_control_law_test.py —— TrackingControlLaw 主控律单元测试。
|
||||
|
||||
移植自 reference-c++/visual_track_control/tests/tracking_control_law_test.cpp。
|
||||
覆盖(物理追踪思路 §2.3/§4/§5/§6/§7/§9):超龄降级(姿态缺失/过期、视觉
|
||||
超龄、降级优先级)、判丢零速刹车、Coast 清积分与标志、速率式/位置式航向
|
||||
通道(含绝对航向合成与回绕,A2)、垂直统一速度支路(A1)、距离通道三种
|
||||
无距离降级动作、首拍变化率限幅(S6)、机体系→NED 输出旋转(A4)。
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from visual_track_control.config import (HeadingMode, NoDistanceAction,
|
||||
TrackingConfig)
|
||||
from visual_track_control.tracking_control_law import TrackingControlLaw
|
||||
from visual_track_control.types import (ControlMode, ControlSnapshot,
|
||||
DegradedReason, TrackState)
|
||||
|
||||
|
||||
def make_config() -> TrackingConfig:
|
||||
"""测试基准配置:大限幅使限幅器近似透明,便于独立验证各通道数学。
|
||||
画面 1000×1000、视场角 90° → 针孔系数 2·tan(45°)/1000 = 0.002,
|
||||
偏移 100 像素 → 视线角 atan(0.2) ≈ 11.3099°。
|
||||
"""
|
||||
cfg = TrackingConfig()
|
||||
cfg.control.frequency_hz = 20.0
|
||||
cfg.control.visual_stale_ms = 200
|
||||
cfg.control.radar_stale_ms = 500
|
||||
cfg.control.attitude_stale_ms = 300
|
||||
cfg.camera.image_width = 1000
|
||||
cfg.camera.image_height = 1000
|
||||
cfg.camera.fov_h_deg = 90.0
|
||||
cfg.camera.fov_v_deg = 90.0
|
||||
cfg.heading.mode = HeadingMode.RATE
|
||||
cfg.heading.gain = 1.0
|
||||
cfg.heading.yaw_rate_limit_dps = 30.0
|
||||
cfg.heading.yaw_slew_limit_dps2 = 100000.0
|
||||
cfg.vertical.vz_gain_mps_per_deg = 0.1
|
||||
cfg.vertical.vz_limit_mps = 2.0
|
||||
cfg.distance.d_exp_m = 10.0
|
||||
cfg.distance.kp = 0.5
|
||||
cfg.distance.ki = 0.0
|
||||
cfg.distance.kd = 0.0
|
||||
cfg.distance.integral_limit = 0.0
|
||||
cfg.distance.derivative_filter_coef = 0.0
|
||||
cfg.distance.approach_velocity_limit_mps = 3.0
|
||||
cfg.distance.retreat_velocity_limit_mps = 1.0
|
||||
cfg.distance.no_distance_action = NoDistanceAction.HOLD
|
||||
cfg.distance.no_distance_approach_limit_mps = 0.5
|
||||
cfg.accel_limit.ax_mps2 = 1000.0
|
||||
cfg.accel_limit.ay_mps2 = 1000.0
|
||||
cfg.accel_limit.az_mps2 = 1000.0
|
||||
return cfg
|
||||
|
||||
|
||||
def make_snapshot(now_ms: int) -> ControlSnapshot:
|
||||
"""新鲜快照:目标在画面中心,姿态/追踪/雷达均新鲜。"""
|
||||
return ControlSnapshot(
|
||||
track=TrackState(tracked=True, is_predicted=False,
|
||||
center=(500.0, 500.0)),
|
||||
track_time_ms=now_ms,
|
||||
attitude_present=True,
|
||||
attitude_time_ms=now_ms,
|
||||
)
|
||||
|
||||
|
||||
# ---- 超龄降级(§7.1 / §9.2) ----
|
||||
|
||||
def test_attitude_missing_degrades():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.attitude_present = False
|
||||
out = law.update(snap, 1000)
|
||||
assert out.mode is ControlMode.DEGRADED_HOLD
|
||||
assert out.degraded_reason is DegradedReason.ATTITUDE_STALE
|
||||
assert not out.valid
|
||||
|
||||
|
||||
def test_attitude_stale_degrades():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.attitude_time_ms = 600 # 400 ms 前 > 300 ms 阈值
|
||||
out = law.update(snap, 1000)
|
||||
assert out.mode is ControlMode.DEGRADED_HOLD
|
||||
assert out.degraded_reason is DegradedReason.ATTITUDE_STALE
|
||||
|
||||
|
||||
def test_vision_stale_degrades():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.track_time_ms = 700 # 300 ms 前 > 200 ms 阈值
|
||||
out = law.update(snap, 1000)
|
||||
assert out.mode is ControlMode.DEGRADED_HOLD
|
||||
assert out.degraded_reason is DegradedReason.VISION_STALE
|
||||
|
||||
|
||||
def test_never_sampled_track_degrades():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.track_time_ms = 0 # 从未取样
|
||||
out = law.update(snap, 1000)
|
||||
assert out.mode is ControlMode.DEGRADED_HOLD
|
||||
assert out.degraded_reason is DegradedReason.VISION_STALE
|
||||
|
||||
|
||||
def test_attitude_stale_beats_vision_stale():
|
||||
# 降级优先级:姿态校验在前(§9.2 姿态过期禁止一切空间转换)
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.attitude_present = False
|
||||
snap.track_time_ms = 0
|
||||
out = law.update(snap, 1000)
|
||||
assert out.degraded_reason is DegradedReason.ATTITUDE_STALE
|
||||
|
||||
|
||||
# ---- 判丢刹车(§6.4) ----
|
||||
|
||||
def test_lost_target_brakes():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.track.tracked = False
|
||||
out = law.update(snap, 1000)
|
||||
assert out.mode is ControlMode.BRAKE_HOVER
|
||||
assert out.valid
|
||||
assert out.vx_mps == 0.0
|
||||
assert out.vy_mps == 0.0
|
||||
assert out.vz_mps == 0.0
|
||||
|
||||
|
||||
# ---- Coast(§6.4) ----
|
||||
|
||||
def test_coast_sets_flag_and_keeps_tracking():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.track.is_predicted = True
|
||||
out = law.update(snap, 1000)
|
||||
assert out.mode is ControlMode.VELOCITY_HEADING
|
||||
assert out.coast_active
|
||||
assert out.valid
|
||||
|
||||
|
||||
# ---- 水平航向通道(§4.2,A2) ----
|
||||
|
||||
def test_rate_mode_maps_beta_to_yaw_rate():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.track.center = (600.0, 500.0) # 目标偏右 100 像素
|
||||
out = law.update(snap, 1000)
|
||||
beta_deg = math.degrees(math.atan(0.2)) # ≈ 11.31°
|
||||
assert out.yaw_rate_dps == pytest.approx(beta_deg, abs=1e-6) # gain=1,右转正
|
||||
assert out.yaw_deg == 0.0
|
||||
|
||||
|
||||
def test_rate_mode_clamps_to_yaw_rate_limit():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.track.center = (950.0, 500.0) # 大偏移:β≈43.5° > 30°/s 上限
|
||||
out = law.update(snap, 1000)
|
||||
assert out.yaw_rate_dps == pytest.approx(30.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_position_mode_composes_absolute_heading():
|
||||
# A2:位置式输出为绝对 NED 航向 = 当前航向 + 限幅修正量
|
||||
cfg = make_config()
|
||||
cfg.heading.mode = HeadingMode.POSITION
|
||||
law = TrackingControlLaw(cfg)
|
||||
snap = make_snapshot(1000)
|
||||
snap.track.center = (600.0, 500.0)
|
||||
snap.yaw_rad = math.radians(45.0)
|
||||
out = law.update(snap, 1000)
|
||||
beta_deg = math.degrees(math.atan(0.2))
|
||||
assert out.yaw_deg == pytest.approx(45.0 + beta_deg, abs=1e-6)
|
||||
assert out.yaw_rate_dps == 0.0
|
||||
|
||||
|
||||
def test_position_mode_wraps_around_180():
|
||||
cfg = make_config()
|
||||
cfg.heading.mode = HeadingMode.POSITION
|
||||
law = TrackingControlLaw(cfg)
|
||||
snap = make_snapshot(1000)
|
||||
snap.track.center = (600.0, 500.0)
|
||||
snap.yaw_rad = math.radians(175.0)
|
||||
out = law.update(snap, 1000)
|
||||
beta_deg = math.degrees(math.atan(0.2))
|
||||
# 175 + 11.31 = 186.31 → 回绕到 ≈ -173.69
|
||||
assert out.yaw_deg == pytest.approx(175.0 + beta_deg - 360.0, abs=1e-6)
|
||||
assert -180.0 < out.yaw_deg < 180.0
|
||||
|
||||
|
||||
# ---- 垂直统一速度支路(§5,A1) ----
|
||||
|
||||
def test_vertical_offset_drives_vz():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.track.center = (500.0, 600.0) # 目标偏低 100 像素 → α>0 → 下降
|
||||
out = law.update(snap, 1000)
|
||||
alpha_deg = math.degrees(math.atan(0.2))
|
||||
assert out.vz_mps == pytest.approx(0.1 * alpha_deg, abs=1e-6)
|
||||
assert out.mode is ControlMode.VELOCITY_HEADING # A1:不切姿态微调模式
|
||||
|
||||
|
||||
def test_vertical_velocity_clamped():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.track.center = (500.0, 990.0) # α≈44.5° × 0.1 = 4.45 > 2 上限
|
||||
out = law.update(snap, 1000)
|
||||
assert out.vz_mps == pytest.approx(2.0, abs=1e-9)
|
||||
|
||||
|
||||
# ---- 距离通道(§6.2 / §6.3) ----
|
||||
|
||||
def test_distance_approach_when_far():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.radar_distance_m = 30.0 # Δd = +20 → kp 输出 10 → 限幅 3
|
||||
snap.associated_to_target = True
|
||||
snap.radar_time_ms = 1000
|
||||
out = law.update(snap, 1000)
|
||||
assert out.vx_mps == pytest.approx(3.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_distance_retreat_clamped_when_too_close():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.radar_distance_m = 5.0 # Δd = -5 → kp 输出 -2.5 → 后退限幅 -1
|
||||
snap.associated_to_target = True
|
||||
snap.radar_time_ms = 1000
|
||||
out = law.update(snap, 1000)
|
||||
assert out.vx_mps == pytest.approx(-1.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_no_distance_hold_zeros_vx():
|
||||
law = TrackingControlLaw(make_config()) # 缺省 kHold
|
||||
snap = make_snapshot(1000)
|
||||
snap.associated_to_target = False
|
||||
out = law.update(snap, 1000)
|
||||
assert out.mode is ControlMode.VELOCITY_HEADING
|
||||
assert out.vx_mps == 0.0
|
||||
|
||||
|
||||
def test_no_distance_slow_approach():
|
||||
cfg = make_config()
|
||||
cfg.distance.no_distance_action = NoDistanceAction.SLOW_APPROACH
|
||||
law = TrackingControlLaw(cfg)
|
||||
snap = make_snapshot(1000)
|
||||
snap.associated_to_target = False
|
||||
out = law.update(snap, 1000)
|
||||
assert out.vx_mps == pytest.approx(0.5, abs=1e-9)
|
||||
|
||||
|
||||
def test_no_distance_exit_degrades():
|
||||
cfg = make_config()
|
||||
cfg.distance.no_distance_action = NoDistanceAction.EXIT
|
||||
law = TrackingControlLaw(cfg)
|
||||
snap = make_snapshot(1000)
|
||||
snap.associated_to_target = False
|
||||
out = law.update(snap, 1000)
|
||||
assert out.mode is ControlMode.DEGRADED_HOLD
|
||||
assert out.degraded_reason is DegradedReason.NO_DISTANCE_EXIT
|
||||
assert not out.valid
|
||||
|
||||
|
||||
def test_stale_radar_falls_back_to_no_distance():
|
||||
law = TrackingControlLaw(make_config()) # kHold
|
||||
snap = make_snapshot(1000)
|
||||
snap.radar_distance_m = 30.0
|
||||
snap.associated_to_target = True
|
||||
snap.radar_time_ms = 400 # 600 ms 前 > 500 ms 阈值 → 距离不可用
|
||||
out = law.update(snap, 1000)
|
||||
assert out.mode is ControlMode.VELOCITY_HEADING
|
||||
assert out.vx_mps == 0.0
|
||||
|
||||
|
||||
# ---- 首拍/限幅(§8、S6) ----
|
||||
|
||||
def test_first_tick_vx_slews_from_zero():
|
||||
# S6:首条速度指令必须经变化率限幅,从 0 渐变而非阶跃
|
||||
cfg = make_config()
|
||||
cfg.accel_limit.ax_mps2 = 2.0
|
||||
law = TrackingControlLaw(cfg)
|
||||
snap = make_snapshot(1000)
|
||||
snap.radar_distance_m = 30.0 # 通道输出本应为 3.0
|
||||
snap.associated_to_target = True
|
||||
snap.radar_time_ms = 1000
|
||||
out = law.update(snap, 1000) # 首拍:dt 按 1/20 Hz 兜底
|
||||
assert out.vx_mps == pytest.approx(2.0 * (1.0 / 20.0), abs=1e-9) # 0.1
|
||||
|
||||
|
||||
# ---- 机体系 → NED 旋转(§2.3,A4) ----
|
||||
|
||||
def test_output_rotated_to_ned_by_yaw():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.yaw_rad = math.pi / 2.0 # 机头朝东
|
||||
snap.radar_distance_m = 30.0
|
||||
snap.associated_to_target = True
|
||||
snap.radar_time_ms = 1000
|
||||
out = law.update(snap, 1000)
|
||||
# 机体前向 3.0 → 东向 3.0,北向 ≈ 0
|
||||
assert out.vx_mps == pytest.approx(0.0, abs=1e-9)
|
||||
assert out.vy_mps == pytest.approx(3.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_zero_yaw_keeps_forward_as_north():
|
||||
law = TrackingControlLaw(make_config())
|
||||
snap = make_snapshot(1000)
|
||||
snap.yaw_rad = 0.0
|
||||
snap.radar_distance_m = 30.0
|
||||
snap.associated_to_target = True
|
||||
snap.radar_time_ms = 1000
|
||||
out = law.update(snap, 1000)
|
||||
assert out.vx_mps == pytest.approx(3.0, abs=1e-9)
|
||||
assert out.vy_mps == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
|
||||
# ---- Reset(§6.4) ----
|
||||
|
||||
def test_reset_restores_slew_from_zero():
|
||||
cfg = make_config()
|
||||
cfg.accel_limit.ax_mps2 = 2.0
|
||||
law = TrackingControlLaw(cfg)
|
||||
snap = make_snapshot(1000)
|
||||
snap.radar_distance_m = 30.0
|
||||
snap.associated_to_target = True
|
||||
snap.radar_time_ms = 1000
|
||||
for t in range(1000, 3000, 50):
|
||||
snap.track_time_ms = t
|
||||
snap.radar_time_ms = t
|
||||
snap.attitude_time_ms = t
|
||||
law.update(snap, t) # 若干拍后 vx 已爬升到限幅 3.0
|
||||
law.reset()
|
||||
snap.track_time_ms = 3000
|
||||
snap.radar_time_ms = 3000
|
||||
snap.attitude_time_ms = 3000
|
||||
out = law.update(snap, 3000)
|
||||
# Reset 后限幅器归零:又从 0.1 开始渐变
|
||||
assert out.vx_mps == pytest.approx(0.1, abs=1e-9)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""vtc_config_test.py —— TrackingConfig::load_from_json 单元测试。
|
||||
|
||||
移植自 reference-c++/visual_track_control/tests/vtc_config_test.cpp。
|
||||
覆盖(物理追踪思路 §8、需求分析 NFR-007):真实配置文件加载成功与取值
|
||||
正确(含 A1/A2 修改后的字段结构)、缺字段 / 缺分组 / 非法枚举 / 非法数值 /
|
||||
文件缺失均抛带字段名的 ValueError。
|
||||
|
||||
C++ 版用字符串替换构造非法变体;Python 版改为 dict 变异(更稳健),
|
||||
临时文件经 pytest 的 tmp_path 提供。
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from visual_track_control.config import (HeadingMode, NoDistanceAction,
|
||||
load_from_json)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
REAL_CONFIG = _REPO_ROOT / "reference-c++" / "visual_track_control" / "config" / "track_control.json"
|
||||
|
||||
|
||||
def full_config() -> dict:
|
||||
"""与真实配置同构的完整合法配置(用于派生各非法变体)。"""
|
||||
return {
|
||||
"control": {
|
||||
"frequency_hz": 20.0,
|
||||
"visual_stale_ms": 200,
|
||||
"radar_stale_ms": 500,
|
||||
"attitude_stale_ms": 300,
|
||||
},
|
||||
"camera": {
|
||||
"image_width": 1920,
|
||||
"image_height": 1080,
|
||||
"fov_h_deg": 60.0,
|
||||
"fov_v_deg": 35.0,
|
||||
},
|
||||
"heading": {
|
||||
"mode": "rate",
|
||||
"gain": 1.0,
|
||||
"yaw_rate_limit_dps": 30.0,
|
||||
"yaw_slew_limit_dps2": 60.0,
|
||||
},
|
||||
"vertical": {
|
||||
"vz_gain_mps_per_deg": 0.1,
|
||||
"vz_limit_mps": 2.0,
|
||||
},
|
||||
"distance": {
|
||||
"d_exp_m": 10.0,
|
||||
"kp": 0.5,
|
||||
"ki": 0.05,
|
||||
"kd": 0.1,
|
||||
"integral_limit": 2.0,
|
||||
"derivative_filter_coef": 0.2,
|
||||
"approach_velocity_limit_mps": 3.0,
|
||||
"retreat_velocity_limit_mps": 1.0,
|
||||
"no_distance_action": "hold",
|
||||
"no_distance_approach_limit_mps": 0.5,
|
||||
},
|
||||
"accel_limit": {
|
||||
"ax_mps2": 2.0,
|
||||
"ay_mps2": 2.0,
|
||||
"az_mps2": 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_temp_json(tmp_path: Path, config: dict, name: str) -> str:
|
||||
path = tmp_path / name
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_load_real_config_succeeds():
|
||||
cfg = load_from_json(str(REAL_CONFIG))
|
||||
assert cfg.control.frequency_hz == 20.0
|
||||
assert cfg.control.visual_stale_ms == 200
|
||||
assert cfg.camera.image_width == 1920
|
||||
assert cfg.camera.image_height == 1080
|
||||
assert cfg.camera.fov_h_deg == 60.0
|
||||
assert cfg.heading.mode is HeadingMode.RATE # A2:默认速率式
|
||||
assert cfg.heading.yaw_rate_limit_dps == 30.0
|
||||
assert cfg.vertical.vz_gain_mps_per_deg == 0.1
|
||||
assert cfg.vertical.vz_limit_mps == 2.0
|
||||
assert cfg.distance.d_exp_m == 10.0
|
||||
assert cfg.distance.no_distance_action is NoDistanceAction.HOLD
|
||||
assert cfg.accel_limit.ax_mps2 == 2.0
|
||||
|
||||
|
||||
def test_full_json_round_trip_matches_expected(tmp_path):
|
||||
# 内嵌合法配置与真实配置文件行为一致
|
||||
path = write_temp_json(tmp_path, full_config(), "vtc_cfg_full.json")
|
||||
cfg = load_from_json(path)
|
||||
assert cfg.heading.mode is HeadingMode.RATE
|
||||
assert cfg.distance.kp == 0.5
|
||||
|
||||
|
||||
def test_position_mode_accepted(tmp_path):
|
||||
config = full_config()
|
||||
config["heading"]["mode"] = "position"
|
||||
path = write_temp_json(tmp_path, config, "vtc_cfg_position.json")
|
||||
cfg = load_from_json(path)
|
||||
assert cfg.heading.mode is HeadingMode.POSITION
|
||||
|
||||
|
||||
def test_missing_file_throws_with_path():
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_from_json("/nonexistent/vtc_no_such_file.json")
|
||||
assert "vtc_no_such_file.json" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_malformed_json_throws(tmp_path):
|
||||
path = tmp_path / "vtc_cfg_bad.json"
|
||||
path.write_text("{ this is not json", encoding="utf-8")
|
||||
with pytest.raises(ValueError):
|
||||
load_from_json(str(path))
|
||||
|
||||
|
||||
def test_missing_field_throws_with_field_name(tmp_path):
|
||||
config = full_config()
|
||||
del config["heading"]["gain"]
|
||||
path = write_temp_json(tmp_path, config, "vtc_cfg_missing.json")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_from_json(path)
|
||||
assert "heading.gain" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_missing_section_throws(tmp_path):
|
||||
config = full_config()
|
||||
del config["accel_limit"]
|
||||
path = write_temp_json(tmp_path, config, "vtc_cfg_nosection.json")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_from_json(path)
|
||||
assert "accel_limit" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_invalid_heading_mode_throws(tmp_path):
|
||||
config = full_config()
|
||||
config["heading"]["mode"] = "magic"
|
||||
path = write_temp_json(tmp_path, config, "vtc_cfg_badmode.json")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_from_json(path)
|
||||
assert "heading.mode" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_invalid_no_distance_action_throws(tmp_path):
|
||||
config = full_config()
|
||||
config["distance"]["no_distance_action"] = "zoom"
|
||||
path = write_temp_json(tmp_path, config, "vtc_cfg_badaction.json")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_from_json(path)
|
||||
assert "no_distance_action" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_negative_frequency_throws(tmp_path):
|
||||
config = full_config()
|
||||
config["control"]["frequency_hz"] = -1.0
|
||||
path = write_temp_json(tmp_path, config, "vtc_cfg_negfreq.json")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_from_json(path)
|
||||
assert "control.frequency_hz" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_fov_out_of_range_throws(tmp_path):
|
||||
config = full_config()
|
||||
config["camera"]["fov_h_deg"] = 200.0
|
||||
path = write_temp_json(tmp_path, config, "vtc_cfg_badfov.json")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_from_json(path)
|
||||
assert "camera.fov_h_deg" in str(exc_info.value)
|
||||
Reference in New Issue
Block a user