"""视觉跟踪控制律配置(TrackingConfig + load_from_json)。 对应 C++ 参考实现 reference-c++/visual_track_control/src/vtc_config.cpp (规格:docs/物理追踪思路.md §8:全部阈值集中配置,NFR-007)。 启动期一次性解析:缺字段 / 类型错误 / 非法取值抛 ValueError 并携带完整 字段名(如 "heading.gain");解析结果与 JSON 脱钩,热路径不做热更新。 """ import json from dataclasses import dataclass, field from enum import Enum from typing import Any from .types import ControlMode # noqa: F401 (re-export 便利性) class HeadingMode(Enum): """航向通道模式(§4.2)。默认速率式:yaw_rate 天然是相对量,无坐标系歧义。""" RATE = "rate" # β → 偏航角速度,输出 yaw_rate_dps POSITION = "position" # β → 修正量,与当前航向合成绝对航向输出 yaw_deg class NoDistanceAction(Enum): """目标距离不可用(无效/超龄)时的距离通道降级动作(§6.3)。""" HOLD = "hold" # 保持:前向速度指令清零 SLOW_APPROACH = "slow_approach" # 限速接近:按 no_distance_approach_limit_mps 缓速 EXIT = "exit" # 退出近距操作:持续无距离达 no_distance_exit_grace_ms # 才降级保持;grace 期间按 SLOW_APPROACH 行为 @dataclass class ControlGroupConfig: frequency_hz: float = 20.0 visual_stale_ms: int = 200 distance_stale_ms: int = 500 attitude_stale_ms: int = 300 @dataclass class CameraGroupConfig: image_width: int = 1920 image_height: int = 1080 fov_h_deg: float = 60.0 fov_v_deg: float = 35.0 @dataclass class HeadingGroupConfig: mode: HeadingMode = HeadingMode.RATE gain: float = 1.0 yaw_rate_limit_dps: float = 30.0 yaw_slew_limit_dps2: float = 60.0 @dataclass class VerticalGroupConfig: vz_gain_mps_per_deg: float = 0.1 vz_limit_mps: float = 2.0 @dataclass class DistanceGroupConfig: d_exp_m: float = 10.0 kp: float = 0.0 ki: float = 0.0 kd: float = 0.0 integral_limit: float = 0.0 derivative_filter_coef: float = 0.0 approach_velocity_limit_mps: float = 3.0 retreat_velocity_limit_mps: float = 1.0 # 默认定速接近(D4 远场策略):双目有效域外无距离是常态,退出仅留给 # 持续无距离(传感器级故障)场景 no_distance_action: NoDistanceAction = NoDistanceAction.SLOW_APPROACH no_distance_approach_limit_mps: float = 0.5 no_distance_exit_grace_ms: int = 3000 # EXIT 门限:持续无距离达此时长才退出(毫秒,占位) @dataclass class AccelLimitConfig: ax_mps2: float = 2.0 ay_mps2: float = 2.0 az_mps2: float = 1.5 @dataclass class TrackingConfig: control: ControlGroupConfig = field(default_factory=ControlGroupConfig) camera: CameraGroupConfig = field(default_factory=CameraGroupConfig) heading: HeadingGroupConfig = field(default_factory=HeadingGroupConfig) vertical: VerticalGroupConfig = field(default_factory=VerticalGroupConfig) distance: DistanceGroupConfig = field(default_factory=DistanceGroupConfig) accel_limit: AccelLimitConfig = field(default_factory=AccelLimitConfig) # --------------------------------------------------------------------------- # JSON 解析与校验(错误消息携带完整字段名,与 C++ 版文本一致) # --------------------------------------------------------------------------- def _require_section(root: dict, section: str) -> dict: value = root.get(section) if not isinstance(value, dict): raise ValueError(f"配置缺失分组或类型错误: {section}") return value def _is_number(value: Any) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) def _is_integer(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) def _require_number(section: dict, section_name: str, key: str) -> float: value = section.get(key) if not _is_number(value): raise ValueError(f"配置缺失字段或类型错误: {section_name}.{key}") return float(value) def _require_integer(section: dict, section_name: str, key: str) -> int: value = section.get(key) if not _is_integer(value): raise ValueError(f"配置缺失字段或类型错误(需整数): {section_name}.{key}") return int(value) def _require_string(section: dict, section_name: str, key: str) -> str: value = section.get(key) if not isinstance(value, str): raise ValueError(f"配置缺失字段或类型错误(需字符串): {section_name}.{key}") return value def _require_positive(value: float, field_name: str) -> float: if not (value > 0.0): raise ValueError(f"配置非法(必须为正): {field_name}") return value def _require_non_negative(value: float, field_name: str) -> float: if value < 0.0: raise ValueError(f"配置非法(不得为负): {field_name}") return value def _require_range(value: float, lo: float, hi: float, field_name: str) -> float: if not (lo < value < hi): raise ValueError(f"配置非法(超出范围): {field_name}") return value def _load_control(root: dict) -> ControlGroupConfig: s = _require_section(root, "control") c = ControlGroupConfig() c.frequency_hz = _require_positive( _require_number(s, "control", "frequency_hz"), "control.frequency_hz") c.visual_stale_ms = _require_integer(s, "control", "visual_stale_ms") c.distance_stale_ms = _require_integer(s, "control", "distance_stale_ms") c.attitude_stale_ms = _require_integer(s, "control", "attitude_stale_ms") if c.visual_stale_ms <= 0 or c.distance_stale_ms <= 0 or c.attitude_stale_ms <= 0: raise ValueError("配置非法: control.*_stale_ms 必须为正") return c def _load_camera(root: dict) -> CameraGroupConfig: s = _require_section(root, "camera") c = CameraGroupConfig() c.image_width = int(_require_positive( float(_require_integer(s, "camera", "image_width")), "camera.image_width")) c.image_height = int(_require_positive( float(_require_integer(s, "camera", "image_height")), "camera.image_height")) c.fov_h_deg = _require_range(_require_number(s, "camera", "fov_h_deg"), 0.0, 180.0, "camera.fov_h_deg") c.fov_v_deg = _require_range(_require_number(s, "camera", "fov_v_deg"), 0.0, 180.0, "camera.fov_v_deg") return c def _load_heading(root: dict) -> HeadingGroupConfig: s = _require_section(root, "heading") h = HeadingGroupConfig() mode = _require_string(s, "heading", "mode") if mode == "position": h.mode = HeadingMode.POSITION elif mode == "rate": h.mode = HeadingMode.RATE else: raise ValueError("配置非法: heading.mode 仅支持 position / rate") h.gain = _require_non_negative(_require_number(s, "heading", "gain"), "heading.gain") h.yaw_rate_limit_dps = _require_positive( _require_number(s, "heading", "yaw_rate_limit_dps"), "heading.yaw_rate_limit_dps") h.yaw_slew_limit_dps2 = _require_positive( _require_number(s, "heading", "yaw_slew_limit_dps2"), "heading.yaw_slew_limit_dps2") return h def _load_vertical(root: dict) -> VerticalGroupConfig: s = _require_section(root, "vertical") v = VerticalGroupConfig() v.vz_gain_mps_per_deg = _require_non_negative( _require_number(s, "vertical", "vz_gain_mps_per_deg"), "vertical.vz_gain_mps_per_deg") v.vz_limit_mps = _require_positive( _require_number(s, "vertical", "vz_limit_mps"), "vertical.vz_limit_mps") return v def _load_distance(root: dict) -> DistanceGroupConfig: s = _require_section(root, "distance") d = DistanceGroupConfig() d.d_exp_m = _require_positive(_require_number(s, "distance", "d_exp_m"), "distance.d_exp_m") d.kp = _require_non_negative(_require_number(s, "distance", "kp"), "distance.kp") d.ki = _require_non_negative(_require_number(s, "distance", "ki"), "distance.ki") d.kd = _require_non_negative(_require_number(s, "distance", "kd"), "distance.kd") d.integral_limit = _require_non_negative( _require_number(s, "distance", "integral_limit"), "distance.integral_limit") # 容许 -0.0:范围下界略低于 0,读入后归一化为 0 d.derivative_filter_coef = _require_range( _require_number(s, "distance", "derivative_filter_coef"), -0.000001, 1.0, "distance.derivative_filter_coef") if d.derivative_filter_coef < 0.0: d.derivative_filter_coef = 0.0 d.approach_velocity_limit_mps = _require_positive( _require_number(s, "distance", "approach_velocity_limit_mps"), "distance.approach_velocity_limit_mps") d.retreat_velocity_limit_mps = _require_positive( _require_number(s, "distance", "retreat_velocity_limit_mps"), "distance.retreat_velocity_limit_mps") action = _require_string(s, "distance", "no_distance_action") if action == "hold": d.no_distance_action = NoDistanceAction.HOLD elif action == "slow_approach": d.no_distance_action = NoDistanceAction.SLOW_APPROACH elif action == "exit": d.no_distance_action = NoDistanceAction.EXIT else: raise ValueError( "配置非法: distance.no_distance_action 仅支持 hold / slow_approach / exit") d.no_distance_approach_limit_mps = _require_non_negative( _require_number(s, "distance", "no_distance_approach_limit_mps"), "distance.no_distance_approach_limit_mps") # 0 容许:等效旧版"首个无效拍即退出"行为 d.no_distance_exit_grace_ms = _require_non_negative( _require_integer(s, "distance", "no_distance_exit_grace_ms"), "distance.no_distance_exit_grace_ms") return d def _load_accel_limit(root: dict) -> AccelLimitConfig: s = _require_section(root, "accel_limit") a = AccelLimitConfig() a.ax_mps2 = _require_positive(_require_number(s, "accel_limit", "ax_mps2"), "accel_limit.ax_mps2") a.ay_mps2 = _require_positive(_require_number(s, "accel_limit", "ay_mps2"), "accel_limit.ay_mps2") a.az_mps2 = _require_positive(_require_number(s, "accel_limit", "az_mps2"), "accel_limit.az_mps2") return a def load_from_json(path: str) -> TrackingConfig: """从 JSON 文件一次性加载配置(启动期调用,调参重启生效)。""" try: with open(path, "r", encoding="utf-8") as fp: root = json.load(fp) except OSError: raise ValueError(f"配置文件打开失败: {path}") from None except json.JSONDecodeError as exc: raise ValueError(f"配置文件解析失败: {path} ({exc})") from None if not isinstance(root, dict): raise ValueError(f"配置文件解析失败: {path} (根节点必须为对象)") cfg = TrackingConfig() cfg.control = _load_control(root) cfg.camera = _load_camera(root) cfg.heading = _load_heading(root) cfg.vertical = _load_vertical(root) cfg.distance = _load_distance(root) cfg.accel_limit = _load_accel_limit(root) return cfg