← 返回博客

从 DCPA/TCPA 到风险加权:ppiais-qt 的雷达避碰算法拆解

2026年3月20日·8 min read
雷达CPADCPATCPA避碰算法Qt

问题定义:为什么不能只看距离

在海上避碰里,单看“当前距离”通常不够。

  • 两船距离很近,但正在分离,风险可能快速下降。
  • 两船距离不算近,但相对速度和相对航向指向会遇点,风险可能快速上升。

所以核心不是“现在有多近”,而是“未来最近会有多近、什么时候最近”。这就是 CPA 相关算法要回答的问题:

  • DCPA:最近会遇距离(Distance at CPA)
  • TCPA:到最近会遇时刻的时间(Time to CPA)

ppiais-qt 的实现也是按这条链路走:

  1. 先算 DCPA/TCPA
  2. 再做风险分级/评分
  3. 最后与警戒区和告警规则联动

DCPA/TCPA 的数学模型与工程含义

在局部平面近似下(单位统一到海里 NM、分钟 min):

  1. 相对位置向量:r = p_target - p_own
  2. 相对速度向量:v = v_target - v_own
  3. 最近会遇时刻:tcpa = - (r · v) / |v|^2
  4. 最近会遇点相对向量:r_cpa = r + v * tcpa
  5. 最近会遇距离:dcpa = |r_cpa|

这里有两个关键工程细节:

  • |v|^2 非常小(两者几乎同速同向)时,tcpa 退化,代码直接回退为“当前距离 + 很大 TCPA 哨兵值”。
  • tcpa < 0 时,表示最近会遇发生在过去,目标总体趋势是远离,不应按高风险处理。

代码映射 1:NavMath::calculateCPATCPA

DCPA/TCPA 核心计算在 NavMath(header-only)里完成,主要步骤和上面公式一一对应。

// src/utils/NavMath.h
auto rel = latLonToXY(tgtLat, tgtLon, ownLat, ownLon);
auto ownV = cogSogToVelocity(ownCog, ownSog);
auto tgtV = cogSogToVelocity(tgtCog, tgtSog);
double dvx = tgtV.x - ownV.x;
double dvy = tgtV.y - ownV.y;
double dvSq = dvx * dvx + dvy * dvy;
if (dvSq < 1e-10)
    return { distanceNM(...), 9999.0 };
double tcpa = -(rel.x * dvx + rel.y * dvy) / dvSq;
double cpx = rel.x + dvx * tcpa;
double cpy = rel.y + dvy * tcpa;
return { sqrt(cpx * cpx + cpy * cpy), tcpa };

这段实现里,COG/SOG -> 速度分量 使用的是 NM/min,因此 TCPA 自然是分钟单位,后续告警和界面展示都能直接复用。

代码映射 2:CollisionEngine 的分级与基础评分

CollisionEngine::evaluate() 会先调用 calculateCPATCPA(),然后给出一版“基础风险结论”。

1) 分级规则

// src/services/CollisionEngine.cpp
if (tcpa < 0) return TargetModel::Safe;
if (dcpa < m_dangerDcpa && tcpa < m_dangerTcpa) return TargetModel::Danger;
if (dcpa < m_cautionDcpa && tcpa < m_cautionTcpa) return TargetModel::Caution;
return TargetModel::Safe;

默认阈值在 CollisionEngine.h

  • Danger: DCPA < 0.5NM && TCPA < 6min
  • Caution: DCPA < 1.0NM && TCPA < 12min

2) 基础分值

基础分值是 TCPA(0-50) + DCPA(0-50),最终截断到 [0, 100]

注意:这层是“基础评分”。在主循环中,最终写回目标模型的是 AppEngine 的加权评分结果,而不是这层分数直接上屏。

代码映射 3:AppEngine 的四因子加权风险

AppEngine::computeWeightedRiskScore()DCPA/TCPA 的基础上又加入了两个工程因子:

  • closing:闭合速度(是否正在快速接近)
  • bearing:相对方位(重点关注船首方向目标)

最终是四因子归一化加权:

score = (w_dcpa * f_dcpa + w_tcpa * f_tcpa + w_closing * f_closing + w_bearing * f_bearing)
        * 100 / (w_dcpa + w_tcpa + w_closing + w_bearing)

因子形状(代码中的分段)

  • f_dcpa<=0.5NM 满分,0.5~2.0NM 线性衰减到 0。
  • f_tcpa<=6min 满分,6~30min 线性衰减;tcpa<=0 不加分。
  • f_closing:闭合速度 >0.2kn 开始计分,>=18kn 饱和。
  • f_bearing:相对方位 <=20° 满分,20°~140° 线性衰减。

另外有一个抑制逻辑:若 tcpa<=0closing<=0,总分乘 0.25,避免“远离目标”被误判为高风险。

分级阈值

加权分再经过 classifyWeightedRisk() 分类:

  • score >= dangerThreshold -> DANGER
  • score >= cautionThreshold -> CAUTION
  • 其他 -> SAFE

默认预设是“航道”档位(代码里 preset=1),权重约为:

  • DCPA=35, TCPA=30, Closing=20, Bearing=15

阈值由预设函数加载后会落在:

  • CAUTION=35
  • DANGER=62

警戒区算法:圆环/扇形与跨 0° 方位处理

警戒区在 GuardZoneService 里实现:

  1. 先算目标到本船的 distancebearing
  2. 判断是否落在 [innerNm, outerNm] 环带
  3. 若是扇形模式,再做方位区间判断

的扇形(例如 330° -> 30°)是常见坑,这里代码采用了专门分支:

  • start <= end:普通区间 b in [start, end]
  • start > end:跨零区间 b >= start || b <= end

主循环里用 evaluateMask() 产出 bitmask,对比前后帧即可得到“进入/离开”事件。

告警规则:进入警戒区、CPA 预警、目标丢失

AppEngine::tick() 里,风险和警戒区结果会触发告警:

  • DANGER:触发碰撞危险告警(3s 冷却)
  • CAUTION0<TCPA<20DCPA<1.2:触发 CPA 预警(5s 冷却)
  • 警戒区 bit 从 0->1:进入告警,并可自动采集目标
  • 警戒区 bit 从 1->0:离开告警
  • AIS 超时:目标标记 Lost 并触发目标丢失告警

告警最终都经过 raiseAlert(),叠加抑制时间(cooldown 与全局 suppression)防止刷屏。

常见误区与工程边界

误区 1:TCPA < 0 就是危险

相反,TCPA < 0 通常意味着最近会遇已经发生在过去,目标总体在远离。代码里这类情况会被明显降权处理。

误区 2:有 DCPA/TCPA 就等于完整雷达算法

ppiais-qt 当前重点是避碰决策链路,不是完整雷达信号处理链路。仓库里明确写了当前边界:

  • 暂无真实雷达数据接入
  • 暂无 CFAR 点迹检测
  • 暂无多源自动融合(AIS-雷达-摄像头)

误区 3:忽略单位统一

这套实现大量依赖 NM / kn / min 的一致性。单位混乱会直接把 TCPA、向量预测、阈值判定全部带偏。

总结

这套算法链路可以概括为:

  1. NavMath 负责“几何与运动学真值”(DCPA/TCPA
  2. CollisionEngine 负责基础阈值语义
  3. AppEngine 负责工程化风险加权与业务告警联动
  4. GuardZoneService 负责空间规则与自动采集触发

它不是“雷达全栈”,但已经把“可解释的避碰风险计算 -> 可执行告警行为”打通了。对一个工程系统来说,这一步通常比追求复杂模型更关键。