从 DCPA/TCPA 到风险加权:ppiais-qt 的雷达避碰算法拆解
问题定义:为什么不能只看距离
在海上避碰里,单看“当前距离”通常不够。
- 两船距离很近,但正在分离,风险可能快速下降。
- 两船距离不算近,但相对速度和相对航向指向会遇点,风险可能快速上升。
所以核心不是“现在有多近”,而是“未来最近会有多近、什么时候最近”。这就是 CPA 相关算法要回答的问题:
DCPA:最近会遇距离(Distance at CPA)TCPA:到最近会遇时刻的时间(Time to CPA)
ppiais-qt 的实现也是按这条链路走:
- 先算
DCPA/TCPA - 再做风险分级/评分
- 最后与警戒区和告警规则联动
DCPA/TCPA 的数学模型与工程含义
在局部平面近似下(单位统一到海里 NM、分钟 min):
- 相对位置向量:
r = p_target - p_own - 相对速度向量:
v = v_target - v_own - 最近会遇时刻:
tcpa = - (r · v) / |v|^2 - 最近会遇点相对向量:
r_cpa = r + v * tcpa - 最近会遇距离:
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 < 6minCaution: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<=0 且 closing<=0,总分乘 0.25,避免“远离目标”被误判为高风险。
分级阈值
加权分再经过 classifyWeightedRisk() 分类:
score >= dangerThreshold -> DANGERscore >= cautionThreshold -> CAUTION- 其他 ->
SAFE
默认预设是“航道”档位(代码里 preset=1),权重约为:
DCPA=35, TCPA=30, Closing=20, Bearing=15
阈值由预设函数加载后会落在:
CAUTION=35DANGER=62
警戒区算法:圆环/扇形与跨 0° 方位处理
警戒区在 GuardZoneService 里实现:
- 先算目标到本船的
distance和bearing - 判断是否落在
[innerNm, outerNm]环带 - 若是扇形模式,再做方位区间判断
跨 0° 的扇形(例如 330° -> 30°)是常见坑,这里代码采用了专门分支:
start <= end:普通区间b in [start, end]start > end:跨零区间b >= start || b <= end
主循环里用 evaluateMask() 产出 bitmask,对比前后帧即可得到“进入/离开”事件。
告警规则:进入警戒区、CPA 预警、目标丢失
在 AppEngine::tick() 里,风险和警戒区结果会触发告警:
DANGER:触发碰撞危险告警(3s 冷却)CAUTION且0<TCPA<20且DCPA<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、向量预测、阈值判定全部带偏。
总结
这套算法链路可以概括为:
NavMath负责“几何与运动学真值”(DCPA/TCPA)CollisionEngine负责基础阈值语义AppEngine负责工程化风险加权与业务告警联动GuardZoneService负责空间规则与自动采集触发
它不是“雷达全栈”,但已经把“可解释的避碰风险计算 -> 可执行告警行为”打通了。对一个工程系统来说,这一步通常比追求复杂模型更关键。