first commit

This commit is contained in:
2026-09-02 15:13:13 +08:00
commit cb00322a50
107 changed files with 10918 additions and 0 deletions
@@ -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