Files
code-test-drone/utils/visual_track_control/tracking_control_law.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

249 lines
13 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.
"""视觉跟踪主控律(TrackingControlLaw)。
对应 C++ 参考实现 reference-c++/visual_track_control/src/tracking_control_law.cpp
(规格:docs/物理追踪思路.md §2.3/§4/§5/§6/§7/§8/§9)。
单周期八步流程:
① 超龄判定(姿态优先于视觉,超龄 → 降级保持)
② 判丢 → 零速刹车(进入时清积分与限幅器历史)
③ Coast(is_predicted)→ 清距离积分防饱和
④ 视线角 β/α:默认像素偏移→针孔模型换算;若 snapshot 直入角度(los_beta_deg/
los_alpha_deg,L1 航向伺服路线 A1)则优先采用,绕过 FOV 换算
⑤ 水平航向(速率式/位置式,A2:位置式合成绝对 NED 航向)
⑥ 垂直统一速度支路(A1:不切姿态微调模式)
⑦ 距离通道 PID → 前向速度;无距离按配置降级(hold/slow_approach/exit,
exit 需持续无距离达 no_distance_exit_grace_ms 门限)
⑧ 机体系 → 局部 NED 旋转(A4)
日志纪律与 C++ 版一致:仅状态切换点打印,热路径零日志。
Python 扩展:ControlOutput.vx_body_mps 记录旋转前的机体系前向分量,
供 MAVSDK 机体速度设定点使用。
"""
import logging
import math
from .config import HeadingMode, NoDistanceAction, TrackingConfig
from .frame_transform import rotate_body_to_ned
from .line_of_sight import PixelToAngle
from .pid_controller import PidController, PidParams
from .rate_limiter import SlewRateLimiter, clamp
from .staleness_checker import is_stale
from .types import ControlMode, ControlOutput, ControlSnapshot, DegradedReason
logger = logging.getLogger(__name__)
_K_PI = 3.14159265358979323846
_K_RAD_TO_DEG = 180.0 / _K_PI
_K_DEG_TO_RAD = _K_PI / 180.0
_K_MAX_DT_S = 0.5 # 单步 dt 上限(秒):异常节拍保护
_MODE_NAMES = {
ControlMode.VELOCITY_HEADING: "速度航向",
ControlMode.BRAKE_HOVER: "零速刹车",
ControlMode.DEGRADED_HOLD: "降级保持",
}
def _wrap_deg_180(deg: float) -> float:
"""角度回绕到 [-180, 180)(位置式绝对航向合成用)。"""
d = math.fmod(deg + 180.0, 360.0)
if d < 0.0:
d += 360.0
return d - 180.0
def _build_distance_pid_params(cfg: TrackingConfig) -> PidParams:
"""由距离配置构造 PID 参数(§6.2);输出限幅取接近/后退限幅的较大者。"""
return PidParams(
kp=cfg.distance.kp,
ki=cfg.distance.ki,
kd=cfg.distance.kd,
integral_limit=cfg.distance.integral_limit,
derivative_filter_coef=cfg.distance.derivative_filter_coef,
output_limit=max(cfg.distance.approach_velocity_limit_mps,
cfg.distance.retreat_velocity_limit_mps),
)
class TrackingControlLaw:
def __init__(self, cfg: TrackingConfig) -> None:
self._cfg = cfg
self._pixel_to_beta = PixelToAngle(cfg.camera.image_width,
cfg.camera.fov_h_deg * _K_DEG_TO_RAD)
self._pixel_to_alpha = PixelToAngle(cfg.camera.image_height,
cfg.camera.fov_v_deg * _K_DEG_TO_RAD)
self._distance_pid = PidController(_build_distance_pid_params(cfg))
self._yaw_deg_limiter = SlewRateLimiter(cfg.heading.yaw_slew_limit_dps2)
self._yaw_rate_limiter = SlewRateLimiter(cfg.heading.yaw_slew_limit_dps2)
self._vx_limiter = SlewRateLimiter(cfg.accel_limit.ax_mps2)
self._vz_limiter = SlewRateLimiter(cfg.accel_limit.az_mps2)
self._last_now_ms = 0
self._last_mode = ControlMode.DEGRADED_HOLD
# EXIT 无距离计时(§6.3:持续无距离达 no_distance_exit_grace_ms 才退出,
# 任一有效距离帧重置)
self._no_distance_active = False
self._no_distance_since_ms = 0
def update(self, snapshot: ControlSnapshot, now_ms: int) -> ControlOutput:
# ---- 节拍推算(§7.1 固定周期;异常节拍用配置频率兜底) ----
# 时钟回拨或 now_ms 未前进(同值)时不信任差值,回退配置频率。
dt_s = 1.0 / self._cfg.control.frequency_hz
if self._last_now_ms > 0 and now_ms > self._last_now_ms:
dt_s = min((now_ms - self._last_now_ms) / 1000.0, _K_MAX_DT_S)
self._last_now_ms = now_ms
out = ControlOutput()
out.mode = ControlMode.VELOCITY_HEADING # 正常路径默认模式,分支可覆盖
# ---- ① 超龄判定(§7.1 / §9.2) ----
# 姿态过期或缺失:禁止空间转换和控制,立即退出正常控制(§9.2)
if (not snapshot.attitude_present
or is_stale(snapshot.attitude_time_ms, now_ms,
self._cfg.control.attitude_stale_ms)):
out.mode = ControlMode.DEGRADED_HOLD
out.degraded_reason = DegradedReason.ATTITUDE_STALE
return self._finish_and_log(out, "姿态过期/缺失,禁止控制")
# 视觉追踪结果超龄:不得继续生成正常指令(§7.1)
if is_stale(snapshot.track_time_ms, now_ms,
self._cfg.control.visual_stale_ms):
out.mode = ControlMode.DEGRADED_HOLD
out.degraded_reason = DegradedReason.VISION_STALE
return self._finish_and_log(out, "视觉追踪结果超龄")
# ---- ② 判丢 → 零速刹车(§6.4:tracked == false 执行 BRAKE_HOVER) ----
if not snapshot.track.tracked:
self._no_distance_active = False # 刹车期间不计 EXIT 无距离时长
if self._last_mode is not ControlMode.BRAKE_HOVER:
# 进入刹车:清积分与限幅器历史,恢复跟踪后指令从零渐变(S6 首条限幅)
self._distance_pid.reset()
self._yaw_deg_limiter.reset()
self._yaw_rate_limiter.reset()
self._vx_limiter.reset()
self._vz_limiter.reset()
out.mode = ControlMode.BRAKE_HOVER # 速度分量保持默认全零
out.valid = True
return self._finish_and_log(out, "判丢,零速刹车")
# ---- ③ Coast:is_predicted → 清积分防饱和(§6.4) ----
if snapshot.track.is_predicted:
self._distance_pid.reset_integral()
# 航向通道为纯比例增益映射(§4.2),无积分项,无需清理
out.coast_active = True
# ---- ④ 视线角 β/α(§4.1 水平 / §5.1 垂直) ----
# 优先角度直入(L1 航向伺服,路线 A1):信源已给角度量则跳过像素换算,
# 避免吊舱变焦后 FOV 变化导致失真;未提供(None)则回退既有像素路径。
# 像素路径:x_offset = cx − W/2(右正),y_offset = cy − H/2(下正)
if snapshot.los_beta_deg is not None:
beta_deg = snapshot.los_beta_deg
else:
x_offset = snapshot.track.center[0] - self._cfg.camera.image_width * 0.5
beta_deg = self._pixel_to_beta.convert(x_offset) * _K_RAD_TO_DEG
if snapshot.los_alpha_deg is not None:
alpha_deg = snapshot.los_alpha_deg
else:
y_offset = snapshot.track.center[1] - self._cfg.camera.image_height * 0.5
alpha_deg = self._pixel_to_alpha.convert(y_offset) * _K_RAD_TO_DEG
# ---- ⑤ 水平航向通道(§4.2:增益 → slew → 角速度限幅) ----
if self._cfg.heading.mode is HeadingMode.RATE:
# 速率式:β → 偏航角速度指令,先变化率限幅再角速度限幅(FR-033)
target_rate = self._cfg.heading.gain * beta_deg
slewed = self._yaw_rate_limiter.limit(target_rate, dt_s)
out.yaw_rate_dps = clamp(slewed, -self._cfg.heading.yaw_rate_limit_dps,
self._cfg.heading.yaw_rate_limit_dps)
else:
# 位置式:β → 航向修正量,变化率限幅(FR-033)后与当前航向合成
# 绝对 NED 航向——ID 84 的 yaw 字段是绝对值,直接发修正量会让
# 机体转向"修正角"方向(A2 修复)。姿态已在 ① 通过超龄校验。
target_yaw = self._cfg.heading.gain * beta_deg
correction_deg = self._yaw_deg_limiter.limit(target_yaw, dt_s)
out.yaw_deg = _wrap_deg_180(snapshot.yaw_rad * _K_RAD_TO_DEG
+ correction_deg)
# ---- ⑥ 垂直速度通道(§5:统一速度支路) ----
# 符号约定:α 下正(目标偏低)→ NED 下正 → vz 为正(下降趋近目标高度)。
# 输出模式保持 VELOCITY_HEADING,距离通道前向速度不会被丢弃(A1 修复)。
vz_target = self._cfg.vertical.vz_gain_mps_per_deg * alpha_deg
slewed = self._vz_limiter.limit(vz_target, dt_s) # 加速度限幅(§8)
out.vz_mps = clamp(slewed, -self._cfg.vertical.vz_limit_mps,
self._cfg.vertical.vz_limit_mps)
# ---- ⑦ 距离通道(§6.2 / §6.3) ----
# 目标距离来自检测框自身的双目测距:仅 distance_valid 且未超龄才可用
# (§6.3、FR-041)
distance_available = (snapshot.distance_valid
and snapshot.target_distance_m > 0.0
and not is_stale(snapshot.distance_time_ms, now_ms,
self._cfg.control.distance_stale_ms))
if distance_available:
self._no_distance_active = False # 任一有效距离帧重置 EXIT 计时
# Δd = d − d_exp:d > d_exp 前向接近,d < d_exp 减速/后退(§6.2)
delta_d = snapshot.target_distance_m - self._cfg.distance.d_exp_m
vx_raw = self._distance_pid.update(delta_d, dt_s)
slewed = self._vx_limiter.limit(vx_raw, dt_s) # 加速度限幅(§8)
out.vx_mps = clamp(slewed,
-self._cfg.distance.retreat_velocity_limit_mps,
self._cfg.distance.approach_velocity_limit_mps)
else:
# 无可用目标距离:清积分防饱和,按配置降级动作(§6.3)
self._distance_pid.reset_integral()
action = self._cfg.distance.no_distance_action
if action is NoDistanceAction.HOLD:
# 保持:前向指令清零(安全占位语义)
out.vx_mps = 0.0
self._vx_limiter.reset(0.0)
elif action is NoDistanceAction.SLOW_APPROACH:
# 限速接近:缓速前向,同样经加速度限幅
out.vx_mps = self._vx_limiter.limit(
self._cfg.distance.no_distance_approach_limit_mps, dt_s)
else: # NoDistanceAction.EXIT
# 退出近距操作:双目有效域外无距离是常态(D4 远场定速接近),
# 首个无效拍不退出;持续无距离达 no_distance_exit_grace_ms
# (疑似传感器级故障)才降级。grace 期间按 SLOW_APPROACH 行为
# 执行(经加速度限幅,vx 平滑过渡);grace=0 保留旧"立即退出"行为
if not self._no_distance_active:
self._no_distance_active = True
self._no_distance_since_ms = now_ms
if (now_ms - self._no_distance_since_ms
>= self._cfg.distance.no_distance_exit_grace_ms):
out.mode = ControlMode.DEGRADED_HOLD
out.degraded_reason = DegradedReason.NO_DISTANCE_EXIT
return self._finish_and_log(out, "持续无距离达门限,退出近距操作")
out.vx_mps = self._vx_limiter.limit(
self._cfg.distance.no_distance_approach_limit_mps, dt_s)
# ---- ⑧ 机体系 → 局部 NED 旋转(§2.3,A4 修复) ----
# 各通道限幅均在机体系完成(旋转为正交变换,合速度不变,限幅约束
# 不被破坏);输出端随快照航向旋转为北/东/地。旋转前记录机体系前向
# 分量,供 MAVSDK 机体速度设定点直接使用(免反旋)。
out.vx_body_mps = out.vx_mps
out.vx_mps, out.vy_mps, out.vz_mps = rotate_body_to_ned(
out.vx_mps, out.vy_mps, out.vz_mps, snapshot.yaw_rad)
out.valid = True
return self._finish_and_log(out,
"Coast 预测跟踪" if out.coast_active
else "正常跟踪")
def reset(self) -> None:
"""停止视觉闭环时清全部状态(§6.4)。"""
self._distance_pid.reset()
self._yaw_deg_limiter.reset()
self._yaw_rate_limiter.reset()
self._vx_limiter.reset()
self._vz_limiter.reset()
self._no_distance_active = False
self._last_now_ms = 0
self._last_mode = ControlMode.DEGRADED_HOLD
def _finish_and_log(self, out: ControlOutput, reason: str) -> ControlOutput:
"""仅模式变化时打印状态切换日志(刹车进出、降级切换等)。"""
if out.mode is not self._last_mode:
logger.info("[ControlLaw] 状态切换: %s -> %s (%s)",
_MODE_NAMES[self._last_mode], _MODE_NAMES[out.mode],
reason)
self._last_mode = out.mode
return out