feat: 发布 EM 轨迹规划首个版本

This commit is contained in:
2026-08-11 20:35:59 +08:00
parent 569de5f13c
commit 1903e71fc1
522 changed files with 4188 additions and 119188 deletions
@@ -1,13 +1,34 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 静态走廊采样、偏移和净空配置;距离单位均为 m。
/// </summary>
public sealed class CorridorConfiguration
{
/// <summary>
/// 沿参考弧长的走廊采样间距;默认值 0.10 m,必须为正有限值,见 <see cref="EmPlannerConfiguration.CreateDefault"/>。
/// </summary>
public double LongitudinalSampleSpacingMeters { get; set; }
/// <summary>
/// 每个采样站横向搜索的间距;默认值 0.025 m,必须为正有限值,见 <see cref="EmPlannerConfiguration.CreateDefault"/>。
/// </summary>
public double LateralSampleSpacingMeters { get; set; }
/// <summary>
/// 相对参考线允许搜索的最大横向偏移;单位 m,默认值 0.30,必须为正有限值。
/// </summary>
public double MaximumLateralOffsetMeters { get; set; }
/// <summary>
/// 碰撞检测之外预留的附加净空;单位 m,默认值 0.02,必须为非负有限值。
/// </summary>
public double AdditionalClearanceReserveMeters { get; set; }
/// <summary>
/// 足迹碰撞检测的最大行进步长;单位 m,默认值 0.025,必须为正有限值。
/// </summary>
public double MaximumCollisionCheckStepMeters { get; set; }
/// <summary>
/// 复制当前走廊配置;输入为当前五个标量,输出为无共享可变状态的可修改快照,不对数值作校验且不产生失败状态。
/// </summary>
internal CorridorConfiguration Copy()
{
return new CorridorConfiguration
@@ -1,15 +1,42 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// EM 规划器的完整可变配置树;所有子配置均须非空,建议通过 <see cref="CreateDefault"/> 创建协调且彼此独立的默认快照。
/// </summary>
public sealed partial class EmPlannerConfiguration
{
/// <summary>
/// 重规划节奏、时间窗口和资源上限配置;无直接单位,必须为非空的 <see cref="SchedulingConfiguration"/>,默认值为新的调度配置对象。
/// </summary>
public SchedulingConfiguration Scheduling { get; set; }
/// <summary>
/// 世界坐标至 Frenet 坐标的投影容差配置;无直接单位,必须为非空的 <see cref="FrenetConfiguration"/>,默认值为新的 Frenet 配置对象。
/// </summary>
public FrenetConfiguration Frenet { get; set; }
/// <summary>
/// 静态无碰撞走廊的采样与净空配置;无直接单位,必须为非空的 <see cref="CorridorConfiguration"/>,默认值为新的走廊配置对象。
/// </summary>
public CorridorConfiguration Corridor { get; set; }
/// <summary>
/// LS 横向优化的约束与权重配置;无直接单位,必须为非空的 <see cref="LateralConfiguration"/>,默认值为新的横向配置对象。
/// </summary>
public LateralConfiguration Lateral { get; set; }
/// <summary>
/// ST 纵向优化的速度、舒适性与权重配置;无直接单位,必须为非空的 <see cref="LongitudinalConfiguration"/>,默认值为新的纵向配置对象。
/// </summary>
public LongitudinalConfiguration Longitudinal { get; set; }
/// <summary>
/// QP 求解器的迭代次数、容差和运行选项;无直接单位,必须为非空的 <see cref="SolverConfiguration"/>,默认值为新的求解器配置对象。
/// </summary>
public SolverConfiguration Solver { get; set; }
/// <summary>
/// 发布前几何、运动学和终端姿态校验容差;无直接单位,必须为非空的 <see cref="ValidationConfiguration"/>,默认值为新的校验配置对象。
/// </summary>
public ValidationConfiguration Validation { get; set; }
/// <summary>
/// 创建用于低速泊车的全套默认配置;每次调用均构造新的子配置和权重对象,因此调用方可修改返回值而不影响其他默认快照。
/// </summary>
public static EmPlannerConfiguration CreateDefault()
{
return new EmPlannerConfiguration
@@ -17,8 +44,8 @@ public sealed partial class EmPlannerConfiguration
Scheduling = new SchedulingConfiguration
{
ReplanPeriodSeconds = 0.20d,
TimeHorizonSeconds = 6d, //窗口的规划时间长度。单位s
DistanceHorizonMeters = 500d, //最大探索s里程距离,单位m
TimeHorizonSeconds = 6d, // 单次规划时间窗口,单位 s。
DistanceHorizonMeters = 500d, // 单次规划的最大前向探索距离,单位 m。
OutputTimeStepSeconds = 0.05d,
SolverTimeoutSeconds = 0.10d,
HandoffLookaheadSeconds = 0.30d,
@@ -103,6 +130,9 @@ public sealed partial class EmPlannerConfiguration
};
}
/// <summary>
/// 深复制当前配置树;输入为当前实例状态,输出与当前实例不共享非空嵌套对象的可修改快照,原本为 <c>null</c> 的子配置保持为 <c>null</c> 且不产生失败状态。
/// </summary>
internal EmPlannerConfiguration Copy()
{
return new EmPlannerConfiguration
@@ -1,11 +1,26 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 世界坐标与 Frenet 参考之间投影和边界判定的容差配置。
/// </summary>
public sealed class FrenetConfiguration
{
/// <summary>
/// 世界姿态投影到方向段所允许的最大欧氏距离;单位 m,默认值 0.50,必须为正有限值。
/// </summary>
public double MaximumProjectionDistanceMeters { get; set; }
/// <summary>
/// Frenet 重建中 <c>1-kappa*l</c> 的最小绝对安全余量;无量纲,默认值 0.20,必须为大于 0 且小于 1 的有限值。
/// </summary>
public double MinimumFrenetDenominator { get; set; }
/// <summary>
/// 判断投影是否锚定在段边界的距离容差;单位 m,默认值 1e-8,必须为正有限值。
/// </summary>
public double BoundaryAnchorToleranceMeters { get; set; }
/// <summary>
/// 复制当前 Frenet 配置;输入为当前三个标量,输出为无共享可变状态的标量快照,不对数值作校验且不产生失败状态。
/// </summary>
internal FrenetConfiguration Copy()
{
return new FrenetConfiguration
@@ -1,13 +1,34 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// LS 横向优化的离散、收敛、曲率和边界限制配置。
/// </summary>
public sealed class LateralConfiguration
{
/// <summary>
/// 相邻外层迭代横向解之间允许的最大偏移量;单位 m,默认值 0.05,必须为正有限值。
/// </summary>
public double MaximumLateralStepPerIterationMeters { get; set; }
/// <summary>
/// 横向偏移对弧长的一阶导数上限;无量纲,默认值 0.50,必须为正有限值。
/// </summary>
public double MaximumLateralSlope { get; set; }
/// <summary>
/// 横向偏移对弧长的二阶导数上限;单位 1/m,默认值 1,必须为正有限值。
/// </summary>
public double MaximumLateralSecondDerivativePerMeter { get; set; }
/// <summary>
/// 横向偏移对弧长的三阶导数上限;单位 1/m²,默认值 2,必须为正有限值。
/// </summary>
public double MaximumLateralThirdDerivativePerSquareMeter { get; set; }
/// <summary>
/// 横向 QP 目标函数的权重集合;无直接单位,必须非空且其每项为非负有限值,默认值为新的 <see cref="LateralWeights"/> 对象。
/// </summary>
public LateralWeights Weights { get; set; }
/// <summary>
/// 复制当前横向配置;输入为当前标量和权重引用,输出为不共享非空权重对象的可修改快照,权重为 <c>null</c> 时保持为空且不产生失败状态。
/// </summary>
internal LateralConfiguration Copy()
{
return new LateralConfiguration
@@ -1,16 +1,46 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 横向 QP 目标函数的权重快照;权重只影响偏好,不放宽安全约束。
/// </summary>
public sealed class LateralWeights
{
/// <summary>
/// 惩罚偏离参考线横向偏移的权重;无量纲,默认值 10,必须为非负有限值。
/// </summary>
public double ReferenceOffset { get; set; }
/// <summary>
/// 惩罚横向偏移一阶导数的权重;无量纲,默认值 1,必须为非负有限值。
/// </summary>
public double HeadingDeviation { get; set; }
/// <summary>
/// 惩罚横向偏移二阶导数的权重;无量纲,默认值 5,必须为非负有限值。
/// </summary>
public double SecondDerivative { get; set; }
/// <summary>
/// 惩罚横向偏移三阶导数的权重;无量纲,默认值 10,必须为非负有限值。
/// </summary>
public double ThirdDerivative { get; set; }
/// <summary>
/// 惩罚由横向解导出的车辆曲率的权重;无量纲,默认值 5,必须为非负有限值。
/// </summary>
public double Curvature { get; set; }
/// <summary>
/// 惩罚相邻站曲率变化的权重;无量纲,默认值 20,必须为非负有限值。
/// </summary>
public double CurvatureVariation { get; set; }
/// <summary>
/// 惩罚偏离上一轮横向轨迹的权重;无量纲,默认值 5,必须为非负有限值。
/// </summary>
public double PreviousTrajectory { get; set; }
/// <summary>
/// 滚动规划末端横向状态的稳定权重;无量纲,默认值 10,必须为非负有限值。
/// </summary>
public double RollingTerminal { get; set; }
/// <summary>
/// 复制当前横向权重;输入为当前八个标量,输出为无共享可变状态的标量快照,不对数值作校验且不产生失败状态。
/// </summary>
internal LateralWeights Copy()
{
return new LateralWeights
@@ -1,20 +1,62 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// ST 纵向优化的速度、舒适性和终端保持限制配置;所有数值由 <see cref="EmPlannerConfiguration.CreateDefault"/> 提供低速泊车默认值。
/// </summary>
public sealed class LongitudinalConfiguration
{
/// <summary>
/// 前进方向允许的最大速度;单位 m/s,默认值 1,必须为正有限值。
/// </summary>
public double MaximumForwardSpeedMetersPerSecond { get; set; }
/// <summary>
/// 倒车方向允许的最大速度;单位 m/s,默认值 0.5,必须为正有限值。
/// </summary>
public double MaximumReverseSpeedMetersPerSecond { get; set; }
/// <summary>
/// 前进方向的目标巡航速度;单位 m/s,默认值 1,必须为正有限值且不得超过 <see cref="MaximumForwardSpeedMetersPerSecond"/>。
/// </summary>
public double DesiredForwardSpeedMetersPerSecond { get; set; }
/// <summary>
/// 倒车方向的目标巡航速度;单位 m/s,默认值 0.5,必须为正有限值且不得超过 <see cref="MaximumReverseSpeedMetersPerSecond"/>。
/// </summary>
public double DesiredReverseSpeedMetersPerSecond { get; set; }
/// <summary>
/// 允许的最大纵向加速度;单位 m/s²,默认值 0.20,必须为正有限值。
/// </summary>
public double MaximumAccelerationMetersPerSecondSquared { get; set; }
/// <summary>
/// 允许的最大纵向减速度幅值;单位 m/s²,默认值 0.30,必须为正有限值。
/// </summary>
public double MaximumDecelerationMetersPerSecondSquared { get; set; }
/// <summary>
/// 允许的最大纵向加加速度幅值;单位 m/s³,默认值 0.50,必须为正有限值。
/// </summary>
public double MaximumJerkMetersPerSecondCubed { get; set; }
/// <summary>
/// 由速度和曲率共同施加的最大横向加速度;单位 m/s²,默认值 0.20,必须为正有限值。
/// </summary>
public double MaximumLateralAccelerationMetersPerSecondSquared { get; set; }
/// <summary>
/// 车辆曲率随时间变化的上限;单位 1/(m·s),默认值 0.50,必须为正有限值。
/// </summary>
public double MaximumCurvatureRatePerMeterPerSecond { get; set; }
/// <summary>
/// 将进度速度视为停止的容差;单位 m/s,默认值 0.01,必须为非负有限值。
/// </summary>
public double StopSpeedToleranceMetersPerSecond { get; set; }
/// <summary>
/// 到达零速度后需保持的时长;单位 s,默认值 0.20,必须为正有限值。
/// </summary>
public double ZeroSpeedHoldSeconds { get; set; }
/// <summary>
/// 纵向 QP 目标函数的权重集合;无直接单位,必须非空且其每项为非负有限值,默认值为新的 <see cref="LongitudinalWeights"/> 对象。
/// </summary>
public LongitudinalWeights Weights { get; set; }
/// <summary>
/// 复制当前纵向配置;输入为当前标量和权重引用,输出为不共享非空权重对象的可修改快照,权重为 <c>null</c> 时保持为空且不产生失败状态。
/// </summary>
internal LongitudinalConfiguration Copy()
{
return new LongitudinalConfiguration
@@ -1,13 +1,34 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 纵向 QP 目标函数的权重快照;权重只改变偏好而不放宽硬约束。
/// </summary>
public sealed class LongitudinalWeights
{
/// <summary>
/// 惩罚偏离参考速度的权重;无量纲,默认值 10,必须为非负有限值。
/// </summary>
public double ReferenceSpeed { get; set; }
/// <summary>
/// 惩罚纵向加速度的权重;无量纲,默认值 1,必须为非负有限值。
/// </summary>
public double Acceleration { get; set; }
/// <summary>
/// 惩罚纵向加加速度的权重;无量纲,默认值 10,必须为非负有限值。
/// </summary>
public double Jerk { get; set; }
/// <summary>
/// 惩罚偏离上一条轨迹速度解的权重;无量纲,默认值 5,必须为非负有限值。
/// </summary>
public double PreviousTrajectory { get; set; }
/// <summary>
/// 惩罚末端加速度的权重;无量纲,默认值 1,必须为非负有限值。
/// </summary>
public double TerminalAcceleration { get; set; }
/// <summary>
/// 复制当前纵向权重;输入为当前五个标量,输出为无共享可变状态的标量快照,不对数值作校验且不产生失败状态。
/// </summary>
internal LongitudinalWeights Copy()
{
return new LongitudinalWeights
@@ -1,19 +1,58 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// EM 规划的触发节奏、预测窗口、离散上限和发布样本上限配置;默认值由 <see cref="EmPlannerConfiguration.CreateDefault"/> 为低速泊车协调。
/// </summary>
public sealed class SchedulingConfiguration
{
/// <summary>
/// 两次规划触发之间的周期;单位 s,默认值 0.20,必须为正有限值。
/// </summary>
public double ReplanPeriodSeconds { get; set; }
/// <summary>
/// 单次优化覆盖的预测时间窗口;单位 s,默认值 6,必须为正有限值。
/// </summary>
public double TimeHorizonSeconds { get; set; }
/// <summary>
/// 单次优化覆盖的最大参考线距离;单位 m,默认值 500,必须为正有限值;滚动规划时还须覆盖制动距离及一个重规划周期内的行程。
/// </summary>
public double DistanceHorizonMeters { get; set; }
/// <summary>
/// 发布轨迹相邻样本的时间间隔;单位 s,默认值 0.05,必须为正有限值。
/// </summary>
public double OutputTimeStepSeconds { get; set; }
/// <summary>
/// 单次求解允许使用的超时预算;单位 s,默认值 0.10,必须为正有限值。
/// </summary>
public double SolverTimeoutSeconds { get; set; }
/// <summary>
/// 与已发布轨迹交接时向前查看的时长;单位 s,默认值 0.30,必须为正有限值。
/// </summary>
public double HandoffLookaheadSeconds { get; set; }
/// <summary>
/// 接受车辆状态输入的最大时效;单位 s,默认值 0.20,必须为正有限值。
/// </summary>
public double MaximumVehicleStateAgeSeconds { get; set; }
/// <summary>
/// 纵向优化结点允许的最大时间间距;单位 s,默认值 0.20,必须为正有限值。
/// </summary>
public double MaximumOptimizationTimeStepSeconds { get; set; }
/// <summary>
/// 横向或走廊优化结点允许的最大空间间距;单位 m,默认值 0.10,必须为正有限值。
/// </summary>
public double MaximumOptimizationSpatialStepMeters { get; set; }
/// <summary>
/// 一次优化允许的最大结点数量;单位为结点数,默认值 401,必须为不小于 3 的整数。
/// </summary>
public int MaximumOptimizationKnotCount { get; set; }
/// <summary>
/// 一条发布轨迹允许的最大样本数量;单位为样本数,默认值 5001,必须为不小于 2 的整数。
/// </summary>
public int MaximumPublishedSampleCount { get; set; }
/// <summary>
/// 复制当前调度配置;输入为当前标量限制,输出为无共享可变状态的标量快照,不对数值作校验且不产生失败状态。
/// </summary>
internal SchedulingConfiguration Copy()
{
return new SchedulingConfiguration
@@ -1,16 +1,46 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// LS/ST QP 求解器的迭代预算、收敛容差和运行选项。
/// </summary>
public sealed class SolverConfiguration
{
/// <summary>
/// 外层顺序凸化最大迭代次数;默认值 5,必须为正整数。
/// </summary>
public int MaximumOuterIterations { get; set; }
/// <summary>
/// 单次 OSQP 求解的最大迭代次数;默认值 4000,必须为正整数。
/// </summary>
public int MaximumOsqpIterations { get; set; }
/// <summary>
/// 求解器绝对残差容差;默认值 1e-5,必须为正有限值。
/// </summary>
public double AbsoluteTolerance { get; set; }
/// <summary>
/// 求解器相对残差容差;默认值 1e-5,必须为正有限值。
/// </summary>
public double RelativeTolerance { get; set; }
/// <summary>
/// 发布前接受解的严格残差容差;默认值 1e-5,必须为正有限值。
/// </summary>
public double StrictResidualTolerance { get; set; }
/// <summary>
/// 是否将上一轮可用解作为初值;无单位,默认值 <c>true</c>,仅接受布尔值。
/// </summary>
public bool WarmStart { get; set; }
/// <summary>
/// 是否请求 OSQP 进行结果修正;无单位,默认值 <c>true</c>,仅接受布尔值。
/// </summary>
public bool Polish { get; set; }
/// <summary>
/// 是否启用原生求解器诊断输出;无单位,默认值 <c>false</c>,仅接受布尔值。
/// </summary>
public bool NativeVerbose { get; set; }
/// <summary>
/// 复制当前求解器配置;输入为当前迭代预算、容差和布尔选项,输出为无共享可变状态的标量快照,不对数值作校验且不产生失败状态。
/// </summary>
internal SolverConfiguration Copy()
{
return new SolverConfiguration
@@ -1,12 +1,30 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 发布前轨迹几何、运动学和终端误差的验收容差配置;默认值由 <see cref="EmPlannerConfiguration.CreateDefault"/> 提供。
/// </summary>
public sealed class ValidationConfiguration
{
/// <summary>
/// 几何位置、弧长和采样比较使用的空间容差;单位 m,默认值 1e-8,必须为正有限值。
/// </summary>
public double SpatialToleranceMeters { get; set; }
/// <summary>
/// 速度、加速度和曲率等无量纲相对比较的容差;无量纲,默认值 1e-5,必须为正有限值。
/// </summary>
public double KinematicTolerance { get; set; }
/// <summary>
/// 精确终止模式下终端位置允许的误差;单位 m,默认值 0,必须为非负有限值。
/// </summary>
public double TerminalPositionToleranceMeters { get; set; }
/// <summary>
/// 精确终止模式下终端航向允许的误差;单位 rad,默认值 0,必须为 [0, π] 内的有限值。
/// </summary>
public double TerminalYawToleranceRadians { get; set; }
/// <summary>
/// 复制当前校验配置;输入为当前四个容差,输出为无共享可变状态的标量快照,不对数值作校验且不产生失败状态。
/// </summary>
internal ValidationConfiguration Copy()
{
return new ValidationConfiguration
@@ -2,8 +2,14 @@ using System;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 验证规划契约中必须为有限数的标量输入。
/// </summary>
internal static class ContractNumeric
{
/// <summary>
/// 拒绝 NaN 或无穷数值。<paramref name="value"/> 的单位由调用方语境决定;<paramref name="parameterName"/> 原样传给异常构造器,方法本身不验证它。
/// </summary>
public static void RequireFinite(double value, string parameterName)
{
if (double.IsNaN(value) || double.IsInfinity(value))
@@ -1,10 +1,28 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 表示 EM 规划中参考段终端或滚动窗口终端的边界类型。
/// </summary>
public enum EmBoundaryType
{
/// <summary>
/// 非边界采样点;不携带终端或换挡边界语义。
/// </summary>
None,
/// <summary>
/// 滚动窗口因安全约束形成的停车终点,而非方向段的真实终点。
/// </summary>
RollingSafetyStop,
/// <summary>
/// 到达换挡位置前的真实停车边界。
/// </summary>
GearSwitchApproach,
/// <summary>
/// 换挡后新方向段开始时的离开边界。
/// </summary>
GearSwitchDeparture,
/// <summary>
/// 整条参考路径或当前任务目标的真实终点边界。
/// </summary>
Goal,
}
@@ -1,8 +1,20 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 指定纵向规划面对当前窗口末端时采用的速度终端语义。
/// </summary>
public enum EmLongitudinalMode
{
/// <summary>
/// 普通滚动窗口末端;保持连续行驶,不要求在本窗口内停止。
/// </summary>
RollingContinuation,
/// <summary>
/// 将真实停车边界纳入窗口,但在可达性不足时仅以可停车方式接近。
/// </summary>
ApproachStopBoundary,
/// <summary>
/// 要求在当前窗口内到达真实边界并保持零速。
/// </summary>
ExactStopAtBoundary,
}
@@ -1,8 +1,20 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 请求允许的车辆运动模型;服务依据此值选择或拒绝相应规划分支。
/// </summary>
public enum EmMotionModel
{
/// <summary>
/// 常规非完整约束车辆,可前进或倒车但不能侧移。
/// </summary>
NonholonomicForwardReverse,
/// <summary>
/// 蟹行平移模型;当前 EM 管线不支持时返回明确状态。
/// </summary>
CrabTranslation,
/// <summary>
/// 原地旋转模型;当前 EM 管线不支持时返回明确状态。
/// </summary>
InPlaceRotation,
}
@@ -1,12 +1,136 @@
using System;
using System.Diagnostics;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.Mapping;
using MultiWheelC.TrajectoryPlanning.PathSmoothing;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
internal interface IEmPlanningPublicationAuthorization
{
TimeSpan Remaining { get; }
bool IsExpired { get; }
EmPlanningPublicationDecision TryPublish(CancellationToken requestCallerCancellationToken,
CancellationToken coordinatorCallerCancellationToken, Action publish);
}
internal enum EmPlanningPublicationDecision
{
Published = 0,
CallerCancelled = 1,
DeadlineExpired = 2,
}
internal sealed class EmPlanningRequestPublicationAuthorization : IEmPlanningPublicationAuthorization
{
private readonly object gate = new object();
private readonly TimeSpan? initialRemaining;
private readonly Stopwatch stopwatch;
private readonly CancellationToken deadlineToken;
private bool callerCancelled;
private bool deadlineExpired;
public EmPlanningRequestPublicationAuthorization(TimeSpan? remaining, CancellationToken deadlineToken)
{
initialRemaining = remaining;
this.deadlineToken = deadlineToken;
stopwatch = remaining.HasValue ? Stopwatch.StartNew() : null;
deadlineExpired = remaining.HasValue && remaining.Value <= TimeSpan.Zero ||
deadlineToken.IsCancellationRequested;
}
public TimeSpan Remaining
{
get
{
lock (gate)
{
ObserveDeadlineInsideGate();
return ComputeRemainingInsideGate();
}
}
}
public bool IsExpired
{
get
{
lock (gate)
{
ObserveDeadlineInsideGate();
return deadlineExpired;
}
}
}
public EmPlanningPublicationDecision TryPublish(CancellationToken requestCallerCancellationToken,
CancellationToken coordinatorCallerCancellationToken, Action publish)
{
if (publish == null)
throw new ArgumentNullException(nameof(publish));
using CancellationTokenRegistration requestCallerRegistration =
requestCallerCancellationToken.Register(ObserveCallerCancellation);
using CancellationTokenRegistration coordinatorCallerRegistration =
coordinatorCallerCancellationToken.Register(ObserveCallerCancellation);
using CancellationTokenRegistration deadlineRegistration =
deadlineToken.Register(ObserveDeadlineCancellation);
lock (gate)
{
if (requestCallerCancellationToken.IsCancellationRequested ||
coordinatorCallerCancellationToken.IsCancellationRequested)
{
callerCancelled = true;
}
ObserveDeadlineInsideGate();
if (callerCancelled)
return EmPlanningPublicationDecision.CallerCancelled;
if (deadlineExpired)
return EmPlanningPublicationDecision.DeadlineExpired;
publish();
return EmPlanningPublicationDecision.Published;
}
}
private void ObserveCallerCancellation()
{
lock (gate)
callerCancelled = true;
}
private void ObserveDeadlineCancellation()
{
lock (gate)
deadlineExpired = true;
}
private void ObserveDeadlineInsideGate()
{
if (deadlineToken.IsCancellationRequested || ComputeRemainingInsideGate() <= TimeSpan.Zero)
deadlineExpired = true;
}
private TimeSpan ComputeRemainingInsideGate()
{
if (!initialRemaining.HasValue)
return deadlineExpired ? TimeSpan.Zero : TimeSpan.MaxValue;
TimeSpan remaining = initialRemaining.Value - stopwatch.Elapsed;
return remaining > TimeSpan.Zero && !deadlineExpired ? remaining : TimeSpan.Zero;
}
}
/// <summary>
/// EM 单次规划的只读输入引用快照,绑定平滑参考路径、地图、车辆状态、目标方向段和输出身份。
/// 坐标使用 m,航向使用 rad,时间使用 UTC;调用方必须在进入服务前保证这些输入属于同一业务版本。
/// </summary>
public sealed class EmPlanningRequest
{
/// <summary>
/// 保存一次规划所需的引用快照和发布身份。构造器只赋值而不校验、复制或取得对象所有权;调用方仍拥有传入引用,服务随后负责验证并复制配置。
/// </summary>
public EmPlanningRequest(
PathSmoothingResult referencePath,
PlanningGridMap map,
@@ -21,7 +145,10 @@ public sealed class EmPlanningRequest
string referencePathId,
string previousTrajectoryId,
EmMotionModel motionModel,
EmPlanningScope planningScope)
EmPlanningScope planningScope,
TimeSpan? cycleDeadlineRemaining = null,
CancellationToken callerCancellationToken = default,
CancellationToken cycleDeadlineToken = default)
{
ReferencePath = referencePath;
Map = map;
@@ -37,25 +164,166 @@ public sealed class EmPlanningRequest
PreviousTrajectoryId = previousTrajectoryId;
MotionModel = motionModel;
PlanningScope = planningScope;
CycleDeadlineRemaining = cycleDeadlineRemaining;
CallerCancellationToken = callerCancellationToken;
CycleDeadlineToken = cycleDeadlineToken;
PublicationAuthorization = new EmPlanningRequestPublicationAuthorization(
cycleDeadlineRemaining, cycleDeadlineToken);
}
internal EmPlanningRequest(
PathSmoothingResult referencePath,
PlanningGridMap map,
VehicleParameters vehicle,
VehicleMotionState vehicleState,
EmPlannerConfiguration configuration,
int segmentIndex,
EmTrajectory previousTrajectory,
DateTimeOffset requestedAtUtc,
DateTimeOffset effectiveAtUtc,
string outputTrajectoryId,
string referencePathId,
string previousTrajectoryId,
EmMotionModel motionModel,
EmPlanningScope planningScope,
TimeSpan? cycleDeadlineRemaining,
CancellationToken callerCancellationToken,
CancellationToken cycleDeadlineToken,
IEmPlanningPublicationAuthorization publicationAuthorization)
: this(referencePath, map, vehicle, vehicleState, configuration, segmentIndex, previousTrajectory,
requestedAtUtc, effectiveAtUtc, outputTrajectoryId, referencePathId, previousTrajectoryId,
motionModel, planningScope, cycleDeadlineRemaining, callerCancellationToken, cycleDeadlineToken)
{
PublicationAuthorization = publicationAuthorization ??
new EmPlanningRequestPublicationAuthorization(cycleDeadlineRemaining, cycleDeadlineToken);
}
/// <summary>
/// 平滑后的参考路径结果引用;服务要求其为可消费的成功结果,且调用方负责保持其与其他输入版本一致。
/// </summary>
public PathSmoothingResult ReferencePath { get; }
/// <summary>
/// 规划使用的栅格地图快照引用;地图坐标单位和快照身份由地图契约定义,构造器不检查 null 或就绪状态。
/// </summary>
public PlanningGridMap Map { get; }
/// <summary>
/// 车辆几何与运动限制引用;调用方拥有该对象,服务在请求验证后使用其约束。
/// </summary>
public VehicleParameters Vehicle { get; }
/// <summary>
/// 一次性捕获的车辆运动状态;其位置为世界坐标 m、航向为 rad,时效和数值有效性由服务验证。
/// </summary>
public VehicleMotionState VehicleState { get; }
/// <summary>
/// 本次规划的配置引用;请求不深拷贝它,验证通过后服务取得配置副本供本次规划使用。
/// </summary>
public EmPlannerConfiguration Configuration { get; }
/// <summary>
/// 参考路径中待规划的方向段索引;必须由服务验证为有效的非负范围。
/// </summary>
public int SegmentIndex { get; }
/// <summary>
/// 可选的上一条已发布轨迹引用,用于衔接或诊断;构造器允许为 null,调用方保有其所有权。
/// </summary>
public EmTrajectory PreviousTrajectory { get; }
/// <summary>
/// 本次规划请求发起的 UTC 时刻;用于车辆状态时效判断和生成轨迹元数据,构造器不验证其时间关系。
/// </summary>
public DateTimeOffset RequestedAtUtc { get; }
/// <summary>
/// 成功发布轨迹开始生效的 UTC 时刻;由下游消费者依据其调度语义使用。
/// </summary>
public DateTimeOffset EffectiveAtUtc { get; }
/// <summary>
/// 成功轨迹的发布标识;构造器不校验,轨迹元数据构造时要求为非空白字符串。
/// </summary>
public string OutputTrajectoryId { get; }
/// <summary>
/// 输入参考路径的业务标识,用于成功轨迹的来源追踪;构造器不校验其 null 或空白值。
/// </summary>
public string ReferencePathId { get; }
/// <summary>
/// 上一条轨迹的业务标识;允许为 null,成功轨迹元数据会将 null 规范化为空字符串。
/// </summary>
public string PreviousTrajectoryId { get; }
/// <summary>
/// 请求的车辆运动模型;不支持或无效的枚举值由服务转换为失败状态。
/// </summary>
public EmMotionModel MotionModel { get; }
/// <summary>
/// 本次请求覆盖滚动窗口或整个方向段的范围;服务负责验证其枚举值和资源约束。
/// </summary>
public EmPlanningScope PlanningScope { get; }
/// <summary>Optional remaining wall-clock budget for the complete outer planning cycle.</summary>
public TimeSpan? CycleDeadlineRemaining { get; }
/// <summary>Dynamic caller-cancellation origin used to preserve terminal-status precedence.</summary>
public CancellationToken CallerCancellationToken { get; }
/// <summary>Dynamic shared-cycle deadline origin checked again at atomic publication.</summary>
public CancellationToken CycleDeadlineToken { get; }
internal IEmPlanningPublicationAuthorization PublicationAuthorization { get; }
internal bool IsCycleDeadlineExpired()
{
try
{
return PublicationAuthorization.IsExpired;
}
catch
{
return true;
}
}
internal TimeSpan? DynamicCycleDeadlineRemaining()
{
try
{
TimeSpan remaining = PublicationAuthorization.Remaining;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
catch
{
return TimeSpan.Zero;
}
}
internal EmPlanningPublicationDecision TryAuthorizePublication(
CancellationToken coordinatorCallerCancellationToken, Action publish)
{
if (publish == null)
throw new ArgumentNullException(nameof(publish));
try
{
return PublicationAuthorization.TryPublish(CallerCancellationToken,
coordinatorCallerCancellationToken, publish);
}
catch
{
return EmPlanningPublicationDecision.DeadlineExpired;
}
}
}
// The request owns this DTO contract; Task 2 adds its request-bound settings.
/// <summary>
/// 为请求契约保留的 <see cref="EmPlannerConfiguration"/> 部分声明;实际配置成员由其他同名分部提供。
/// </summary>
public sealed partial class EmPlannerConfiguration
{
}
@@ -2,8 +2,14 @@ using System;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 一次 EM 规划的不可变结果。只有成功状态才携带可发布的完整轨迹;其余状态仅提供诊断,不能作为部分执行轨迹消费。
/// </summary>
public sealed class EmPlanningResult
{
/// <summary>
/// 创建规划结果并强制成功状态与轨迹发布的一致性:<see cref="EmPlanningStatus.Success"/> 和 <see cref="EmPlanningStatus.SuccessWithFallback"/> 必须提供非 null 轨迹,其他状态必须提供 null 轨迹;null 失败原因规范化为空字符串。
/// </summary>
public EmPlanningResult(EmPlanningStatus status, EmTrajectory trajectory, string failureReason)
{
if (!Enum.IsDefined(typeof(EmPlanningStatus), status))
@@ -20,9 +26,18 @@ public sealed class EmPlanningResult
FailureReason = failureReason ?? string.Empty;
}
/// <summary>
/// 本次规划的最终状态;消费者必须先判断其是否为成功状态,再访问可发布轨迹。
/// </summary>
public EmPlanningStatus Status { get; }
/// <summary>
/// 仅在成功或成功降级状态下存在的完整可发布轨迹;失败、取消和无效输入结果始终为 null,消费者不得把失败结果当作部分轨迹执行。
/// </summary>
public EmTrajectory Trajectory { get; }
/// <summary>
/// 面向诊断的失败或降级原因;null 输入已规范化为空字符串,不替代 <see cref="Status"/> 的机器可读状态。
/// </summary>
public string FailureReason { get; }
}
@@ -1,7 +1,16 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 定义一次 EM 规划覆盖当前滚动窗口还是完整方向段。
/// </summary>
public enum EmPlanningScope
{
/// <summary>
/// 仅覆盖有限的滚动规划窗口;段末之外保留给后续重规划。
/// </summary>
RollingHorizon,
/// <summary>
/// 覆盖选定方向段直至真实停车边界,并受完整段资源上限保护。
/// </summary>
FullDirectionSegment,
}
@@ -1,26 +1,96 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 表示 EM 规划请求的最终处理状态;仅两个成功状态允许发布轨迹,其余状态要求消费者保留现有安全策略并读取诊断。
/// </summary>
public enum EmPlanningStatus
{
/// <summary>
/// 所有规划和验证阶段成功,结果可按其生效时间发布。
/// </summary>
Success,
/// <summary>
/// 已发布经过验证的降级或回退轨迹;消费者仍可使用轨迹,并应记录诊断原因。
/// </summary>
SuccessWithFallback,
/// <summary>
/// 请求对象或其成员未满足服务输入要求。
/// </summary>
InvalidInput,
/// <summary>
/// 请求的车辆运动模型未被当前 EM 管线支持。
/// </summary>
UnsupportedMotionMode,
/// <summary>
/// 车辆状态采集时刻相对于请求时刻过旧。
/// </summary>
StaleVehicleState,
/// <summary>
/// 车辆状态的行驶方向与选定参考路径方向段不一致。
/// </summary>
StateDirectionMismatch,
/// <summary>
/// 参考路径结果、方向段或其状态不能用于规划。
/// </summary>
InvalidReferencePath,
/// <summary>
/// 无法将车辆状态投影到选定参考路径或方向段。
/// </summary>
ProjectionFailed,
/// <summary>
/// 地图和车辆约束下未能构造可行的行驶走廊。
/// </summary>
CorridorInfeasible,
/// <summary>
/// 横向优化未得到满足约束的候选解。
/// </summary>
LateralInfeasible,
/// <summary>
/// 纵向优化未得到满足时空与动力学约束的候选解。
/// </summary>
LongitudinalInfeasible,
/// <summary>
/// 当前速度、距离或限制不足以在所需边界前完成停车。
/// </summary>
StoppingDistanceInsufficient,
/// <summary>
/// 所需二次规划求解器不可用。
/// </summary>
SolverUnavailable,
/// <summary>
/// 求解在共享时间预算或迭代限制内未完成。
/// </summary>
SolverTimedOut,
/// <summary>
/// 调用方在可发布结果生成前取消了请求。
/// </summary>
Cancelled,
/// <summary>
/// 候选轨迹未通过最终世界空间、动力学或终端语义验证。
/// </summary>
ValidationFailed,
/// <summary>
/// 请求在处理期间被更新版本的规划工作替代。
/// </summary>
Superseded,
/// <summary>
/// 候选轨迹相对于当前状态未形成足够的有效进展。
/// </summary>
NoProgress,
/// <summary>
/// 轨迹末端位姿未满足要求的真实终端位姿。
/// </summary>
TerminalPoseMismatch,
/// <summary>
/// 完整方向段规划超过配置的时间、采样或求解资源上限。
/// </summary>
FullSegmentResourceLimitExceeded,
/// <summary>
/// 未被其他状态细分的规划失败。
/// </summary>
Failed,
/// <summary>
/// The outer planning cycle exhausted its shared bootstrap-to-publication deadline.
/// </summary>
CycleDeadlineExpired,
}
@@ -1,8 +1,20 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 表示已发布轨迹末端所对应的规划终端语义。
/// </summary>
public enum EmTerminalType
{
/// <summary>
/// 因滚动窗口安全约束形成的停车终端,不代表方向段完成。
/// </summary>
RollingSafetyStop,
/// <summary>
/// 用于在方向改变前后执行换挡的终端。
/// </summary>
GearSwitch,
/// <summary>
/// 最终任务目标的终端。
/// </summary>
Goal,
}
@@ -4,8 +4,14 @@ using System.Collections.ObjectModel;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 供成功规划结果发布的时间参数化轨迹;本类型只施加结构约束,世界空间复核由上游服务完成,点序列、元数据和终端语义在发布后保持不可变。
/// </summary>
public sealed class EmTrajectory
{
/// <summary>
/// 创建可发布的轨迹并复制点列表容器。元数据和每个点对象按引用保存、调用方仍拥有它们;传入列表可随后修改而不影响 <see cref="Points"/>,且 null 或空列表会抛出异常。
/// </summary>
public EmTrajectory(EmTrajectoryMetadata metadata, IReadOnlyList<EmTrajectoryPoint> points)
{
if (metadata == null)
@@ -27,7 +33,13 @@ public sealed class EmTrajectory
Points = new ReadOnlyCollection<EmTrajectoryPoint>(copy);
}
/// <summary>
/// 轨迹的不可变发布元数据引用;构造时必须非 null,未在本类中深拷贝。
/// </summary>
public EmTrajectoryMetadata Metadata { get; }
/// <summary>
/// 按调用方提供顺序保存的只读点列表;列表容器为构造时复制的快照,至少包含一个非 null 点,时间单调性由上游验证保证而非本构造器检查。
/// </summary>
public IReadOnlyList<EmTrajectoryPoint> Points { get; }
}
@@ -3,8 +3,14 @@ using MultiWheelC.TrajectoryPlanning.CoarsePath;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 轨迹发布身份、来源版本、有效时间、方向段和终端边界的不可变元数据。
/// </summary>
public sealed class EmTrajectoryMetadata
{
/// <summary>
/// 创建轨迹的发布和来源元数据。验证标识、序列号、索引和枚举值;<paramref name="previousTrajectoryId"/> 可为 null 并会规范化为空字符串,两个 UTC 时刻的先后关系不在此处验证。
/// </summary>
public EmTrajectoryMetadata(
string trajectoryId,
DateTimeOffset generatedAtUtc,
@@ -52,16 +58,63 @@ public sealed class EmTrajectoryMetadata
PlanningScope = planningScope;
}
/// <summary>
/// 非空白的本次发布轨迹标识,由消费者用于去重、替换和追踪。
/// </summary>
public string TrajectoryId { get; }
/// <summary>
/// 生成此元数据的 UTC 时刻;值原样保存,构造器不与生效时刻比较。
/// </summary>
public DateTimeOffset GeneratedAtUtc { get; }
/// <summary>
/// 轨迹计划开始生效的 UTC 时刻;执行消费者负责按其调度策略解释。
/// </summary>
public DateTimeOffset EffectiveAtUtc { get; }
/// <summary>
/// 地图快照的非负版本标识;用于确认轨迹依赖的环境版本。
/// </summary>
public long MapSnapshotId { get; }
/// <summary>
/// 非空白的输入参考路径标识;用于关联轨迹的几何来源。
/// </summary>
public string ReferencePathId { get; }
/// <summary>
/// 车辆状态的非负序列版本;用于判断轨迹是否基于当前状态快照。
/// </summary>
public long VehicleStateSequenceId { get; }
/// <summary>
/// 前一轨迹的可选标识;null 输入已规范化为空字符串,空字符串表示没有可关联的前轨迹标识。
/// </summary>
public string PreviousTrajectoryId { get; }
/// <summary>
/// 参考路径中此轨迹所属的非负方向段索引。
/// </summary>
public int SegmentIndex { get; }
/// <summary>
/// 所属方向段的已验证行驶方向。
/// </summary>
public TravelDirection Direction { get; }
/// <summary>
/// 轨迹最后一个终端的已验证业务类型。
/// </summary>
public EmTerminalType TerminalType { get; }
/// <summary>
/// 生成轨迹时采用的已验证纵向终端速度语义。
/// </summary>
public EmLongitudinalMode LongitudinalMode { get; }
/// <summary>
/// 生成轨迹时采用的已验证规划覆盖范围。
/// </summary>
public EmPlanningScope PlanningScope { get; }
}
@@ -3,8 +3,14 @@ using MultiWheelC.TrajectoryPlanning.CoarsePath;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 轨迹中的单个时间样本。世界位置单位 m、航向单位 rad、速度单位 m/s、曲率单位 1/m。
/// </summary>
public sealed class EmTrajectoryPoint
{
/// <summary>
/// 创建一个轨迹时间样本。所有浮点输入必须有限;时间、段内弧长和路径弧长必须非负,段索引和枚举值必须有效。派生的绝对速度、世界速度分量和偏航角速度由有符号纵向速度、航向和曲率计算。
/// </summary>
public EmTrajectoryPoint(
double x,
double y,
@@ -62,23 +68,88 @@ public sealed class EmTrajectoryPoint
LongitudinalJerk = longitudinalJerk;
}
/// <summary>
/// 世界坐标系 X 位置,单位 m。
/// </summary>
public double X { get; }
/// <summary>
/// 世界坐标系 Y 位置,单位 m。
/// </summary>
public double Y { get; }
/// <summary>
/// 车辆在世界坐标系中的航向,单位 rad;构造器仅要求有限,不归一化角度。
/// </summary>
public double Yaw { get; }
/// <summary>
/// 沿车辆前向轴的有符号纵向速度,单位 m/s;符号表示前进或倒车。
/// </summary>
public double SignedLongitudinalVelocity { get; }
/// <summary>
/// 有符号纵向速度的绝对值,单位 m/s。
/// </summary>
public double Speed { get; }
/// <summary>
/// 由有符号纵向速度和航向导出的世界坐标 X 速度分量,单位 m/s。
/// </summary>
public double VelocityX { get; }
/// <summary>
/// 由有符号纵向速度和航向导出的世界坐标 Y 速度分量,单位 m/s。
/// </summary>
public double VelocityY { get; }
/// <summary>
/// 由有符号纵向速度乘车辆曲率导出的偏航角速度,单位 rad/s。
/// </summary>
public double YawRate { get; }
/// <summary>
/// 从本条轨迹开始执行起累计的非负时间,单位 s。
/// </summary>
public double TimeFromStart { get; }
/// <summary>
/// 车辆路径曲率,单位 1/m;符号约定由上游几何计算定义。
/// </summary>
public double VehicleCurvature { get; }
/// <summary>
/// 此点所属的非负方向段索引。
/// </summary>
public int SegmentIndex { get; }
/// <summary>
/// 从该方向段开始沿参考路径累计的非负弧长,单位 m。
/// </summary>
public double SegmentLocalS { get; }
/// <summary>
/// 从整条参考路径开始累计的非负弧长,单位 m。
/// </summary>
public double PathS { get; }
/// <summary>
/// 此点的已验证行驶方向。
/// </summary>
public TravelDirection Direction { get; }
/// <summary>
/// 此点的已验证边界语义;普通内部点使用 <see cref="EmBoundaryType.None"/>。
/// </summary>
public EmBoundaryType BoundaryType { get; }
/// <summary>
/// 沿车辆前向轴的纵向加速度,单位 m/s²;仅供同程序集的轨迹验证和执行逻辑读取。
/// </summary>
internal double LongitudinalAcceleration { get; }
/// <summary>
/// 沿车辆前向轴的纵向加加速度,单位 m/s³;仅供同程序集的轨迹验证和执行逻辑读取。
/// </summary>
internal double LongitudinalJerk { get; }
}
@@ -3,8 +3,14 @@ using MultiWheelC.TrajectoryPlanning.CoarsePath;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 调用方一次性捕获的车辆运动状态;EMPlanner 只消费此快照,不直接读取定位、轮速或系统时钟。
/// </summary>
public sealed class VehicleMotionState
{
/// <summary>
/// 保存调用方捕获的车辆状态。构造器不校验 null、数值有限性、UTC 时效或序列号;服务在接受请求前负责验证,传入 <paramref name="pose"/> 的所有权仍归调用方。
/// </summary>
public VehicleMotionState(
Pose2D pose,
double signedLongitudinalSpeedMetersPerSecond,
@@ -19,13 +25,28 @@ public sealed class VehicleMotionState
SequenceId = sequenceId;
}
/// <summary>
/// 车辆世界位姿引用,其中位置单位为 m、航向单位为 rad;构造器允许 null,但服务要求有效位姿。
/// </summary>
public Pose2D Pose { get; }
/// <summary>
/// 沿车辆前向轴的有符号纵向速度,单位 m/s;正负方向约定由上游状态生产者负责,服务要求其为有限数。
/// </summary>
public double SignedLongitudinalSpeedMetersPerSecond { get; }
/// <summary>
/// 可选的沿车辆前向轴纵向加速度,单位 m/s²;null 表示采集方未提供该量,非 null 值必须由服务验证为有限数。
/// </summary>
public double? LongitudinalAccelerationMetersPerSecondSquared { get; }
/// <summary>
/// 采集此状态的 UTC 时刻;服务用它相对请求发起时刻判断状态是否过期。
/// </summary>
public DateTimeOffset CapturedAtUtc { get; }
/// <summary>
/// 调用方提供的状态快照序列版本;服务要求其非负,成功轨迹将其写入元数据供消费者关联。
/// </summary>
public long SequenceId { get; }
}
@@ -2,9 +2,17 @@ using System;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Connected free lateral interval at one exact reference-S station.</summary>
/// <summary>
/// 一个精确参考站 S 处与种子横向位置连通的自由横向区间。
/// 参考站和横向偏移均以 m 计,横向正负号遵循该方向段的行驶坐标系;构造时拒绝非有限值、反向边界,或超出边界 1e-12 m 的种子。
/// </summary>
public sealed class LateralInterval
{
/// <summary>
/// 创建一个固定参考站上的闭合自由横向区间。
/// 参数:referenceS、minimumL、maximumL 与 seedL 均为 mseedL 必须在闭区间内(容许 1e-12 m 数值误差)。
/// 返回:保存已验证边界的不可变区间;非法数值或不连通种子会引发 <see cref="ArgumentOutOfRangeException"/>。
/// </summary>
public LateralInterval(double referenceS, double minimumL, double maximumL, double seedL)
{
if (!IsFinite(referenceS) || !IsFinite(minimumL) || !IsFinite(maximumL) || !IsFinite(seedL) ||
@@ -17,11 +25,30 @@ public sealed class LateralInterval
SeedL = seedL;
}
/// <summary>
/// 本区间所属方向段局部参考弧长 S,单位 m。
/// </summary>
public double ReferenceS { get; }
/// <summary>
/// 可通行横向偏移闭区间的下界,单位 m。
/// </summary>
public double MinimumL { get; }
/// <summary>
/// 可通行横向偏移闭区间的上界,单位 m。
/// </summary>
public double MaximumL { get; }
/// <summary>
/// 用于保持拓扑连通性的种子横向偏移,单位 m。
/// </summary>
public double SeedL { get; }
/// <summary>
/// 判定标量能否参与走廊边界计算。
/// 参数:value 为无单位或 m 制实数;返回:仅非 NaN 且非无穷大时为 true。
/// </summary>
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
@@ -4,9 +4,16 @@ using System.Collections.ObjectModel;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Immutable lateral hard bounds for one already-selected topological corridor.</summary>
/// <summary>
/// 一个已选择拓扑走廊在离散参考站上的不可变横向硬边界集合。
/// 站点按方向段局部 S(m)非递减保存,调用方只能沿每个站点的种子连通自由区间优化。
/// </summary>
public sealed class StaticCorridor
{
/// <summary>
/// 从站点序列创建走廊的防御性只读副本。
/// 参数:stations 不能为空且至少含一个按 ReferenceS(m)非递减的非空区间;违反这些约束会被拒绝。
/// </summary>
public StaticCorridor(IReadOnlyList<LateralInterval> stations)
{
if (stations == null || stations.Count == 0)
@@ -25,5 +32,8 @@ public sealed class StaticCorridor
Stations = new ReadOnlyCollection<LateralInterval>(copy);
}
/// <summary>
/// 按方向段局部参考弧长 S(m)排序的横向硬边界只读列表。
/// </summary>
public IReadOnlyList<LateralInterval> Stations { get; }
}
@@ -6,24 +6,49 @@ using MultiWheelC.TrajectoryPlanning.Mapping;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Builds only the seed-connected static free-space corridor of a direction segment.</summary>
/// <summary>
/// 为单一方向段构建仅与种子轨迹横向连通的静态自由空间走廊。
/// 采样站和横向偏移使用 Frenet S/L(m),碰撞在世界 X/Y(m)车体几何中验证;任何无效、碰撞或断连站都会拒绝整个走廊。
/// </summary>
public sealed class StaticCorridorBuilder
{
/// <summary>
/// 横向边界、采样去重和相邻区间重叠判定使用的数值容差,单位 m。
/// </summary>
private const double Epsilon = 1e-12d;
/// <summary>
/// Frenet 重建时拒绝 1-κL 接近零的最小正分母,无量纲。
/// </summary>
private const double ReconstructionDenominator = 1e-12d;
/// <summary>
/// 对未被保守净空快速放行的位置执行精确车体碰撞复核的依赖项。
/// </summary>
private readonly FootprintCollisionChecker _collisionChecker;
/// <summary>
/// 创建使用默认连续车体碰撞检查器的静态走廊构建器。
/// </summary>
public StaticCorridorBuilder()
: this(new FootprintCollisionChecker())
{
}
/// <summary>
/// 创建使用指定车体碰撞检查器的静态走廊构建器。
/// 参数:collisionChecker 不可为空;该检查器在世界坐标中按车辆尺寸和安全裕度验证采样位姿。
/// </summary>
public StaticCorridorBuilder(FootprintCollisionChecker collisionChecker)
{
_collisionChecker = collisionChecker ?? throw new ArgumentNullException(nameof(collisionChecker));
}
/// <summary>
/// 从 S 起止锚点、种子轨迹和栅格地图构建种子连通的横向硬边界。
/// 参数:startReferenceS/endReferenceS、种子 S/L 和配置采样尺度均为 m,map 使用世界 X/Y 栅格,vehicle 提供 m 制尺寸;区间限定在一个方向段内。
/// 返回:每站均存在含种子的自由区间且相邻区间在 1e-12 m 内重叠时返回 true;否则 corridor 为 null 并写入拒绝原因。
/// </summary>
public bool TryBuild(DirectionSegmentView segment, double startReferenceS, double endReferenceS,
IReadOnlyList<FrenetProjection> seed, PlanningGridMap map, VehicleParameters vehicle,
CorridorConfiguration configuration, out StaticCorridor corridor, out string failureReason)
@@ -82,6 +107,10 @@ public sealed class StaticCorridorBuilder
return true;
}
/// <summary>
/// 验证构建走廊所需的段、S 范围、地图、车辆和采样配置。
/// 参数:S 锚点和配置距离均为 m,车辆尺寸为 m;返回:地图未就绪、非有限量、越段锚点或非正采样尺度时为 false 并说明原因。
/// </summary>
private static bool TryValidateInput(DirectionSegmentView segment, double startReferenceS, double endReferenceS,
PlanningGridMap map, VehicleParameters vehicle, CorridorConfiguration configuration, out string failureReason)
{
@@ -115,6 +144,10 @@ public sealed class StaticCorridorBuilder
return true;
}
/// <summary>
/// 验证并按局部参考 S 排序输入种子投影。
/// 参数:input 可为 null(表示中心线 L=0 种子),各投影 S/L 为 m;返回:种子属于当前段且横向量有限时为 true,否则为 false。
/// </summary>
private static bool TryReadSeeds(IReadOnlyList<FrenetProjection> input, DirectionSegmentView segment,
out List<SeedSample> seeds, out string failureReason)
{
@@ -139,6 +172,10 @@ public sealed class StaticCorridorBuilder
return true;
}
/// <summary>
/// 生成包含起止锚点的纵向采样站。
/// 参数:起止 S 和 spacing 单位均为 m;返回:起点、严格内部等距站以及不同于起点超过 1e-12 m 的终点。
/// </summary>
private static IEnumerable<double> CreateStations(double startReferenceS, double endReferenceS, double spacing)
{
yield return startReferenceS;
@@ -148,6 +185,10 @@ public sealed class StaticCorridorBuilder
yield return endReferenceS;
}
/// <summary>
/// 找出横向离散样本中包含种子的连续无碰撞区间。
/// 参数:reference 为世界几何,seedL 及配置横向距离为 m;返回:种子样本自由时给出 [minimumL, maximumL],碰撞或断连时返回 false。
/// </summary>
private bool TrySelectSeedConnectedInterval(FrenetReferencePoint reference, double seedL, PlanningGridMap map,
VehicleParameters vehicle, CorridorConfiguration configuration, out double minimumL, out double maximumL)
{
@@ -190,6 +231,10 @@ public sealed class StaticCorridorBuilder
return false;
}
/// <summary>
/// 在配置横向范围内生成有序且包含种子的唯一 L 采样值。
/// 参数:seedL、maximumOffset 和 spacing 为 m;返回:包含两端和种子、以 1e-12 m 去重的升序列表。
/// </summary>
private static List<double> CreateLateralSamples(double seedL, double maximumOffset, double spacing)
{
var samples = new List<double>();
@@ -202,6 +247,10 @@ public sealed class StaticCorridorBuilder
return samples;
}
/// <summary>
/// 重建给定 L 的世界车体位姿并检查其是否无碰撞。
/// 参数:lateralOffset 为 mreference 使用世界 X/Y(m)和 rad 航向,车辆与储备净空为 m;返回:重建奇异、越图或碰撞均为 false。
/// </summary>
private bool IsCollisionFree(FrenetReferencePoint reference, double lateralOffset, PlanningGridMap map,
VehicleParameters vehicle, CorridorConfiguration configuration)
{
@@ -214,6 +263,10 @@ public sealed class StaticCorridorBuilder
configuration.AdditionalClearanceReserveMeters, out _);
}
/// <summary>
/// 用保守障碍距离和四个扩张车体角点快速确认明显净空。
/// 参数:pose 为世界 X/Y(m)和 rad,车辆尺寸及额外储备为 m;返回:圆形下界安全且四角均在图内时为 true,否则交由精确检查器。
/// </summary>
private static bool IsObviouslyClear(Pose2D pose, PlanningGridMap map, VehicleParameters vehicle,
double additionalClearanceReserveMeters)
{
@@ -237,6 +290,10 @@ public sealed class StaticCorridorBuilder
return true;
}
/// <summary>
/// 以相邻种子 S 线性插值得到采样站的横向种子。
/// 参数:seeds 已按局部 Sm)升序,referenceS 为 m;返回:区间外保持端点 L,重合 S 间隔不超过 1e-12 m 时取上端 L。
/// </summary>
private static double GetSeedL(IReadOnlyList<SeedSample> seeds, double referenceS)
{
if (seeds.Count == 0)
@@ -257,6 +314,10 @@ public sealed class StaticCorridorBuilder
return seeds[seeds.Count - 1].LateralOffset;
}
/// <summary>
/// 向样本列表加入未在 1e-12 m 容差内出现过的横向值。
/// 参数:samples 保存 m 制 L 值,value 为待加入 L(m);返回:重复近似值被拒绝,唯一值追加后由调用方排序。
/// </summary>
private static void AddSortedUnique(List<double> samples, double value)
{
for (int index = 0; index < samples.Count; index++)
@@ -265,25 +326,48 @@ public sealed class StaticCorridorBuilder
samples.Add(value);
}
/// <summary>
/// 判定采样步长或车辆尺寸是否为正的有限值。
/// 参数:value 为对应单位的标量;返回:仅有限且严格大于零时为 true。
/// </summary>
private static bool IsPositiveFinite(double value)
{
return IsFinite(value) && value > 0d;
}
/// <summary>
/// 判定走廊计算输入是否为有限实数。
/// 参数:value 为任意标量;返回:NaN 和无穷时为 false。
/// </summary>
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
/// <summary>
/// 走廊种子在一个局部参考站上的轻量不可变记录。
/// ReferenceS 与 LateralOffset 均以 m 计,仅用于站间种子插值。
/// </summary>
private sealed class SeedSample
{
/// <summary>
/// 创建一个种子记录。
/// 参数:referenceS 为段局部 Sm),lateralOffset 为行驶坐标系中的 L(m)。
/// </summary>
public SeedSample(double referenceS, double lateralOffset)
{
ReferenceS = referenceS;
LateralOffset = lateralOffset;
}
/// <summary>
/// 种子所在方向段局部参考弧长 S,单位 m。
/// </summary>
public double ReferenceS { get; }
/// <summary>
/// 种子相对中心线的横向偏移 L,单位 m。
/// </summary>
public double LateralOffset { get; }
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
@@ -9,28 +10,65 @@ namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Runs one deterministic EM LS/ST planning pipeline and publishes only independently validated trajectories.</summary>
public sealed class EmPlanningService : IEmPlanningService
{
private static readonly TimeSpan MaximumCycleDeadlineRemaining =
TimeSpan.FromMilliseconds(int.MaxValue);
private readonly IQpSolver qpSolver;
private readonly IEmPlannerDebugSink defaultDebugSink;
private readonly Func<TimeSpan> cycleElapsedForTesting;
public EmPlanningService(IQpSolver qpSolver, IEmPlannerDebugSink defaultDebugSink = null)
: this(qpSolver, defaultDebugSink, null)
{
}
internal EmPlanningService(IQpSolver qpSolver, IEmPlannerDebugSink defaultDebugSink,
Func<TimeSpan> cycleElapsedForTesting)
{
this.qpSolver = qpSolver ?? throw new ArgumentNullException(nameof(qpSolver));
this.defaultDebugSink = defaultDebugSink;
this.cycleElapsedForTesting = cycleElapsedForTesting;
}
public EmPlanningResult Plan(EmPlanningRequest request, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
Stopwatch cycleStopwatch = cycleElapsedForTesting == null ? Stopwatch.StartNew() : null;
Func<TimeSpan> cycleElapsed = cycleElapsedForTesting ?? (() => cycleStopwatch.Elapsed);
if (CallerCancellationRequested(request, cancellationToken))
return Failure(EmPlanningStatus.Cancelled, request, "Planning was cancelled before request validation.");
if (request?.CycleDeadlineRemaining is TimeSpan suppliedRemaining &&
(suppliedRemaining < TimeSpan.Zero || suppliedRemaining > MaximumCycleDeadlineRemaining))
{
return Failure(EmPlanningStatus.InvalidInput, request,
"CycleDeadlineRemaining must be between zero and " +
MaximumCycleDeadlineRemaining.TotalMilliseconds + " milliseconds.");
}
if (DeadlineExpired(request, cycleElapsed))
return CycleDeadlineFailure(request, "request", CycleRemaining(request, cycleElapsed));
EmPlanningRequestValidationResult requestValidation = EmPlanningRequestValidator.Validate(request);
if (!requestValidation.IsValid)
return Failure(requestValidation.Status, request, requestValidation.FailureReason);
EmPlannerConfiguration configuration = requestValidation.Snapshot.Configuration;
EmitDebug(request, "request/config validation succeeded");
if (TryTerminalFailure(request, cancellationToken, cycleElapsed,
"request-validation", out EmPlanningResult validationFailure))
{
return validationFailure;
}
TimeSpan expirationRemaining = CycleRemaining(request, cycleElapsed);
using var cycleExpiration = request.CycleDeadlineRemaining.HasValue
? new CancellationTokenSource(expirationRemaining)
: null;
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, request.CallerCancellationToken, request.CycleDeadlineToken,
cycleExpiration?.Token ?? CancellationToken.None);
CancellationToken planningCancellationToken = linkedCancellation.Token;
try
{
if (TryTerminalFailure(request, cancellationToken, cycleElapsed,
"projection", out EmPlanningResult deadlineFailure))
return deadlineFailure;
IReadOnlyList<DirectionSegmentView> segments = ReferencePathSegmenter.Create(request.ReferencePath);
if (request.SegmentIndex < 0 || request.SegmentIndex >= segments.Count)
return Failure(EmPlanningStatus.InvalidReferencePath, request, "The requested direction segment is unavailable.");
@@ -59,7 +97,11 @@ public sealed class EmPlanningService : IEmPlanningService
"Vehicle travel heading differs by at least 90 degrees from the selected direction segment.");
}
EmitDebug(request, "bounded ego projection succeeded");
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "projection", out deadlineFailure))
return deadlineFailure;
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "corridor", out deadlineFailure))
return deadlineFailure;
double initialProgressSpeed = Math.Abs(request.VehicleState.SignedLongitudinalSpeedMetersPerSecond);
double initialAcceleration = request.VehicleState.LongitudinalAccelerationMetersPerSecondSquared ?? 0d;
var horizonSelector = new PlanningHorizonSelector();
@@ -84,18 +126,31 @@ public sealed class EmPlanningService : IEmPlanningService
return Failure(EmPlanningStatus.CorridorInfeasible, request, corridorReason);
}
EmitDebug(request, "static connected corridor succeeded");
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "corridor", out deadlineFailure))
return deadlineFailure;
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "lateral", out deadlineFailure))
return deadlineFailure;
TimeSpan lateralBudget = SmallerBudget(
TimeSpan.FromSeconds(configuration.Scheduling.SolverTimeoutSeconds),
CycleRemaining(request, cycleElapsed));
EmPlannerConfiguration lateralConfiguration = configuration.Copy();
lateralConfiguration.Scheduling.SolverTimeoutSeconds = lateralBudget.TotalSeconds;
var lateralInput = new LateralPlanningInput(segment, corridor, startProjection, horizon.TerminalType,
request.Vehicle, configuration, previousSeed);
TimeSpan totalSolveBudget = TimeSpan.FromSeconds(configuration.Scheduling.SolverTimeoutSeconds);
request.Vehicle, lateralConfiguration, previousSeed);
TimeSpan totalSolveBudget = lateralBudget;
var solveBudgetStopwatch = Stopwatch.StartNew();
LateralPlanningResult lateral = new LateralPlanner(qpSolver).Plan(lateralInput, cancellationToken);
LateralPlanningResult lateral = new LateralPlanner(qpSolver).Plan(lateralInput, planningCancellationToken);
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "lateral", out deadlineFailure))
return deadlineFailure;
if (!IsSuccess(lateral.Status))
return Failure(lateral.Status, request, lateral.FailureReason);
if (cancellationToken.IsCancellationRequested)
return Failure(EmPlanningStatus.Cancelled, request, "Planning was cancelled after LS optimization.");
EmitDebug(request, "LS optimization and validation succeeded");
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "envelope", out deadlineFailure))
return deadlineFailure;
EmPlanningStatus envelopeStatus = new PathSpeedLimitBuilder().Build(lateral.Path, segment.Direction,
initialProgressSpeed, initialAcceleration, horizon.TerminalType, configuration, out PathSpeedLimit speedLimit,
out string envelopeReason);
@@ -119,9 +174,15 @@ public sealed class EmPlanningService : IEmPlanningService
new LongitudinalPreviousTrajectorySeedBuilder().Build(
request.PreviousTrajectory, lateral.Path, request.EffectiveAtUtc, knotSchedule,
segment.SegmentIndex, segment.Direction);
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "envelope", out deadlineFailure))
return deadlineFailure;
TimeSpan remainingSolveBudget = totalSolveBudget - solveBudgetStopwatch.Elapsed;
remainingSolveBudget = SmallerBudget(remainingSolveBudget, CycleRemaining(request, cycleElapsed));
if (remainingSolveBudget <= TimeSpan.Zero)
{
if (TryTerminalFailure(request, cancellationToken, cycleElapsed,
"longitudinal", out deadlineFailure))
return deadlineFailure;
return Failure(EmPlanningStatus.SolverTimedOut, request,
"LS/ST optimization exhausted the shared solve budget before ST optimization.");
}
@@ -136,13 +197,22 @@ public sealed class EmPlanningService : IEmPlanningService
return Failure(envelopeStatus, request, envelopeReason);
EmitDebug(request, "PathS speed envelope succeeded");
LongitudinalPlanningResult longitudinal = new LongitudinalPlanner(qpSolver).Plan(longitudinalInput, cancellationToken);
if (TryTerminalFailure(request, cancellationToken, cycleElapsed,
"longitudinal", out deadlineFailure))
return deadlineFailure;
LongitudinalPlanningResult longitudinal = new LongitudinalPlanner(qpSolver).Plan(
longitudinalInput, planningCancellationToken);
if (TryTerminalFailure(request, cancellationToken, cycleElapsed,
"longitudinal", out deadlineFailure))
return deadlineFailure;
if (!IsSuccess(longitudinal.Status))
return Failure(longitudinal.Status, request, longitudinal.FailureReason);
if (cancellationToken.IsCancellationRequested)
return Failure(EmPlanningStatus.Cancelled, request, "Planning was cancelled after ST optimization.");
EmitDebug(request, "ST optimization and validation succeeded");
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "assembly", out deadlineFailure))
return deadlineFailure;
var metadata = new EmTrajectoryMetadata(request.OutputTrajectoryId, request.RequestedAtUtc, request.EffectiveAtUtc,
request.Map.SnapshotId, request.ReferencePathId, request.VehicleState.SequenceId, request.PreviousTrajectoryId,
segment.SegmentIndex, segment.Direction, horizon.TerminalType, horizon.LongitudinalMode,
@@ -151,10 +221,15 @@ public sealed class EmPlanningService : IEmPlanningService
longitudinal, metadata, out EmTrajectory trajectory, out string assemblyFailure);
if (assemblyStatus != EmPlanningStatus.Success)
return Failure(assemblyStatus, request, assemblyFailure);
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "assembly", out deadlineFailure))
return deadlineFailure;
if (cancellationToken.IsCancellationRequested)
return Failure(EmPlanningStatus.Cancelled, request, "Planning was cancelled after trajectory assembly.");
EmitDebug(request, "trajectory assembly succeeded");
if (TryTerminalFailure(request, cancellationToken, cycleElapsed,
"world-validation", out deadlineFailure))
return deadlineFailure;
EmBoundaryType terminalBoundary = slice.TerminalBoundary.BoundaryType;
Pose2D terminalPose = IsRealTerminalBoundary(terminalBoundary)
? TerminalPose(lateral.Path)
@@ -167,8 +242,13 @@ public sealed class EmPlanningService : IEmPlanningService
return Failure(MapPublicationFailure(publication.Failure), request,
publication.Failure + " at point " + publication.PointIndex + ": " + publication.Message);
}
if (TryTerminalFailure(request, cancellationToken, cycleElapsed,
"world-validation", out deadlineFailure))
return deadlineFailure;
EmitDebug(request, "world-space publication validation succeeded");
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "publication", out deadlineFailure))
return deadlineFailure;
if (cancellationToken.IsCancellationRequested)
return Failure(EmPlanningStatus.Cancelled, request, "Planning was cancelled before trajectory publication.");
@@ -176,11 +256,25 @@ public sealed class EmPlanningService : IEmPlanningService
longitudinal.Status == EmPlanningStatus.SuccessWithFallback
? EmPlanningStatus.SuccessWithFallback
: EmPlanningStatus.Success;
return new EmPlanningResult(finalStatus, trajectory,
DiagnosticsPrefix(request) + ";terminal=" + horizon.TerminalType + ";publication=validated");
string publicationDiagnostic = DiagnosticsPrefix(request) + ";terminal=" + horizon.TerminalType +
";publication=validated";
if (lateral.Status == EmPlanningStatus.SuccessWithFallback)
publicationDiagnostic += ";lateralFallback=" + lateral.FailureReason;
if (!string.IsNullOrWhiteSpace(longitudinal.FailureReason))
{
publicationDiagnostic += longitudinal.Status == EmPlanningStatus.SuccessWithFallback
? ";longitudinalFallback=" + longitudinal.FailureReason
: ";longitudinal=" + longitudinal.FailureReason;
}
if (TryTerminalFailure(request, cancellationToken, cycleElapsed, "publication", out deadlineFailure))
return deadlineFailure;
return new EmPlanningResult(finalStatus, trajectory, publicationDiagnostic);
}
catch (OperationCanceledException)
{
if (TryTerminalFailure(request, cancellationToken, cycleElapsed,
"operation", out EmPlanningResult deadlineFailure))
return deadlineFailure;
return Failure(EmPlanningStatus.Cancelled, request, "Planning was cancelled.");
}
catch (ArgumentException exception)
@@ -193,6 +287,71 @@ public sealed class EmPlanningService : IEmPlanningService
}
}
private static bool TryTerminalFailure(EmPlanningRequest request, CancellationToken cancellationToken,
Func<TimeSpan> elapsed, string phase, out EmPlanningResult failure)
{
if (CallerCancellationRequested(request, cancellationToken))
{
failure = Failure(EmPlanningStatus.Cancelled, request,
"callerCancellation=true;phase=" + phase);
return true;
}
TimeSpan remaining = CycleRemaining(request, elapsed);
if (!DeadlineExpired(request, elapsed))
{
failure = null;
return false;
}
failure = CycleDeadlineFailure(request, phase, remaining);
return true;
}
private static bool CallerCancellationRequested(EmPlanningRequest request,
CancellationToken cancellationToken)
{
if (request?.CallerCancellationToken.IsCancellationRequested == true)
return true;
if (!cancellationToken.IsCancellationRequested)
return false;
return request == null || !request.CycleDeadlineToken.IsCancellationRequested ||
!request.CallerCancellationToken.CanBeCanceled;
}
private static bool DeadlineExpired(EmPlanningRequest request, Func<TimeSpan> elapsed)
{
return request != null && (request.CycleDeadlineToken.IsCancellationRequested ||
request.IsCycleDeadlineExpired() ||
request.CycleDeadlineRemaining.HasValue && CycleRemaining(request, elapsed) <= TimeSpan.Zero);
}
private static TimeSpan CycleRemaining(EmPlanningRequest request, Func<TimeSpan> elapsed)
{
TimeSpan snapshotRemaining = TimeSpan.MaxValue;
if (request.CycleDeadlineRemaining.HasValue)
{
snapshotRemaining = request.CycleDeadlineRemaining.Value - elapsed();
if (snapshotRemaining < TimeSpan.Zero)
snapshotRemaining = TimeSpan.Zero;
}
TimeSpan? dynamicRemaining = request.DynamicCycleDeadlineRemaining();
if (!dynamicRemaining.HasValue)
return snapshotRemaining;
return dynamicRemaining.Value < snapshotRemaining ? dynamicRemaining.Value : snapshotRemaining;
}
private static TimeSpan SmallerBudget(TimeSpan configured, TimeSpan cycleRemaining)
{
return configured < cycleRemaining ? configured : cycleRemaining;
}
private static EmPlanningResult CycleDeadlineFailure(EmPlanningRequest request, string phase,
TimeSpan remaining)
{
return Failure(EmPlanningStatus.CycleDeadlineExpired, request,
"cycleDeadlineExpired=true;phase=" + phase + ";remainingMs=" +
remaining.TotalMilliseconds.ToString("F3", CultureInfo.InvariantCulture));
}
private void EmitDebug(EmPlanningRequest request, string message)
{
if (defaultDebugSink == null || request == null || request.Configuration == null || request.Configuration.Solver == null ||
@@ -2,9 +2,17 @@ using System;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>World pose projected onto one bounded direction segment.</summary>
/// <summary>
/// 世界车辆位姿投影到单一受限方向段后的 Frenet 描述。
/// S 与 L 单位均为 m,航向误差为 rad,距离以 m² 保存;对象不代表跨越换向边界的投影。
/// </summary>
public sealed class FrenetProjection
{
/// <summary>
/// 从参考点和相对量创建不可变投影结果。
/// 参数:lateralOffset 为沿行驶方向左法线的 Lm),headingError 为行驶航向差(rad),squaredDistanceMeters 为非负 m²。
/// 返回:携带参考 S 的投影;空参考点、非有限值或负平方距离会被拒绝。
/// </summary>
public FrenetProjection(FrenetReferencePoint referencePoint, double lateralOffset, double headingError,
double squaredDistanceMeters)
{
@@ -20,12 +28,35 @@ public sealed class FrenetProjection
SquaredDistanceMeters = squaredDistanceMeters;
}
/// <summary>
/// 投影命中的插值参考点,其坐标为世界 X/Y(m)。
/// </summary>
public FrenetReferencePoint ReferencePoint { get; }
/// <summary>
/// 命中点在当前方向段的局部参考弧长 S,单位 m。
/// </summary>
public double ReferenceS { get; }
/// <summary>
/// 世界位姿相对参考行驶方向左法线的横向偏移 L,单位 m。
/// </summary>
public double LateralOffset { get; }
/// <summary>
/// 车辆行驶航向相对参考行驶航向的归一化误差,单位 rad。
/// </summary>
public double HeadingError { get; }
/// <summary>
/// 世界位置与命中参考位置的欧氏距离平方,单位 m²。
/// </summary>
public double SquaredDistanceMeters { get; }
/// <summary>
/// 判定投影标量是否有限。
/// 参数:value 为任意实数;返回:NaN 和正负无穷均返回 false。
/// </summary>
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
@@ -4,11 +4,21 @@ using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Deterministically projects a world pose only inside the supplied direction segment and S window.</summary>
/// <summary>
/// 在指定方向段及局部 S 窗口内确定性地投影世界位姿。
/// 投影不跨越换向边界;世界 X/Y 与距离为 m,航向为 rad,并以种子 S 消除等距候选的拓扑歧义。
/// </summary>
public sealed class FrenetProjector
{
/// <summary>
/// 等距候选比较和零长度线段识别使用的 S/距离平方数值容差 1e-14。
/// </summary>
private const double TieTolerance = 1e-14d;
/// <summary>
/// 在窗口内投影世界位姿,并以窗口起点作为等距候选的种子 S。
/// 参数:窗口与 maximumDistanceMeters 均为当前段局部 m 制距离;返回:命中距离不超过阈值时返回 true,否则 projection 为 null。
/// </summary>
public bool TryProject(Pose2D worldPose, DirectionSegmentView segment, double minimumReferenceS,
double maximumReferenceS, double maximumDistanceMeters, out FrenetProjection projection)
{
@@ -16,6 +26,10 @@ public sealed class FrenetProjector
minimumReferenceS, out projection);
}
/// <summary>
/// 在窗口内投影世界位姿,按距离、种子 S 距离及较小 S 的顺序稳定选择候选。
/// 参数:worldPose 使用世界 X/Y(m)和车身航向(rad),S 窗口、距离阈值和种子均为 m;返回:窗口非法、无候选或候选超阈值时返回 false。
/// </summary>
public bool TryProject(Pose2D worldPose, DirectionSegmentView segment, double minimumReferenceS,
double maximumReferenceS, double maximumDistanceMeters, double seedReferenceS, out FrenetProjection projection)
{
@@ -63,6 +77,10 @@ public sealed class FrenetProjector
return true;
}
/// <summary>
/// 考察被 S 窗口裁剪后的参考折线边,并将世界点的正交投影加入最优候选比较。
/// 参数:端点为世界 X/Ym)和局部 Sm),seedReferenceS 为 m;零长度(不超过 1e-14)边退化为两个端点比较。
/// </summary>
private static void ConsiderLine(Pose2D worldPose, FrenetReferencePoint start, FrenetReferencePoint end,
double seedReferenceS, ref Candidate best)
{
@@ -84,12 +102,20 @@ public sealed class FrenetProjector
Consider(worldPose, referenceS, x, y, seedReferenceS, ref best);
}
/// <summary>
/// 将一个离散参考点作为投影候选参与比较。
/// 参数:point 的位置为世界 m 制坐标,seedReferenceS 为局部 m 制种子;结果通过 best 原位更新。
/// </summary>
private static void ConsiderPoint(Pose2D worldPose, FrenetReferencePoint point, double seedReferenceS,
ref Candidate best)
{
Consider(worldPose, point.ReferenceS, point.X, point.Y, seedReferenceS, ref best);
}
/// <summary>
/// 依据世界平面平方距离登记候选,并按确定性优先级替换当前最佳值。
/// 参数:referenceS、x、y 与 seedReferenceS 分别为局部 S(m)和世界坐标(m);平方距离由内部计算,单位 m²。
/// </summary>
private static void Consider(Pose2D worldPose, double referenceS, double x, double y, double seedReferenceS,
ref Candidate best)
{
@@ -101,13 +127,25 @@ public sealed class FrenetProjector
best = candidate;
}
/// <summary>
/// 判定投影计算的标量是否有限。
/// 参数:value 为任意实数;返回:NaN 与无穷均返回 false。
/// </summary>
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
/// <summary>
/// 保存一个待比较的折线投影候选。
/// ReferenceS 和 SeedDistance 使用 mSquaredDistanceMeters 使用 m²,优先级由距离、种子距离及较小 S 依次决定。
/// </summary>
private sealed class Candidate
{
/// <summary>
/// 创建投影候选。
/// 参数:referenceS 与 seedDistance 为 msquaredDistanceMeters 为 m²;调用方仅传入有限的已计算值。
/// </summary>
public Candidate(double referenceS, double squaredDistanceMeters, double seedDistance)
{
ReferenceS = referenceS;
@@ -115,10 +153,25 @@ public sealed class FrenetProjector
SeedDistance = seedDistance;
}
/// <summary>
/// 候选在当前方向段的局部参考弧长 S,单位 m。
/// </summary>
public double ReferenceS { get; }
/// <summary>
/// 候选世界位置到车辆位置的距离平方,单位 m²。
/// </summary>
public double SquaredDistanceMeters { get; }
/// <summary>
/// 候选 S 与调用方种子 S 的绝对距离,单位 m。
/// </summary>
public double SeedDistance { get; }
/// <summary>
/// 比较两个候选的稳定优先级。
/// 参数:other 为非空候选;返回:平方距离差超过 1e-14 时取较小者,随后取较近种子,仍相等时取较小 S。
/// </summary>
public bool IsPreferredTo(Candidate other)
{
if (SquaredDistanceMeters < other.SquaredDistanceMeters - TieTolerance) return true;
@@ -129,3 +182,7 @@ public sealed class FrenetProjector
}
}
}
/// <summary>
/// 在窗口内投影世界位姿,按距离、种子 S 距离及较小 S 的顺序稳定选择候选。
/// 参数:worldPose 使用世界 X/Y(m)和车身航向(rad),S 窗口、距离阈值和种子均为 m;返回:窗口非法、无候选或候选超阈值时返回 false。
/// </summary>
@@ -4,9 +4,17 @@ using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Immutable interpolated reference sample in a single direction segment.</summary>
/// <summary>
/// 单一方向段内的不可变插值参考样本,连接世界几何与 Frenet 坐标。
/// X/Y 与参考 S 为 m,航向为 rad,曲率为 1/m、曲率导数为 1/m²;倒车段仍以车辆航向保存几何。
/// </summary>
public sealed class FrenetReferencePoint
{
/// <summary>
/// 创建已验证的方向段参考样本。
/// 参数:位置采用世界 X/Y(m),referenceS 为段局部弧长(m),航向为 rad,曲率量遵循 1/m 与 1/m²;所有数值必须有限。
/// 返回:车辆航向会规范到 [-π, π];任一非有限输入会引发 <see cref="ArgumentOutOfRangeException"/>。
/// </summary>
public FrenetReferencePoint(double referenceS, double x, double y, double vehicleYaw, double unwrappedVehicleYaw,
TravelDirection direction, double geometricCurvature, double vehicleCurvature,
double vehicleCurvatureDerivative, double bodyClearance)
@@ -33,23 +41,69 @@ public sealed class FrenetReferencePoint
BodyClearance = bodyClearance;
}
/// <summary>
/// 样本在当前方向段的局部参考弧长 S,单位 m。
/// </summary>
public double ReferenceS { get; }
/// <summary>
/// 参考中心线点的世界 X 坐标,单位 m。
/// </summary>
public double X { get; }
/// <summary>
/// 参考中心线点的世界 Y 坐标,单位 m。
/// </summary>
public double Y { get; }
/// <summary>
/// 归一化后的车辆车身航向,单位 rad。
/// </summary>
public double VehicleYaw { get; }
/// <summary>
/// 连续展开的车辆车身航向,单位 rad,供跨点几何插值使用。
/// </summary>
public double UnwrappedVehicleYaw { get; }
/// <summary>
/// 该参考样本的行驶方向,决定 Frenet 横向正负号和行驶航向。
/// </summary>
public TravelDirection Direction { get; }
/// <summary>
/// 中心线几何曲率,单位 1/m。
/// </summary>
public double GeometricCurvature { get; }
/// <summary>
/// 满足车辆模型后的车辆曲率,单位 1/m。
/// </summary>
public double VehicleCurvature { get; }
/// <summary>
/// 车辆曲率相对弧长的导数,单位 1/m²。
/// </summary>
public double VehicleCurvatureDerivative { get; }
/// <summary>
/// 参考点记录的车体净空或可用裕度,单位 m。
/// </summary>
public double BodyClearance { get; }
/// <summary>Unwrapped direction of travel, used internally for geometry.</summary>
/// <summary>
/// 获取与当前行驶方向一致的连续航向。
/// 返回:前进时为车辆展开航向,倒车时加 π;单位 rad,仅供 Frenet 几何计算,不重新归一化。
/// </summary>
public double TravelYaw
{
get { return Direction == TravelDirection.Forward ? UnwrappedVehicleYaw : UnwrappedVehicleYaw + Math.PI; }
}
/// <summary>
/// 拒绝不能安全保存为参考几何的数值。
/// 参数:value 是待验证的任意单位标量,name 是异常参数名;NaN 或无穷会引发 <see cref="ArgumentOutOfRangeException"/>。
/// </summary>
private static void RequireFinite(double value, string name)
{
if (double.IsNaN(value) || double.IsInfinity(value))
@@ -4,9 +4,17 @@ using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Coordinate conversion that keeps Frenet lateral sign relative to travel direction.</summary>
/// <summary>
/// 在世界坐标与 Frenet 横向约定间转换,并始终以实际行驶方向确定 L 的正负。
/// 世界位置使用 X/Y(m)、航向使用 rad;倒车时车辆车身航向与行驶航向相差 π。
/// </summary>
public static class FrenetTransform
{
/// <summary>
/// 将参考点及其横向状态重建为世界车辆中心位姿。
/// 参数:lateralOffset 为 Lm),lateralDerivative 为 dL/dS(无量纲),minimumFrenetDenominator 为正的奇异性下界;参考点采用世界 X/Y(m)与 rad 航向。
/// 返回:当 1-κL 有限且不小于下界、重建位姿有限时返回 true;空参考点、非法输入或接近 Frenet 奇异点时返回 false。
/// </summary>
public static bool TryReconstruct(FrenetReferencePoint referencePoint, double lateralOffset, double lateralDerivative,
double minimumFrenetDenominator, out Pose2D pose)
{
@@ -33,11 +41,19 @@ public static class FrenetTransform
return true;
}
/// <summary>
/// 从车辆车身航向取得对应实际行驶的连续航向。
/// 参数:vehicleYaw 为 raddirection 指定前进或倒车;返回:前进原样、倒车加 π,结果不归一化。
/// </summary>
internal static double GetTravelYaw(double vehicleYaw, TravelDirection direction)
{
return direction == TravelDirection.Forward ? vehicleYaw : vehicleYaw + Math.PI;
}
/// <summary>
/// 判定重建中间量是否为有限实数。
/// 参数:value 为任意标量;返回:NaN 或正负无穷时为 false。
/// </summary>
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
@@ -4,11 +4,22 @@ using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Interpolates reference geometry inside exactly one direction segment.</summary>
/// <summary>
/// 在恰好一个方向段内按局部参考弧长插值参考几何。
/// 输入和输出 S、X/Y 与净空均为 m,航向为 rad,曲率为 1/m;插值不会越过方向段边界。
/// </summary>
public static class ReferencePathInterpolator
{
/// <summary>
/// 接受端点轻微 S 超差并判断精确节点的数值容差,单位 m。
/// </summary>
private const double Epsilon = 1e-12d;
/// <summary>
/// 为给定局部 S 生成方向一致的 Frenet 参考样本。
/// 参数:segment 不可为空,referenceS 为 m[-1e-12, Length+1e-12] 内的端点超差会夹紧到边界。
/// 返回:精确点直接转换,区间内对位置、展开航向和几何量线性插值;超界或退化跨度会引发异常。
/// </summary>
public static FrenetReferencePoint Interpolate(DirectionSegmentView segment, double referenceS)
{
if (segment == null)
@@ -45,6 +56,10 @@ public static class ReferencePathInterpolator
return FromPoint(segment.Points[segment.Points.Count - 1]);
}
/// <summary>
/// 将原始平滑路径点转换为同一局部 S 的 Frenet 参考样本。
/// 参数:point 已携带世界 X/Y(m)、航向(rad)和曲率量;返回:保持其方向及所有几何量的不可变副本。
/// </summary>
private static FrenetReferencePoint FromPoint(SmoothedPathPoint point)
{
return new FrenetReferencePoint(point.ArcLength, point.X, point.Y, point.Heading, point.UnwrappedHeading,
@@ -52,11 +67,19 @@ public static class ReferencePathInterpolator
point.BodyClearance);
}
/// <summary>
/// 在线性标量区间内计算插值值。
/// 参数:lower、upper 为同单位端点,fraction 为无单位比例;返回:同单位的未夹紧线性结果。
/// </summary>
private static double Lerp(double lower, double upper, double fraction)
{
return lower + (upper - lower) * fraction;
}
/// <summary>
/// 判定插值输入是否为有限实数。
/// 参数:value 为任意标量;返回:NaN 或无穷时为 false。
/// </summary>
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
@@ -16,7 +17,15 @@ public sealed class LongitudinalConstraintBuilder
public bool TryBuild(LongitudinalPlanningInput input, PathSpeedLimit speedLimit, LongitudinalCandidate iterate,
out QuadraticProgram problem, out string failureReason)
{
return TryBuildCore(input, speedLimit, iterate, false, out problem, out failureReason);
return TryBuildCore(input, speedLimit, iterate, false, null, null, 0d, out problem, out failureReason);
}
internal bool TryBuildTrusted(LongitudinalPlanningInput input, PathSpeedLimit speedLimit,
LongitudinalCandidate anchor, LongitudinalEnvelopeTrustRegion trustRegion, double strictTolerance,
out QuadraticProgram problem, out string failureReason)
{
return TryBuildCore(input, speedLimit, anchor, false, trustRegion, anchor, strictTolerance,
out problem, out failureReason);
}
/// <summary>Builds the bounded full-scope feasibility projection before objective optimization.</summary>
@@ -38,12 +47,14 @@ public sealed class LongitudinalConstraintBuilder
failureReason = "Initial feasibility projection is only defined for full-direction exact-stop planning.";
return false;
}
return TryBuildCore(input, speedLimit, linearizationIterate, true, out problem,
out failureReason);
return TryBuildCore(input, speedLimit, linearizationIterate, true, null, null, 0d,
out problem, out failureReason);
}
private bool TryBuildCore(LongitudinalPlanningInput input, PathSpeedLimit speedLimit, LongitudinalCandidate iterate,
bool useScheduleReferenceObjective, out QuadraticProgram problem, out string failureReason)
bool useScheduleReferenceObjective, LongitudinalEnvelopeTrustRegion trustRegion,
LongitudinalCandidate trustedAnchor, double strictTolerance, out QuadraticProgram problem,
out string failureReason)
{
problem = null;
failureReason = string.Empty;
@@ -63,6 +74,15 @@ public sealed class LongitudinalConstraintBuilder
{
throw new ArgumentException("The ST iterate does not match the configured knot layout.");
}
if ((trustRegion == null) != (trustedAnchor == null))
throw new ArgumentException("Trusted QP construction requires both a trust region and anchor.");
if (trustRegion != null && (trustRegion.MinimumPathS.Count != layout.KnotCount ||
trustRegion.MaximumPathS.Count != layout.KnotCount ||
trustRegion.SpeedSlope.Count != layout.KnotCount ||
trustRegion.SpeedIntercept.Count != layout.KnotCount))
{
throw new ArgumentException("The trust region does not match the configured knot layout.");
}
if (!PathSpeedLimitBuilder.TryGetLimits(input, out double directionMaximum, out double maximumAcceleration,
out double maximumDeceleration, out double maximumJerk, out _, out _, out failureReason))
{
@@ -84,12 +104,14 @@ public sealed class LongitudinalConstraintBuilder
_objectiveBuilder.AddTerms(input, speedLimit, layout, iterate, hessian, linearCost);
int stabilizationStart = GetStabilizationStart(input, expectedTimes, layout.KnotCount);
int stationaryKnotCount = layout.KnotCount - stabilizationStart;
int expectedRows = 9 * layout.KnotCount - 3 + 3 * stationaryKnotCount;
int expectedRows = 10 * layout.KnotCount - 3 + 3 * stationaryKnotCount;
var constraints = new SparseTripletBuilder(expectedRows, layout.VariableCount);
var lower = new List<double>(expectedRows);
var upper = new List<double>(expectedRows);
int row = 0;
AddVariableBounds(input, speedLimit, iterate, layout, maximumAcceleration, maximumDeceleration, maximumJerk,
AddVariableBounds(input, speedLimit, iterate, trustRegion, layout, maximumAcceleration,
maximumDeceleration, maximumJerk, constraints, lower, upper, ref row);
AddLowSpeedDecelerationReleaseEnvelope(layout, iterate, maximumJerk,
constraints, lower, upper, ref row);
AddMonotonicProgress(layout, constraints, lower, upper, ref row);
AddExactDynamics(expectedTimes, layout, constraints, lower, upper, ref row);
@@ -99,6 +121,20 @@ public sealed class LongitudinalConstraintBuilder
if (row != expectedRows)
throw new InvalidOperationException("ST constraint row accounting is inconsistent.");
problem = new QuadraticProgram(hessian.Build(), linearCost, constraints.Build(), lower, upper);
if (trustedAnchor != null)
{
LongitudinalQpAuditResult audit = LongitudinalQpFeasibilityAudit.Evaluate(problem, trustedAnchor,
strictTolerance, layout, stabilizationStart);
if (!audit.IsFeasible)
{
problem = null;
failureReason = "Planner invariant failure: strict anchor is outside trusted QP" +
";row=" + audit.WorstRow + ";category=" + audit.Category +
";residual=" + audit.MaximumResidual.ToString("R", CultureInfo.InvariantCulture) +
audit.Unit + ";tolerance=" + strictTolerance.ToString("R", CultureInfo.InvariantCulture);
return false;
}
}
return true;
}
catch (ArgumentException exception)
@@ -145,7 +181,8 @@ public sealed class LongitudinalConstraintBuilder
}
private static void AddVariableBounds(LongitudinalPlanningInput input, PathSpeedLimit speedLimit,
LongitudinalCandidate iterate, LongitudinalVariableLayout layout, double maximumAcceleration,
LongitudinalCandidate iterate, LongitudinalEnvelopeTrustRegion trustRegion,
LongitudinalVariableLayout layout, double maximumAcceleration,
double maximumDeceleration, double maximumJerk, SparseTripletBuilder constraints, IList<double> lower,
IList<double> upper, ref int row)
{
@@ -153,14 +190,32 @@ public sealed class LongitudinalConstraintBuilder
{
if (iterate.S[index] < 0d || iterate.S[index] > input.PathUpperBoundS)
throw new ArgumentException("The ST iterate progress lies outside actual PathS bounds.");
AddSingleVariableRow(constraints, lower, upper, layout.S(index), 0d, input.PathUpperBoundS, ref row);
if (trustRegion == null)
AddSingleVariableRow(constraints, lower, upper, layout.S(index), 0d, input.PathUpperBoundS, ref row);
else
AddSingleVariableRow(constraints, lower, upper, layout.S(index),
trustRegion.MinimumPathS[index], trustRegion.MaximumPathS[index], ref row);
double maximumSpeed = index == 0
? input.DirectionMaximumSpeedMetersPerSecond
: input.DirectionMaximumSpeedMetersPerSecond;
AddSingleVariableRow(constraints, lower, upper, layout.U(index), 0d, maximumSpeed, ref row);
if (index > 0)
AddLinearizedSpeedEnvelopeRow(speedLimit, iterate.S[index], layout.S(index), layout.U(index),
constraints, lower, upper, ref row);
{
if (trustRegion == null)
{
AddLinearizedSpeedEnvelopeRow(speedLimit, iterate.S[index], layout.S(index), layout.U(index),
constraints, lower, upper, ref row);
}
else
{
AddRow(constraints, lower, upper, row, new[]
{
new Coefficient(layout.U(index), 1d),
new Coefficient(layout.S(index), -trustRegion.SpeedSlope[index]),
}, -QuadraticProgram.MaximumFiniteBound, trustRegion.SpeedIntercept[index]);
row++;
}
}
AddSingleVariableRow(constraints, lower, upper, layout.A(index), -maximumDeceleration, maximumAcceleration,
ref row);
}
@@ -209,6 +264,34 @@ public sealed class LongitudinalConstraintBuilder
}
}
internal static void CalculateLowSpeedDecelerationReleaseTangent(
double anchorAcceleration, double maximumJerk,
out double accelerationCoefficient, out double lowerBound)
{
if (!IsFinite(anchorAcceleration) || !IsFinite(maximumJerk) || maximumJerk <= 0d)
throw new ArgumentOutOfRangeException(nameof(anchorAcceleration));
double a0 = Math.Min(0d, anchorAcceleration);
accelerationCoefficient = -a0 / maximumJerk;
lowerBound = -(a0 * a0) / (2d * maximumJerk);
}
private static void AddLowSpeedDecelerationReleaseEnvelope(LongitudinalVariableLayout layout,
LongitudinalCandidate iterate, double maximumJerk, SparseTripletBuilder constraints,
IList<double> lower, IList<double> upper, ref int row)
{
for (int index = 0; index < layout.KnotCount; index++)
{
CalculateLowSpeedDecelerationReleaseTangent(iterate.A[index], maximumJerk,
out double accelerationCoefficient, out double lowerBound);
AddRow(constraints, lower, upper, row, new[]
{
new Coefficient(layout.U(index), 1d),
new Coefficient(layout.A(index), accelerationCoefficient),
}, lowerBound, QuadraticProgram.MaximumFiniteBound);
row++;
}
}
private static void AddExactDynamics(IReadOnlyList<double> times, LongitudinalVariableLayout layout,
SparseTripletBuilder constraints, IList<double> lower, IList<double> upper, ref int row)
{
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Proves the constant-jerk profile used by publication is continuously forward-progressing.</summary>
internal static class LongitudinalContinuousProfileValidator
{
public static bool TryValidate(LongitudinalCandidate candidate, double tolerance, out string failureReason)
{
failureReason = string.Empty;
if (candidate == null)
{
failureReason = "A longitudinal candidate is required.";
return false;
}
if (!IsFinite(tolerance) || tolerance < 0d)
{
failureReason = "A finite nonnegative continuous-profile tolerance is required.";
return false;
}
double highWater = candidate.S[0];
for (int interval = 0; interval < candidate.J.Count; interval++)
{
double duration = candidate.KnotTimes[interval + 1] - candidate.KnotTimes[interval];
double initialS = candidate.S[interval];
double initialU = candidate.U[interval];
double initialA = candidate.A[interval];
double jerk = candidate.J[interval];
var evaluationTimes = new List<double>(5) { 0d, duration };
if (jerk != 0d)
AddIfInside(evaluationTimes, -initialA / jerk, duration);
AddSpeedRoots(evaluationTimes, initialU, initialA, jerk, duration);
evaluationTimes.Sort();
double previousTime = double.NegativeInfinity;
for (int point = 0; point < evaluationTimes.Count; point++)
{
double localTime = evaluationTimes[point];
if (localTime == previousTime)
continue;
previousTime = localTime;
Evaluate(initialS, initialU, initialA, jerk, localTime,
out double progress, out double speed, out double acceleration);
double regression = highWater - progress;
if (!IsFinite(progress) || !IsFinite(speed) || !IsFinite(acceleration) ||
speed < -tolerance || regression > tolerance)
{
string kind = !IsFinite(progress) || !IsFinite(speed) || !IsFinite(acceleration)
? "non-finite"
: speed < -tolerance ? "negative-speed" : "PathS-regression";
failureReason = FormatFailure(kind, interval, localTime, progress, speed,
acceleration, jerk, regression, string.Empty);
return false;
}
if (progress > highWater)
highWater = progress;
}
Evaluate(initialS, initialU, initialA, jerk, duration,
out double integratedS, out double integratedU, out double integratedA);
double sMismatch = Math.Abs(integratedS - candidate.S[interval + 1]);
double uMismatch = Math.Abs(integratedU - candidate.U[interval + 1]);
double aMismatch = Math.Abs(integratedA - candidate.A[interval + 1]);
if (!IsFinite(sMismatch) || !IsFinite(uMismatch) || !IsFinite(aMismatch) ||
sMismatch > tolerance || uMismatch > tolerance || aMismatch > tolerance)
{
string detail = ";endpointMismatchS=" + Invariant(sMismatch) +
";endpointMismatchU=" + Invariant(uMismatch) +
";endpointMismatchA=" + Invariant(aMismatch);
failureReason = FormatFailure("endpoint-mismatch", interval, duration, integratedS,
integratedU, integratedA, jerk, highWater - integratedS, detail);
return false;
}
}
return true;
}
private static void AddSpeedRoots(ICollection<double> times, double initialU, double initialA,
double jerk, double duration)
{
if (jerk == 0d)
{
if (initialA != 0d)
AddIfInside(times, -initialU / initialA, duration);
return;
}
double discriminant = initialA * initialA - 2d * jerk * initialU;
if (!IsFinite(discriminant) || discriminant < 0d)
return;
double rootTerm = Math.Sqrt(discriminant);
AddIfInside(times, (-initialA - rootTerm) / jerk, duration);
AddIfInside(times, (-initialA + rootTerm) / jerk, duration);
}
private static void AddIfInside(ICollection<double> times, double localTime, double duration)
{
if (IsFinite(localTime) && localTime >= 0d && localTime <= duration)
times.Add(localTime);
}
private static void Evaluate(double initialS, double initialU, double initialA, double jerk,
double localTime, out double progress, out double speed, out double acceleration)
{
acceleration = initialA + jerk * localTime;
speed = initialU + initialA * localTime + 0.5d * jerk * localTime * localTime;
progress = initialS + initialU * localTime + 0.5d * initialA * localTime * localTime +
jerk * localTime * localTime * localTime / 6d;
}
private static string FormatFailure(string kind, int interval, double localTime, double progress,
double speed, double acceleration, double jerk, double regression, string detail)
{
return "Continuous ST profile rejected: kind=" + kind +
";interval=" + interval.ToString(CultureInfo.InvariantCulture) +
";localTime=" + Invariant(localTime) +
";S=" + Invariant(progress) +
";U=" + Invariant(speed) +
";A=" + Invariant(acceleration) +
";J=" + Invariant(jerk) +
";regression=" + Invariant(regression) + detail + ".";
}
private static string Invariant(double value)
{
return value.ToString("R", CultureInfo.InvariantCulture);
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
}
@@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
internal sealed class LongitudinalEnvelopeTrustRegion
{
internal LongitudinalEnvelopeTrustRegion(IReadOnlyList<double> minimumPathS, IReadOnlyList<double> maximumPathS,
IReadOnlyList<double> speedSlope, IReadOnlyList<double> speedIntercept,
IReadOnlyList<int> envelopeSegmentIndex, double scale)
{
MinimumPathS = Copy(minimumPathS, nameof(minimumPathS));
MaximumPathS = Copy(maximumPathS, nameof(maximumPathS));
SpeedSlope = Copy(speedSlope, nameof(speedSlope));
SpeedIntercept = Copy(speedIntercept, nameof(speedIntercept));
EnvelopeSegmentIndex = Copy(envelopeSegmentIndex, nameof(envelopeSegmentIndex));
Scale = scale;
}
internal IReadOnlyList<double> MinimumPathS { get; }
internal IReadOnlyList<double> MaximumPathS { get; }
internal IReadOnlyList<double> SpeedSlope { get; }
internal IReadOnlyList<double> SpeedIntercept { get; }
internal IReadOnlyList<int> EnvelopeSegmentIndex { get; }
internal double Scale { get; }
internal bool CanShrinkTo(double nextScale, double minimumActiveWidthMeters, out string failureReason)
{
failureReason = string.Empty;
if (!IsFinite(nextScale) || nextScale <= 0d || nextScale >= Scale)
{
failureReason = "Next trust-region scale must be finite, positive, and smaller than the current scale.";
return false;
}
if (!IsFinite(minimumActiveWidthMeters) || minimumActiveWidthMeters <= 0d)
{
failureReason = "Minimum active width must be finite and positive.";
return false;
}
double ratio = nextScale / Scale;
for (int index = 0; index < MinimumPathS.Count; index++)
{
double currentWidth = MaximumPathS[index] - MinimumPathS[index];
if (currentWidth > 0d && currentWidth * ratio < minimumActiveWidthMeters)
{
failureReason = "The next trust-region scale would fall below the minimum width at knot " + index + ".";
return false;
}
}
return true;
}
private static IReadOnlyList<double> Copy(IReadOnlyList<double> source, string parameterName)
{
if (source == null)
throw new ArgumentNullException(parameterName);
var copy = new List<double>(source.Count);
for (int index = 0; index < source.Count; index++)
copy.Add(source[index]);
return new ReadOnlyCollection<double>(copy);
}
private static IReadOnlyList<int> Copy(IReadOnlyList<int> source, string parameterName)
{
if (source == null)
throw new ArgumentNullException(parameterName);
var copy = new List<int>(source.Count);
for (int index = 0; index < source.Count; index++)
copy.Add(source[index]);
return new ReadOnlyCollection<int>(copy);
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
}
internal sealed class LongitudinalEnvelopeTrustRegionBuilder
{
private const double ScheduleProgressTolerance = 1e-12d;
private const double StationSelectionTolerance = 1e-12d;
internal bool TryBuild(PathSpeedLimit speedLimit, LongitudinalCandidate anchor,
IReadOnlyList<double> referencePathS, int terminalHoldStartIndex, double scale,
double minimumActiveWidthMeters, out LongitudinalEnvelopeTrustRegion region,
out string failureReason)
{
region = null;
failureReason = string.Empty;
if (speedLimit == null || anchor == null || referencePathS == null)
{
failureReason = "Trust region inputs must be present.";
return false;
}
if (!IsSupportedScale(scale))
{
failureReason = "Trust-region scale must be one of 1, 0.5, 0.25, or 0.125.";
return false;
}
if (!IsFinite(minimumActiveWidthMeters) || minimumActiveWidthMeters <= 0d)
{
failureReason = "Minimum active width must be finite and positive.";
return false;
}
if (referencePathS.Count != anchor.S.Count)
{
failureReason = "Reference PathS count must match the anchor knot count.";
return false;
}
if (terminalHoldStartIndex == -1)
terminalHoldStartIndex = anchor.S.Count;
if (terminalHoldStartIndex < 1 || terminalHoldStartIndex > anchor.S.Count)
{
failureReason = "Terminal-hold start index is outside the anchor knot range.";
return false;
}
if (!TryValidateAnchorAndReference(speedLimit, anchor, referencePathS, out failureReason))
return false;
int knotCount = anchor.S.Count;
var minimumPathS = new double[knotCount];
var maximumPathS = new double[knotCount];
var speedSlope = new double[knotCount];
var speedIntercept = new double[knotCount];
var segmentIndex = new int[knotCount];
for (int index = 0; index < knotCount; index++)
{
double anchorS = anchor.S[index];
bool fixedKnot = index == 0 || index >= terminalHoldStartIndex;
int segment = SelectSegment(speedLimit, anchorS, referencePathS, index, fixedKnot);
FindMaximalExactAffineRun(speedLimit, segment, out int firstSegment, out int lastSegment,
out double slope, out double intercept);
double lower = speedLimit.PathS[firstSegment];
double upper = speedLimit.PathS[lastSegment + 1];
if (anchorS < lower && lower - anchorS <= StationSelectionTolerance)
lower = anchorS;
if (anchorS > upper && anchorS - upper <= StationSelectionTolerance)
upper = anchorS;
double trustedLower = fixedKnot ? anchorS : anchorS - scale * (anchorS - lower);
double trustedUpper = fixedKnot ? anchorS : anchorS + scale * (upper - anchorS);
if (!fixedKnot && scale < 1d && trustedUpper - trustedLower < minimumActiveWidthMeters)
{
failureReason = "Active trust-region interval is narrower than the minimum width at knot " + index + ".";
return false;
}
minimumPathS[index] = trustedLower;
maximumPathS[index] = trustedUpper;
speedSlope[index] = slope;
speedIntercept[index] = intercept;
segmentIndex[index] = segment;
}
region = new LongitudinalEnvelopeTrustRegion(minimumPathS, maximumPathS, speedSlope, speedIntercept,
segmentIndex, scale);
return true;
}
private static bool TryValidateAnchorAndReference(PathSpeedLimit speedLimit, LongitudinalCandidate anchor,
IReadOnlyList<double> referencePathS, out string failureReason)
{
if (anchor.S[0] != 0d)
{
failureReason = "The anchor must begin at exact PathS zero.";
return false;
}
double minimumPathS = speedLimit.PathS[0];
double maximumPathS = speedLimit.PathS[speedLimit.PathS.Count - 1];
double previousAnchorS = double.NegativeInfinity;
for (int index = 0; index < anchor.S.Count; index++)
{
double anchorS = anchor.S[index];
double referenceS = referencePathS[index];
if (!IsFinite(anchorS) || anchorS < minimumPathS || anchorS > maximumPathS)
{
failureReason = "Anchor PathS is outside the speed-limit range at knot " + index + ".";
return false;
}
if (anchorS < previousAnchorS)
{
failureReason = "Anchor PathS must be nondecreasing.";
return false;
}
if (!IsFinite(referenceS))
{
failureReason = "Reference PathS must be finite.";
return false;
}
previousAnchorS = anchorS;
}
failureReason = string.Empty;
return true;
}
private static int SelectSegment(PathSpeedLimit speedLimit, double anchorS, IReadOnlyList<double> referencePathS,
int knotIndex, bool terminalHold)
{
int lastSegment = speedLimit.PathS.Count - 2;
if (Math.Abs(anchorS - speedLimit.PathS[0]) <= StationSelectionTolerance)
return 0;
if (Math.Abs(anchorS - speedLimit.PathS[speedLimit.PathS.Count - 1]) <= StationSelectionTolerance)
return lastSegment;
for (int index = 1; index < speedLimit.PathS.Count - 1; index++)
{
if (Math.Abs(anchorS - speedLimit.PathS[index]) <= StationSelectionTolerance)
{
if (terminalHold)
return index;
double scheduleDelta = referencePathS[knotIndex] - referencePathS[knotIndex - 1];
if (scheduleDelta > ScheduleProgressTolerance)
return index;
if (scheduleDelta < -ScheduleProgressTolerance)
return index - 1;
double leftWidth = speedLimit.PathS[index] - speedLimit.PathS[index - 1];
double rightWidth = speedLimit.PathS[index + 1] - speedLimit.PathS[index];
return rightWidth >= leftWidth ? index : index - 1;
}
if (anchorS < speedLimit.PathS[index])
return index - 1;
}
return lastSegment;
}
private static void GetAffineLine(PathSpeedLimit limit, int segment,
out double slope, out double intercept)
{
double lower = limit.PathS[segment];
double upper = limit.PathS[segment + 1];
slope = (limit.MaximumSpeedMetersPerSecond[segment + 1] -
limit.MaximumSpeedMetersPerSecond[segment]) / (upper - lower);
intercept = limit.MaximumSpeedMetersPerSecond[segment] - slope * lower;
}
private static void FindMaximalExactAffineRun(PathSpeedLimit limit, int selectedSegment,
out int firstSegment, out int lastSegment, out double slope, out double intercept)
{
GetAffineLine(limit, selectedSegment, out slope, out intercept);
firstSegment = selectedSegment;
while (firstSegment > 0)
{
GetAffineLine(limit, firstSegment - 1, out double candidateSlope, out double candidateIntercept);
if (candidateSlope != slope || candidateIntercept != intercept)
break;
firstSegment--;
}
lastSegment = selectedSegment;
while (lastSegment < limit.PathS.Count - 2)
{
GetAffineLine(limit, lastSegment + 1, out double candidateSlope, out double candidateIntercept);
if (candidateSlope != slope || candidateIntercept != intercept)
break;
lastSegment++;
}
}
private static bool IsSupportedScale(double scale)
{
return scale == 1d || scale == 0.5d || scale == 0.25d || scale == 0.125d;
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
}
@@ -0,0 +1,217 @@
using System;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
internal sealed class LongitudinalQpAuditResult
{
internal LongitudinalQpAuditResult(bool isFeasible, double maximumResidual, int worstRow,
string category, string unit)
{
IsFeasible = isFeasible;
MaximumResidual = maximumResidual;
WorstRow = worstRow;
Category = category;
Unit = unit;
}
internal bool IsFeasible { get; }
internal double MaximumResidual { get; }
internal int WorstRow { get; }
internal string Category { get; }
internal string Unit { get; }
}
internal static class LongitudinalQpFeasibilityAudit
{
internal static LongitudinalQpAuditResult Evaluate(QuadraticProgram problem,
LongitudinalCandidate candidate, double tolerance, LongitudinalVariableLayout layout,
int stabilizationStart)
{
if (problem == null)
throw new ArgumentNullException(nameof(problem));
if (candidate == null)
throw new ArgumentNullException(nameof(candidate));
if (layout == null)
throw new ArgumentNullException(nameof(layout));
if (problem.VariableCount != layout.VariableCount || candidate.S.Count != layout.KnotCount ||
candidate.U.Count != layout.KnotCount || candidate.A.Count != layout.KnotCount ||
candidate.J.Count != layout.KnotCount - 1)
{
throw new ArgumentException("The QP, candidate, and longitudinal layout must have matching dimensions.");
}
double[] primal = ToPrimal(candidate, layout);
var activity = new double[problem.ConstraintCount];
bool allFinite = IsFinite(tolerance);
SparseCscMatrix matrix = problem.ConstraintMatrix;
for (int column = 0; column < matrix.ColumnCount; column++)
{
double value = primal[column];
allFinite &= IsFinite(value);
for (int entry = matrix.ColumnPointers[column]; entry < matrix.ColumnPointers[column + 1]; entry++)
activity[matrix.RowIndices[entry]] += matrix.Values[entry] * value;
}
double maximumResidual = 0d;
int worstRow = problem.ConstraintCount == 0 ? -1 : 0;
for (int row = 0; row < problem.ConstraintCount; row++)
{
double residual;
if (!IsFinite(activity[row]))
{
allFinite = false;
residual = double.PositiveInfinity;
}
else
{
residual = Math.Max(0d, Math.Max(
problem.LowerBounds[row] - activity[row],
activity[row] - problem.UpperBounds[row]));
}
if (row == 0 || residual > maximumResidual)
{
maximumResidual = residual;
worstRow = row;
}
}
DescribeRow(worstRow, layout.KnotCount, stabilizationStart, out string category, out string unit);
return new LongitudinalQpAuditResult(allFinite && maximumResidual <= tolerance,
maximumResidual, worstRow, category, unit);
}
private static double[] ToPrimal(LongitudinalCandidate candidate, LongitudinalVariableLayout layout)
{
var primal = new double[layout.VariableCount];
for (int index = 0; index < layout.KnotCount; index++)
{
primal[layout.S(index)] = candidate.S[index];
primal[layout.U(index)] = candidate.U[index];
primal[layout.A(index)] = candidate.A[index];
}
for (int index = 0; index < layout.KnotCount - 1; index++)
primal[layout.J(index)] = candidate.J[index];
return primal;
}
private static void DescribeRow(int targetRow, int knotCount, int stabilizationStart,
out string category, out string unit)
{
int row = 0;
for (int index = 0; index < knotCount; index++)
{
if (targetRow == row++)
{
category = "PathS trust";
unit = "m";
return;
}
if (targetRow == row++)
{
category = "speed";
unit = "m/s";
return;
}
if (index > 0 && targetRow == row++)
{
category = "speed envelope";
unit = "m/s";
return;
}
if (targetRow == row++)
{
category = "acceleration";
unit = "m/s^2";
return;
}
}
for (int index = 0; index < knotCount - 1; index++)
{
if (targetRow == row++)
{
category = "jerk";
unit = "m/s^3";
return;
}
}
for (int index = 0; index < knotCount; index++)
{
if (targetRow == row++)
{
category = "low-speed deceleration release";
unit = "m/s";
return;
}
}
for (int index = 0; index < knotCount - 1; index++)
{
if (targetRow == row++)
{
category = "monotonic progress";
unit = "m";
return;
}
}
for (int index = 0; index < knotCount - 1; index++)
{
if (targetRow == row++)
{
category = "exact dynamics";
unit = "m/s^2";
return;
}
if (targetRow == row++)
{
category = "exact dynamics";
unit = "m/s";
return;
}
if (targetRow == row++)
{
category = "exact dynamics";
unit = "m";
return;
}
}
string[] stateUnits = { "m", "m/s", "m/s^2" };
for (int state = 0; state < stateUnits.Length; state++)
{
if (targetRow == row++)
{
category = "exact start";
unit = stateUnits[state];
return;
}
}
int stopKnotCount = stabilizationStart >= 0 && stabilizationStart < knotCount
? knotCount - stabilizationStart
: 0;
for (int index = 0; index < stopKnotCount; index++)
{
for (int state = 0; state < stateUnits.Length; state++)
{
if (targetRow == row++)
{
category = "exact stop";
unit = stateUnits[state];
return;
}
}
}
category = "row accounting";
unit = string.Empty;
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
@@ -48,6 +49,8 @@ public sealed class LongitudinalSolutionValidator
failureReason = "ST candidate violates exact constant-jerk dynamics.";
return false;
}
if (!LongitudinalContinuousProfileValidator.TryValidate(candidate, tolerance, out failureReason))
return false;
if (!AreClose(candidate.S[0], 0d, tolerance) ||
!AreClose(candidate.U[0], input.InitialProgressSpeedMetersPerSecond, tolerance) ||
!AreClose(candidate.A[0], input.InitialAccelerationMetersPerSecondSquared, tolerance))
@@ -56,6 +59,9 @@ public sealed class LongitudinalSolutionValidator
return false;
}
var canonicalS = new double[candidate.S.Count];
var canonicalU = new double[candidate.U.Count];
var canonicalA = new double[candidate.A.Count];
for (int index = 0; index < candidate.S.Count; index++)
{
double progress = candidate.S[index];
@@ -83,6 +89,11 @@ public sealed class LongitudinalSolutionValidator
failureReason = "ST candidate PathS decreases at knot " + index + ".";
return false;
}
canonicalS[index] = progress;
canonicalU[index] = speed < 0d ? 0d : speed;
canonicalA[index] = speed < 0d && acceleration < 0d && acceleration >= -tolerance
? 0d
: acceleration;
}
for (int index = 0; index < candidate.J.Count; index++)
{
@@ -122,25 +133,29 @@ public sealed class LongitudinalSolutionValidator
{
for (int index = 0; index < candidate.S.Count; index++)
{
if (!JerkLimitedStoppingMath.TryCalculate(candidate.U[index], candidate.A[index],
maximumDeceleration, maximumJerk, out JerkLimitedStoppingProfile stop, out _) ||
candidate.S[index] + stop.DistanceMeters > input.StopBoundaryPathS + tolerance)
bool hasStop = JerkLimitedStoppingMath.TryCalculate(canonicalU[index], canonicalA[index],
maximumDeceleration, maximumJerk, out JerkLimitedStoppingProfile stop,
out string stoppingFailure);
double stopDistance = hasStop ? stop.DistanceMeters : double.NaN;
double margin = hasStop
? input.StopBoundaryPathS - canonicalS[index] - stopDistance
: double.NaN;
if (!hasStop || canonicalS[index] + stopDistance > input.StopBoundaryPathS + tolerance)
{
failureReason = "ST candidate leaves the jerk-limited stoppable set at knot " + index + ".";
failureReason = "ST candidate leaves the jerk-limited stoppable set: knot=" +
index.ToString(CultureInfo.InvariantCulture) +
";S=" + Invariant(canonicalS[index]) +
";U=" + Invariant(canonicalU[index]) +
";A=" + Invariant(canonicalA[index]) +
";stopDistance=" + Invariant(stopDistance) +
";stopBoundary=" + Invariant(input.StopBoundaryPathS) +
";margin=" + Invariant(margin) +
(hasStop ? string.Empty : ";stoppingReason=" + stoppingFailure) + ".";
return false;
}
}
}
var canonicalS = new double[candidate.S.Count];
var canonicalU = new double[candidate.U.Count];
var canonicalA = new double[candidate.A.Count];
for (int index = 0; index < candidate.S.Count; index++)
{
canonicalS[index] = candidate.S[index];
canonicalU[index] = candidate.U[index];
canonicalA[index] = candidate.A[index];
}
canonicalS[0] = 0d;
canonicalU[0] = input.InitialProgressSpeedMetersPerSecond;
canonicalA[0] = input.InitialAccelerationMetersPerSecondSquared;
@@ -202,6 +217,11 @@ public sealed class LongitudinalSolutionValidator
return Math.Abs(actual - expected) <= tolerance;
}
private static string Invariant(double value)
{
return value.ToString("R", CultureInfo.InvariantCulture);
}
private static double RequireNonnegative(double value, string parameterName)
{
if (!IsFinite(value) || value < 0d)
@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
internal sealed class LongitudinalSolveTrace
{
private const int MaximumEntries = 12;
private readonly List<string> _entries = new List<string>(MaximumEntries);
internal void Add(string phase, int callOrdinal, int anchorUpdateIndex, double trustScale,
TimeSpan remainingBudget, TimeSpan remainingAfterReserve, TimeSpan elapsed, QpSolveResult result,
double anchorObjective, double candidateObjective, string rejection)
{
if (_entries.Count >= MaximumEntries)
throw new InvalidOperationException("Longitudinal solve trace exceeded the twelve-call cap.");
string status = result == null ? "null" : result.Status.ToString();
int iterations = result == null ? -1 : result.Iterations;
double primal = result == null ? double.NaN : result.PrimalResidual;
double dual = result == null ? double.NaN : result.DualResidual;
_entries.Add("phase:" + phase +
",call:" + callOrdinal.ToString(CultureInfo.InvariantCulture) +
",anchor:" + anchorUpdateIndex.ToString(CultureInfo.InvariantCulture) +
",scale:" + trustScale.ToString("R", CultureInfo.InvariantCulture) +
",budgetMs:" + remainingBudget.TotalMilliseconds.ToString("F3", CultureInfo.InvariantCulture) +
",remainingAfterReserveMs:" + remainingAfterReserve.TotalMilliseconds.ToString(
"F3", CultureInfo.InvariantCulture) +
",elapsedMs:" + elapsed.TotalMilliseconds.ToString("F3", CultureInfo.InvariantCulture) +
",status:" + status +
",iterations:" + iterations.ToString(CultureInfo.InvariantCulture) +
",primal:" + primal.ToString("R", CultureInfo.InvariantCulture) +
",dual:" + dual.ToString("R", CultureInfo.InvariantCulture) +
",anchorObj:" + anchorObjective.ToString("R", CultureInfo.InvariantCulture) +
",candidateObj:" + candidateObjective.ToString("R", CultureInfo.InvariantCulture) +
",rejection:" + Sanitize(rejection));
}
internal string Format() => string.Join("|", _entries);
private static string Sanitize(string text)
{
if (string.IsNullOrEmpty(text))
return "none";
return text.Replace(';', '/').Replace('|', '/').Replace('\r', '/').Replace('\n', '/');
}
}
@@ -10,13 +10,21 @@ namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Bounded ST envelope iteration retaining only independently validated physical candidates.</summary>
public sealed class SequentialLongitudinalOptimizer
{
private const int MaximumEnvelopeIterations = 5;
private const double OrdinaryEnvelopeProbeLookaheadSteps = 1d;
private const double OrdinaryTerminalProbeFraction = 0.5d;
private const int MaximumAcceptedAnchorUpdates = 5;
private const int MaximumQpSolveCalls = 12;
private const double MinimumTrustRegionWidthMeters = 0.001d;
private const double ObjectiveAcceptanceRelativeTolerance = 1e-9d;
private const double HighPrecisionRetryTolerance = 1e-7d;
private const double StaticStartSeedBudgetFraction = 0.10d;
private static readonly TimeSpan MaximumStaticStartSeedBudget = TimeSpan.FromMilliseconds(250d);
private static readonly TimeSpan PublicationReserve = TimeSpan.FromMilliseconds(250d);
private static readonly double[] TrustRegionScales = { 1d, 0.5d, 0.25d, 0.125d };
private readonly IQpSolver _qpSolver;
private readonly PathSpeedLimitBuilder _speedLimitBuilder;
private readonly LongitudinalConstraintBuilder _constraintBuilder;
private readonly LongitudinalSolutionValidator _solutionValidator;
private readonly LongitudinalEnvelopeTrustRegionBuilder _trustRegionBuilder =
new LongitudinalEnvelopeTrustRegionBuilder();
public SequentialLongitudinalOptimizer(IQpSolver qpSolver)
: this(qpSolver, new PathSpeedLimitBuilder(), new LongitudinalConstraintBuilder(new LongitudinalObjectiveBuilder()),
@@ -65,133 +73,418 @@ public sealed class SequentialLongitudinalOptimizer
return Failed(speedStatus, speedFailure);
var stopwatch = Stopwatch.StartNew();
LongitudinalCandidate iterate;
LongitudinalCandidate lastStrictCandidate = null;
int remainingObjectiveIterations = iterationLimit;
var solveTrace = new LongitudinalSolveTrace();
LongitudinalCandidate initialCandidate;
int projectionSolveCount = 0;
if (input.PlanningScope == EmPlanningScope.FullDirectionSegment &&
input.Mode == EmLongitudinalMode.ExactStopAtBoundary)
{
if (!TryCreateInitialFeasibleCandidate(input, speedLimit, settings, totalBudget, convergenceTolerance,
iterationLimit, stopwatch, cancellationToken, out iterate, out int projectionSolveCount,
out EmPlanningStatus projectionStatus, out string projectionFailure))
iterationLimit, stopwatch, solveTrace, cancellationToken, out initialCandidate,
out int usedProjectionSolveCount, out EmPlanningStatus projectionStatus,
out string projectionFailure))
{
return Failed(projectionStatus, projectionFailure);
}
lastStrictCandidate = CopyCandidate(iterate);
remainingObjectiveIterations -= projectionSolveCount;
if (remainingObjectiveIterations <= 0)
{
return new LongitudinalPlanningResult(EmPlanningStatus.SuccessWithFallback, lastStrictCandidate,
"The strict initial feasibility projection consumed the configured outer-iteration budget.");
string projectionTrace = solveTrace.Format();
return Failed(projectionStatus, projectionFailure +
(string.IsNullOrEmpty(projectionTrace) ? string.Empty : ";solveTrace=" + projectionTrace));
}
projectionSolveCount = usedProjectionSolveCount;
}
else
{
iterate = CreateInitialIterate(input, speedLimit);
if (!_solutionValidator.TryValidate(input, speedLimit, iterate, out lastStrictCandidate, out _))
lastStrictCandidate = null;
LongitudinalCandidate seed = CreateInitialIterate(input, speedLimit);
if (!_solutionValidator.TryValidate(input, speedLimit, seed, out initialCandidate,
out EmPlanningStatus initializationStatus, out string initializationFailure))
{
return Failed(initializationStatus, "No strictly validated longitudinal candidate was found. " +
initializationFailure);
}
}
double[] warmStart = ToPrimal(iterate);
bool hasDynamicsConsistentInitialWarmStart = iterate.SatisfiesExactDiscreteDynamics(1e-12d);
string lastCandidateRejection = string.Empty;
bool hasPreviousObjective = false;
double previousObjective = 0d;
for (int iteration = 0; iteration < remainingObjectiveIterations; iteration++)
LongitudinalCandidate anchor = CopyCandidate(initialCandidate);
int stabilizationStart = GetStabilizationStart(input);
int qpSolveCount = projectionSolveCount;
int trustShrinkCount = 0;
int acceptedAnchorCount = 0;
int acceptedUpdateLimit = Math.Min(MaximumAcceptedAnchorUpdates, iterationLimit);
double finalTrustScale = 1d;
string lastRejection = string.Empty;
while (acceptedAnchorCount < acceptedUpdateLimit && qpSolveCount < MaximumQpSolveCalls)
{
bool promoted = false;
for (int scaleIndex = 0; scaleIndex < TrustRegionScales.Length; scaleIndex++)
{
double scale = TrustRegionScales[scaleIndex];
finalTrustScale = scale;
if (cancellationToken.IsCancellationRequested || totalBudget - stopwatch.Elapsed <= TimeSpan.Zero)
{
return FinishFromAnchor(anchor, acceptedAnchorCount, qpSolveCount, trustShrinkCount,
finalTrustScale, cancellationToken.IsCancellationRequested, lastRejection, solveTrace);
}
if (!_trustRegionBuilder.TryBuild(speedLimit, anchor, input.KnotSchedule.ReferencePathS,
stabilizationStart, scale, MinimumTrustRegionWidthMeters,
out LongitudinalEnvelopeTrustRegion region, out string regionFailure))
{
lastRejection = regionFailure;
break;
}
if (!_constraintBuilder.TryBuildTrusted(input, speedLimit, anchor, region,
convergenceTolerance, out QuadraticProgram problem, out string buildFailure))
{
lastRejection = buildFailure;
break;
}
TrustedSolveAttempt attempt = SolveTrustedProblem(problem, input, speedLimit, anchor, settings,
totalBudget, stopwatch, convergenceTolerance, stabilizationStart,
MaximumQpSolveCalls - qpSolveCount, solveTrace, qpSolveCount, acceptedAnchorCount,
scale, acceptedAnchorCount > 0, cancellationToken);
qpSolveCount += attempt.SolveCount;
lastRejection = attempt.FailureReason;
if (attempt.Status == EmPlanningStatus.Cancelled)
{
return Failed(EmPlanningStatus.Cancelled, CreateRunDiagnostic(qpSolveCount,
trustShrinkCount, acceptedAnchorCount, finalTrustScale, lastRejection, solveTrace));
}
if (attempt.Accepted)
{
anchor = CopyCandidate(attempt.Candidate);
acceptedAnchorCount++;
promoted = true;
break;
}
if (attempt.Status != EmPlanningStatus.SuccessWithFallback)
{
return FinishFromAnchor(anchor, acceptedAnchorCount, qpSolveCount, trustShrinkCount,
finalTrustScale, false, lastRejection, solveTrace);
}
if (scaleIndex + 1 < TrustRegionScales.Length)
{
double nextScale = TrustRegionScales[scaleIndex + 1];
if (!region.CanShrinkTo(nextScale, MinimumTrustRegionWidthMeters, out string shrinkFailure))
{
lastRejection = shrinkFailure;
break;
}
trustShrinkCount++;
}
}
if (!promoted)
break;
}
return FinishFromAnchor(anchor, acceptedAnchorCount, qpSolveCount, trustShrinkCount,
finalTrustScale, cancellationToken.IsCancellationRequested, lastRejection, solveTrace);
}
private TrustedSolveAttempt SolveTrustedProblem(QuadraticProgram problem,
LongitudinalPlanningInput input, PathSpeedLimit speedLimit, LongitudinalCandidate anchor,
QpSolverSettings settings, TimeSpan totalBudget, Stopwatch stopwatch, double strictTolerance,
int stabilizationStart, int remainingCallCount, LongitudinalSolveTrace solveTrace,
int solveOrdinalOffset, int anchorUpdateIndex, double trustScale,
bool optionalImprovement, CancellationToken cancellationToken)
{
int solveCount = 0;
double anchorObjective = EvaluateObjective(problem, ToPrimal(anchor));
if (!IsFinite(anchorObjective))
{
return new TrustedSolveAttempt(EmPlanningStatus.LongitudinalInfeasible, null, solveCount, false,
"The strict anchor objective is non-finite for the trusted QP.");
}
IReadOnlyList<double> warmStart = ToPrimal(anchor);
string lastFailure = string.Empty;
for (int attemptIndex = 0; attemptIndex < 2; attemptIndex++)
{
if (solveCount >= remainingCallCount)
{
return new TrustedSolveAttempt(EmPlanningStatus.SolverTimedOut, null, solveCount, false,
string.IsNullOrWhiteSpace(lastFailure) ? "The longitudinal QP solve-call cap was reached." : lastFailure);
}
if (cancellationToken.IsCancellationRequested)
return FallbackOrFailure(lastStrictCandidate, EmPlanningStatus.Cancelled, "Longitudinal optimization was cancelled.");
{
return new TrustedSolveAttempt(EmPlanningStatus.Cancelled, null, solveCount, false,
"Longitudinal optimization was cancelled before the trusted QP solve.");
}
TimeSpan remainingBudget = totalBudget - stopwatch.Elapsed;
if (remainingBudget <= TimeSpan.Zero)
{
return FallbackOrFailure(lastStrictCandidate, EmPlanningStatus.SolverTimedOut,
"Longitudinal optimization exhausted its solve budget.");
return new TrustedSolveAttempt(EmPlanningStatus.SolverTimedOut, null, solveCount, false,
"Longitudinal optimization exhausted its shared solve budget.");
}
if (!_constraintBuilder.TryBuild(input, speedLimit, iterate, out QuadraticProgram problem, out string buildFailure))
bool hasOptionalSolveBudget = TryGetOptionalSolveBudget(remainingBudget,
out TimeSpan remainingAfterReserve);
if (optionalImprovement && !hasOptionalSolveBudget)
{
return FallbackOrFailure(lastStrictCandidate, EmPlanningStatus.LongitudinalInfeasible,
"Longitudinal constraints are infeasible: " + buildFailure);
return new TrustedSolveAttempt(EmPlanningStatus.SolverTimedOut, null, solveCount, false,
CreatePublicationReserveSkipDiagnostic(remainingAfterReserve));
}
TimeSpan solveBudget = optionalImprovement ? remainingAfterReserve : remainingBudget;
bool highPrecision = attemptIndex == 1;
double absoluteTolerance = highPrecision
? Math.Min(settings.AbsoluteTolerance, HighPrecisionRetryTolerance)
: settings.AbsoluteTolerance;
double relativeTolerance = highPrecision
? Math.Min(settings.AbsoluteTolerance, HighPrecisionRetryTolerance)
: settings.RelativeTolerance;
var solveStopwatch = Stopwatch.StartNew();
QpSolveResult solved = _qpSolver.Solve(problem,
new QpSolverSettings(settings.MaximumIterations, absoluteTolerance, relativeTolerance,
solveBudget, settings.EnableWarmStart, settings.EnablePolishing,
settings.EnableNativeVerboseOutput), warmStart, cancellationToken);
solveStopwatch.Stop();
solveCount++;
double candidateObjective = double.NaN;
void AddSolveTrace(string rejection)
{
solveTrace.Add(attemptIndex == 0 ? "normal" : "retry",
solveOrdinalOffset + solveCount, anchorUpdateIndex, trustScale,
solveBudget, remainingAfterReserve, solveStopwatch.Elapsed, solved, anchorObjective,
candidateObjective, rejection);
}
QpSolveResult solved = _qpSolver.Solve(problem,
new QpSolverSettings(settings.MaximumIterations, settings.AbsoluteTolerance, settings.RelativeTolerance,
remainingBudget, settings.EnableWarmStart && (iteration > 0 || hasDynamicsConsistentInitialWarmStart),
settings.EnablePolishing,
settings.EnableNativeVerboseOutput),
warmStart, cancellationToken);
if (cancellationToken.IsCancellationRequested)
return Failed(EmPlanningStatus.Cancelled, "Longitudinal optimization was cancelled after the QP solve.");
if (solved == null)
return FallbackOrFailure(lastStrictCandidate, EmPlanningStatus.Failed, "The longitudinal QP solver returned no result.");
if (solved.Status == QpSolveStatus.TimeLimit || solved.Status == QpSolveStatus.MaximumIterations)
{
return FallbackOrFailure(lastStrictCandidate, EmPlanningStatus.SolverTimedOut,
"The longitudinal QP solver timed out (status=" + solved.NativeStatus +
", iterations=" + solved.Iterations + ", primal=" + solved.PrimalResidual +
", dual=" + solved.DualResidual + "): " + solved.Diagnostic);
const string rejection = "Longitudinal optimization was cancelled after the trusted QP solve.";
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.Cancelled, null, solveCount, false,
rejection);
}
if (solved == null)
{
const string rejection = "The longitudinal QP solver returned no result.";
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.Failed, null, solveCount, false,
rejection);
}
if (solved.Status == QpSolveStatus.Cancelled)
return FallbackOrFailure(lastStrictCandidate, EmPlanningStatus.Cancelled,
"The longitudinal QP solver was cancelled: " + solved.Diagnostic);
{
string rejection = "The longitudinal QP solver was cancelled: " + solved.Diagnostic;
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.Cancelled, null, solveCount, false,
rejection);
}
if (solved.Status == QpSolveStatus.TimeLimit || solved.Status == QpSolveStatus.MaximumIterations)
{
string rejection = "The longitudinal QP solver timed out (status=" + solved.NativeStatus +
", iterations=" + solved.Iterations + ", primal=" + solved.PrimalResidual +
", dual=" + solved.DualResidual + "): " + solved.Diagnostic;
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.SolverTimedOut, null, solveCount, false,
rejection);
}
if (solved.Status == QpSolveStatus.PrimalInfeasible || solved.Status == QpSolveStatus.DualInfeasible)
{
return FallbackOrFailure(lastStrictCandidate, EmPlanningStatus.LongitudinalInfeasible,
"The longitudinal QP solver reported infeasibility: " + solved.Diagnostic);
string rejection = "The preflight-feasible longitudinal QP solver reported infeasibility;" +
"solverNumericalAnomaly=true;status=" + solved.NativeStatus + ": " + solved.Diagnostic;
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.LongitudinalInfeasible, null, solveCount, false,
rejection);
}
if (solved.Status == QpSolveStatus.SolverUnavailable)
return FallbackOrFailure(lastStrictCandidate, EmPlanningStatus.SolverUnavailable,
"The longitudinal QP solver is unavailable: " + solved.Diagnostic);
{
string rejection = "The longitudinal QP solver is unavailable: " + solved.Diagnostic;
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.SolverUnavailable, null, solveCount, false,
rejection);
}
if (solved.Status != QpSolveStatus.Solved && solved.Status != QpSolveStatus.SolvedInaccurate)
{
return FallbackOrFailure(lastStrictCandidate, EmPlanningStatus.Failed,
"The longitudinal QP solver failed: " + solved.Diagnostic);
string rejection = "The longitudinal QP solver failed (status=" + solved.NativeStatus + "): " +
solved.Diagnostic;
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.Failed, null, solveCount, false,
rejection);
}
if (solved.Status == QpSolveStatus.SolvedInaccurate && !HasStrictResiduals(solved, convergenceTolerance))
if (!TryCreateCandidate(anchor.KnotTimes, solved.Primal, out LongitudinalCandidate candidate))
{
lastCandidateRejection = "SolvedInaccurate residuals exceed the strict acceptance tolerance" +
const string rejection =
"The solver primal does not match the ST variable layout or contains non-finite values.";
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.SuccessWithFallback, null, solveCount, false,
rejection);
}
bool accepted = true;
if (solved.Status == QpSolveStatus.SolvedInaccurate && !HasStrictResiduals(solved, strictTolerance))
{
accepted = false;
lastFailure = "SolvedInaccurate residuals exceed the strict acceptance tolerance" +
" (primal=" + solved.PrimalResidual + ", dual=" + solved.DualResidual + ").";
if (TryCreateCandidate(iterate.KnotTimes, solved.Primal, out LongitudinalCandidate inaccurateCandidate))
warmStart = ToPrimal(inaccurateCandidate);
continue;
}
if (!TryCreateCandidate(iterate.KnotTimes, solved.Primal, out LongitudinalCandidate candidate))
if (accepted && !TryFastValidateCandidate(problem, candidate, strictTolerance,
stabilizationStart, out lastFailure))
{
lastCandidateRejection = "The solver primal does not match the ST variable layout.";
continue;
accepted = false;
}
if (!_solutionValidator.TryValidate(input, speedLimit, candidate, out LongitudinalCandidate validated,
LongitudinalCandidate validated = null;
if (accepted && !_solutionValidator.TryValidate(input, speedLimit, candidate, out validated,
out string validationFailure))
{
string rejection = validationFailure + CreateEnvelopeDiagnostic(speedLimit, iterate, candidate,
iteration + 1);
lastCandidateRejection = string.IsNullOrEmpty(lastCandidateRejection)
? rejection
: lastCandidateRejection + " | " + rejection;
if (TryCreateEnvelopeIterate(input, iterate, candidate, out LongitudinalCandidate nextIterate))
accepted = false;
lastFailure = validationFailure;
}
if (accepted)
{
candidateObjective = EvaluateObjective(problem, ToPrimal(validated));
if (!IsObjectiveAccepted(anchorObjective, candidateObjective))
{
iterate = nextIterate;
warmStart = ToPrimal(candidate);
accepted = false;
lastFailure = "The strictly valid candidate worsens the current trusted-QP objective" +
" (anchor=" + anchorObjective.ToString("R", CultureInfo.InvariantCulture) +
", candidate=" + candidateObjective.ToString("R", CultureInfo.InvariantCulture) + ").";
}
continue;
}
if (accepted && cancellationToken.IsCancellationRequested)
{
const string rejection = "Longitudinal optimization was cancelled before strict-anchor promotion.";
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.Cancelled, null, solveCount, false,
rejection);
}
if (accepted && totalBudget - stopwatch.Elapsed <= TimeSpan.Zero)
{
const string rejection =
"Longitudinal optimization exhausted its shared solve budget before strict-anchor promotion.";
AddSolveTrace(rejection);
return new TrustedSolveAttempt(EmPlanningStatus.SolverTimedOut, null, solveCount, false,
rejection);
}
if (accepted)
{
AddSolveTrace(string.Empty);
return new TrustedSolveAttempt(EmPlanningStatus.Success, validated, solveCount, true, string.Empty);
}
if (highPrecision)
{
AddSolveTrace(lastFailure);
return new TrustedSolveAttempt(EmPlanningStatus.SuccessWithFallback, null, solveCount, false,
lastFailure);
}
double maximumChange = MaximumProgressOrSpeedChange(iterate, validated);
double relativeObjectiveImprovement = hasPreviousObjective
? RelativeObjectiveImprovement(previousObjective, solved.Objective)
: double.PositiveInfinity;
lastStrictCandidate = CopyCandidate(validated);
iterate = validated;
warmStart = ToPrimal(validated);
previousObjective = solved.Objective;
hasPreviousObjective = true;
if (maximumChange <= convergenceTolerance && relativeObjectiveImprovement <= convergenceTolerance)
return new LongitudinalPlanningResult(EmPlanningStatus.Success, lastStrictCandidate, string.Empty);
AddSolveTrace(lastFailure);
warmStart = ToPrimal(candidate);
}
return lastStrictCandidate == null
? Failed(EmPlanningStatus.LongitudinalInfeasible, "No strictly validated longitudinal candidate was found. " +
lastCandidateRejection)
: new LongitudinalPlanningResult(EmPlanningStatus.Success, lastStrictCandidate, string.Empty);
return new TrustedSolveAttempt(EmPlanningStatus.SuccessWithFallback, null, solveCount, false,
lastFailure);
}
private static double EvaluateObjective(QuadraticProgram problem, IReadOnlyList<double> primal)
{
if (problem == null || primal == null || primal.Count != problem.VariableCount)
return double.NaN;
double objective = 0d;
SparseCscMatrix hessian = problem.UpperTriangularP;
for (int column = 0; column < hessian.ColumnCount; column++)
{
double columnValue = primal[column];
if (!IsFinite(columnValue))
return double.NaN;
for (int entry = hessian.ColumnPointers[column]; entry < hessian.ColumnPointers[column + 1]; entry++)
{
int row = hessian.RowIndices[entry];
double term = hessian.Values[entry] * primal[row] * columnValue;
objective += row == column ? 0.5d * term : term;
if (!IsFinite(objective))
return double.NaN;
}
objective += problem.LinearCost[column] * columnValue;
if (!IsFinite(objective))
return double.NaN;
}
return objective;
}
private static bool IsObjectiveAccepted(double anchorObjective, double candidateObjective)
{
if (!IsFinite(anchorObjective) || !IsFinite(candidateObjective))
return false;
double tolerance = ObjectiveAcceptanceRelativeTolerance * Math.Max(1d, Math.Abs(anchorObjective));
return candidateObjective <= anchorObjective + tolerance;
}
private static int GetStabilizationStart(LongitudinalPlanningInput input)
{
if (input.Mode != EmLongitudinalMode.ExactStopAtBoundary)
return input.KnotSchedule.KnotTimes.Count;
if (input.PlanningScope == EmPlanningScope.FullDirectionSegment)
return input.KnotSchedule.TerminalHoldStartIndex;
return LongitudinalTerminalSchedule.GetStabilizationStartIndex(input.KnotSchedule.KnotTimes,
input.Configuration.Scheduling.OutputTimeStepSeconds);
}
private static bool TryFastValidateCandidate(QuadraticProgram problem, LongitudinalCandidate candidate,
double strictTolerance, int stabilizationStart, out string failureReason)
{
failureReason = string.Empty;
var layout = new LongitudinalVariableLayout(candidate.KnotTimes.Count);
LongitudinalQpAuditResult audit;
try
{
audit = LongitudinalQpFeasibilityAudit.Evaluate(problem, candidate, strictTolerance,
layout, stabilizationStart);
}
catch (ArgumentException exception)
{
failureReason = "The candidate cannot be audited against the current trusted QP: " + exception.Message;
return false;
}
if (audit.IsFeasible)
return true;
failureReason = "The candidate violates the current trusted QP" +
";row=" + audit.WorstRow + ";category=" + audit.Category +
";residual=" + audit.MaximumResidual.ToString("R", CultureInfo.InvariantCulture) + audit.Unit +
";tolerance=" + strictTolerance.ToString("R", CultureInfo.InvariantCulture);
return false;
}
private static string CreateRunDiagnostic(int qpSolveCount, int trustShrinkCount,
int acceptedAnchorCount, double finalTrustScale, string lastRejection,
LongitudinalSolveTrace solveTrace)
{
return "qpSolves=" + qpSolveCount +
",trustShrinks=" + trustShrinkCount +
",acceptedAnchors=" + acceptedAnchorCount +
",trustScale=" + finalTrustScale.ToString("R", CultureInfo.InvariantCulture) +
(string.IsNullOrWhiteSpace(lastRejection) ? string.Empty : ";lastRejection=" + lastRejection) +
(string.IsNullOrEmpty(solveTrace.Format()) ? string.Empty : ";solveTrace=" + solveTrace.Format());
}
internal static bool TryGetOptionalSolveBudget(TimeSpan remaining, out TimeSpan solveBudget)
{
solveBudget = remaining - PublicationReserve;
if (solveBudget <= TimeSpan.Zero)
{
solveBudget = TimeSpan.Zero;
return false;
}
return true;
}
private static string CreatePublicationReserveSkipDiagnostic(TimeSpan remainingAfterReserve)
{
return "remainingAfterReserveMs=" + remainingAfterReserve.TotalMilliseconds.ToString(
"F3", CultureInfo.InvariantCulture) +
";publicationReserveMs=" + PublicationReserve.TotalMilliseconds.ToString(
"F0", CultureInfo.InvariantCulture) +
";optionalImprovement=skipped";
}
private static LongitudinalPlanningResult FinishFromAnchor(LongitudinalCandidate anchor,
int acceptedAnchorCount, int qpSolveCount, int trustShrinkCount, double finalTrustScale,
bool cancelled, string lastRejection, LongitudinalSolveTrace solveTrace)
{
string diagnostic = CreateRunDiagnostic(qpSolveCount, trustShrinkCount, acceptedAnchorCount,
finalTrustScale, lastRejection, solveTrace);
if (cancelled)
return Failed(EmPlanningStatus.Cancelled, diagnostic);
EmPlanningStatus status = acceptedAnchorCount > 0
? EmPlanningStatus.Success
: EmPlanningStatus.SuccessWithFallback;
return new LongitudinalPlanningResult(status, CopyCandidate(anchor), diagnostic);
}
private static bool TryCreateSettings(LongitudinalPlanningInput input, out QpSolverSettings settings,
@@ -222,7 +515,7 @@ public sealed class SequentialLongitudinalOptimizer
settings = new QpSolverSettings(solver.MaximumOsqpIterations, solver.AbsoluteTolerance, solver.RelativeTolerance,
totalBudget, solver.WarmStart, solver.Polish, solver.NativeVerbose);
convergenceTolerance = solver.StrictResidualTolerance;
iterationLimit = Math.Min(MaximumEnvelopeIterations, solver.MaximumOuterIterations);
iterationLimit = Math.Min(MaximumAcceptedAnchorUpdates, solver.MaximumOuterIterations);
return true;
}
catch (ArgumentException exception)
@@ -234,8 +527,9 @@ public sealed class SequentialLongitudinalOptimizer
private bool TryCreateInitialFeasibleCandidate(LongitudinalPlanningInput input, PathSpeedLimit speedLimit,
QpSolverSettings settings, TimeSpan totalBudget, double convergenceTolerance, int iterationLimit,
Stopwatch stopwatch, CancellationToken cancellationToken, out LongitudinalCandidate candidate,
out int projectionSolveCount, out EmPlanningStatus failureStatus, out string failureReason)
Stopwatch stopwatch, LongitudinalSolveTrace solveTrace, CancellationToken cancellationToken,
out LongitudinalCandidate candidate, out int projectionSolveCount,
out EmPlanningStatus failureStatus, out string failureReason)
{
candidate = null;
projectionSolveCount = 0;
@@ -248,8 +542,9 @@ public sealed class SequentialLongitudinalOptimizer
input.Configuration.Validation.KinematicTolerance;
bool staticStartSeedUsed = false;
string staticStartSeedFailure = string.Empty;
if (staticStartEligible && TryCreateStaticStartSeed(input, speedLimit, out LongitudinalCandidate staticStartSeed,
out staticStartSeedFailure))
TimeSpan staticStartSeedDeadline = stopwatch.Elapsed + GetStaticStartSeedBudget(totalBudget);
if (staticStartEligible && TryCreateStaticStartSeed(input, speedLimit, stopwatch, staticStartSeedDeadline,
out LongitudinalCandidate staticStartSeed, out staticStartSeedFailure))
{
staticStartSeedUsed = true;
candidate = staticStartSeed;
@@ -289,60 +584,93 @@ public sealed class SequentialLongitudinalOptimizer
double projectionTolerance = Math.Min(settings.AbsoluteTolerance,
input.Configuration.Validation.KinematicTolerance * 0.1d);
double anchorObjective = double.NaN;
var solveStopwatch = Stopwatch.StartNew();
QpSolveResult solved = _qpSolver.Solve(problem,
new QpSolverSettings(settings.MaximumIterations, projectionTolerance, projectionTolerance,
remainingBudget, settings.EnableWarmStart && linearizationIterate.SatisfiesExactDiscreteDynamics(1e-12d),
settings.EnablePolishing, settings.EnableNativeVerboseOutput),
ToPrimal(linearizationIterate), cancellationToken);
solveStopwatch.Stop();
projectionSolveCount++;
int projectionCallOrdinal = projectionSolveCount;
double candidateObjective = double.NaN;
void AddProjectionTrace(string rejection)
{
TryGetOptionalSolveBudget(remainingBudget, out TimeSpan remainingAfterReserve);
solveTrace.Add("projection", projectionCallOrdinal, 0, 1d, remainingBudget,
remainingAfterReserve, solveStopwatch.Elapsed, solved, anchorObjective,
candidateObjective, rejection);
}
if (cancellationToken.IsCancellationRequested)
{
const string rejection =
"Initial full-direction feasibility projection was cancelled after the QP solve.";
AddProjectionTrace(rejection);
failureStatus = EmPlanningStatus.Cancelled;
failureReason = WithStaticSeedDiagnostic("Initial full-direction feasibility projection was cancelled after the QP solve.");
failureReason = WithStaticSeedDiagnostic(rejection);
return false;
}
if (solved == null)
{
const string rejection = "The initial full-direction feasibility solver returned no result.";
AddProjectionTrace(rejection);
failureStatus = EmPlanningStatus.Failed;
failureReason = WithStaticSeedDiagnostic("The initial full-direction feasibility solver returned no result.");
failureReason = WithStaticSeedDiagnostic(rejection);
return false;
}
if (solved.Status == QpSolveStatus.TimeLimit || solved.Status == QpSolveStatus.MaximumIterations)
{
failureStatus = EmPlanningStatus.SolverTimedOut;
failureReason = WithStaticSeedDiagnostic("Initial full-direction feasibility projection timed out (status=" + solved.NativeStatus +
string rejection = "Initial full-direction feasibility projection timed out (status=" + solved.NativeStatus +
", iterations=" + solved.Iterations + ", primal=" + solved.PrimalResidual + ", dual=" +
solved.DualResidual + "): " + solved.Diagnostic);
solved.DualResidual + "): " + solved.Diagnostic;
AddProjectionTrace(rejection);
failureStatus = EmPlanningStatus.SolverTimedOut;
failureReason = WithStaticSeedDiagnostic(rejection);
return false;
}
if (solved.Status == QpSolveStatus.Cancelled)
{
string rejection =
"Initial full-direction feasibility projection was cancelled: " + solved.Diagnostic;
AddProjectionTrace(rejection);
failureStatus = EmPlanningStatus.Cancelled;
failureReason = WithStaticSeedDiagnostic("Initial full-direction feasibility projection was cancelled: " + solved.Diagnostic);
failureReason = WithStaticSeedDiagnostic(rejection);
return false;
}
if (solved.Status == QpSolveStatus.PrimalInfeasible || solved.Status == QpSolveStatus.DualInfeasible)
{
string rejection =
"Initial full-direction feasibility projection is infeasible: " + solved.Diagnostic;
AddProjectionTrace(rejection);
failureStatus = EmPlanningStatus.LongitudinalInfeasible;
failureReason = WithStaticSeedDiagnostic("Initial full-direction feasibility projection is infeasible: " + solved.Diagnostic);
failureReason = WithStaticSeedDiagnostic(rejection);
return false;
}
if (solved.Status == QpSolveStatus.SolverUnavailable)
{
string rejection =
"Initial full-direction feasibility solver is unavailable: " + solved.Diagnostic;
AddProjectionTrace(rejection);
failureStatus = EmPlanningStatus.SolverUnavailable;
failureReason = WithStaticSeedDiagnostic("Initial full-direction feasibility solver is unavailable: " + solved.Diagnostic);
failureReason = WithStaticSeedDiagnostic(rejection);
return false;
}
if (solved.Status != QpSolveStatus.Solved && solved.Status != QpSolveStatus.SolvedInaccurate)
{
string rejection = "Initial full-direction feasibility solver failed: " + solved.Diagnostic;
AddProjectionTrace(rejection);
failureStatus = EmPlanningStatus.Failed;
failureReason = WithStaticSeedDiagnostic("Initial full-direction feasibility solver failed: " + solved.Diagnostic);
failureReason = WithStaticSeedDiagnostic(rejection);
return false;
}
if (!TryCreateCandidate(input.KnotSchedule.KnotTimes, solved.Primal, out LongitudinalCandidate projected))
{
const string rejection =
"Initial full-direction feasibility solver primal does not match the ST layout.";
AddProjectionTrace(rejection);
failureStatus = EmPlanningStatus.LongitudinalInfeasible;
failureReason = WithStaticSeedDiagnostic("Initial full-direction feasibility solver primal does not match the ST layout.");
failureReason = WithStaticSeedDiagnostic(rejection);
return false;
}
if (solved.Status == QpSolveStatus.Solved || HasStrictResiduals(solved, convergenceTolerance))
@@ -350,11 +678,13 @@ public sealed class SequentialLongitudinalOptimizer
if (_solutionValidator.TryValidate(input, speedLimit, projected, out LongitudinalCandidate strict,
out EmPlanningStatus validationStatus, out string validationFailure))
{
AddProjectionTrace(string.Empty);
candidate = strict;
return true;
}
if (validationStatus == EmPlanningStatus.NoProgress)
{
AddProjectionTrace(validationFailure);
failureStatus = validationStatus;
failureReason = WithStaticSeedDiagnostic(validationFailure);
return false;
@@ -365,8 +695,11 @@ public sealed class SequentialLongitudinalOptimizer
if (!TryCreateFeasibilityEnvelopeIterate(input, projected,
out LongitudinalCandidate nextLinearization))
{
const string rejection =
"Initial full-direction feasibility candidate could not be relinearized against the PathS envelope.";
AddProjectionTrace(rejection);
failureStatus = EmPlanningStatus.LongitudinalInfeasible;
failureReason = WithStaticSeedDiagnostic("Initial full-direction feasibility candidate could not be relinearized against the PathS envelope.");
failureReason = WithStaticSeedDiagnostic(rejection);
return false;
}
linearizationIterate = nextLinearization;
@@ -374,6 +707,7 @@ public sealed class SequentialLongitudinalOptimizer
lastRejection = "Initial feasibility projection residuals exceed the strict acceptance tolerance.";
else if (string.IsNullOrEmpty(lastRejection))
lastRejection = "Initial feasibility projection violated the strict physical validator.";
AddProjectionTrace(lastRejection);
}
failureStatus = EmPlanningStatus.LongitudinalInfeasible;
failureReason = WithStaticSeedDiagnostic("Initial full-direction feasibility projection exhausted the configured outer iterations. " +
@@ -503,7 +837,7 @@ public sealed class SequentialLongitudinalOptimizer
}
private bool TryCreateStaticStartSeed(LongitudinalPlanningInput input, PathSpeedLimit speedLimit,
out LongitudinalCandidate candidate, out string failureReason)
Stopwatch stopwatch, TimeSpan deadline, out LongitudinalCandidate candidate, out string failureReason)
{
candidate = null;
failureReason = "unknown";
@@ -515,9 +849,11 @@ public sealed class SequentialLongitudinalOptimizer
}
IReadOnlyList<double> times = input.KnotSchedule.KnotTimes;
if (TryCreateExactJerkSeed(input, times, stabilizationStart, speedLimit, out candidate))
if (TryCreateStaticStartScurveSeed(input, times, stabilizationStart, speedLimit, stopwatch, deadline, out candidate))
return true;
failureReason = "exactJerkSeed=failed";
if (TryCreateExactJerkSeed(input, times, stabilizationStart, speedLimit, stopwatch, deadline, out candidate))
return true;
failureReason = "scurveSeed=failed; exactJerkSeed=failed";
double firstDuration = times[1] - times[0];
double secondDuration = times[2] - times[1];
LongitudinalConfiguration configuration = input.Configuration.Longitudinal;
@@ -526,6 +862,8 @@ public sealed class SequentialLongitudinalOptimizer
configuration.MaximumJerkMetersPerSecondCubed * secondDuration / firstDuration));
for (int sample = -256; sample <= 256; sample++)
{
if (HasReachedDeadline(stopwatch, deadline))
return false;
if (sample == 0)
continue;
double firstJerk = maximumFirstJerk * sample / 256d;
@@ -545,18 +883,13 @@ public sealed class SequentialLongitudinalOptimizer
return true;
}
}
if (TryCreateStaticStartScurveSeed(input, times, stabilizationStart, speedLimit, out candidate))
{
failureReason = string.Empty;
return true;
}
failureReason = "exactJerkSeed=failed; sampledSeeds=failed; scurveSeeds=failed";
failureReason = "scurveSeed=failed; exactJerkSeed=failed; sampledSeeds=failed";
return false;
}
private bool TryCreateStaticStartScurveSeed(LongitudinalPlanningInput input,
IReadOnlyList<double> times, int stabilizationStart, PathSpeedLimit speedLimit,
out LongitudinalCandidate candidate)
Stopwatch stopwatch, TimeSpan deadline, out LongitudinalCandidate candidate)
{
candidate = null;
int intervalCount = stabilizationStart;
@@ -569,6 +902,8 @@ public sealed class SequentialLongitudinalOptimizer
{
for (int plateau = 0; 4 * ramp + 2 * plateau <= intervalCount; plateau++)
{
if (HasReachedDeadline(stopwatch, deadline))
return false;
int cruise = intervalCount - 4 * ramp - 2 * plateau;
var basisAccel = new double[intervalCount];
var basisBrake = new double[intervalCount];
@@ -688,6 +1023,14 @@ public sealed class SequentialLongitudinalOptimizer
private bool TryCreateExactJerkSeed(LongitudinalPlanningInput input, IReadOnlyList<double> times,
int stabilizationStart, PathSpeedLimit speedLimit, out LongitudinalCandidate candidate)
{
return TryCreateExactJerkSeed(input, times, stabilizationStart, speedLimit, Stopwatch.StartNew(),
TimeSpan.MaxValue, out candidate);
}
private bool TryCreateExactJerkSeed(LongitudinalPlanningInput input, IReadOnlyList<double> times,
int stabilizationStart, PathSpeedLimit speedLimit, Stopwatch stopwatch, TimeSpan deadline,
out LongitudinalCandidate candidate)
{
candidate = null;
int intervalCount = stabilizationStart;
@@ -747,6 +1090,8 @@ public sealed class SequentialLongitudinalOptimizer
{
for (int basisIndex = 0; basisIndex < intervalCount; basisIndex++)
{
if (HasReachedDeadline(stopwatch, deadline))
return false;
double[] direction = CreateEndpointNullspaceDirection(influence, gram, basisIndex);
if (direction == null)
continue;
@@ -755,6 +1100,8 @@ public sealed class SequentialLongitudinalOptimizer
double bestViolation = currentViolation;
for (int sample = -256; sample <= 256; sample++)
{
if (HasReachedDeadline(stopwatch, deadline))
return false;
double scale = maximumJerk * sample / 256d;
var probeJerk = new double[intervalCount];
for (int interval = 0; interval < intervalCount; interval++)
@@ -776,6 +1123,18 @@ public sealed class SequentialLongitudinalOptimizer
return false;
}
private static TimeSpan GetStaticStartSeedBudget(TimeSpan totalBudget)
{
double milliseconds = Math.Min(MaximumStaticStartSeedBudget.TotalMilliseconds,
Math.Max(1d, totalBudget.TotalMilliseconds * StaticStartSeedBudgetFraction));
return TimeSpan.FromMilliseconds(milliseconds);
}
private static bool HasReachedDeadline(Stopwatch stopwatch, TimeSpan deadline)
{
return stopwatch.Elapsed >= deadline;
}
private bool TryValidateExactSeed(LongitudinalPlanningInput input, IReadOnlyList<double> times,
int stabilizationStart, PathSpeedLimit speedLimit, IReadOnlyList<double> jerk,
out LongitudinalCandidate candidate)
@@ -1065,72 +1424,6 @@ public sealed class SequentialLongitudinalOptimizer
return primal;
}
private static bool TryCreateEnvelopeIterate(LongitudinalPlanningInput input, LongitudinalCandidate previous,
LongitudinalCandidate candidate, out LongitudinalCandidate nextIterate)
{
nextIterate = null;
if (candidate.S.Count != previous.S.Count)
return false;
int stabilizationStart = input.Mode != EmLongitudinalMode.ExactStopAtBoundary
? candidate.S.Count
: input.PlanningScope == EmPlanningScope.FullDirectionSegment
? input.KnotSchedule.TerminalHoldStartIndex
: LongitudinalTerminalSchedule.GetStabilizationStartIndex(candidate.KnotTimes,
input.Configuration.Scheduling.OutputTimeStepSeconds);
var candidateProgressSamples = new double[candidate.S.Count];
double priorProgress = double.NegativeInfinity;
double priorPreviousProgress = double.NegativeInfinity;
for (int index = 0; index < candidate.S.Count; index++)
{
double candidateProgress = candidate.S[index];
double previousProgress = previous.S[index];
if (!IsFinite(previousProgress) || previousProgress < 0d || previousProgress > input.PathUpperBoundS ||
previousProgress < priorPreviousProgress)
{
return false;
}
if (!IsFinite(candidateProgress))
{
candidateProgress = previousProgress;
}
candidateProgress = Math.Max(0d, Math.Min(input.PathUpperBoundS, candidateProgress));
if (input.Mode == EmLongitudinalMode.ExactStopAtBoundary && index >= stabilizationStart)
candidateProgress = input.StopBoundaryPathS;
candidateProgress = Math.Max(priorProgress, candidateProgress);
candidateProgressSamples[index] = candidateProgress;
priorProgress = candidateProgress;
priorPreviousProgress = previousProgress;
}
var progress = new double[candidate.S.Count];
double previousNextProgress = 0d;
for (int index = 0; index < progress.Length; index++)
{
double candidateProgress = candidateProgressSamples[index];
if (index == 0 || index == progress.Length - 1 || (input.Mode == EmLongitudinalMode.ExactStopAtBoundary &&
index >= stabilizationStart) || candidateProgress >= input.PathUpperBoundS)
{
progress[index] = candidateProgress;
}
else
{
double timeStep = candidate.KnotTimes[index + 1] - candidate.KnotTimes[index];
double iterationAdvance = Math.Max(0d, candidateProgress - previous.S[index]);
double candidateSpeed = IsFinite(candidate.U[index]) ? Math.Max(0d, candidate.U[index]) : 0d;
double lookaheadAdvance = IsFinite(candidate.U[index])
? OrdinaryEnvelopeProbeLookaheadSteps * candidateSpeed * timeStep
: 0d;
double terminalLimitedAdvance = OrdinaryTerminalProbeFraction *
(input.PathUpperBoundS - candidateProgress);
double advance = Math.Min(Math.Max(iterationAdvance, lookaheadAdvance), terminalLimitedAdvance);
progress[index] = candidateProgress + advance;
}
progress[index] = Math.Max(previousNextProgress, progress[index]);
previousNextProgress = progress[index];
}
nextIterate = new LongitudinalCandidate(candidate.KnotTimes, progress, previous.U, previous.A, previous.J);
return true;
}
private static bool TryCreateFeasibilityEnvelopeIterate(LongitudinalPlanningInput input,
LongitudinalCandidate candidate, out LongitudinalCandidate nextIterate)
{
@@ -1161,52 +1454,27 @@ public sealed class SequentialLongitudinalOptimizer
result.PrimalResidual <= tolerance && result.DualResidual <= tolerance;
}
private static string CreateEnvelopeDiagnostic(PathSpeedLimit speedLimit, LongitudinalCandidate iterate,
LongitudinalCandidate candidate, int iteration)
private readonly struct TrustedSolveAttempt
{
int worstIndex = -1;
double worstExcess = double.NegativeInfinity;
for (int index = 0; index < candidate.S.Count; index++)
internal TrustedSolveAttempt(EmPlanningStatus status, LongitudinalCandidate candidate,
int solveCount, bool accepted, string failureReason)
{
if (!IsFinite(candidate.S[index]) || !IsFinite(candidate.U[index]))
continue;
double candidateProgress = Math.Max(0d, Math.Min(speedLimit.PathUpperBoundS, candidate.S[index]));
double limit = speedLimit.MaximumSpeedAt(candidateProgress);
double excess = candidate.U[index] - limit;
if (excess > worstExcess)
{
worstExcess = excess;
worstIndex = index;
}
Status = status;
Candidate = candidate;
SolveCount = solveCount;
Accepted = accepted;
FailureReason = failureReason ?? string.Empty;
}
if (worstIndex < 0)
return "";
return " Envelope iteration " + iteration + " used PathS=" + iterate.S[worstIndex] +
" and produced PathS=" + candidate.S[worstIndex] + " at its largest speed-envelope excess.";
}
private static double MaximumProgressOrSpeedChange(LongitudinalCandidate previous, LongitudinalCandidate current)
{
double maximum = 0d;
for (int index = 0; index < previous.S.Count; index++)
{
maximum = Math.Max(maximum, Math.Abs(current.S[index] - previous.S[index]));
maximum = Math.Max(maximum, Math.Abs(current.U[index] - previous.U[index]));
}
return maximum;
}
internal EmPlanningStatus Status { get; }
private static double RelativeObjectiveImprovement(double previous, double current)
{
return Math.Abs(previous - current) / Math.Max(1d, Math.Abs(previous));
}
internal LongitudinalCandidate Candidate { get; }
private static LongitudinalPlanningResult FallbackOrFailure(LongitudinalCandidate candidate,
EmPlanningStatus failureStatus, string failureReason)
{
return failureStatus == EmPlanningStatus.Cancelled || candidate == null
? Failed(failureStatus, failureReason)
: new LongitudinalPlanningResult(EmPlanningStatus.SuccessWithFallback, candidate, failureReason);
internal int SolveCount { get; }
internal bool Accepted { get; }
internal string FailureReason { get; }
}
private static LongitudinalPlanningResult Failed(EmPlanningStatus status, string reason)
@@ -3,6 +3,9 @@ using System.Threading;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 求解器中立的二次规划边界。实现必须将数值状态转换为 <see cref="QpSolveResult"/>,而不能把原生求解器对象泄漏给规划层。
/// </summary>
public interface IQpSolver
{
QpSolveResult Solve(
@@ -5,6 +5,9 @@ using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// QP 求解后的数值和诊断快照。调用方必须结合状态判断解向量是否可用,不能将失败输出当作候选轨迹。
/// </summary>
public sealed class QpSolveResult
{
public QpSolveResult(
@@ -5,6 +5,9 @@ using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 供横向或纵向规划器提交给 <see cref="IQpSolver"/> 的二次规划快照;矩阵维度与变量布局必须一致。
/// </summary>
public sealed class QuadraticProgram
{
public const double MaximumFiniteBound = 1e30d;
@@ -6,8 +6,16 @@ using MultiWheelC.TrajectoryPlanning.PathSmoothing;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 平滑参考路径中单一前进或倒车方向段的只读视图;EM 规划和投影不得跨越该段的精确换向边界。
/// </summary>
public sealed class DirectionSegmentView
{
/// <summary>
/// 创建一个已重基到局部 S=0 的方向段只读视图。
/// 参数:points 的 ArcLength 为 m 且方向必须一致,边界与 sourceStartArcLength 为 m;首点与起边界必须位于局部 S=0,终边界须在 1e-12 m 内匹配末点。
/// 返回:防御性复制的不可变段;索引、方向、边界或弧长不一致时引发异常。
/// </summary>
public DirectionSegmentView(
int segmentIndex,
TravelDirection direction,
@@ -48,17 +56,38 @@ public sealed class DirectionSegmentView
SourceStartArcLength = sourceStartArcLength;
}
/// <summary>
/// 在原始平滑路径方向段序列中的零基索引。
/// </summary>
public int SegmentIndex { get; }
/// <summary>
/// 全段唯一的行驶方向;投影和优化不得在本视图内切换方向。
/// </summary>
public TravelDirection Direction { get; }
/// <summary>
/// 局部弧长已重基、按 S(m)非递减排列的平滑参考点只读副本。
/// </summary>
public IReadOnlyList<SmoothedPathPoint> Points { get; }
/// <summary>
/// 局部 S=0 的起始边界,含换向离开语义(若有)。
/// </summary>
public ReferenceBoundary StartBoundary { get; }
/// <summary>
/// 局部段末端的边界,含目标或换向接近语义(若有)。
/// </summary>
public ReferenceBoundary EndBoundary { get; }
/// <summary>
/// 本段局部 S=0 在原始平滑路径中的累计弧长,单位 m。
/// </summary>
public double SourceStartArcLength { get; }
/// <summary>
/// 本段从局部 S=0 到终边界的长度,单位 m。
/// </summary>
public double LengthMeters { get { return EndBoundary.SegmentLocalS; } }
}
@@ -4,9 +4,16 @@ using MultiWheelC.TrajectoryPlanning.CoarsePath;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Reference-distance terminal chosen before LS without crossing the current direction segment.</summary>
/// <summary>
/// 在纵横向优化前选定的参考距离终端,保证不会跨越当前方向段。
/// 所有参考站量以当前段局部 S(m)表示;边界类型和纵向模式明确滚动延续、接近停车或精确停车语义。
/// </summary>
public sealed class PlanningHorizonSelection
{
/// <summary>
/// 创建仅供本程序集发布的窗口与停车边界选择。
/// 参数:两个参考站均为局部 Sm),boundary/terminal/mode 为已判定枚举;hasStopBoundary 表示终端是否受真实停车边界约束。
/// </summary>
internal PlanningHorizonSelection(double windowEndReferenceS, EmBoundaryType windowEndBoundaryType,
EmTerminalType terminalType, EmLongitudinalMode longitudinalMode,
double stopBoundaryReferenceS, bool hasStopBoundary)
@@ -19,25 +26,58 @@ public sealed class PlanningHorizonSelection
HasStopBoundary = hasStopBoundary;
}
/// <summary>
/// 本次优化窗口末端的局部参考弧长 S,单位 m。
/// </summary>
public double WindowEndReferenceS { get; }
/// <summary>
/// 规划输出的终端参考弧长 S,当前与窗口末端相同,单位 m。
/// </summary>
public double TerminalReferenceS => WindowEndReferenceS;
/// <summary>
/// 窗口末端的边界语义;中途截断时为滚动安全停车边界。
/// </summary>
public EmBoundaryType WindowEndBoundaryType { get; }
/// <summary>
/// 供终端约束使用的目标、换向或滚动终端类型。
/// </summary>
public EmTerminalType TerminalType { get; }
/// <summary>
/// 纵向候选应滚动延续、接近停车边界还是在边界精确停车的模式。
/// </summary>
public EmLongitudinalMode LongitudinalMode { get; }
/// <summary>
/// 实际目标或换向停车边界的局部参考弧长 S,单位 m;无此边界时仍保存段末。
/// </summary>
public double StopBoundaryReferenceS { get; }
/// <summary>
/// 是否存在必须在当前方向段内处理的目标或换向停车边界。
/// </summary>
public bool HasStopBoundary { get; }
}
/// <summary>
/// 根据段边界、初始纵向状态和调度窗口选择 EM 优化地平线。
/// 输入速度为 m/s、加速度为 m/s²、距离为 m;无效输入、越界初态或不足以停车的段会以明确状态拒绝。
/// </summary>
public sealed class PlanningHorizonSelector
{
/// <summary>
/// 段终点、初态限值和停车距离比较使用的局部 S/距离容差,单位 m。
/// </summary>
private const double BoundaryTolerance = 1e-8d;
/// <summary>
/// 为当前方向段选择不跨界的参考距离窗口和纵向终端模式。
/// 参数:currentSegmentReferenceS 为 m,初速为 m/s,初加速度为 m/s²,configuration 提供 m、s 制限值;planningScope 必须为定义的范围枚举。
/// 返回:成功时给出 selection;配置/初态非法返回 InvalidInput,停车距离越段返回 StoppingDistanceInsufficient,非法全段终边界返回 InvalidReferencePath。
/// </summary>
public EmPlanningStatus Select(DirectionSegmentView segment, double currentSegmentReferenceS,
double initialProgressSpeedMetersPerSecond, double initialAccelerationMetersPerSecondSquared,
EmPlanningScope planningScope, EmPlannerConfiguration configuration, out PlanningHorizonSelection selection,
@@ -138,11 +178,19 @@ public sealed class PlanningHorizonSelector
return EmPlanningStatus.Success;
}
/// <summary>
/// 判断边界是否要求车辆在当前段内停止。
/// 参数:boundaryType 为边界枚举;返回:Goal 或 GearSwitchApproach 时为 true,其余边界允许滚动延续。
/// </summary>
private static bool IsStopBoundary(EmBoundaryType boundaryType)
{
return boundaryType == EmBoundaryType.Goal || boundaryType == EmBoundaryType.GearSwitchApproach;
}
/// <summary>
/// 将参考边界语义映射为纵向终端类型。
/// 参数:boundaryType 为边界枚举;返回:目标、任一换向边界分别映射到 Goal、GearSwitch,其余映射为 RollingSafetyStop。
/// </summary>
private static EmTerminalType ToTerminalType(EmBoundaryType boundaryType)
{
return boundaryType == EmBoundaryType.Goal
@@ -152,11 +200,19 @@ public sealed class PlanningHorizonSelector
: EmTerminalType.RollingSafetyStop;
}
/// <summary>
/// 判断配置中的正量是否可用于时间、距离或动力学计算。
/// 参数:value 为对应单位的标量;返回:仅有限且严格大于零时为 true。
/// </summary>
private static bool IsPositiveFinite(double value)
{
return IsFinite(value) && value > 0d;
}
/// <summary>
/// 判断规划输入是否为有限实数。
/// 参数:value 为任意标量;返回:NaN 与无穷均返回 false。
/// </summary>
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
@@ -2,8 +2,16 @@ using System;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 方向段的不可变参考边界标识,携带局部与源路径弧长以及边界语义。
/// 两个弧长均以 m 计;相等性仅代表同一段、同一局部位置和同一类型,不以源弧长参与判等。
/// </summary>
public sealed class ReferenceBoundary : IEquatable<ReferenceBoundary>
{
/// <summary>
/// 创建已验证的参考边界。
/// 参数:segmentIndex 必须非负,segmentLocalS 与 sourceArcLength 为非负有限 m 制弧长,boundaryType 必须是已定义枚举;非法输入会引发异常。
/// </summary>
public ReferenceBoundary(int segmentIndex, double segmentLocalS, EmBoundaryType boundaryType, double sourceArcLength)
{
if (segmentIndex < 0)
@@ -21,22 +29,46 @@ public sealed class ReferenceBoundary : IEquatable<ReferenceBoundary>
SourceArcLength = sourceArcLength;
}
/// <summary>
/// 边界所属方向段在路径段序列中的零基索引。
/// </summary>
public int SegmentIndex { get; }
/// <summary>
/// 边界相对该方向段起点的局部参考弧长 S,单位 m。
/// </summary>
public double SegmentLocalS { get; }
/// <summary>
/// 边界的目标、换向或普通边界语义。
/// </summary>
public EmBoundaryType BoundaryType { get; }
/// <summary>
/// 边界在未重基原始平滑路径中的累计弧长,单位 m。
/// </summary>
public double SourceArcLength { get; }
/// <summary>
/// 比较两个边界在优化语义上是否相同。
/// 参数:other 可为 null;返回:仅段索引、局部 S(精确 double 比较)和边界类型都相同时为 true,源弧长不参与比较。
/// </summary>
public bool Equals(ReferenceBoundary other)
{
return other != null && SegmentIndex == other.SegmentIndex && SegmentLocalS.Equals(other.SegmentLocalS) &&
BoundaryType == other.BoundaryType;
}
/// <summary>
/// 比较任意对象是否为同一参考边界。
/// 参数:obj 可为空或非边界对象;返回:仅可转换为 <see cref="ReferenceBoundary"/> 且满足强类型相等性时为 true。
/// </summary>
public override bool Equals(object obj) { return Equals(obj as ReferenceBoundary); }
/// <summary>
/// 计算与边界相等性一致的哈希值。
/// 返回:由段索引、局部 S 与边界类型组成的哈希;源弧长不参与以保持与 <see cref="Equals(ReferenceBoundary)"/> 一致。
/// </summary>
public override int GetHashCode()
{
unchecked
@@ -6,8 +6,15 @@ using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 当前方向段内供一次 EM 优化使用的参考路径切片,保留全局参考站与切片局部弧长的对应关系。
/// </summary>
public sealed class ReferenceHorizonSlice
{
/// <summary>
/// 创建一个以当前段起点为基准的参考地平线切片。
/// 参数:points 为局部 Sm)有序的参考点,terminalBoundary 必须属于 segment;返回:points 的防御性只读副本,空输入或边界不匹配会被拒绝。
/// </summary>
public ReferenceHorizonSlice(DirectionSegmentView segment, IReadOnlyList<SmoothedPathPoint> points,
ReferenceBoundary terminalBoundary)
{
@@ -25,17 +32,38 @@ public sealed class ReferenceHorizonSlice
TerminalBoundary = terminalBoundary;
}
/// <summary>
/// 切片所属且不会跨越的单一方向段。
/// </summary>
public DirectionSegmentView Segment { get; }
/// <summary>
/// 从段起点至精确终端点的参考点只读副本,局部 ArcLength 单位为 m。
/// </summary>
public IReadOnlyList<SmoothedPathPoint> Points { get; }
/// <summary>
/// 切片末端的精确参考边界;中途截断时具有滚动安全停车语义。
/// </summary>
public ReferenceBoundary TerminalBoundary { get; }
}
/// <summary>
/// 按局部参考距离截取单一方向段,并在请求终端精确插值参考点。
/// 切片只包含本段坐标;S、原始弧长与 X/Y 使用 m,航向使用 rad,终端附近以 1e-12 m 判定相等。
/// </summary>
public static class ReferenceHorizonSlicer
{
/// <summary>
/// 端点复制、精确节点匹配和原段终点判定使用的局部 S 容差,单位 m。
/// </summary>
private const double Epsilon = 1e-12d;
/// <summary>
/// 将方向段截取到请求的局部终端 S,并追加该终端的精确点。
/// 参数:requestedEndSegmentLocalS 为非负有限 m 制局部 S;超过段长的请求夹紧到段末。
/// 返回:段末保留原边界,中途截断创建 RollingSafetyStop 边界;空段或非法 S 会引发异常。
/// </summary>
public static ReferenceHorizonSlice Slice(DirectionSegmentView segment, double requestedEndSegmentLocalS)
{
if (segment == null)
@@ -60,6 +88,10 @@ public static class ReferenceHorizonSlicer
return new ReferenceHorizonSlice(segment, points, terminal);
}
/// <summary>
/// 取得给定局部 S 的精确已有点或其相邻点之间的插值点。
/// 参数:terminalS 为已夹紧的 m 制局部 S;返回:与节点相差不超过 1e-12 m 时复用节点,否则在正跨度内线性插值。
/// </summary>
private static SmoothedPathPoint GetExactTerminalPoint(DirectionSegmentView segment, double terminalS)
{
for (int index = 0; index < segment.Points.Count; index++)
@@ -76,6 +108,10 @@ public static class ReferenceHorizonSlicer
return segment.Points[segment.Points.Count - 1];
}
/// <summary>
/// 在线性弧长区间内插值一个终端平滑路径点。
/// 参数:lower/upper 的 ArcLength 为 mlocalS 为其间的 m 制位置;返回:位置、展开航向、曲率和净空线性插值,标记为 Interpolated。
/// </summary>
private static SmoothedPathPoint Interpolate(SmoothedPathPoint lower, SmoothedPathPoint upper, double localS)
{
double interval = upper.ArcLength - lower.ArcLength;
@@ -98,6 +134,10 @@ public static class ReferenceHorizonSlicer
SmoothedPathPointSource.Interpolated);
}
/// <summary>
/// 计算同单位标量的线性插值。
/// 参数:lower、upper 为端点,fraction 为无单位比例;返回:未夹紧的线性结果。
/// </summary>
private static double Interpolate(double lower, double upper, double fraction)
{
return lower + (upper - lower) * fraction;
@@ -5,8 +5,16 @@ using MultiWheelC.TrajectoryPlanning.PathSmoothing;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// 将已发布的平滑参考路径拆分为不会跨越换向点的局部方向段视图。
/// 每个输出段以局部 S=0 重基(m),同时保留原始累计弧长(m)及目标/换向边界语义;索引覆盖或弧长不连续会被拒绝。
/// </summary>
public static class ReferencePathSegmenter
{
/// <summary>
/// 从已验证的平滑路径及其方向分段创建局部方向段视图。
/// 参数:referencePath 必须含完整 Path 和 Segments,路径弧长为有限 m 制量;返回:按源段索引排列的只读视图,任何漏覆盖、方向或严格递增弧长错误均引发异常。
/// </summary>
public static IReadOnlyList<DirectionSegmentView> Create(PathSmoothingResult referencePath)
{
if (referencePath == null || referencePath.Path == null || referencePath.Segments == null ||
@@ -65,6 +73,10 @@ public static class ReferencePathSegmenter
return new ReadOnlyCollection<DirectionSegmentView>(result);
}
/// <summary>
/// 复制一个源参考点并将其弧长重基到当前方向段局部坐标。
/// 参数:source 保存世界 X/Y(m)、航向(rad)和曲率量,localS 为非负段局部 m 制弧长;返回:除 ArcLength 外保持源点几何和来源不变的副本。
/// </summary>
internal static SmoothedPathPoint CloneAtLocalS(SmoothedPathPoint source, double localS)
{
return new SmoothedPathPoint(