diff --git a/ClumsyPilot/ClumsyPilot.csproj b/ClumsyPilot/ClumsyPilot.csproj
index cbff9fa..21e8698 100644
--- a/ClumsyPilot/ClumsyPilot.csproj
+++ b/ClumsyPilot/ClumsyPilot.csproj
@@ -23,6 +23,9 @@
+
+
+
diff --git a/ClumsyPilot/Control/Abstractions/ILateralController.cs b/ClumsyPilot/Control/Abstractions/ILateralController.cs
new file mode 100644
index 0000000..25fbcaf
--- /dev/null
+++ b/ClumsyPilot/Control/Abstractions/ILateralController.cs
@@ -0,0 +1,19 @@
+namespace MultiWheelC.Control.Abstractions
+{
+ ///
+ /// 定义Stanley、LQR和MPC等车体中心横向控制器的统一接口。
+ ///
+ public interface ILateralController
+ {
+ ///
+ /// 根据本周期车辆状态和轨迹误差计算车体中心目标曲率。
+ ///
+ LateralControlCommand Compute(
+ PathTrackingContext context);
+
+ ///
+ /// 清除控制器跨周期状态,以便开始新轨迹或异常恢复后重新运行。
+ ///
+ void Reset();
+ }
+}
diff --git a/ClumsyPilot/Control/Abstractions/ILongitudinalController.cs b/ClumsyPilot/Control/Abstractions/ILongitudinalController.cs
new file mode 100644
index 0000000..810eda3
--- /dev/null
+++ b/ClumsyPilot/Control/Abstractions/ILongitudinalController.cs
@@ -0,0 +1,19 @@
+namespace MultiWheelC.Control.Abstractions
+{
+ ///
+ /// 定义根据参考速度和实际纵向速度生成底盘命令速度的统一接口。
+ ///
+ public interface ILongitudinalController
+ {
+ ///
+ /// 根据本周期速度目标、速度反馈和时间间隔计算有符号底盘命令速度。
+ ///
+ double ComputeSpeedMetersPerSecond(
+ PathTrackingContext context);
+
+ ///
+ /// 清除积分、历史误差和其他跨周期状态,以便安全开始新的控制过程。
+ ///
+ void Reset();
+ }
+}
diff --git a/ClumsyPilot/Control/Abstractions/LateralControlCommand.cs b/ClumsyPilot/Control/Abstractions/LateralControlCommand.cs
new file mode 100644
index 0000000..cb170c9
--- /dev/null
+++ b/ClumsyPilot/Control/Abstractions/LateralControlCommand.cs
@@ -0,0 +1,76 @@
+using System;
+
+namespace MultiWheelC.Control.Abstractions
+{
+ ///
+ /// 表示横向控制器生成的前、后GCP目标转角,单位为rad,逆时针为正。
+ ///
+ public readonly struct LateralControlCommand
+ {
+ ///
+ /// 创建前、后GCP目标转角命令。
+ ///
+ public LateralControlCommand(
+ double frontGcpAngleRadians,
+ double rearGcpAngleRadians)
+ {
+ EnsureFinite(
+ frontGcpAngleRadians,
+ nameof(frontGcpAngleRadians));
+ EnsureFinite(
+ rearGcpAngleRadians,
+ nameof(rearGcpAngleRadians));
+
+ FrontGcpAngleRadians =
+ frontGcpAngleRadians;
+ RearGcpAngleRadians =
+ rearGcpAngleRadians;
+ }
+
+ ///
+ /// 获取前GCP目标转角,单位为rad,逆时针为正。
+ ///
+ public double FrontGcpAngleRadians { get; }
+
+ ///
+ /// 获取后GCP目标转角,单位为rad,逆时针为正。
+ ///
+ public double RearGcpAngleRadians { get; }
+
+ ///
+ /// 获取前后GCP的共同转角分量,主要用于横向平移修正。
+ ///
+ public double CommonAngleRadians =>
+ (FrontGcpAngleRadians +
+ RearGcpAngleRadians) / 2.0;
+
+ ///
+ /// 获取前后GCP的差动转角分量,主要用于曲率前馈和航向修正。
+ ///
+ public double DifferentialAngleRadians =>
+ (FrontGcpAngleRadians -
+ RearGcpAngleRadians) / 2.0;
+
+ ///
+ /// 创建前后GCP均保持车头方向的直线命令。
+ ///
+ public static LateralControlCommand Straight =>
+ new LateralControlCommand(0.0, 0.0);
+
+ ///
+ /// 检查GCP目标转角是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "GCP目标转角必须是有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Control/Abstractions/PathTrackingContext.cs b/ClumsyPilot/Control/Abstractions/PathTrackingContext.cs
new file mode 100644
index 0000000..578468a
--- /dev/null
+++ b/ClumsyPilot/Control/Abstractions/PathTrackingContext.cs
@@ -0,0 +1,126 @@
+using System;
+using MultiWheelC.StateEstimation;
+using MultiWheelC.Trajectory;
+
+namespace MultiWheelC.Control.Abstractions
+{
+ ///
+ /// 保存一次轨迹跟踪控制周期使用的车辆状态、轨迹投影和真实时间间隔。
+ ///
+ public readonly struct PathTrackingContext
+ {
+ ///
+ /// 创建横向和纵向控制器共享的只读控制输入快照。
+ ///
+ public PathTrackingContext(
+ VehicleState vehicleState,
+ TrajectoryProjection projection,
+ double referenceSpeedMetersPerSecond,
+ double deltaTimeSeconds)
+ {
+ EnsureFinite(
+ referenceSpeedMetersPerSecond,
+ nameof(referenceSpeedMetersPerSecond));
+ EnsureFinitePositive(
+ deltaTimeSeconds,
+ nameof(deltaTimeSeconds));
+
+ VehicleState = vehicleState;
+ Projection = projection;
+ ReferenceSpeedMetersPerSecond =
+ referenceSpeedMetersPerSecond;
+ DeltaTimeSeconds = deltaTimeSeconds;
+ }
+
+ ///
+ /// 获取本周期经过校验的实际车辆位姿和速度状态。
+ ///
+ public VehicleState VehicleState { get; }
+
+ ///
+ /// 获取实际车体中心投影到参考轨迹后得到的参考状态和跟踪误差。
+ ///
+ public TrajectoryProjection Projection { get; }
+
+ ///
+ /// 获取本次控制计算距离上次计算的真实时间间隔,单位为s。
+ ///
+ public double DeltaTimeSeconds { get; }
+
+ ///
+ /// 获取轨迹投影点要求的有符号参考速度,单位为m/s。
+ ///
+ public double ReferenceSpeedMetersPerSecond { get; }
+
+ ///
+ /// 获取车辆在车体X轴方向上的实际纵向速度,单位为m/s。
+ ///
+ public double ActualLongitudinalSpeedMetersPerSecond =>
+ VehicleState.TwistInBody
+ .VxMetersPerSecond;
+
+ ///
+ /// 获取轨迹投影点的参考曲率,单位为1/m,左转为正。
+ ///
+ public double ReferenceCurvaturePerMeter =>
+ Projection.ReferencePoint
+ .CurvaturePerMeter;
+
+ ///
+ /// 获取参考轨迹相对车辆的有符号横向误差,单位为m,轨迹在车辆左侧时为正。
+ ///
+ public double LateralErrorMeters =>
+ Projection.LateralErrorMeters;
+
+ ///
+ /// 获取参考航向减实际车体航向的最短角差,单位为rad,逆时针为正。
+ ///
+ public double HeadingErrorRadians =>
+ Projection.HeadingErrorRadians;
+
+ ///
+ /// 获取当前投影位置沿参考轨迹到终点的剩余距离,单位为m。
+ ///
+ public double RemainingDistanceMeters =>
+ Projection.RemainingDistanceMeters;
+
+ ///
+ /// 获取实际速度是否已经由至少两个连续有效定位样本估算得到。
+ ///
+ public bool HasValidVelocityEstimate =>
+ VehicleState.HasValidVelocityEstimate;
+
+ ///
+ /// 检查控制周期是否为正有限值。
+ ///
+ private static void EnsureFinitePositive(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value) ||
+ value <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "轨迹跟踪控制周期必须是正有限值。");
+ }
+ }
+
+ ///
+ /// 检查控制参考速度是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "轨迹跟踪参考速度必须是有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Control/Allocation/GcpCommandAllocator.cs b/ClumsyPilot/Control/Allocation/GcpCommandAllocator.cs
new file mode 100644
index 0000000..77ccdca
--- /dev/null
+++ b/ClumsyPilot/Control/Allocation/GcpCommandAllocator.cs
@@ -0,0 +1,104 @@
+using System;
+using MultiWheelC.Control.Abstractions;
+
+namespace MultiWheelC.Control.Allocation
+{
+ ///
+ /// 独立限制前后GCP目标转角并与纵向速度组合成底盘运动命令。
+ ///
+ public sealed class GcpCommandAllocator
+ {
+ ///
+ /// 创建使用指定前后GCP最大转角的命令分配器。
+ ///
+ public GcpCommandAllocator(double maximumGcpAngleRadians)
+ {
+ EnsureFinitePositive(
+ maximumGcpAngleRadians,
+ nameof(maximumGcpAngleRadians));
+
+ if (maximumGcpAngleRadians >= Math.PI / 2.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(maximumGcpAngleRadians),
+ "最大GCP转角必须小于π/2。");
+ }
+
+ MaximumGcpAngleRadians = maximumGcpAngleRadians;
+ }
+
+ ///
+ /// 获取前后GCP允许的最大转角绝对值,单位为rad。
+ ///
+ public double MaximumGcpAngleRadians { get; }
+
+ ///
+ /// 将纵向速度和前后GCP转角组合为底盘运动命令。
+ ///
+ public GcpMotionCommand Allocate(
+ double speedMetersPerSecond,
+ LateralControlCommand lateralCommand)
+ {
+ EnsureFinite(
+ speedMetersPerSecond,
+ nameof(speedMetersPerSecond));
+
+ var frontAngleRadians = ClampSymmetric(
+ lateralCommand.FrontGcpAngleRadians,
+ MaximumGcpAngleRadians);
+ var rearAngleRadians = ClampSymmetric(
+ lateralCommand.RearGcpAngleRadians,
+ MaximumGcpAngleRadians);
+
+ return new GcpMotionCommand(
+ speedMetersPerSecond,
+ frontAngleRadians,
+ rearAngleRadians);
+ }
+
+ ///
+ /// 将数值按正负对称方式限制在指定绝对值内。
+ ///
+ private static double ClampSymmetric(
+ double value,
+ double maximumAbsoluteValue)
+ {
+ return Math.Max(
+ -maximumAbsoluteValue,
+ Math.Min(maximumAbsoluteValue, value));
+ }
+
+ ///
+ /// 检查参数是否为正有限值。
+ ///
+ private static void EnsureFinitePositive(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "GCP分配参数必须是正有限值。");
+ }
+ }
+
+ ///
+ /// 检查参数或命令是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "GCP分配参数和命令必须是有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Control/Allocation/GcpMotionCommand.cs b/ClumsyPilot/Control/Allocation/GcpMotionCommand.cs
new file mode 100644
index 0000000..f36ce0e
--- /dev/null
+++ b/ClumsyPilot/Control/Allocation/GcpMotionCommand.cs
@@ -0,0 +1,67 @@
+using System;
+
+namespace MultiWheelC.Control.Allocation
+{
+ ///
+ /// 表示发送给旧版多舵轮四轮解算前的有符号速度和前后GCP角度命令。
+ ///
+ public readonly struct GcpMotionCommand
+ {
+ ///
+ /// 创建统一使用m/s和rad的前后几何控制点运动命令。
+ ///
+ public GcpMotionCommand(
+ double speedMetersPerSecond,
+ double frontAngleRadians,
+ double rearAngleRadians)
+ {
+ EnsureFinite(
+ speedMetersPerSecond,
+ nameof(speedMetersPerSecond));
+ EnsureFinite(
+ frontAngleRadians,
+ nameof(frontAngleRadians));
+ EnsureFinite(
+ rearAngleRadians,
+ nameof(rearAngleRadians));
+
+ SpeedMetersPerSecond =
+ speedMetersPerSecond;
+ FrontAngleRadians =
+ frontAngleRadians;
+ RearAngleRadians =
+ rearAngleRadians;
+ }
+
+ ///
+ /// 获取准备交给底盘的有符号纵向速度,单位为m/s,正值表示前进。
+ ///
+ public double SpeedMetersPerSecond { get; }
+
+ ///
+ /// 获取前几何控制点相对车体X轴的目标方向,单位为rad,逆时针为正。
+ ///
+ public double FrontAngleRadians { get; }
+
+ ///
+ /// 获取后几何控制点相对车体X轴的目标方向,单位为rad,逆时针为正。
+ ///
+ public double RearAngleRadians { get; }
+
+ ///
+ /// 检查底盘中间命令是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "GCP运动命令必须由有限值组成。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Control/Common/PidController.cs b/ClumsyPilot/Control/Common/PidController.cs
new file mode 100644
index 0000000..9d81320
--- /dev/null
+++ b/ClumsyPilot/Control/Common/PidController.cs
@@ -0,0 +1,307 @@
+using System;
+
+namespace MultiWheelC.Control.Common
+{
+ ///
+ /// 使用真实控制周期计算带积分限幅、输出限幅和抗饱和的通用有状态PID输出。
+ ///
+ public sealed class PidController
+ {
+ private double _integralState;
+ private double _previousError;
+ private double _previousMeasurement;
+ private bool _hasPreviousSample;
+
+ ///
+ /// 创建具有指定增益、积分输出限制和微分形式的PID控制器。
+ ///
+ public PidController(
+ double proportionalGain,
+ double integralGainPerSecond,
+ double derivativeGainSeconds,
+ double maximumIntegralOutput,
+ bool derivativeOnMeasurement = true)
+ {
+ EnsureFiniteNonNegative(
+ proportionalGain,
+ nameof(proportionalGain));
+ EnsureFiniteNonNegative(
+ integralGainPerSecond,
+ nameof(integralGainPerSecond));
+ EnsureFiniteNonNegative(
+ derivativeGainSeconds,
+ nameof(derivativeGainSeconds));
+ EnsureFiniteNonNegative(
+ maximumIntegralOutput,
+ nameof(maximumIntegralOutput));
+
+ ProportionalGain = proportionalGain;
+ IntegralGainPerSecond = integralGainPerSecond;
+ DerivativeGainSeconds = derivativeGainSeconds;
+ MaximumIntegralOutput = maximumIntegralOutput;
+ DerivativeOnMeasurement = derivativeOnMeasurement;
+ }
+
+ ///
+ /// 获取比例增益。
+ ///
+ public double ProportionalGain { get; }
+
+ ///
+ /// 获取积分增益,单位为1/s。
+ ///
+ public double IntegralGainPerSecond { get; }
+
+ ///
+ /// 获取微分增益,单位为s。
+ ///
+ public double DerivativeGainSeconds { get; }
+
+ ///
+ /// 获取积分项允许产生的最大输出绝对值。
+ ///
+ public double MaximumIntegralOutput { get; }
+
+ ///
+ /// 获取微分项是否作用于测量值,以避免设定值变化产生微分冲击。
+ ///
+ public bool DerivativeOnMeasurement { get; }
+
+ ///
+ /// 获取最近一次设定值减测量值的误差。
+ ///
+ public double LastError { get; private set; }
+
+ ///
+ /// 获取最近一次比例项输出。
+ ///
+ public double LastProportionalOutput { get; private set; }
+
+ ///
+ /// 获取最近一次积分项输出。
+ ///
+ public double LastIntegralOutput { get; private set; }
+
+ ///
+ /// 获取最近一次微分项输出。
+ ///
+ public double LastDerivativeOutput { get; private set; }
+
+ ///
+ /// 获取最近一次经过输出范围限制后的PID输出。
+ ///
+ public double LastOutput { get; private set; }
+
+ ///
+ /// 根据设定值、测量值、真实时间间隔和本周期输出范围更新PID。
+ ///
+ public double Update(
+ double setPoint,
+ double measurement,
+ double deltaTimeSeconds,
+ double minimumOutput,
+ double maximumOutput)
+ {
+ EnsureFinite(setPoint, nameof(setPoint));
+ EnsureFinite(measurement, nameof(measurement));
+ EnsureFinitePositive(
+ deltaTimeSeconds,
+ nameof(deltaTimeSeconds));
+ EnsureFinite(minimumOutput, nameof(minimumOutput));
+ EnsureFinite(maximumOutput, nameof(maximumOutput));
+
+ if (minimumOutput > maximumOutput)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(minimumOutput),
+ "PID最小输出不能大于最大输出。");
+ }
+
+ var error = setPoint - measurement;
+ var proportionalOutput =
+ ProportionalGain * error;
+ var derivativeOutput = CalculateDerivativeOutput(
+ error,
+ measurement,
+ deltaTimeSeconds);
+
+ var candidateIntegralState =
+ _integralState +
+ error * deltaTimeSeconds;
+ var integralOutput = CalculateIntegralOutput(
+ candidateIntegralState);
+
+ // 同步截断积分状态本身,避免积分输出虽已限幅、内部状态仍继续增长。
+ candidateIntegralState =
+ IntegralGainPerSecond > 0.0 &&
+ MaximumIntegralOutput > 0.0
+ ? integralOutput /
+ IntegralGainPerSecond
+ : 0.0;
+
+ var unlimitedOutput =
+ proportionalOutput +
+ integralOutput +
+ derivativeOutput;
+ var output = Clamp(
+ unlimitedOutput,
+ minimumOutput,
+ maximumOutput);
+
+ // 根据实际允许输出反算积分项,避免执行器饱和期间继续积累误差。
+ if (IntegralGainPerSecond > 0.0 &&
+ output != unlimitedOutput)
+ {
+ integralOutput = Clamp(
+ output -
+ proportionalOutput -
+ derivativeOutput,
+ -MaximumIntegralOutput,
+ MaximumIntegralOutput);
+ candidateIntegralState =
+ integralOutput /
+ IntegralGainPerSecond;
+ }
+
+ _integralState =
+ IntegralGainPerSecond > 0.0 &&
+ MaximumIntegralOutput > 0.0
+ ? candidateIntegralState
+ : 0.0;
+ _previousError = error;
+ _previousMeasurement = measurement;
+ _hasPreviousSample = true;
+
+ LastError = error;
+ LastProportionalOutput = proportionalOutput;
+ LastIntegralOutput = integralOutput;
+ LastDerivativeOutput = derivativeOutput;
+ LastOutput = output;
+
+ return output;
+ }
+
+ ///
+ /// 清除积分、历史采样和最近一次PID诊断输出。
+ ///
+ public void Reset()
+ {
+ _integralState = 0.0;
+ _previousError = 0.0;
+ _previousMeasurement = 0.0;
+ _hasPreviousSample = false;
+ LastError = 0.0;
+ LastProportionalOutput = 0.0;
+ LastIntegralOutput = 0.0;
+ LastDerivativeOutput = 0.0;
+ LastOutput = 0.0;
+ }
+
+ ///
+ /// 使用测量值微分或误差微分计算本周期微分项输出。
+ ///
+ private double CalculateDerivativeOutput(
+ double error,
+ double measurement,
+ double deltaTimeSeconds)
+ {
+ if (!_hasPreviousSample ||
+ DerivativeGainSeconds <= 0.0)
+ {
+ return 0.0;
+ }
+
+ if (DerivativeOnMeasurement)
+ {
+ return -DerivativeGainSeconds *
+ (measurement - _previousMeasurement) /
+ deltaTimeSeconds;
+ }
+
+ return DerivativeGainSeconds *
+ (error - _previousError) /
+ deltaTimeSeconds;
+ }
+
+ ///
+ /// 根据积分状态计算经过绝对值限制的积分项输出。
+ ///
+ private double CalculateIntegralOutput(
+ double integralState)
+ {
+ if (IntegralGainPerSecond <= 0.0 ||
+ MaximumIntegralOutput <= 0.0)
+ {
+ return 0.0;
+ }
+
+ return Clamp(
+ IntegralGainPerSecond * integralState,
+ -MaximumIntegralOutput,
+ MaximumIntegralOutput);
+ }
+
+ ///
+ /// 将数值限制在指定闭区间内。
+ ///
+ private static double Clamp(
+ double value,
+ double minimum,
+ double maximum)
+ {
+ return Math.Max(
+ minimum,
+ Math.Min(maximum, value));
+ }
+
+ ///
+ /// 检查参数是否为正有限值。
+ ///
+ private static void EnsureFinitePositive(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "PID时间间隔必须是正有限值。");
+ }
+ }
+
+ ///
+ /// 检查参数是否为非负有限值。
+ ///
+ private static void EnsureFiniteNonNegative(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "PID增益和积分输出限幅必须是非负有限值。");
+ }
+ }
+
+ ///
+ /// 检查参数是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "PID参数和输入必须是有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Control/Execution/GcpCommandExecutor.cs b/ClumsyPilot/Control/Execution/GcpCommandExecutor.cs
new file mode 100644
index 0000000..ee21a84
--- /dev/null
+++ b/ClumsyPilot/Control/Execution/GcpCommandExecutor.cs
@@ -0,0 +1,177 @@
+using System;
+using MultiWheelC.Control.Allocation;
+using MyParking.Shared;
+
+namespace MultiWheelC.Control.Execution
+{
+ ///
+ /// 将SI单位的GCP运动命令安全转换为现有多舵轮底盘调用。
+ ///
+ public sealed class GcpCommandExecutor
+ {
+ private const double StopSpeedDeadbandMetersPerSecond =
+ 1e-6;
+
+ private readonly MultiWheelChassisAdapter _chassisAdapter;
+ private double _lastFrontAngleRadians;
+ private double _lastRearAngleRadians;
+
+ ///
+ /// 创建绑定指定单车底盘适配器的GCP命令执行器。
+ ///
+ public GcpCommandExecutor(
+ MultiWheelChassisAdapter chassisAdapter,
+ double maximumGcpAngleRateRadiansPerSecond =
+ 10.0 * Math.PI / 180.0)
+ {
+ _chassisAdapter = chassisAdapter ??
+ throw new ArgumentNullException(
+ nameof(chassisAdapter));
+ EnsureFinitePositive(
+ maximumGcpAngleRateRadiansPerSecond,
+ nameof(maximumGcpAngleRateRadiansPerSecond));
+
+ MaximumGcpAngleRateRadiansPerSecond =
+ maximumGcpAngleRateRadiansPerSecond;
+ }
+
+ ///
+ /// 获取执行器绑定的车辆编号。
+ ///
+ public int VehicleId =>
+ _chassisAdapter.VehicleId;
+
+ ///
+ /// 获取前后GCP目标角度允许的最大变化率,单位为rad/s。
+ ///
+ public double MaximumGcpAngleRateRadiansPerSecond { get; }
+
+ ///
+ /// 获取最近一次控制器请求的未限速GCP命令。
+ ///
+ public GcpMotionCommand? LastRequestedCommand { get; private set; }
+
+ ///
+ /// 获取最近一次经过GCP角速度限制后实际发送给底盘的命令。
+ ///
+ public GcpMotionCommand? LastSentCommand { get; private set; }
+
+ ///
+ /// 获取最近一次旧版底盘运动分解失败原因。
+ ///
+ public string LastFailureReason { get; private set; } =
+ string.Empty;
+
+ ///
+ /// 使用真实控制周期执行一条GCP命令,并在分解失败时保持停车。
+ ///
+ public bool Execute(
+ GcpMotionCommand command,
+ double deltaTimeSeconds)
+ {
+ EnsureFinitePositive(
+ deltaTimeSeconds,
+ nameof(deltaTimeSeconds));
+ LastRequestedCommand = command;
+
+ if (Math.Abs(command.SpeedMetersPerSecond) <=
+ StopSpeedDeadbandMetersPerSecond)
+ {
+ Stop();
+ LastSentCommand = new GcpMotionCommand(
+ 0.0,
+ _lastFrontAngleRadians,
+ _lastRearAngleRadians);
+ return true;
+ }
+
+ var maximumAngleChangeRadians =
+ MaximumGcpAngleRateRadiansPerSecond *
+ deltaTimeSeconds;
+ _lastFrontAngleRadians = MoveTowards(
+ _lastFrontAngleRadians,
+ command.FrontAngleRadians,
+ maximumAngleChangeRadians);
+ _lastRearAngleRadians = MoveTowards(
+ _lastRearAngleRadians,
+ command.RearAngleRadians,
+ maximumAngleChangeRadians);
+
+ var limitedCommand = new GcpMotionCommand(
+ command.SpeedMetersPerSecond,
+ _lastFrontAngleRadians,
+ _lastRearAngleRadians);
+ LastSentCommand = limitedCommand;
+
+ var success = _chassisAdapter.SendGcpMotion(
+ limitedCommand.SpeedMetersPerSecond,
+ limitedCommand.FrontAngleRadians,
+ limitedCommand.RearAngleRadians,
+ TimeSpan.FromSeconds(deltaTimeSeconds));
+
+ LastFailureReason = success
+ ? string.Empty
+ : BuildFailureReason();
+
+ return success;
+ }
+
+ ///
+ /// 立即清零底盘驱动速度并清除执行器失败状态。
+ ///
+ public void Stop()
+ {
+ _chassisAdapter.StopImmediately();
+ LastFailureReason = string.Empty;
+ }
+
+ ///
+ /// 以不超过指定单周期变化量的速度使当前值接近目标值。
+ ///
+ private static double MoveTowards(
+ double current,
+ double target,
+ double maximumChange)
+ {
+ var difference = target - current;
+
+ if (Math.Abs(difference) <= maximumChange)
+ {
+ return target;
+ }
+
+ return current +
+ Math.Sign(difference) *
+ maximumChange;
+ }
+
+ ///
+ /// 将底盘返回的空失败原因替换为可诊断的默认说明。
+ ///
+ private string BuildFailureReason()
+ {
+ return string.IsNullOrWhiteSpace(
+ _chassisAdapter.LastFailureReason)
+ ? "旧版SendMotion未能完成GCP运动分解。"
+ : _chassisAdapter.LastFailureReason;
+ }
+
+ ///
+ /// 检查控制周期是否为正有限值且能够转换为TimeSpan。
+ ///
+ private static void EnsureFinitePositive(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value) ||
+ value <= 0.0 ||
+ value > TimeSpan.MaxValue.TotalSeconds)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "GCP命令控制周期必须是TimeSpan可表示的正有限秒数。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Control/Execution/ParkingGeometricController.cs b/ClumsyPilot/Control/Execution/ParkingGeometricController.cs
new file mode 100644
index 0000000..b17c559
--- /dev/null
+++ b/ClumsyPilot/Control/Execution/ParkingGeometricController.cs
@@ -0,0 +1,551 @@
+using System;
+using MultiWheelC.Control.Abstractions;
+using MultiWheelC.Control.Allocation;
+using MultiWheelC.StateEstimation;
+using MultiWheelC.Trajectory;
+using MyParking.Shared;
+
+namespace MultiWheelC.Control.Execution
+{
+ ///
+ /// 表示新版停车机器人单周期轨迹控制的执行结果。
+ ///
+ public enum ParkingControlCycleResult
+ {
+ Inactive = 0,
+ CommandSent = 1,
+ Completed = 2,
+ StateUnavailable = 3,
+ Faulted = 4
+ }
+
+ ///
+ /// 组织状态读取、轨迹投影、横纵向控制、GCP分配和底盘命令执行。
+ ///
+ public sealed class ParkingGeometricController
+ {
+ private const double ZeroReferenceSpeedToleranceMetersPerSecond =
+ 1e-6;
+ private const double StartupRegionMeters = 0.02;
+ private const double StartupPreviewDistanceMeters = 0.05;
+ private const double MaximumStartupSpeedMetersPerSecond = 0.08;
+
+ private readonly IVehicleStateProvider _stateProvider;
+ private readonly ILateralController _lateralController;
+ private readonly ILongitudinalController _longitudinalController;
+ private readonly GcpCommandAllocator _gcpAllocator;
+ private readonly GcpCommandExecutor _commandExecutor;
+
+ private Trajectory2D _trajectory;
+
+ ///
+ /// 创建具有终点判定和轨迹偏离保护的单车轨迹控制器。
+ ///
+ public ParkingGeometricController(
+ IVehicleStateProvider stateProvider,
+ ILateralController lateralController,
+ ILongitudinalController longitudinalController,
+ GcpCommandAllocator gcpAllocator,
+ GcpCommandExecutor commandExecutor,
+ double finishDistanceMeters = 0.04,
+ double finishSpeedMetersPerSecond = 0.02,
+ double finishHeadingToleranceRadians =
+ 3.0 * Math.PI / 180.0,
+ double maximumDistanceToTrajectoryMeters = 0.30)
+ {
+ _stateProvider = stateProvider ??
+ throw new ArgumentNullException(
+ nameof(stateProvider));
+ _lateralController = lateralController ??
+ throw new ArgumentNullException(
+ nameof(lateralController));
+ _longitudinalController = longitudinalController ??
+ throw new ArgumentNullException(
+ nameof(longitudinalController));
+ _gcpAllocator = gcpAllocator ??
+ throw new ArgumentNullException(
+ nameof(gcpAllocator));
+ _commandExecutor = commandExecutor ??
+ throw new ArgumentNullException(
+ nameof(commandExecutor));
+
+ EnsureFinitePositive(
+ finishDistanceMeters,
+ nameof(finishDistanceMeters));
+ EnsureFiniteNonNegative(
+ finishSpeedMetersPerSecond,
+ nameof(finishSpeedMetersPerSecond));
+ EnsureFinitePositive(
+ finishHeadingToleranceRadians,
+ nameof(finishHeadingToleranceRadians));
+ EnsureFinitePositive(
+ maximumDistanceToTrajectoryMeters,
+ nameof(maximumDistanceToTrajectoryMeters));
+
+ FinishDistanceMeters = finishDistanceMeters;
+ FinishSpeedMetersPerSecond =
+ finishSpeedMetersPerSecond;
+ FinishHeadingToleranceRadians =
+ finishHeadingToleranceRadians;
+ MaximumDistanceToTrajectoryMeters =
+ maximumDistanceToTrajectoryMeters;
+ }
+
+ ///
+ /// 获取终点位置和剩余弧长允许的误差,单位为m。
+ ///
+ public double FinishDistanceMeters { get; }
+
+ ///
+ /// 获取判定轨迹执行完成时允许的最大实际线速度,单位为m/s。
+ ///
+ public double FinishSpeedMetersPerSecond { get; }
+
+ ///
+ /// 获取判定轨迹完成时允许的最大终点航向误差,单位为rad。
+ ///
+ public double FinishHeadingToleranceRadians { get; }
+
+ ///
+ /// 获取允许车辆偏离参考轨迹的最大距离,单位为m。
+ ///
+ public double MaximumDistanceToTrajectoryMeters { get; }
+
+ ///
+ /// 获取控制器当前是否持有并正在执行一条轨迹。
+ ///
+ public bool IsActive { get; private set; }
+
+ ///
+ /// 获取最近一次轨迹是否已经满足终点完成条件。
+ ///
+ public bool IsCompleted { get; private set; }
+
+ ///
+ /// 获取最近一次控制失败原因,正常时为空字符串。
+ ///
+ public string LastFailureReason { get; private set; } =
+ string.Empty;
+
+ ///
+ /// 获取最近一次控制异常,正常时为空。
+ ///
+ public Exception LastException { get; private set; }
+
+ ///
+ /// 获取最近一次有效车辆状态。
+ ///
+ public VehicleState? LastVehicleState { get; private set; }
+
+ ///
+ /// 获取最近一次车体中心到参考轨迹的投影结果。
+ ///
+ public TrajectoryProjection? LastProjection { get; private set; }
+
+ ///
+ /// 获取最近一次发送或准备发送的GCP运动命令。
+ ///
+ public GcpMotionCommand? LastCommand { get; private set; }
+
+ ///
+ /// 获取最近控制周期实际交给纵向控制器的参考速度,单位为m/s。
+ ///
+ public double? LastReferenceSpeedMetersPerSecond { get; private set; }
+
+ ///
+ /// 停止当前底盘并从起点开始执行指定二维轨迹。
+ ///
+ public void Start(Trajectory2D trajectory)
+ {
+ if (trajectory == null)
+ {
+ throw new ArgumentNullException(
+ nameof(trajectory));
+ }
+
+ StopAndResetControllers();
+ _trajectory = trajectory;
+ IsActive = true;
+ IsCompleted = false;
+ ClearDiagnostics();
+ }
+
+ ///
+ /// 读取本周期车辆状态并执行一次完整的轨迹跟踪控制计算。
+ ///
+ public ParkingControlCycleResult ExecuteCycle(
+ double deltaTimeSeconds)
+ {
+ EnsureFinitePositive(
+ deltaTimeSeconds,
+ nameof(deltaTimeSeconds));
+
+ if (!IsActive || _trajectory == null)
+ {
+ return ParkingControlCycleResult.Inactive;
+ }
+
+ try
+ {
+ if (!_stateProvider.TryGetState(
+ out var vehicleState))
+ {
+ StopForUnavailableState();
+ return ParkingControlCycleResult
+ .StateUnavailable;
+ }
+
+ LastVehicleState = vehicleState;
+
+ var projection = TrajectoryProjector.Project(
+ _trajectory,
+ vehicleState.PoseInWorld);
+ LastProjection = projection;
+
+ if (projection.DistanceToTrajectoryMeters >
+ MaximumDistanceToTrajectoryMeters)
+ {
+ return EnterFault(
+ "车辆距离参考轨迹" +
+ $"{projection.DistanceToTrajectoryMeters:F3}m," +
+ "超过允许值" +
+ $"{MaximumDistanceToTrajectoryMeters:F3}m。");
+ }
+
+ if (HasReachedEnd(
+ vehicleState,
+ projection))
+ {
+ CompleteTrajectory();
+ return ParkingControlCycleResult.Completed;
+ }
+
+ if (HasStoppedAtUnsatisfiedTerminal(
+ vehicleState,
+ projection,
+ out var terminalFailureReason))
+ {
+ return EnterFault(
+ terminalFailureReason);
+ }
+
+ var referenceSpeedMetersPerSecond =
+ ResolveReferenceSpeedForControl(
+ projection);
+ LastReferenceSpeedMetersPerSecond =
+ referenceSpeedMetersPerSecond;
+ var context = new PathTrackingContext(
+ vehicleState,
+ projection,
+ referenceSpeedMetersPerSecond,
+ deltaTimeSeconds);
+ var lateralCommand =
+ _lateralController.Compute(context);
+ var commandSpeedMetersPerSecond =
+ _longitudinalController
+ .ComputeSpeedMetersPerSecond(context);
+ var gcpCommand = _gcpAllocator.Allocate(
+ commandSpeedMetersPerSecond,
+ lateralCommand);
+
+ if (!_commandExecutor.Execute(
+ gcpCommand,
+ deltaTimeSeconds))
+ {
+ return EnterFault(
+ string.IsNullOrWhiteSpace(
+ _commandExecutor.LastFailureReason)
+ ? "GCP底盘命令执行失败。"
+ : _commandExecutor.LastFailureReason);
+ }
+
+ LastCommand =
+ _commandExecutor.LastSentCommand;
+
+ LastFailureReason = string.Empty;
+ LastException = null;
+ return ParkingControlCycleResult.CommandSent;
+ }
+ catch (Exception exception)
+ {
+ return EnterFault(
+ "停车机器人轨迹控制周期异常:" +
+ exception.Message,
+ exception);
+ }
+ }
+
+ ///
+ /// 主动取消当前轨迹、立即停车并清除全部控制器状态。
+ ///
+ public void Cancel()
+ {
+ StopAndResetControllers();
+ _trajectory = null;
+ IsActive = false;
+ IsCompleted = false;
+ ClearDiagnostics();
+ }
+
+ ///
+ /// 在轨迹起点零速固定点处读取前方速度,并限制为低速起步命令。
+ ///
+ private double ResolveReferenceSpeedForControl(
+ TrajectoryProjection projection)
+ {
+ var currentReferenceSpeed =
+ projection.ReferencePoint
+ .ReferenceSpeedMetersPerSecond;
+
+ var requiresStartupRelease =
+ projection.ArcLengthMeters <=
+ StartupRegionMeters &&
+ projection.RemainingDistanceMeters >
+ FinishDistanceMeters &&
+ Math.Abs(currentReferenceSpeed) <=
+ ZeroReferenceSpeedToleranceMetersPerSecond;
+
+ if (!requiresStartupRelease)
+ {
+ return currentReferenceSpeed;
+ }
+
+ var previewArcLengthMeters = Math.Min(
+ _trajectory.TotalLengthMeters,
+ projection.ArcLengthMeters +
+ StartupPreviewDistanceMeters);
+ var previewReferenceSpeed =
+ _trajectory
+ .SampleAtArcLength(
+ previewArcLengthMeters)
+ .ReferenceSpeedMetersPerSecond;
+
+ if (Math.Abs(previewReferenceSpeed) <=
+ ZeroReferenceSpeedToleranceMetersPerSecond)
+ {
+ return 0.0;
+ }
+
+ return Math.Sign(previewReferenceSpeed) *
+ Math.Min(
+ Math.Abs(previewReferenceSpeed),
+ MaximumStartupSpeedMetersPerSecond);
+ }
+
+ ///
+ /// 根据终点距离、剩余弧长和实际线速度判断轨迹是否完成。
+ ///
+ private bool HasReachedEnd(
+ VehicleState vehicleState,
+ TrajectoryProjection projection)
+ {
+ if (!vehicleState.HasValidVelocityEstimate)
+ {
+ return false;
+ }
+
+ return projection.RemainingDistanceMeters <=
+ FinishDistanceMeters &&
+ CalculateDistanceToEndMeters(
+ vehicleState) <=
+ FinishDistanceMeters &&
+ CalculateHeadingErrorToEndRadians(
+ vehicleState) <=
+ FinishHeadingToleranceRadians &&
+ CalculateActualLinearSpeedMetersPerSecond(
+ vehicleState) <=
+ FinishSpeedMetersPerSecond;
+ }
+
+ ///
+ /// 检查车辆是否已在终点零速参考处停稳但最终位置或航向仍不合格。
+ ///
+ private bool HasStoppedAtUnsatisfiedTerminal(
+ VehicleState vehicleState,
+ TrajectoryProjection projection,
+ out string failureReason)
+ {
+ failureReason = string.Empty;
+
+ var isTerminalZeroSpeedReference =
+ projection.RemainingDistanceMeters <=
+ FinishDistanceMeters &&
+ Math.Abs(
+ projection.ReferencePoint
+ .ReferenceSpeedMetersPerSecond) <=
+ ZeroReferenceSpeedToleranceMetersPerSecond;
+
+ if (!isTerminalZeroSpeedReference ||
+ !vehicleState.HasValidVelocityEstimate ||
+ CalculateActualLinearSpeedMetersPerSecond(
+ vehicleState) >
+ FinishSpeedMetersPerSecond)
+ {
+ return false;
+ }
+
+ var positionErrorMeters =
+ CalculateDistanceToEndMeters(
+ vehicleState);
+ var headingErrorRadians =
+ CalculateHeadingErrorToEndRadians(
+ vehicleState);
+
+ failureReason =
+ "车辆已在终点零速参考处停稳,但终点精度不满足要求:" +
+ $"位置误差={positionErrorMeters:F3}m," +
+ "航向误差=" +
+ $"{AngleMath.RadiansToDegrees(headingErrorRadians):F2}°。";
+ return true;
+ }
+
+ ///
+ /// 计算实际车体中心到轨迹终点的欧氏距离,单位为m。
+ ///
+ private double CalculateDistanceToEndMeters(
+ VehicleState vehicleState)
+ {
+ var endPoint = _trajectory.EndPoint.PoseInWorld;
+ var deltaX =
+ vehicleState.PoseInWorld.XMeters -
+ endPoint.XMeters;
+ var deltaY =
+ vehicleState.PoseInWorld.YMeters -
+ endPoint.YMeters;
+
+ return Math.Sqrt(
+ deltaX * deltaX +
+ deltaY * deltaY);
+ }
+
+ ///
+ /// 计算实际车体航向到轨迹终点航向的最短角度误差绝对值,单位为rad。
+ ///
+ private double CalculateHeadingErrorToEndRadians(
+ VehicleState vehicleState)
+ {
+ return Math.Abs(
+ AngleMath.ShortestDifferenceRadians(
+ _trajectory.EndPoint
+ .PoseInWorld.YawRadians,
+ vehicleState
+ .PoseInWorld.YawRadians));
+ }
+
+ ///
+ /// 计算车体坐标系实际线速度的合速度绝对值,单位为m/s。
+ ///
+ private static double CalculateActualLinearSpeedMetersPerSecond(
+ VehicleState vehicleState)
+ {
+ return Math.Sqrt(
+ vehicleState.TwistInBody.VxMetersPerSecond *
+ vehicleState.TwistInBody.VxMetersPerSecond +
+ vehicleState.TwistInBody.VyMetersPerSecond *
+ vehicleState.TwistInBody.VyMetersPerSecond);
+ }
+
+ ///
+ /// 在状态暂不可用时停车并重置反馈控制器,同时保留轨迹等待下一周期恢复。
+ ///
+ private void StopForUnavailableState()
+ {
+ _commandExecutor.Stop();
+ _lateralController.Reset();
+ _longitudinalController.Reset();
+ LastCommand = null;
+ LastFailureReason =
+ "当前无法获得有效车辆状态,底盘已停车并等待定位恢复。";
+ LastException = null;
+ }
+
+ ///
+ /// 完成当前轨迹并停车,但保留最后状态和投影供实验记录读取。
+ ///
+ private void CompleteTrajectory()
+ {
+ StopAndResetControllers();
+ IsActive = false;
+ IsCompleted = true;
+ LastCommand = new GcpMotionCommand(
+ 0.0,
+ 0.0,
+ 0.0);
+ LastFailureReason = string.Empty;
+ LastException = null;
+ }
+
+ ///
+ /// 发生不可继续的控制故障时停车、退出活动状态并保存诊断信息。
+ ///
+ private ParkingControlCycleResult EnterFault(
+ string reason,
+ Exception exception = null)
+ {
+ StopAndResetControllers();
+ IsActive = false;
+ IsCompleted = false;
+ LastCommand = null;
+ LastFailureReason = reason;
+ LastException = exception;
+ return ParkingControlCycleResult.Faulted;
+ }
+
+ ///
+ /// 立即停止底盘并清除横向和纵向控制器的跨周期状态。
+ ///
+ private void StopAndResetControllers()
+ {
+ _commandExecutor.Stop();
+ _lateralController.Reset();
+ _longitudinalController.Reset();
+ }
+
+ ///
+ /// 清除上一条轨迹留下的状态、命令和故障诊断信息。
+ ///
+ private void ClearDiagnostics()
+ {
+ LastVehicleState = null;
+ LastProjection = null;
+ LastCommand = null;
+ LastReferenceSpeedMetersPerSecond = null;
+ LastFailureReason = string.Empty;
+ LastException = null;
+ }
+
+ ///
+ /// 检查控制参数是否为正有限值。
+ ///
+ private static void EnsureFinitePositive(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value) ||
+ value <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "轨迹控制器距离和周期参数必须是正有限值。");
+ }
+ }
+
+ ///
+ /// 检查控制参数是否为非负有限值。
+ ///
+ private static void EnsureFiniteNonNegative(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value) ||
+ value < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "轨迹控制器速度参数必须是非负有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Control/Lateral/StanleyLateralController.cs b/ClumsyPilot/Control/Lateral/StanleyLateralController.cs
new file mode 100644
index 0000000..9c281a7
--- /dev/null
+++ b/ClumsyPilot/Control/Lateral/StanleyLateralController.cs
@@ -0,0 +1,250 @@
+using System;
+using MultiWheelC.Control.Abstractions;
+
+namespace MultiWheelC.Control.Lateral
+{
+ ///
+ /// 将参考曲率、横向误差和航向误差分别转换为前、后GCP目标转角。
+ ///
+ public sealed class StanleyLateralController : ILateralController
+ {
+ ///
+ /// 创建使用指定GCP几何、Stanley增益和转角保护参数的横向控制器。
+ ///
+ public StanleyLateralController(
+ double controlPointRadiusMeters,
+ double crossTrackGainPerSecond,
+ double headingErrorGain,
+ double minimumSpeedMetersPerSecond,
+ bool useActualSpeedForGain = true,
+ double maximumCrossTrackCorrectionRadians =
+ 10.0 * Math.PI / 180.0,
+ double maximumHeadingCorrectionRadians =
+ 10.0 * Math.PI / 180.0)
+ {
+ EnsureFinitePositive(
+ controlPointRadiusMeters,
+ nameof(controlPointRadiusMeters));
+ EnsureFiniteNonNegative(
+ crossTrackGainPerSecond,
+ nameof(crossTrackGainPerSecond));
+ EnsureFiniteNonNegative(
+ headingErrorGain,
+ nameof(headingErrorGain));
+ EnsureFinitePositive(
+ minimumSpeedMetersPerSecond,
+ nameof(minimumSpeedMetersPerSecond));
+ EnsureFinitePositive(
+ maximumCrossTrackCorrectionRadians,
+ nameof(maximumCrossTrackCorrectionRadians));
+ EnsureFinitePositive(
+ maximumHeadingCorrectionRadians,
+ nameof(maximumHeadingCorrectionRadians));
+
+ ControlPointRadiusMeters = controlPointRadiusMeters;
+ CrossTrackGainPerSecond = crossTrackGainPerSecond;
+ HeadingErrorGain = headingErrorGain;
+ MinimumSpeedMetersPerSecond = minimumSpeedMetersPerSecond;
+ UseActualSpeedForGain = useActualSpeedForGain;
+ MaximumCrossTrackCorrectionRadians =
+ maximumCrossTrackCorrectionRadians;
+ MaximumHeadingCorrectionRadians =
+ maximumHeadingCorrectionRadians;
+ }
+
+ ///
+ /// 获取车体中心到前、后GCP的距离,单位为m。
+ ///
+ public double ControlPointRadiusMeters { get; }
+
+ ///
+ /// 获取横向误差增益,单位为1/s。
+ ///
+ public double CrossTrackGainPerSecond { get; }
+
+ ///
+ /// 获取航向误差的无量纲增益。
+ ///
+ public double HeadingErrorGain { get; }
+
+ ///
+ /// 获取Stanley分母使用的最小速度绝对值,单位为m/s。
+ ///
+ public double MinimumSpeedMetersPerSecond { get; }
+
+ ///
+ /// 获取是否优先使用当前状态源提供的实际纵向速度计算横向修正。
+ ///
+ public bool UseActualSpeedForGain { get; }
+
+ ///
+ /// 获取横向误差共同转角分量的最大绝对值,单位为rad。
+ ///
+ public double MaximumCrossTrackCorrectionRadians { get; }
+
+ ///
+ /// 获取航向误差差动转角分量的最大绝对值,单位为rad。
+ ///
+ public double MaximumHeadingCorrectionRadians { get; }
+
+ ///
+ /// 分别计算横向共同转角以及曲率和航向差动转角,并生成前后GCP命令。
+ ///
+ public LateralControlCommand Compute(
+ PathTrackingContext context)
+ {
+ var speedForGain = SelectSpeedForGain(context);
+ var speedMagnitude = Math.Max(
+ Math.Abs(speedForGain),
+ MinimumSpeedMetersPerSecond);
+ var travelDirection = SelectTravelDirection(context);
+
+ // 参考曲率决定前后反向的差动转角,使无跟踪误差时也能沿曲线行驶。
+ var feedforwardAngleRadians = Math.Atan(
+ context.ReferenceCurvaturePerMeter *
+ ControlPointRadiusMeters);
+
+ // 横向误差生成前后同向的共同转角,使四舵轮车辆平稳靠近轨迹。
+ var crossTrackCorrectionRadians =
+ ClampSymmetric(
+ Math.Atan(
+ CrossTrackGainPerSecond *
+ context.LateralErrorMeters /
+ speedMagnitude),
+ MaximumCrossTrackCorrectionRadians);
+
+ // 航向误差生成前后反向的差动转角,只负责调整车身朝向。
+ var headingCorrectionRadians =
+ ClampSymmetric(
+ HeadingErrorGain *
+ context.HeadingErrorRadians,
+ MaximumHeadingCorrectionRadians);
+
+ var commonAngleRadians =
+ travelDirection *
+ crossTrackCorrectionRadians;
+ var differentialAngleRadians =
+ feedforwardAngleRadians +
+ travelDirection *
+ headingCorrectionRadians;
+
+ return new LateralControlCommand(
+ commonAngleRadians +
+ differentialAngleRadians,
+ commonAngleRadians -
+ differentialAngleRadians);
+ }
+
+ ///
+ /// 清除横向控制器状态;当前Stanley实现没有跨周期状态。
+ ///
+ public void Reset()
+ {
+ }
+
+ ///
+ /// 选择Stanley横向误差项使用的实际速度或参考速度。
+ ///
+ private double SelectSpeedForGain(
+ PathTrackingContext context)
+ {
+ if (UseActualSpeedForGain &&
+ context.HasValidVelocityEstimate)
+ {
+ return context
+ .ActualLongitudinalSpeedMetersPerSecond;
+ }
+
+ return context.ReferenceSpeedMetersPerSecond;
+ }
+
+ ///
+ /// 根据有符号参考速度确定前进或倒车时的反馈修正方向。
+ ///
+ private static double SelectTravelDirection(
+ PathTrackingContext context)
+ {
+ const double directionDeadbandMetersPerSecond = 1e-6;
+
+ if (Math.Abs(context.ReferenceSpeedMetersPerSecond) >
+ directionDeadbandMetersPerSecond)
+ {
+ return Math.Sign(
+ context.ReferenceSpeedMetersPerSecond);
+ }
+
+ if (context.HasValidVelocityEstimate &&
+ Math.Abs(
+ context.ActualLongitudinalSpeedMetersPerSecond) >
+ directionDeadbandMetersPerSecond)
+ {
+ return Math.Sign(
+ context.ActualLongitudinalSpeedMetersPerSecond);
+ }
+
+ return 1.0;
+ }
+
+ ///
+ /// 将数值按正负对称方式限制在指定绝对值内。
+ ///
+ private static double ClampSymmetric(
+ double value,
+ double maximumAbsoluteValue)
+ {
+ return Math.Max(
+ -maximumAbsoluteValue,
+ Math.Min(maximumAbsoluteValue, value));
+ }
+
+ ///
+ /// 检查控制参数是否为正有限值。
+ ///
+ private static void EnsureFinitePositive(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "Stanley控制器的几何尺寸、速度和角度限制必须是正有限值。");
+ }
+ }
+
+ ///
+ /// 检查控制增益是否为非负有限值。
+ ///
+ private static void EnsureFiniteNonNegative(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "Stanley控制增益必须是非负有限值。");
+ }
+ }
+
+ ///
+ /// 检查控制参数是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "Stanley控制参数必须是有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Control/Longitudinal/PidLongitudinalController.cs b/ClumsyPilot/Control/Longitudinal/PidLongitudinalController.cs
new file mode 100644
index 0000000..ecf61f0
--- /dev/null
+++ b/ClumsyPilot/Control/Longitudinal/PidLongitudinalController.cs
@@ -0,0 +1,224 @@
+using System;
+using MultiWheelC.Control.Abstractions;
+using MultiWheelC.Control.Common;
+
+namespace MultiWheelC.Control.Longitudinal
+{
+ ///
+ /// 将轨迹参考速度前馈与通用PID速度反馈组合为有符号底盘命令速度。
+ ///
+ public sealed class PidLongitudinalController
+ : ILongitudinalController
+ {
+ private const double ReferenceStopDeadbandMetersPerSecond =
+ 1e-6;
+
+ private readonly PidController _feedbackPid;
+
+ ///
+ /// 创建具有积分抗饱和和命令速度限幅的纵向速度外环。
+ ///
+ public PidLongitudinalController(
+ double proportionalGain,
+ double integralGainPerSecond,
+ double derivativeGainSeconds,
+ double maximumIntegralCorrectionMetersPerSecond,
+ double maximumCommandSpeedMetersPerSecond,
+ double speedErrorDeadbandMetersPerSecond = 0.025)
+ {
+ EnsureFinitePositive(
+ maximumCommandSpeedMetersPerSecond,
+ nameof(maximumCommandSpeedMetersPerSecond));
+ EnsureFiniteNonNegative(
+ speedErrorDeadbandMetersPerSecond,
+ nameof(speedErrorDeadbandMetersPerSecond));
+
+ _feedbackPid = new PidController(
+ proportionalGain,
+ integralGainPerSecond,
+ derivativeGainSeconds,
+ maximumIntegralCorrectionMetersPerSecond,
+ derivativeOnMeasurement: true);
+ MaximumCommandSpeedMetersPerSecond =
+ maximumCommandSpeedMetersPerSecond;
+ SpeedErrorDeadbandMetersPerSecond =
+ speedErrorDeadbandMetersPerSecond;
+ }
+
+ ///
+ /// 获取负责计算速度误差修正量的通用PID控制器。
+ ///
+ public PidController FeedbackPid => _feedbackPid;
+
+ ///
+ /// 获取底盘命令速度的最大绝对值,单位为m/s。
+ ///
+ public double MaximumCommandSpeedMetersPerSecond { get; }
+
+ ///
+ /// 获取不触发纵向PID修正的速度误差死区,单位为m/s。
+ ///
+ public double SpeedErrorDeadbandMetersPerSecond { get; }
+
+ ///
+ /// 获取最近一次有效控制周期的参考速度减实际速度,单位为m/s。
+ ///
+ public double LastSpeedErrorMetersPerSecond =>
+ _feedbackPid.LastError;
+
+ ///
+ /// 获取最近一次比例项产生的速度修正,单位为m/s。
+ ///
+ public double LastProportionalCorrectionMetersPerSecond =>
+ _feedbackPid.LastProportionalOutput;
+
+ ///
+ /// 获取最近一次积分项产生的速度修正,单位为m/s。
+ ///
+ public double LastIntegralCorrectionMetersPerSecond =>
+ _feedbackPid.LastIntegralOutput;
+
+ ///
+ /// 获取最近一次微分项产生的速度修正,单位为m/s。
+ ///
+ public double LastDerivativeCorrectionMetersPerSecond =>
+ _feedbackPid.LastDerivativeOutput;
+
+ ///
+ /// 根据轨迹参考速度和Detour实际纵向速度计算底盘命令速度。
+ ///
+ public double ComputeSpeedMetersPerSecond(
+ PathTrackingContext context)
+ {
+ var referenceSpeedMetersPerSecond =
+ context.ReferenceSpeedMetersPerSecond;
+
+ // 轨迹明确要求停车时直接输出零,防止速度反馈使车辆在终点反向纠偏。
+ if (Math.Abs(referenceSpeedMetersPerSecond) <=
+ ReferenceStopDeadbandMetersPerSecond)
+ {
+ Reset();
+ return 0.0;
+ }
+
+ // 定位速度尚不可用时只透传参考速度,不使用无效反馈更新PID状态。
+ if (!context.HasValidVelocityEstimate)
+ {
+ Reset();
+ return LimitReferenceSpeed(
+ referenceSpeedMetersPerSecond);
+ }
+
+ var speedErrorMetersPerSecond =
+ referenceSpeedMetersPerSecond -
+ context.ActualLongitudinalSpeedMetersPerSecond;
+
+ // Detour差分速度在参考速度附近会有小幅波动;死区内只使用速度前馈,
+ // 同时清除PID历史,避免噪声持续积累后产生突发修正。
+ if (Math.Abs(speedErrorMetersPerSecond) <=
+ SpeedErrorDeadbandMetersPerSecond)
+ {
+ Reset();
+ return LimitReferenceSpeed(
+ referenceSpeedMetersPerSecond);
+ }
+
+ GetCorrectionOutputRange(
+ referenceSpeedMetersPerSecond,
+ out var minimumCorrectionMetersPerSecond,
+ out var maximumCorrectionMetersPerSecond);
+
+ var correctionMetersPerSecond =
+ _feedbackPid.Update(
+ referenceSpeedMetersPerSecond,
+ context
+ .ActualLongitudinalSpeedMetersPerSecond,
+ context.DeltaTimeSeconds,
+ minimumCorrectionMetersPerSecond,
+ maximumCorrectionMetersPerSecond);
+
+ return referenceSpeedMetersPerSecond +
+ correctionMetersPerSecond;
+ }
+
+ ///
+ /// 清除纵向速度外环的积分、历史测量值和诊断输出。
+ ///
+ public void Reset()
+ {
+ _feedbackPid.Reset();
+ }
+
+ ///
+ /// 根据参考行驶方向计算PID修正量允许使用的动态输出范围。
+ ///
+ private void GetCorrectionOutputRange(
+ double referenceSpeedMetersPerSecond,
+ out double minimumCorrectionMetersPerSecond,
+ out double maximumCorrectionMetersPerSecond)
+ {
+ if (referenceSpeedMetersPerSecond > 0.0)
+ {
+ minimumCorrectionMetersPerSecond =
+ -referenceSpeedMetersPerSecond;
+ maximumCorrectionMetersPerSecond =
+ MaximumCommandSpeedMetersPerSecond -
+ referenceSpeedMetersPerSecond;
+ return;
+ }
+
+ minimumCorrectionMetersPerSecond =
+ -MaximumCommandSpeedMetersPerSecond -
+ referenceSpeedMetersPerSecond;
+ maximumCorrectionMetersPerSecond =
+ -referenceSpeedMetersPerSecond;
+ }
+
+ ///
+ /// 在没有有效速度反馈时限制参考速度的绝对值。
+ ///
+ private double LimitReferenceSpeed(
+ double referenceSpeedMetersPerSecond)
+ {
+ return Math.Max(
+ -MaximumCommandSpeedMetersPerSecond,
+ Math.Min(
+ MaximumCommandSpeedMetersPerSecond,
+ referenceSpeedMetersPerSecond));
+ }
+
+ ///
+ /// 检查最大命令速度是否为正有限值。
+ ///
+ private static void EnsureFinitePositive(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value) ||
+ value <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "纵向控制器最大命令速度必须是正有限值。");
+ }
+ }
+
+ ///
+ /// 检查速度误差死区是否为非负有限值。
+ ///
+ private static void EnsureFiniteNonNegative(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value) ||
+ value < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "纵向控制器速度误差死区必须是非负有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Movements/TrajectoryTrackingMovement.cs b/ClumsyPilot/Movements/TrajectoryTrackingMovement.cs
new file mode 100644
index 0000000..ad6f2cb
--- /dev/null
+++ b/ClumsyPilot/Movements/TrajectoryTrackingMovement.cs
@@ -0,0 +1,306 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using ClumsyCore.Interfaces;
+using ClumsyCore.Pilot;
+using CommonUsage.Chassis;
+using MultiWheelC.Control.Allocation;
+using MultiWheelC.Control.Execution;
+using MultiWheelC.Control.Lateral;
+using MultiWheelC.Control.Longitudinal;
+using MultiWheelC.StateEstimation;
+using MultiWheelC.Trajectory;
+using MyParking.Shared;
+
+namespace MultiWheelC
+{
+ ///
+ /// 使用新版横纵向控制器持续跟踪一条世界坐标系二维轨迹。
+ ///
+ public sealed class TrajectoryTrackingMovement
+ : MovementDefinition
+ {
+ ///
+ /// 获取或设置本次动作需要跟踪的世界坐标系轨迹。
+ ///
+ public Trajectory2D Trajectory;
+
+ ///
+ /// 获取或设置本次动作使用的车辆状态源;为空时自动创建Detour状态源。
+ ///
+ public IVehicleStateProvider StateProvider;
+
+ ///
+ /// 获取或设置每个有效控制周期结束后的诊断数据观察回调。
+ ///
+ public Action CycleObserver;
+
+ ///
+ /// Stanley横向误差增益,单位为1/s。
+ ///
+ public double StanleyCrossTrackGainPerSecond = 0.4;
+
+ ///
+ /// Stanley航向误差增益。
+ ///
+ public double StanleyHeadingErrorGain = 1.0;
+
+ ///
+ /// Stanley低速分母保护速度,单位为m/s。
+ ///
+ public double StanleyMinimumSpeedMetersPerSecond = 0.15;
+
+ ///
+ /// 获取或设置Stanley是否优先使用当前状态源提供的实际纵向速度。
+ ///
+ public bool StanleyUsesActualSpeed = true;
+
+ ///
+ /// Stanley横向误差共同转角分量的最大绝对值,单位为rad。
+ ///
+ public double MaximumCrossTrackCorrectionRadians =
+ AngleMath.DegreesToRadians(10.0);
+
+ ///
+ /// Stanley航向误差差动转角分量的最大绝对值,单位为rad。
+ ///
+ public double MaximumHeadingCorrectionRadians =
+ AngleMath.DegreesToRadians(10.0);
+
+ ///
+ /// 纵向速度外环比例增益。
+ ///
+ public double LongitudinalKp = 0.5;
+
+ ///
+ /// 纵向速度外环积分增益,单位为1/s。
+ ///
+ public double LongitudinalKiPerSecond;
+
+ ///
+ /// 纵向速度外环微分增益,单位为s。
+ ///
+ public double LongitudinalKdSeconds;
+
+ ///
+ /// 纵向积分项允许产生的最大速度修正绝对值,单位为m/s。
+ ///
+ public double MaximumIntegralCorrectionMetersPerSecond = 0.05;
+
+ ///
+ /// 纵向PID不进行反馈修正的速度误差死区,单位为m/s。
+ ///
+ public double LongitudinalSpeedErrorDeadbandMetersPerSecond =
+ 0.025;
+
+ ///
+ /// 底盘纵向命令速度绝对值上限,单位为m/s。
+ ///
+ public double MaximumCommandSpeedMetersPerSecond = 0.50;
+
+ ///
+ /// 前后GCP允许的最大转角绝对值,单位为rad。
+ ///
+ public double MaximumGcpAngleRadians =
+ AngleMath.DegreesToRadians(45.0);
+
+ ///
+ /// 前后GCP目标转角最大变化率,单位为rad/s。
+ ///
+ public double MaximumGcpAngleRateRadiansPerSecond =
+ AngleMath.DegreesToRadians(15.0);
+
+ ///
+ /// 终点位置和剩余弧长的完成容差,单位为m。
+ ///
+ public double FinishDistanceMeters = 0.03;
+
+ ///
+ /// 终点停稳判定允许的实际线速度,单位为m/s。
+ ///
+ public double FinishSpeedMetersPerSecond = 0.02;
+
+ ///
+ /// 终点航向完成容差,单位为rad。
+ ///
+ public double FinishHeadingToleranceRadians =
+ AngleMath.DegreesToRadians(3.0);
+
+ ///
+ /// 车辆允许偏离参考轨迹的最大欧氏距离,单位为m。
+ ///
+ public double MaximumDistanceToTrajectoryMeters = 0.30;
+
+ ///
+ /// 单次轨迹动作允许的最长执行时间,单位为s。
+ ///
+ public double ExecutionTimeoutSeconds = 120.0;
+
+ ///
+ /// 获取本次动作创建的控制器,尚未开始时为空。
+ ///
+ public ParkingGeometricController Controller { get; private set; }
+
+ ///
+ /// 创建控制器并持续执行控制周期,直到轨迹完成、失败或动作被取消。
+ ///
+ public override IEnumerable Get()
+ {
+ ValidateParameters();
+
+ var chassis =
+ PilotDefinition.Chassis as MultiWheelChassis;
+ if (chassis == null)
+ {
+ throw new InvalidOperationException(
+ "当前底盘不是MultiWheelChassis,无法执行新版轨迹跟踪动作。");
+ }
+
+ var adapter = new MultiWheelChassisAdapter(
+ chassis,
+ PilotDefinition.Self.CarNum);
+
+ // 新版GCP控制统一以真实车头为车体X正方向,避免继承上一次蟹行偏置。
+ adapter.ResetToBodyFrame();
+
+ var stateProvider =
+ StateProvider ??
+ new DetourVehicleStateProvider();
+ var controlPointRadiusMeters =
+ chassis.ControlPointRadius / 1000.0;
+
+ var lateralController =
+ new StanleyLateralController(
+ controlPointRadiusMeters,
+ StanleyCrossTrackGainPerSecond,
+ StanleyHeadingErrorGain,
+ StanleyMinimumSpeedMetersPerSecond,
+ StanleyUsesActualSpeed,
+ MaximumCrossTrackCorrectionRadians,
+ MaximumHeadingCorrectionRadians);
+ var longitudinalController =
+ new PidLongitudinalController(
+ LongitudinalKp,
+ LongitudinalKiPerSecond,
+ LongitudinalKdSeconds,
+ MaximumIntegralCorrectionMetersPerSecond,
+ MaximumCommandSpeedMetersPerSecond,
+ LongitudinalSpeedErrorDeadbandMetersPerSecond);
+ var gcpAllocator =
+ new GcpCommandAllocator(
+ MaximumGcpAngleRadians);
+ var commandExecutor =
+ new GcpCommandExecutor(
+ adapter,
+ MaximumGcpAngleRateRadiansPerSecond);
+
+ Controller = new ParkingGeometricController(
+ stateProvider,
+ lateralController,
+ longitudinalController,
+ gcpAllocator,
+ commandExecutor,
+ FinishDistanceMeters,
+ FinishSpeedMetersPerSecond,
+ FinishHeadingToleranceRadians,
+ MaximumDistanceToTrajectoryMeters);
+
+ var clock = Stopwatch.StartNew();
+ var previousCycleSeconds =
+ clock.Elapsed.TotalSeconds;
+ Controller.Start(Trajectory);
+
+ try
+ {
+ while (true)
+ {
+ if (clock.Elapsed.TotalSeconds >
+ ExecutionTimeoutSeconds)
+ {
+ throw new TimeoutException(
+ $"新版轨迹跟踪超过{ExecutionTimeoutSeconds:F1}s仍未完成。");
+ }
+
+ var currentCycleSeconds =
+ clock.Elapsed.TotalSeconds;
+ var deltaTimeSeconds =
+ currentCycleSeconds -
+ previousCycleSeconds;
+ previousCycleSeconds =
+ currentCycleSeconds;
+
+ // 极短首周期不参与PID和GCP角速度限制,等待调度器进入下一周期。
+ if (deltaTimeSeconds <= 1e-6)
+ {
+ yield return true;
+ continue;
+ }
+
+ var result =
+ Controller.ExecuteCycle(
+ deltaTimeSeconds);
+
+ if (Controller.LastVehicleState.HasValue)
+ {
+ CycleObserver?.Invoke(Controller);
+ }
+
+ if (result ==
+ ParkingControlCycleResult.Completed)
+ {
+ break;
+ }
+
+ if (result ==
+ ParkingControlCycleResult.Faulted)
+ {
+ throw new InvalidOperationException(
+ string.IsNullOrWhiteSpace(
+ Controller.LastFailureReason)
+ ? "新版轨迹跟踪控制器发生未知故障。"
+ : Controller.LastFailureReason,
+ Controller.LastException);
+ }
+
+ if (result ==
+ ParkingControlCycleResult.Inactive)
+ {
+ throw new InvalidOperationException(
+ "新版轨迹跟踪控制器在轨迹完成前意外停止活动。");
+ }
+
+ // CommandSent和短暂StateUnavailable均继续下一控制周期;
+ // 后者已经由控制器主动停车,等待Detour恢复。
+ yield return true;
+ }
+ }
+ finally
+ {
+ Controller.Cancel();
+ }
+
+ yield return false;
+ }
+
+ ///
+ /// 在接管实际底盘前检查动作自身无法由子控制器检查的参数。
+ ///
+ private void ValidateParameters()
+ {
+ if (Trajectory == null)
+ {
+ throw new InvalidOperationException(
+ "新版轨迹跟踪动作没有设置Trajectory。");
+ }
+
+ if (double.IsNaN(ExecutionTimeoutSeconds) ||
+ double.IsInfinity(ExecutionTimeoutSeconds) ||
+ ExecutionTimeoutSeconds <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(ExecutionTimeoutSeconds),
+ "轨迹跟踪超时时间必须是正有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Shared/Chassis/MultiWheelChassisAdapter.cs b/ClumsyPilot/Shared/Chassis/MultiWheelChassisAdapter.cs
new file mode 100644
index 0000000..5d2fbe2
--- /dev/null
+++ b/ClumsyPilot/Shared/Chassis/MultiWheelChassisAdapter.cs
@@ -0,0 +1,576 @@
+// 将统一命令转换为原 Chassis API 调用
+using System;
+using CommonUsage.Chassis;
+
+namespace MyParking.Shared
+{
+ ///
+ /// 将统一的单车车体速度命令转换为旧版MultiWheelChassis调用。
+ /// 车体坐标系固定为X向前、Y向左、逆时针为正。
+ ///
+ public sealed class MultiWheelChassisAdapter
+ {
+ #region 辅助内容
+ private const double RadiansToDegrees = 180.0 / Math.PI;
+ private const float BiasTolerance = 0.001f;
+ private readonly MultiWheelChassis _chassis;
+ ///
+ /// 当前适配器对应的车辆编号。
+ ///
+ public int VehicleId { get; }
+
+ ///
+ /// Maximum distance from the body origin to a wheel center, in metres.
+ ///
+ public double MaximumWheelRadiusMeters { get; }
+
+ ///
+ /// Maximum longitudinal wheel offset from the body origin, in metres.
+ /// For a symmetric four-wheel-steering chassis this is half the wheelbase.
+ ///
+ public double HalfWheelBaseMeters { get; }
+
+ ///
+ /// 车体原点到最外侧舵轮中心的最大横向距离,单位为米。
+ /// 对称四舵轮底盘中,它也是蟹行虚拟阿克曼模型的半轴距。
+ ///
+ public double HalfTrackWidthMeters { get; }
+
+ ///
+ /// Width of the steering-alignment speed gate, in degrees.
+ ///
+ public double SteeringAlignmentSigmaDegrees
+ {
+ get => _chassis.SteeringAlignmentSigmaDegrees;
+ set
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value) ||
+ value <= 0.0 ||
+ value > float.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(value),
+ "Steering alignment sigma must be a positive finite value.");
+ }
+
+ _chassis.SteeringAlignmentSigmaDegrees =
+ (float)value;
+ }
+ }
+
+ ///
+ /// 检查旧底盘是否仍处于无偏置的真实车体坐标系。
+ ///
+ private void EnsureBodyFrameIsActive()
+ {
+ EnsureMotionFrameIsActive(0.0);
+ }
+
+ ///
+ /// 检查旧底盘当前是否处于指定的运动坐标系。
+ /// motionDirectionRadians表示该运动系X轴在真实车体坐标系中的方向。
+ ///
+ private void EnsureMotionFrameIsActive(
+ double motionDirectionRadians)
+ {
+ ValidateFinite(
+ motionDirectionRadians,
+ nameof(motionDirectionRadians));
+
+ var expectedBiasDegrees =
+ (float)(
+ -FrameTransform2D.NormalizeAngle(
+ motionDirectionRadians) *
+ RadiansToDegrees);
+ var bias = _chassis.GetOriginBias();
+ var angleErrorDegrees =
+ NormalizeDegrees(
+ bias.Z - expectedBiasDegrees);
+
+ if (Math.Abs(bias.X) <= BiasTolerance &&
+ Math.Abs(bias.Y) <= BiasTolerance &&
+ Math.Abs(angleErrorDegrees) <=
+ BiasTolerance)
+ {
+ return;
+ }
+
+ throw new InvalidOperationException(
+ "MultiWheelChassis当前运动坐标系与命令不一致。" +
+ $"当前偏置为X={bias.X}, Y={bias.Y}, Th={bias.Z}°," +
+ $"期望Th={expectedBiasDegrees}°。");
+ }
+
+ ///
+ /// 将角度归一化到[-180°,180°]附近。
+ ///
+ private static float NormalizeDegrees(float degrees)
+ {
+ return (float)(
+ degrees -
+ Math.Round(degrees / 360.0) * 360.0);
+ }
+ ///
+ /// 检查底盘命令是否包含无效数值。
+ ///
+ private static void ValidateTwist(Twist2D twist)
+ {
+ ValidateFinite(
+ twist.VxMetersPerSecond,
+ nameof(twist.VxMetersPerSecond));
+
+ ValidateFinite(
+ twist.VyMetersPerSecond,
+ nameof(twist.VyMetersPerSecond));
+
+ ValidateFinite(
+ twist.OmegaRadiansPerSecond,
+ nameof(twist.OmegaRadiansPerSecond));
+ }
+ ///
+ /// 检查数值是否为有限值。
+ ///
+ private static void ValidateFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "底盘速度命令不能是NaN或无穷大。");
+ }
+
+ if (value > float.MaxValue ||
+ value < -float.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "底盘速度命令超过float可表示范围。");
+ }
+ }
+
+ ///
+ /// 获取最近一次底盘运动分解失败原因。
+ ///
+ public string LastFailureReason =>
+ _chassis.LastMotionDecomposeFailureReason;
+
+ #endregion
+
+ ///
+ /// 将旧底盘的原点偏置恢复为真实单车车体坐标系。
+ ///
+ public void ResetToBodyFrame()
+ {
+ ActivateMotionFrame(0.0);
+ }
+
+ ///
+ /// 激活指定运动方向对应的SendMotion坐标系。
+ /// 0表示真实车头,正90度表示将车体左侧作为虚拟车头。
+ ///
+ public void ActivateMotionFrame(
+ double motionDirectionRadians)
+ {
+ ValidateFinite(
+ motionDirectionRadians,
+ nameof(motionDirectionRadians));
+
+ var biasDegrees =
+ (float)(
+ -FrameTransform2D.NormalizeAngle(
+ motionDirectionRadians) *
+ RadiansToDegrees);
+ var currentBias =
+ _chassis.GetOriginBias();
+
+ if (Math.Abs(currentBias.X) <=
+ BiasTolerance &&
+ Math.Abs(currentBias.Y) <=
+ BiasTolerance &&
+ Math.Abs(
+ NormalizeDegrees(
+ currentBias.Z -
+ biasDegrees)) <=
+ BiasTolerance)
+ {
+ return;
+ }
+
+ _chassis.SetOriginBias(
+ x: 0.0f,
+ y: 0.0f,
+ th: biasDegrees);
+ }
+ public MultiWheelChassisAdapter(MultiWheelChassis chassis, int vehicleId)
+ {
+ _chassis = chassis ?? throw new ArgumentNullException(nameof(chassis));
+ if (vehicleId <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(vehicleId),
+ "车辆编号必须大于零。");
+ }
+ VehicleId = vehicleId;
+#pragma warning disable CS0612, CS0618
+ var wheels = _chassis.GetSteerWheels();
+#pragma warning restore CS0612, CS0618
+
+ if (wheels.Count == 0)
+ {
+ throw new InvalidOperationException(
+ "MultiWheelChassis尚未完成舵轮初始化," +
+ "不能创建底盘适配器。");
+ }
+ // 禁用旧版DirectionAngle/ZeroDirection坐标偏置,
+ // 保证SendXYThSpeed直接使用真实车体坐标系。
+ var maximumWheelRadiusMillimeters = 0.0;
+ var maximumLongitudinalOffsetMillimeters = 0.0;
+ var maximumLateralOffsetMillimeters = 0.0;
+ foreach (var wheel in wheels)
+ {
+ maximumWheelRadiusMillimeters = Math.Max(
+ maximumWheelRadiusMillimeters,
+ wheel.PhysicalPosition.Length());
+
+ maximumLongitudinalOffsetMillimeters = Math.Max(
+ maximumLongitudinalOffsetMillimeters,
+ Math.Abs(wheel.PhysicalPosition.X));
+
+ maximumLateralOffsetMillimeters = Math.Max(
+ maximumLateralOffsetMillimeters,
+ Math.Abs(wheel.PhysicalPosition.Y));
+ }
+
+ MaximumWheelRadiusMeters =
+ maximumWheelRadiusMillimeters / 1000.0;
+ HalfWheelBaseMeters =
+ maximumLongitudinalOffsetMillimeters / 1000.0;
+ HalfTrackWidthMeters =
+ maximumLateralOffsetMillimeters / 1000.0;
+
+ if (MaximumWheelRadiusMeters <= 0.0 ||
+ HalfWheelBaseMeters <= 0.0 ||
+ HalfTrackWidthMeters <= 0.0)
+ {
+ throw new InvalidOperationException(
+ "Wheel positions cannot produce valid chassis dimensions.");
+ }
+
+ // 通过反转轮速表达反向运动,避免蟹行正反切换时舵轮无意义地旋转180°。
+ _chassis.PreferMinimumSteeringTravel = true;
+ }
+
+ ///
+ /// 将车体坐标系速度命令发送给多舵轮底盘。
+ ///
+ public bool Send(ChassisCommand command, TimeSpan? interval = null)
+ {
+ if (command.VehicleId != VehicleId)
+ {
+ throw new InvalidOperationException(
+ $"命令车辆编号{command.VehicleId}与适配器车辆编号" +
+ $"{VehicleId}不一致。");
+ }
+ ValidateTwist(command.BodyTwist);
+ // 防止其他旧逻辑再次调用DirectionAngle或
+ // SetOriginBias改变底盘坐标语义。
+ EnsureBodyFrameIsActive();
+ var vxMetersPerSecond =
+ (float)command.BodyTwist.VxMetersPerSecond;
+ var vyMetersPerSecond =
+ (float)command.BodyTwist.VyMetersPerSecond;
+ var omegaDegreesPerSecond =
+ (float)(
+ command.BodyTwist.OmegaRadiansPerSecond *
+ RadiansToDegrees);
+ var success = _chassis.SendXYThSpeed(
+ vxMetersPerSecond,
+ vyMetersPerSecond,
+ omegaDegreesPerSecond,
+ interval,
+ enableDifferentialSteerFeedforward: true);
+ if (!success)
+ {
+ // 防止分解失败后继续执行上一条运动命令。
+ _chassis.PredefinedDriveStop();
+ }
+ return success;
+ }
+
+
+ ///
+ /// 在已经激活的运动坐标系中使用SendMotion执行虚拟阿克曼运动。
+ /// 转向角均相对该运动坐标系表达;正90度运动系对应车体左侧蟹行。
+ ///
+ public bool SendVirtualAckermannMotion(
+ double motionDirectionRadians,
+ double speedMetersPerSecond,
+ double steeringRadians,
+ TimeSpan? interval = null)
+ {
+ ValidateFinite(
+ motionDirectionRadians,
+ nameof(motionDirectionRadians));
+ ValidateFinite(
+ speedMetersPerSecond,
+ nameof(speedMetersPerSecond));
+ ValidateFinite(
+ steeringRadians,
+ nameof(steeringRadians));
+ EnsureMotionFrameIsActive(
+ motionDirectionRadians);
+
+ if (Math.Abs(steeringRadians) >=
+ Math.PI / 2.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(steeringRadians),
+ "虚拟阿克曼转向角必须位于正负90度以内。");
+ }
+
+ var steeringDegrees =
+ (float)(
+ steeringRadians *
+ RadiansToDegrees);
+ var success =
+ _chassis.SendMotion(
+ (float)speedMetersPerSecond,
+ steeringDegrees,
+ -steeringDegrees,
+ interval);
+
+ if (!success)
+ {
+ _chassis.PredefinedDriveStop();
+ }
+
+ return success;
+ }
+
+ ///
+ /// 在真实车体坐标系中将有符号速度和独立前后GCP角度发送给旧版SendMotion。
+ ///
+ public bool SendGcpMotion(
+ double speedMetersPerSecond,
+ double frontAngleRadians,
+ double rearAngleRadians,
+ TimeSpan? interval = null)
+ {
+ ValidateFinite(
+ speedMetersPerSecond,
+ nameof(speedMetersPerSecond));
+ ValidateFinite(
+ frontAngleRadians,
+ nameof(frontAngleRadians));
+ ValidateFinite(
+ rearAngleRadians,
+ nameof(rearAngleRadians));
+ EnsureBodyFrameIsActive();
+
+ if (Math.Abs(frontAngleRadians) >=
+ Math.PI / 2.0 ||
+ Math.Abs(rearAngleRadians) >=
+ Math.PI / 2.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(frontAngleRadians),
+ "前后GCP角度必须位于正负90度以内,避免四轮几何解算出现奇异值。");
+ }
+
+ var success = _chassis.SendMotion(
+ (float)speedMetersPerSecond,
+ (float)(frontAngleRadians *
+ RadiansToDegrees),
+ (float)(rearAngleRadians *
+ RadiansToDegrees),
+ interval);
+
+ if (!success)
+ {
+ // 分解失败后立即清除上一条驱动速度,避免车辆继续执行陈旧命令。
+ _chassis.PredefinedDriveStop();
+ }
+
+ return success;
+ }
+
+ ///
+ /// 立即将所有驱动轮速度下发为零。
+ ///
+ public void StopImmediately()
+ {
+ _chassis.PredefinedDriveStop();
+ }
+
+ ///
+ /// 清零XYTh驱动速度,但保留已经准备好的自转舵角和轮速方向。
+ ///
+ public void StopXYThDrivePreserveSteeringState()
+ {
+ _chassis.StopXYThDrivePreserveSteeringState();
+ }
+
+ ///
+ /// 停车并将所有舵轮转到指定的车体角度。
+ /// 只调整舵轮角度,不产生车辆线速度。
+ ///
+ public bool PrepareParallelDirection(
+ double directionRadians)
+ {
+ EnsureBodyFrameIsActive();
+ var targetDegrees = (float)(FrameTransform2D.NormalizeAngle(directionRadians) *
+ RadiansToDegrees);
+
+#pragma warning disable CS0612, CS0618
+ var wheels = _chassis.GetSteerWheels();
+#pragma warning restore CS0612, CS0618
+
+ // 没有舵轮时不能认为预对齐成功。
+ if (wheels.Count == 0)
+ {
+ return false;
+ }
+
+ // 先检查所有舵轮能否到达目标机械角度。
+ foreach (var wheel in wheels)
+ {
+ if (targetDegrees < wheel.AngleLowerLimit ||
+ targetDegrees > wheel.AngleUpperLimit)
+ {
+ return false;
+ }
+ }
+
+ // 模式切换前立即停止驱动轮。
+ _chassis.PredefinedDriveStop();
+ // 检查完成后再统一下发,避免只转动一部分舵轮。
+ foreach (var wheel in wheels)
+ {
+ wheel.WriteAngle(targetDegrees);
+ }
+
+ return true;
+ }
+
+ ///
+ /// 检查所有舵轮是否已经对准给定方向。
+ ///
+ public bool AreParallelWheelsAligned(
+ double directionRadians,
+ double toleranceRadians)
+ {
+ if (double.IsNaN(toleranceRadians) ||
+ double.IsInfinity(toleranceRadians) ||
+ toleranceRadians < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(toleranceRadians),
+ "舵轮到位容差必须是非负有限值。");
+ }
+
+ EnsureBodyFrameIsActive();
+ var targetDegrees = (float)(
+ FrameTransform2D.NormalizeAngle(directionRadians) *
+ 180.0 / Math.PI);
+
+ var toleranceDegrees = (float)(
+ Math.Abs(toleranceRadians) *
+ 180.0 / Math.PI);
+
+#pragma warning disable CS0612, CS0618
+ var wheels = _chassis.GetSteerWheels();
+#pragma warning restore CS0612, CS0618
+
+ foreach (var wheel in wheels)
+ {
+ var angleErrorDegrees = targetDegrees - wheel.ReadAngle();
+
+ if (Math.Abs(angleErrorDegrees) >
+ toleranceDegrees)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// 停车并将舵轮预对齐到原地自转方向。
+ /// 返回是否成功生成舵轮目标。
+ ///
+ public bool PrepareSpin(
+ TimeSpan? interval = null,
+ double alignmentToleranceDegrees = 2.0)
+ {
+ ValidateFinite(
+ alignmentToleranceDegrees,
+ nameof(alignmentToleranceDegrees));
+
+ if (alignmentToleranceDegrees < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(alignmentToleranceDegrees),
+ "自转舵轮到位容差必须是非负有限值。");
+ }
+
+ EnsureBodyFrameIsActive();
+
+ var success =
+ _chassis.PrepareRotateWheels(
+ alignmentToleranceDegrees:
+ (float)alignmentToleranceDegrees);
+
+ if (!success)
+ {
+ _chassis.PredefinedDriveStop();
+ }
+ return success;
+ }
+
+ ///
+ /// 将已到位的自转舵角和轮速方向一次性交接给XYTh,
+ /// 防止普通SendXYThSpeed正式运动首帧重新初始化运动状态。
+ ///
+ public bool AdoptPreparedSpinForXYTh(
+ double toleranceRadians =
+ 2.0 * Math.PI / 180.0)
+ {
+ if (double.IsNaN(toleranceRadians) ||
+ double.IsInfinity(toleranceRadians) ||
+ toleranceRadians < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(toleranceRadians),
+ "自转状态交接容差必须是非负有限值。");
+ }
+
+ EnsureBodyFrameIsActive();
+
+ var success =
+ _chassis
+ .AdoptPreparedRotateWheelsForXYTh(
+ (float)(
+ toleranceRadians *
+ RadiansToDegrees));
+
+ if (!success)
+ {
+ _chassis.PredefinedDriveStop();
+ }
+
+ return success;
+ }
+ ///
+ /// 所有舵轮是否已对齐到原地自转方向。
+ ///
+ public bool AreSpinWheelsAligned => _chassis.LastRotateAligned;
+
+
+
+ }
+}
diff --git a/ClumsyPilot/Shared/Mathematics/AngleMath.cs b/ClumsyPilot/Shared/Mathematics/AngleMath.cs
new file mode 100644
index 0000000..ca3803f
--- /dev/null
+++ b/ClumsyPilot/Shared/Mathematics/AngleMath.cs
@@ -0,0 +1,138 @@
+using System;
+
+namespace MyParking.Shared
+{
+ ///
+ /// 提供与坐标系无关的角度归一化、角度差和单位转换功能。
+ ///
+ public static class AngleMath
+ {
+ public const double TwoPi = 2.0 * Math.PI;
+
+ ///
+ /// 将弧度归一化到[-π, π)区间。
+ /// -π包含在结果中,+π不包含在结果中,因此+π会返回-π。
+ ///
+ public static double NormalizeRadians(double angleRadians)
+ {
+ EnsureFinite(angleRadians, nameof(angleRadians));
+
+ var normalized = angleRadians % TwoPi;
+
+ if (normalized >= Math.PI)
+ {
+ normalized -= TwoPi;
+ }
+ else if (normalized < -Math.PI)
+ {
+ normalized += TwoPi;
+ }
+
+ return normalized == 0.0 ? 0.0 : normalized;
+ }
+
+ ///
+ /// 将角度归一化到[-180°, 180°)区间。
+ /// -180°包含在结果中,+180°不包含在结果中,因此+180°会返回-180°。
+ ///
+ public static double NormalizeDegrees(double angleDegrees)
+ {
+ EnsureFinite(angleDegrees, nameof(angleDegrees));
+
+ var normalized = angleDegrees % 360.0;
+
+ if (normalized >= 180.0)
+ {
+ normalized -= 360.0;
+ }
+ else if (normalized < -180.0)
+ {
+ normalized += 360.0;
+ }
+
+ return normalized == 0.0 ? 0.0 : normalized;
+ }
+
+ ///
+ /// 计算从当前方向旋转到目标方向的最短有符号角度差,单位为弧度。
+ /// 返回值位于[-π, π);正值表示逆时针,负值表示顺时针。
+ ///
+ public static double ShortestDifferenceRadians(
+ double targetRadians,
+ double currentRadians)
+ {
+ EnsureFinite(targetRadians, nameof(targetRadians));
+ EnsureFinite(currentRadians, nameof(currentRadians));
+
+ return NormalizeRadians(targetRadians - currentRadians);
+ }
+
+ ///
+ /// 计算从当前方向旋转到目标方向的最短有符号角度差,单位为度。
+ /// 返回值位于[-180°, 180°);正值表示逆时针,负值表示顺时针。
+ ///
+ public static double ShortestDifferenceDegrees(
+ double targetDegrees,
+ double currentDegrees)
+ {
+ EnsureFinite(targetDegrees, nameof(targetDegrees));
+ EnsureFinite(currentDegrees, nameof(currentDegrees));
+
+ return NormalizeDegrees(targetDegrees - currentDegrees);
+ }
+
+ ///
+ /// 沿圆周最短方向在两个航向角之间插值,输入和结果单位均为弧度。
+ /// ratio为0时返回起始角,ratio为1时返回终止角;本方法不限制ratio,
+ /// 轨迹线段内插值时应先使用InterpolationMath.Clamp01进行限制。
+ /// 结果归一化到[-π, π)区间;角度差恰好为π时按负方向插值。
+ ///
+ public static double LerpRadians(
+ double startRadians,
+ double endRadians,
+ double ratio)
+ {
+ EnsureFinite(startRadians, nameof(startRadians));
+ EnsureFinite(endRadians, nameof(endRadians));
+ EnsureFinite(ratio, nameof(ratio));
+
+ var shortestDifference = ShortestDifferenceRadians(
+ endRadians,
+ startRadians);
+
+ return NormalizeRadians(
+ startRadians + ratio * shortestDifference);
+ }
+
+ ///
+ /// 将角度从度转换为弧度,不进行归一化。
+ ///
+ public static double DegreesToRadians(double angleDegrees)
+ {
+ EnsureFinite(angleDegrees, nameof(angleDegrees));
+ return angleDegrees * Math.PI / 180.0;
+ }
+
+ ///
+ /// 将角度从弧度转换为度,不进行归一化。
+ ///
+ public static double RadiansToDegrees(double angleRadians)
+ {
+ EnsureFinite(angleRadians, nameof(angleRadians));
+ return angleRadians * 180.0 / Math.PI;
+ }
+
+ ///
+ /// 验证角度是可用于计算的有限数值。
+ ///
+ private static void EnsureFinite(double angle, string parameterName)
+ {
+ if (double.IsNaN(angle) || double.IsInfinity(angle))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "角度必须是有限数值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Shared/Mathematics/FrameTransform2D.cs b/ClumsyPilot/Shared/Mathematics/FrameTransform2D.cs
new file mode 100644
index 0000000..58e33db
--- /dev/null
+++ b/ClumsyPilot/Shared/Mathematics/FrameTransform2D.cs
@@ -0,0 +1,165 @@
+// 车体、运动、车队坐标系之间的转换
+using System;
+
+namespace MyParking.Shared
+{
+ ///
+ /// 提供二维刚体坐标系之间的点、向量、位姿和速度变换。
+ /// 坐标系采用X向前、Y向左、逆时针为正的右手系。
+ ///
+ public static class FrameTransform2D
+ {
+ ///
+ /// 将角度归一化到[-π, π)范围。
+ ///
+ public static double NormalizeAngle(double angleRadians)
+ {
+ return AngleMath.NormalizeRadians(angleRadians);
+ }
+
+ ///
+ /// 计算从current到target的最短角度差。
+ /// 返回正值表示逆时针旋转。
+ ///
+ public static double ShortestAngleDifference(
+ double targetRadians,
+ double currentRadians)
+ {
+ return AngleMath.ShortestDifferenceRadians(
+ targetRadians,
+ currentRadians);
+ }
+
+ ///
+ /// 将源坐标系中的点变换到目标坐标系。
+ /// sourcePoseInTarget表示源坐标系在目标坐标系中的位姿。
+ ///
+ public static Point2D TransformPoint(
+ Pose2D sourcePoseInTarget,
+ Point2D pointInSource)
+ {
+ var cos = Math.Cos(sourcePoseInTarget.YawRadians);
+ var sin = Math.Sin(sourcePoseInTarget.YawRadians);
+
+ return new Point2D(
+ sourcePoseInTarget.XMeters +
+ cos * pointInSource.XMeters -
+ sin * pointInSource.YMeters,
+
+ sourcePoseInTarget.YMeters +
+ sin * pointInSource.XMeters +
+ cos * pointInSource.YMeters);
+ }
+
+ ///
+ /// 将目标坐标系中的点反向变换到源坐标系。
+ ///
+ public static Point2D InverseTransformPoint(
+ Pose2D sourcePoseInTarget,
+ Point2D pointInTarget)
+ {
+ var dx = pointInTarget.XMeters - sourcePoseInTarget.XMeters;
+
+ var dy = pointInTarget.YMeters - sourcePoseInTarget.YMeters;
+
+ var cos = Math.Cos(sourcePoseInTarget.YawRadians);
+ var sin = Math.Sin(sourcePoseInTarget.YawRadians);
+
+ return new Point2D(
+ cos * dx + sin * dy,
+ -sin * dx + cos * dy);
+ }
+
+ ///
+ /// 将源坐标系中的向量旋转到目标坐标系。
+ /// 向量没有位置,因此不叠加平移量。
+ ///
+ public static Point2D TransformVector(
+ Pose2D sourcePoseInTarget,
+ Point2D vectorInSource)
+ {
+ var cos = Math.Cos(sourcePoseInTarget.YawRadians);
+ var sin = Math.Sin(sourcePoseInTarget.YawRadians);
+
+ return new Point2D(
+ cos * vectorInSource.XMeters -
+ sin * vectorInSource.YMeters,
+
+ sin * vectorInSource.XMeters +
+ cos * vectorInSource.YMeters);
+ }
+
+ ///
+ /// 组合两级坐标变换。
+ /// parentFromMiddle表示middle在parent中的位姿;
+ /// middleFromChild表示child在middle中的位姿;
+ /// 返回child在parent中的位姿。
+ ///
+ public static Pose2D Compose(
+ Pose2D parentFromMiddle,
+ Pose2D middleFromChild)
+ {
+ var childPositionInParent = TransformPoint(
+ parentFromMiddle,
+ middleFromChild.Position);
+
+ return new Pose2D(
+ childPositionInParent.XMeters,
+ childPositionInParent.YMeters,
+ NormalizeAngle(
+ parentFromMiddle.YawRadians +
+ middleFromChild.YawRadians));
+ }
+
+ ///
+ /// 对坐标变换求逆。
+ /// 输入child在parent中的位姿,返回parent在child中的位姿。
+ ///
+ public static Pose2D Inverse(Pose2D childPoseInParent)
+ {
+ var cos = Math.Cos(childPoseInParent.YawRadians);
+ var sin = Math.Sin(childPoseInParent.YawRadians);
+
+ return new Pose2D(
+ -cos * childPoseInParent.XMeters -
+ sin * childPoseInParent.YMeters,
+
+ sin * childPoseInParent.XMeters -
+ cos * childPoseInParent.YMeters,
+
+ NormalizeAngle(
+ -childPoseInParent.YawRadians));
+ }
+
+
+ ///
+ /// 将源坐标系中的位姿变换到目标坐标系。
+ ///
+ public static Pose2D TransformPose(
+ Pose2D sourcePoseInTarget,
+ Pose2D poseInSource)
+ {
+ return Compose(sourcePoseInTarget, poseInSource);
+ }
+
+ ///
+ /// 转换同一物理参考点处的速度表达坐标系。
+ /// 只旋转线速度,角速度保持不变。
+ ///
+ public static Twist2D TransformTwistAtSamePoint(
+ Pose2D sourcePoseInTarget,
+ Twist2D twistInSource)
+ {
+ var linearVelocityInTarget = TransformVector(
+ sourcePoseInTarget,
+ new Point2D(
+ twistInSource.VxMetersPerSecond,
+ twistInSource.VyMetersPerSecond));
+
+ return new Twist2D(
+ linearVelocityInTarget.XMeters,
+ linearVelocityInTarget.YMeters,
+ twistInSource.OmegaRadiansPerSecond);
+ }
+ }
+}
diff --git a/ClumsyPilot/Shared/Mathematics/InterpolationMath.cs b/ClumsyPilot/Shared/Mathematics/InterpolationMath.cs
new file mode 100644
index 0000000..61db6e3
--- /dev/null
+++ b/ClumsyPilot/Shared/Mathematics/InterpolationMath.cs
@@ -0,0 +1,61 @@
+
+using System;
+
+namespace MyParking.Shared
+{
+ ///
+ /// 提供与具体业务和坐标系无关的基础插值功能。
+ ///
+ public static class InterpolationMath
+ {
+ ///
+ /// 将插值比例限制到[0, 1]闭区间。
+ ///
+ public static double Clamp01(double value)
+ {
+ EnsureFinite(value, nameof(value));
+
+ if (value <= 0.0)
+ {
+ return 0.0;
+ }
+
+ if (value >= 1.0)
+ {
+ return 1.0;
+ }
+
+ return value;
+ }
+
+ ///
+ /// 对两个标量执行线性插值。
+ /// ratio为0时返回start,ratio为1时返回end;本方法不限制ratio,
+ /// 因此也支持区间外的线性外插。
+ ///
+ public static double Lerp(
+ double start,
+ double end,
+ double ratio)
+ {
+ EnsureFinite(start, nameof(start));
+ EnsureFinite(end, nameof(end));
+ EnsureFinite(ratio, nameof(ratio));
+
+ return start + ratio * (end - start);
+ }
+
+ ///
+ /// 验证输入是可用于插值计算的有限数值。
+ ///
+ private static void EnsureFinite(double value, string parameterName)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "插值参数必须是有限数值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Shared/Models/ChassisCommand.cs b/ClumsyPilot/Shared/Models/ChassisCommand.cs
new file mode 100644
index 0000000..73545dd
--- /dev/null
+++ b/ClumsyPilot/Shared/Models/ChassisCommand.cs
@@ -0,0 +1,183 @@
+// 纯数据层:只描述坐标、速度和命令
+// 定义二维坐标、位姿、速度、车队布局和单车底盘命令。
+// Shared层统一使用SI单位:位置m、线速度m/s、角度rad、角速度rad/s。
+// 车体坐标系采用右手系:X向前、Y向左、逆时针角度和角速度为正。
+// 命名约定:XxxInYyy表示Xxx在Yyy坐标系中的表达。
+
+namespace MyParking.Shared
+{
+ ///
+ /// 二维坐标点,X、Y单位均为米。
+ ///
+ public readonly struct Point2D
+ {
+ public Point2D(double xMeters, double yMeters)
+ {
+ XMeters = xMeters;
+ YMeters = yMeters;
+ }
+
+ public double XMeters { get; }
+
+ public double YMeters { get; }
+
+ public static Point2D Zero => new Point2D(0.0, 0.0);
+ }
+
+ ///
+ /// 二维局部坐标系在父坐标系中的位姿。
+ /// 位置单位为米,朝向单位为弧度,逆时针为正。
+ /// 具体父子关系由变量名称说明,例如RadarPoseInBody。
+ ///
+ public readonly struct Pose2D
+ {
+ public Pose2D(
+ double xMeters,
+ double yMeters,
+ double yawRadians)
+ {
+ XMeters = xMeters;
+ YMeters = yMeters;
+ YawRadians = yawRadians;
+ }
+
+ public double XMeters { get; }
+
+ public double YMeters { get; }
+
+ public double YawRadians { get; }
+
+ public Point2D Position =>
+ new Point2D(XMeters, YMeters);
+
+ public static Pose2D Identity =>
+ new Pose2D(0.0, 0.0, 0.0);
+ }
+
+ ///
+ /// 二维刚体速度。
+ /// 线速度单位为m/s,角速度单位为rad/s。
+ /// 速度所属坐标系由持有该Twist2D的外层类型或变量名称确定。
+ ///
+ public readonly struct Twist2D
+ {
+ public Twist2D(
+ double vxMetersPerSecond,
+ double vyMetersPerSecond,
+ double omegaRadiansPerSecond)
+ {
+ VxMetersPerSecond = vxMetersPerSecond;
+ VyMetersPerSecond = vyMetersPerSecond;
+ OmegaRadiansPerSecond = omegaRadiansPerSecond;
+ }
+
+ public double VxMetersPerSecond { get; }
+
+ public double VyMetersPerSecond { get; }
+
+ public double OmegaRadiansPerSecond { get; }
+
+ public static Twist2D Zero =>
+ new Twist2D(0.0, 0.0, 0.0);
+ }
+
+ ///
+ /// 发送给单辆车的车体坐标系速度命令。
+ ///
+ public readonly struct ChassisCommand
+ {
+ public ChassisCommand(
+ int vehicleId,
+ Twist2D bodyTwist)
+ {
+ VehicleId = vehicleId;
+ BodyTwist = bodyTwist;
+ }
+
+ public int VehicleId { get; }
+
+ ///
+ /// 单车车体坐标系速度:X向前、Y向左、逆时针旋转为正。
+ ///
+ public Twist2D BodyTwist { get; }
+
+ ///
+ /// 创建指定车辆的停止命令。
+ ///
+ public static ChassisCommand Stop(int vehicleId)
+ {
+ return new ChassisCommand(
+ vehicleId,
+ Twist2D.Zero);
+ }
+ }
+
+ ///
+ /// 单辆车的车体坐标系在车队坐标系中的位姿。
+ ///
+ public readonly struct VehicleLayout
+ {
+ public VehicleLayout(
+ int vehicleId,
+ Pose2D poseInFleet)
+ {
+ VehicleId = vehicleId;
+ PoseInFleet = poseInFleet;
+ }
+
+ public int VehicleId { get; }
+
+ public Pose2D PoseInFleet { get; }
+ }
+
+ ///
+ /// 车队整体运动命令,速度分量均在车队坐标系中表达。
+ ///
+ public readonly struct FleetMotionCommand
+ {
+ public FleetMotionCommand(
+ Point2D referencePointInFleet,
+ Twist2D twistAtReferencePoint)
+ {
+ ReferencePointInFleet = referencePointInFleet;
+ TwistAtReferencePoint = twistAtReferencePoint;
+ }
+
+ ///
+ /// 速度命令对应的参考点,也可作为自定义旋转中心。
+ ///
+ public Point2D ReferencePointInFleet { get; }
+
+ ///
+ /// 参考点处的车队速度。
+ ///
+ public Twist2D TwistAtReferencePoint { get; }
+
+ ///
+ /// 创建绕指定中心原地旋转的车队命令。
+ ///
+ public static FleetMotionCommand RotateAround(
+ Point2D rotationCenterInFleet,
+ double omegaRadiansPerSecond)
+ {
+ return new FleetMotionCommand(
+ rotationCenterInFleet,
+ new Twist2D(
+ 0.0,
+ 0.0,
+ omegaRadiansPerSecond));
+ }
+
+ ///
+ /// 创建车队停止命令。
+ ///
+ public static FleetMotionCommand Stop()
+ {
+ return new FleetMotionCommand(
+ Point2D.Zero,
+ Twist2D.Zero);
+ }
+ }
+
+
+}
diff --git a/ClumsyPilot/StateEstimation/DetourVehicleStateProvider.cs b/ClumsyPilot/StateEstimation/DetourVehicleStateProvider.cs
new file mode 100644
index 0000000..4a07a9b
--- /dev/null
+++ b/ClumsyPilot/StateEstimation/DetourVehicleStateProvider.cs
@@ -0,0 +1,509 @@
+using System;
+using System.Diagnostics;
+using ClumsyCore.Interfaces;
+using MyParking.Shared;
+
+namespace MultiWheelC.StateEstimation
+{
+ ///
+ /// 读取Detour位姿,忽略重复或明显异常的观测,并估算车辆二维速度。
+ ///
+ public sealed class DetourVehicleStateProvider
+ : IVehicleStateProvider
+ {
+ public const double DefaultMaximumLinearSpeedMetersPerSecond =
+ 1.20;
+ public const double DefaultMaximumAngularSpeedRadiansPerSecond =
+ Math.PI / 4.0;
+ public const double DefaultPositionJumpMarginMeters =
+ 0.03;
+ public const double DefaultHeadingJumpMarginRadians =
+ 5.0 * Math.PI / 180.0;
+ public const double DefaultVelocityPositionResidualMeters =
+ 0.04;
+ public const double DefaultVelocityHeadingResidualRadians =
+ 5.0 * Math.PI / 180.0;
+ public const double DefaultStationaryConfirmationSeconds =
+ 0.35;
+
+ private const double MillimetersPerMeter = 1000.0;
+ private const double PositionEqualityToleranceMeters = 1e-9;
+ private const double HeadingEqualityToleranceRadians = 1e-8;
+
+ private readonly object _syncRoot = new object();
+ private readonly Stopwatch _clock = Stopwatch.StartNew();
+ private readonly VelocityEstimator2D _velocityEstimator;
+ private readonly double _maximumLinearSpeedMetersPerSecond;
+ private readonly double _maximumAngularSpeedRadiansPerSecond;
+ private readonly double _positionJumpMarginMeters;
+ private readonly double _headingJumpMarginRadians;
+ private readonly double _velocityPositionResidualMeters;
+ private readonly double _velocityHeadingResidualRadians;
+ private readonly double _stationaryConfirmationSeconds;
+
+ private bool _hasAcceptedPose;
+ private Pose2D _acceptedPoseInWorld;
+ private double _acceptedTimestampSeconds;
+ private VehicleState _latestState;
+ private bool _stationaryHoldActive;
+
+ ///
+ /// 创建使用停车机器人默认物理边界和速度滤波参数的Detour状态源。
+ ///
+ public DetourVehicleStateProvider()
+ : this(
+ new VelocityEstimator2D(),
+ DefaultMaximumLinearSpeedMetersPerSecond,
+ DefaultMaximumAngularSpeedRadiansPerSecond,
+ DefaultPositionJumpMarginMeters,
+ DefaultHeadingJumpMarginRadians,
+ DefaultVelocityPositionResidualMeters,
+ DefaultVelocityHeadingResidualRadians,
+ DefaultStationaryConfirmationSeconds)
+ {
+ }
+
+ ///
+ /// 创建使用指定物理边界、静止确认时间和速度估计器的Detour状态源。
+ ///
+ public DetourVehicleStateProvider(
+ VelocityEstimator2D velocityEstimator,
+ double maximumLinearSpeedMetersPerSecond,
+ double maximumAngularSpeedRadiansPerSecond,
+ double positionJumpMarginMeters,
+ double headingJumpMarginRadians,
+ double velocityPositionResidualMeters,
+ double velocityHeadingResidualRadians,
+ double stationaryConfirmationSeconds)
+ {
+ _velocityEstimator = velocityEstimator ??
+ throw new ArgumentNullException(
+ nameof(velocityEstimator));
+
+ EnsureFinitePositive(
+ maximumLinearSpeedMetersPerSecond,
+ nameof(maximumLinearSpeedMetersPerSecond));
+ EnsureFinitePositive(
+ maximumAngularSpeedRadiansPerSecond,
+ nameof(maximumAngularSpeedRadiansPerSecond));
+ EnsureFiniteNonNegative(
+ positionJumpMarginMeters,
+ nameof(positionJumpMarginMeters));
+ EnsureFiniteNonNegative(
+ headingJumpMarginRadians,
+ nameof(headingJumpMarginRadians));
+ EnsureFinitePositive(
+ velocityPositionResidualMeters,
+ nameof(velocityPositionResidualMeters));
+ EnsureFinitePositive(
+ velocityHeadingResidualRadians,
+ nameof(velocityHeadingResidualRadians));
+ EnsureFinitePositive(
+ stationaryConfirmationSeconds,
+ nameof(stationaryConfirmationSeconds));
+
+ _maximumLinearSpeedMetersPerSecond =
+ maximumLinearSpeedMetersPerSecond;
+ _maximumAngularSpeedRadiansPerSecond =
+ maximumAngularSpeedRadiansPerSecond;
+ _positionJumpMarginMeters =
+ positionJumpMarginMeters;
+ _headingJumpMarginRadians =
+ headingJumpMarginRadians;
+ _velocityPositionResidualMeters =
+ velocityPositionResidualMeters;
+ _velocityHeadingResidualRadians =
+ velocityHeadingResidualRadians;
+ _stationaryConfirmationSeconds =
+ stationaryConfirmationSeconds;
+ }
+
+ ///
+ /// 获取最近一次读取失败或异常观测被忽略的原因,正常时为空字符串。
+ ///
+ public string LastFailureReason { get; private set; } = "";
+
+ ///
+ /// 尝试读取Detour;重复帧保留最近状态,明显异常帧只忽略本次观测。
+ ///
+ public bool TryGetState(out VehicleState state)
+ {
+ lock (_syncRoot)
+ {
+ try
+ {
+ var poseInWorld =
+ ReadDetourPoseInWorld();
+ var timestampSeconds =
+ _clock.Elapsed.TotalSeconds;
+
+ if (!_hasAcceptedPose)
+ {
+ state = AcceptPoseAfterReset(
+ poseInWorld,
+ timestampSeconds);
+ LastFailureReason = "";
+ return true;
+ }
+
+ if (ArePosesEquivalent(
+ poseInWorld,
+ _acceptedPoseInWorld))
+ {
+ state = HandleRepeatedPose(
+ timestampSeconds);
+ LastFailureReason = "";
+ return true;
+ }
+
+ // 静止保持后出现新定位时重新建立差分基准,
+ // 避免用很长的静止时间稀释第一次运动速度。
+ if (_stationaryHoldActive)
+ {
+ state = AcceptPoseAfterReset(
+ poseInWorld,
+ timestampSeconds);
+ LastFailureReason = "";
+ return true;
+ }
+
+ var elapsedSeconds =
+ timestampSeconds -
+ _acceptedTimestampSeconds;
+
+ if (!IsMotionPlausible(
+ _acceptedPoseInWorld,
+ poseInWorld,
+ elapsedSeconds))
+ {
+ // 单帧异常不进入差分器,也不中断调用方;下一次
+ // 正常观测仍相对最近有效位姿和真实时间差计算。
+ state = _latestState;
+ LastFailureReason =
+ "Detour位姿变化超过车辆绝对运动边界,本次观测已忽略。";
+ return true;
+ }
+
+ if (IsVelocityInnovationAbnormal(
+ poseInWorld,
+ elapsedSeconds))
+ {
+ state = AcceptPoseAfterReset(
+ poseInWorld,
+ timestampSeconds);
+ LastFailureReason =
+ "Detour位姿偏离速度预测,已重新建立速度估计基准。";
+ return true;
+ }
+
+ state = AcceptContinuousPose(
+ poseInWorld,
+ timestampSeconds);
+ LastFailureReason = "";
+ return true;
+ }
+ catch (Exception exception)
+ {
+ state = default;
+ LastFailureReason =
+ "Detour车辆状态读取失败:" +
+ exception.Message;
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// 清除Detour位姿历史和速度估计状态。
+ ///
+ public void Reset()
+ {
+ lock (_syncRoot)
+ {
+ _velocityEstimator.Reset();
+ _hasAcceptedPose = false;
+ _acceptedPoseInWorld = Pose2D.Identity;
+ _acceptedTimestampSeconds = 0.0;
+ _latestState = default;
+ _stationaryHoldActive = false;
+ LastFailureReason = "";
+ }
+ }
+
+ ///
+ /// 读取Detour毫米和角度数据并转换为世界坐标SI位姿。
+ ///
+ private static Pose2D ReadDetourPoseInWorld()
+ {
+ var location =
+ DetourInterface.getCartLocation();
+
+ EnsureFinite(location.x, "DetourX");
+ EnsureFinite(location.y, "DetourY");
+ EnsureFinite(location.th, "DetourTheta");
+
+ return new Pose2D(
+ location.x / MillimetersPerMeter,
+ location.y / MillimetersPerMeter,
+ AngleMath.NormalizeRadians(
+ AngleMath.DegreesToRadians(
+ location.th)));
+ }
+
+ ///
+ /// 接受连续有效定位并更新速度估计和差分基准。
+ ///
+ private VehicleState AcceptContinuousPose(
+ Pose2D poseInWorld,
+ double timestampSeconds)
+ {
+ _latestState =
+ _velocityEstimator.Update(
+ poseInWorld,
+ timestampSeconds);
+ _acceptedPoseInWorld = poseInWorld;
+ _acceptedTimestampSeconds =
+ timestampSeconds;
+ _stationaryHoldActive = false;
+ return _latestState;
+ }
+
+ ///
+ /// 接受跳变后的新位姿基准,但不让该位移进入速度差分和低通滤波器。
+ ///
+ private VehicleState AcceptPoseAfterVelocityRebase(
+ Pose2D poseInWorld,
+ double timestampSeconds)
+ {
+ _latestState =
+ _velocityEstimator
+ .RebasePreservingVelocity(
+ poseInWorld,
+ timestampSeconds);
+ _acceptedPoseInWorld = poseInWorld;
+ _acceptedTimestampSeconds =
+ timestampSeconds;
+ _stationaryHoldActive = false;
+ return _latestState;
+ }
+
+ ///
+ /// 接受首帧或静止后的首个新位姿并重新建立零速差分基准。
+ ///
+ private VehicleState AcceptPoseAfterReset(
+ Pose2D poseInWorld,
+ double timestampSeconds)
+ {
+ _latestState =
+ _velocityEstimator.Reset(
+ poseInWorld,
+ timestampSeconds);
+ _acceptedPoseInWorld = poseInWorld;
+ _acceptedTimestampSeconds =
+ timestampSeconds;
+ _hasAcceptedPose = true;
+ _stationaryHoldActive = false;
+ return _latestState;
+ }
+
+ ///
+ /// 对重复Detour观测保留最近状态,并在持续不变后将速度归零。
+ ///
+ private VehicleState HandleRepeatedPose(
+ double timestampSeconds)
+ {
+ var unchangedSeconds =
+ timestampSeconds -
+ _acceptedTimestampSeconds;
+
+ if (!_stationaryHoldActive &&
+ unchangedSeconds >=
+ _stationaryConfirmationSeconds)
+ {
+ _latestState =
+ new VehicleState(
+ timestampSeconds,
+ _acceptedPoseInWorld,
+ Twist2D.Zero,
+ true);
+ _stationaryHoldActive = true;
+ }
+
+ return _latestState;
+ }
+
+ ///
+ /// 判断两次有效Detour观测之间的变化是否超过车辆绝对运动能力。
+ ///
+ private bool IsMotionPlausible(
+ Pose2D startPoseInWorld,
+ Pose2D endPoseInWorld,
+ double deltaTimeSeconds)
+ {
+ if (!IsFinite(deltaTimeSeconds) ||
+ deltaTimeSeconds <= 0.0)
+ {
+ return false;
+ }
+
+ var deltaX =
+ endPoseInWorld.XMeters -
+ startPoseInWorld.XMeters;
+ var deltaY =
+ endPoseInWorld.YMeters -
+ startPoseInWorld.YMeters;
+ var displacementMeters =
+ Math.Sqrt(
+ deltaX * deltaX +
+ deltaY * deltaY);
+ var headingChangeRadians =
+ Math.Abs(
+ AngleMath.ShortestDifferenceRadians(
+ endPoseInWorld.YawRadians,
+ startPoseInWorld.YawRadians));
+
+ var maximumDisplacementMeters =
+ _maximumLinearSpeedMetersPerSecond *
+ deltaTimeSeconds +
+ _positionJumpMarginMeters;
+ var maximumHeadingChangeRadians =
+ _maximumAngularSpeedRadiansPerSecond *
+ deltaTimeSeconds +
+ _headingJumpMarginRadians;
+
+ return displacementMeters <=
+ maximumDisplacementMeters &&
+ headingChangeRadians <=
+ maximumHeadingChangeRadians;
+ }
+
+ ///
+ /// 判断新位姿是否明显偏离上一滤波速度给出的恒速预测。
+ ///
+ private bool IsVelocityInnovationAbnormal(
+ Pose2D poseInWorld,
+ double deltaTimeSeconds)
+ {
+ if (!_latestState.HasValidVelocityEstimate)
+ {
+ return false;
+ }
+
+ var predictedX =
+ _acceptedPoseInWorld.XMeters +
+ _latestState.TwistInWorld
+ .VxMetersPerSecond *
+ deltaTimeSeconds;
+ var predictedY =
+ _acceptedPoseInWorld.YMeters +
+ _latestState.TwistInWorld
+ .VyMetersPerSecond *
+ deltaTimeSeconds;
+ var predictedYaw =
+ AngleMath.NormalizeRadians(
+ _acceptedPoseInWorld.YawRadians +
+ _latestState.TwistInWorld
+ .OmegaRadiansPerSecond *
+ deltaTimeSeconds);
+
+ var positionResidualX =
+ poseInWorld.XMeters - predictedX;
+ var positionResidualY =
+ poseInWorld.YMeters - predictedY;
+ var positionResidualMeters =
+ Math.Sqrt(
+ positionResidualX * positionResidualX +
+ positionResidualY * positionResidualY);
+ var headingResidualRadians =
+ Math.Abs(
+ AngleMath.ShortestDifferenceRadians(
+ poseInWorld.YawRadians,
+ predictedYaw));
+
+ return positionResidualMeters >
+ _velocityPositionResidualMeters ||
+ headingResidualRadians >
+ _velocityHeadingResidualRadians;
+ }
+
+ ///
+ /// 判断两次读取是否为Detour保持输出的同一数值帧。
+ ///
+ private static bool ArePosesEquivalent(
+ Pose2D firstPose,
+ Pose2D secondPose)
+ {
+ return Math.Abs(
+ firstPose.XMeters -
+ secondPose.XMeters) <=
+ PositionEqualityToleranceMeters &&
+ Math.Abs(
+ firstPose.YMeters -
+ secondPose.YMeters) <=
+ PositionEqualityToleranceMeters &&
+ Math.Abs(
+ AngleMath.ShortestDifferenceRadians(
+ firstPose.YawRadians,
+ secondPose.YawRadians)) <=
+ HeadingEqualityToleranceRadians;
+ }
+
+ ///
+ /// 检查数值是否为正有限值。
+ ///
+ private static void EnsureFinitePositive(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "状态源参数必须是正有限值。");
+ }
+ }
+
+ ///
+ /// 检查数值是否为非负有限值。
+ ///
+ private static void EnsureFiniteNonNegative(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "状态源参数必须是非负有限值。");
+ }
+ }
+
+ ///
+ /// 检查数值是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (!IsFinite(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "状态源参数和Detour位姿必须是有限值。");
+ }
+ }
+
+ ///
+ /// 判断数值是否可用于状态估计。
+ ///
+ private static bool IsFinite(double value)
+ {
+ return !double.IsNaN(value) &&
+ !double.IsInfinity(value);
+ }
+ }
+}
diff --git a/ClumsyPilot/StateEstimation/FirstOrderLowPassFilter.cs b/ClumsyPilot/StateEstimation/FirstOrderLowPassFilter.cs
new file mode 100644
index 0000000..29464bc
--- /dev/null
+++ b/ClumsyPilot/StateEstimation/FirstOrderLowPassFilter.cs
@@ -0,0 +1,142 @@
+using System;
+
+namespace MultiWheelC.StateEstimation
+{
+ ///
+ /// 使用真实采样时间间隔对单个连续量执行在线一阶低通滤波。
+ ///
+ public sealed class FirstOrderLowPassFilter
+ {
+ private readonly double _timeConstantSeconds;
+ private bool _isInitialized;
+ private double _value;
+
+ ///
+ /// 创建使用指定时间常数的一阶低通滤波器。
+ ///
+ public FirstOrderLowPassFilter(
+ double timeConstantSeconds)
+ {
+ EnsureFinitePositive(
+ timeConstantSeconds,
+ nameof(timeConstantSeconds));
+
+ _timeConstantSeconds =
+ timeConstantSeconds;
+ }
+
+ ///
+ /// 获取滤波时间常数,单位为s;数值越大,滤波越强但响应越慢。
+ ///
+ public double TimeConstantSeconds =>
+ _timeConstantSeconds;
+
+ ///
+ /// 获取滤波器是否已经接收过有效初值。
+ ///
+ public bool IsInitialized =>
+ _isInitialized;
+
+ ///
+ /// 获取当前滤波输出;尚未初始化时读取会抛出异常。
+ ///
+ public double Value
+ {
+ get
+ {
+ if (!_isInitialized)
+ {
+ throw new InvalidOperationException(
+ "一阶低通滤波器尚未初始化。");
+ }
+
+ return _value;
+ }
+ }
+
+ ///
+ /// 使用当前输入和真实采样间隔更新滤波结果。
+ ///
+ public double Update(
+ double input,
+ double deltaTimeSeconds)
+ {
+ EnsureFinite(
+ input,
+ nameof(input));
+ EnsureFinitePositive(
+ deltaTimeSeconds,
+ nameof(deltaTimeSeconds));
+
+ if (!_isInitialized)
+ {
+ _value = input;
+ _isInitialized = true;
+ return _value;
+ }
+
+ var alpha =
+ deltaTimeSeconds /
+ (_timeConstantSeconds +
+ deltaTimeSeconds);
+
+ _value += alpha * (input - _value);
+ return _value;
+ }
+
+ ///
+ /// 清除历史输出,使下一次有效输入直接成为新的初值。
+ ///
+ public void Reset()
+ {
+ _value = 0.0;
+ _isInitialized = false;
+ }
+
+ ///
+ /// 将滤波器立即重置到指定的有限初值。
+ ///
+ public void Reset(double initialValue)
+ {
+ EnsureFinite(
+ initialValue,
+ nameof(initialValue));
+
+ _value = initialValue;
+ _isInitialized = true;
+ }
+
+ ///
+ /// 检查数值是否为正有限值。
+ ///
+ private static void EnsureFinitePositive(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "滤波时间常数和采样间隔必须是正有限值。");
+ }
+ }
+
+ ///
+ /// 检查数值是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "滤波输入必须是有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/StateEstimation/IVehicleStateProvider.cs b/ClumsyPilot/StateEstimation/IVehicleStateProvider.cs
new file mode 100644
index 0000000..fcb25d8
--- /dev/null
+++ b/ClumsyPilot/StateEstimation/IVehicleStateProvider.cs
@@ -0,0 +1,14 @@
+namespace MultiWheelC.StateEstimation
+{
+ ///
+ /// 为轨迹控制器提供与具体定位来源无关的统一车辆状态读取接口。
+ ///
+ public interface IVehicleStateProvider
+ {
+ ///
+ /// 尝试读取当前有效车辆状态;定位不可用或过期时返回false。
+ ///
+ bool TryGetState(out VehicleState state);
+ }
+}
+
diff --git a/ClumsyPilot/StateEstimation/VehicleState.cs b/ClumsyPilot/StateEstimation/VehicleState.cs
new file mode 100644
index 0000000..af4814c
--- /dev/null
+++ b/ClumsyPilot/StateEstimation/VehicleState.cs
@@ -0,0 +1,138 @@
+using System;
+using MyParking.Shared;
+
+namespace MultiWheelC.StateEstimation
+{
+ ///
+ /// 保存一次经过校验的车辆位姿和速度估计快照,统一使用SI单位。
+ ///
+ public readonly struct VehicleState
+ {
+ ///
+ /// 创建车辆状态,并将世界坐标速度同步转换到车体坐标系。
+ ///
+ public VehicleState(
+ double sampleTimestampSeconds,
+ Pose2D poseInWorld,
+ Twist2D twistInWorld,
+ bool hasValidVelocityEstimate)
+ {
+ EnsureFiniteNonNegative(
+ sampleTimestampSeconds,
+ nameof(sampleTimestampSeconds));
+ EnsureFinitePose(
+ poseInWorld,
+ nameof(poseInWorld));
+ EnsureFiniteTwist(
+ twistInWorld,
+ nameof(twistInWorld));
+
+ SampleTimestampSeconds =
+ sampleTimestampSeconds;
+ PoseInWorld = new Pose2D(
+ poseInWorld.XMeters,
+ poseInWorld.YMeters,
+ AngleMath.NormalizeRadians(
+ poseInWorld.YawRadians));
+ HasValidVelocityEstimate =
+ hasValidVelocityEstimate;
+
+ // 第一帧或定位重置后的速度不可用于闭环控制,
+ // 此时显式置零,避免调用方误用残留速度。
+ TwistInWorld = hasValidVelocityEstimate
+ ? twistInWorld
+ : Twist2D.Zero;
+
+ var worldPoseInBody =
+ FrameTransform2D.Inverse(
+ PoseInWorld);
+ TwistInBody =
+ FrameTransform2D.TransformTwistAtSamePoint(
+ worldPoseInBody,
+ TwistInWorld);
+ }
+
+ ///
+ /// 获取状态源单调时钟中的采样时刻,单位为s。
+ ///
+ public double SampleTimestampSeconds { get; }
+
+ ///
+ /// 获取车体中心在Detour世界坐标系中的位姿,单位为m和rad。
+ ///
+ public Pose2D PoseInWorld { get; }
+
+ ///
+ /// 获取在世界坐标系中表达的车辆速度,单位为m/s和rad/s。
+ ///
+ public Twist2D TwistInWorld { get; }
+
+ ///
+ /// 获取在车体坐标系中表达的车辆速度,X向前、Y向左、逆时针为正。
+ ///
+ public Twist2D TwistInBody { get; }
+
+ ///
+ /// 获取当前速度是否已由至少两个连续有效定位样本估算得到。
+ ///
+ public bool HasValidVelocityEstimate { get; }
+
+ ///
+ /// 检查位姿是否由有限数值组成。
+ ///
+ private static void EnsureFinitePose(
+ Pose2D pose,
+ string parameterName)
+ {
+ if (!IsFinite(pose.XMeters) ||
+ !IsFinite(pose.YMeters) ||
+ !IsFinite(pose.YawRadians))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "车辆位姿必须由有限数值组成。");
+ }
+ }
+
+ ///
+ /// 检查速度是否由有限数值组成。
+ ///
+ private static void EnsureFiniteTwist(
+ Twist2D twist,
+ string parameterName)
+ {
+ if (!IsFinite(twist.VxMetersPerSecond) ||
+ !IsFinite(twist.VyMetersPerSecond) ||
+ !IsFinite(twist.OmegaRadiansPerSecond))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "车辆速度必须由有限数值组成。");
+ }
+ }
+
+ ///
+ /// 检查数值是否为非负有限值。
+ ///
+ private static void EnsureFiniteNonNegative(
+ double value,
+ string parameterName)
+ {
+ if (!IsFinite(value) || value < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "采样时刻必须是非负有限值。");
+ }
+ }
+
+ ///
+ /// 判断数值是否可用于车辆状态计算。
+ ///
+ private static bool IsFinite(double value)
+ {
+ return !double.IsNaN(value) &&
+ !double.IsInfinity(value);
+ }
+ }
+}
diff --git a/ClumsyPilot/StateEstimation/VelocityEstimator2D.cs b/ClumsyPilot/StateEstimation/VelocityEstimator2D.cs
new file mode 100644
index 0000000..3d8a85d
--- /dev/null
+++ b/ClumsyPilot/StateEstimation/VelocityEstimator2D.cs
@@ -0,0 +1,275 @@
+using System;
+using MyParking.Shared;
+
+namespace MultiWheelC.StateEstimation
+{
+ ///
+ /// 根据连续有效的Detour世界位姿和真实时间差估算车辆二维速度。
+ ///
+ public sealed class VelocityEstimator2D
+ {
+ public const double DefaultLinearFilterTimeConstantSeconds =
+ 0.15;
+ public const double DefaultAngularFilterTimeConstantSeconds =
+ 0.20;
+
+ private readonly FirstOrderLowPassFilter
+ _worldVelocityXFilter;
+ private readonly FirstOrderLowPassFilter
+ _worldVelocityYFilter;
+ private readonly FirstOrderLowPassFilter
+ _angularVelocityFilter;
+
+ private bool _hasPreviousSample;
+ private Pose2D _previousPoseInWorld;
+ private double _previousTimestampSeconds;
+
+ ///
+ /// 创建使用默认0.15s线速度和0.20s角速度时间常数的估计器。
+ ///
+ public VelocityEstimator2D()
+ : this(
+ DefaultLinearFilterTimeConstantSeconds,
+ DefaultAngularFilterTimeConstantSeconds)
+ {
+ }
+
+ ///
+ /// 创建使用指定线速度和角速度滤波时间常数的估计器。
+ ///
+ public VelocityEstimator2D(
+ double linearFilterTimeConstantSeconds,
+ double angularFilterTimeConstantSeconds)
+ {
+ _worldVelocityXFilter =
+ new FirstOrderLowPassFilter(
+ linearFilterTimeConstantSeconds);
+ _worldVelocityYFilter =
+ new FirstOrderLowPassFilter(
+ linearFilterTimeConstantSeconds);
+ _angularVelocityFilter =
+ new FirstOrderLowPassFilter(
+ angularFilterTimeConstantSeconds);
+ }
+
+ ///
+ /// 获取是否已经保存了可用于下一次差分的位姿基准。
+ ///
+ public bool HasPreviousSample =>
+ _hasPreviousSample;
+
+ ///
+ /// 使用一个新的有效定位样本更新并返回车辆状态。
+ ///
+ public VehicleState Update(
+ Pose2D poseInWorld,
+ double sampleTimestampSeconds)
+ {
+ EnsureFinitePose(
+ poseInWorld,
+ nameof(poseInWorld));
+ EnsureFiniteNonNegative(
+ sampleTimestampSeconds,
+ nameof(sampleTimestampSeconds));
+
+ var normalizedPoseInWorld =
+ new Pose2D(
+ poseInWorld.XMeters,
+ poseInWorld.YMeters,
+ AngleMath.NormalizeRadians(
+ poseInWorld.YawRadians));
+
+ if (!_hasPreviousSample)
+ {
+ return Reset(
+ normalizedPoseInWorld,
+ sampleTimestampSeconds);
+ }
+
+ var deltaTimeSeconds =
+ sampleTimestampSeconds -
+ _previousTimestampSeconds;
+
+ if (deltaTimeSeconds <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(sampleTimestampSeconds),
+ "新定位样本的单调时间戳必须严格大于上一帧。");
+ }
+
+ var rawVelocityXInWorld =
+ (normalizedPoseInWorld.XMeters -
+ _previousPoseInWorld.XMeters) /
+ deltaTimeSeconds;
+ var rawVelocityYInWorld =
+ (normalizedPoseInWorld.YMeters -
+ _previousPoseInWorld.YMeters) /
+ deltaTimeSeconds;
+ var rawAngularVelocity =
+ AngleMath.ShortestDifferenceRadians(
+ normalizedPoseInWorld.YawRadians,
+ _previousPoseInWorld.YawRadians) /
+ deltaTimeSeconds;
+
+ var filteredTwistInWorld =
+ new Twist2D(
+ _worldVelocityXFilter.Update(
+ rawVelocityXInWorld,
+ deltaTimeSeconds),
+ _worldVelocityYFilter.Update(
+ rawVelocityYInWorld,
+ deltaTimeSeconds),
+ _angularVelocityFilter.Update(
+ rawAngularVelocity,
+ deltaTimeSeconds));
+
+ _previousPoseInWorld =
+ normalizedPoseInWorld;
+ _previousTimestampSeconds =
+ sampleTimestampSeconds;
+
+ return new VehicleState(
+ sampleTimestampSeconds,
+ normalizedPoseInWorld,
+ filteredTwistInWorld,
+ true);
+ }
+
+ ///
+ /// 更新位姿差分基准但保留当前滤波速度,避免定位跳变形成虚假速度尖峰。
+ ///
+ public VehicleState RebasePreservingVelocity(
+ Pose2D poseInWorld,
+ double sampleTimestampSeconds)
+ {
+ EnsureFinitePose(
+ poseInWorld,
+ nameof(poseInWorld));
+ EnsureFiniteNonNegative(
+ sampleTimestampSeconds,
+ nameof(sampleTimestampSeconds));
+
+ var normalizedPoseInWorld =
+ new Pose2D(
+ poseInWorld.XMeters,
+ poseInWorld.YMeters,
+ AngleMath.NormalizeRadians(
+ poseInWorld.YawRadians));
+
+ _previousPoseInWorld =
+ normalizedPoseInWorld;
+ _previousTimestampSeconds =
+ sampleTimestampSeconds;
+ _hasPreviousSample = true;
+
+ var hasValidVelocityEstimate =
+ _worldVelocityXFilter.IsInitialized &&
+ _worldVelocityYFilter.IsInitialized &&
+ _angularVelocityFilter.IsInitialized;
+
+ var retainedTwistInWorld =
+ hasValidVelocityEstimate
+ ? new Twist2D(
+ _worldVelocityXFilter.Value,
+ _worldVelocityYFilter.Value,
+ _angularVelocityFilter.Value)
+ : Twist2D.Zero;
+
+ return new VehicleState(
+ sampleTimestampSeconds,
+ normalizedPoseInWorld,
+ retainedTwistInWorld,
+ hasValidVelocityEstimate);
+ }
+
+ ///
+ /// 使用当前定位重新建立差分基准,并返回速度无效的零速状态。
+ ///
+ public VehicleState Reset(
+ Pose2D poseInWorld,
+ double sampleTimestampSeconds)
+ {
+ EnsureFinitePose(
+ poseInWorld,
+ nameof(poseInWorld));
+ EnsureFiniteNonNegative(
+ sampleTimestampSeconds,
+ nameof(sampleTimestampSeconds));
+
+ _previousPoseInWorld =
+ new Pose2D(
+ poseInWorld.XMeters,
+ poseInWorld.YMeters,
+ AngleMath.NormalizeRadians(
+ poseInWorld.YawRadians));
+ _previousTimestampSeconds =
+ sampleTimestampSeconds;
+ _hasPreviousSample = true;
+
+ _worldVelocityXFilter.Reset();
+ _worldVelocityYFilter.Reset();
+ _angularVelocityFilter.Reset();
+
+ return new VehicleState(
+ sampleTimestampSeconds,
+ _previousPoseInWorld,
+ Twist2D.Zero,
+ false);
+ }
+
+ ///
+ /// 清除差分基准和全部滤波历史,使下一帧重新初始化估计器。
+ ///
+ public void Reset()
+ {
+ _hasPreviousSample = false;
+ _previousPoseInWorld = Pose2D.Identity;
+ _previousTimestampSeconds = 0.0;
+
+ _worldVelocityXFilter.Reset();
+ _worldVelocityYFilter.Reset();
+ _angularVelocityFilter.Reset();
+ }
+
+ ///
+ /// 检查位姿是否由有限数值组成。
+ ///
+ private static void EnsureFinitePose(
+ Pose2D pose,
+ string parameterName)
+ {
+ if (!IsFinite(pose.XMeters) ||
+ !IsFinite(pose.YMeters) ||
+ !IsFinite(pose.YawRadians))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "速度估计使用的车辆位姿必须由有限数值组成。");
+ }
+ }
+
+ ///
+ /// 检查数值是否为非负有限值。
+ ///
+ private static void EnsureFiniteNonNegative(
+ double value,
+ string parameterName)
+ {
+ if (!IsFinite(value) || value < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "速度估计使用的采样时刻必须是非负有限值。");
+ }
+ }
+
+ ///
+ /// 判断数值是否可用于速度估计。
+ ///
+ private static bool IsFinite(double value)
+ {
+ return !double.IsNaN(value) &&
+ !double.IsInfinity(value);
+ }
+ }
+}
diff --git a/ClumsyPilot/Trajectory/LegacyTrackAdapter.cs b/ClumsyPilot/Trajectory/LegacyTrackAdapter.cs
new file mode 100644
index 0000000..d8a2ebb
--- /dev/null
+++ b/ClumsyPilot/Trajectory/LegacyTrackAdapter.cs
@@ -0,0 +1 @@
+// 兼容现有 MDCS 的 AbstractTrack
\ No newline at end of file
diff --git a/ClumsyPilot/Trajectory/Trajectory2D.cs b/ClumsyPilot/Trajectory/Trajectory2D.cs
new file mode 100644
index 0000000..a991bde
--- /dev/null
+++ b/ClumsyPilot/Trajectory/Trajectory2D.cs
@@ -0,0 +1,260 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using MyParking.Shared;
+
+namespace MultiWheelC.Trajectory
+{
+ ///
+ /// 保存一条经过基本合法性检查的只读二维参考轨迹。
+ ///
+ public sealed class Trajectory2D
+ {
+ private const double StartArcLengthToleranceMeters = 1e-9;
+ private const double MinimumSegmentLengthMeters = 1e-6;
+
+ private readonly TrajectoryPoint[] _points;
+ private readonly ReadOnlyCollection _readOnlyPoints;
+
+ ///
+ /// 复制并验证按累计弧长升序排列的参考轨迹点。
+ ///
+ public Trajectory2D(
+ IEnumerable points)
+ {
+ if (points == null)
+ {
+ throw new ArgumentNullException(
+ nameof(points));
+ }
+
+ _points = points.ToArray();
+
+ if (_points.Length < 2)
+ {
+ throw new ArgumentException(
+ "二维轨迹至少需要两个轨迹点。",
+ nameof(points));
+ }
+
+ if (Math.Abs(_points[0].ArcLengthMeters) >
+ StartArcLengthToleranceMeters)
+ {
+ throw new ArgumentException(
+ "二维轨迹起点的累计弧长必须为0m。",
+ nameof(points));
+ }
+
+ for (var index = 1;
+ index < _points.Length;
+ index++)
+ {
+ ValidateSegment(
+ _points[index - 1],
+ _points[index],
+ index,
+ nameof(points));
+ }
+
+ _readOnlyPoints =
+ Array.AsReadOnly(_points);
+ }
+
+ ///
+ /// 获取轨迹点数量。
+ ///
+ public int Count => _points.Length;
+
+ ///
+ /// 获取指定索引处的轨迹点。
+ ///
+ public TrajectoryPoint this[int index] =>
+ _points[index];
+
+ ///
+ /// 获取不可修改的有序轨迹点集合。
+ ///
+ public IReadOnlyList Points =>
+ _readOnlyPoints;
+
+ ///
+ /// 获取轨迹起点。
+ ///
+ public TrajectoryPoint StartPoint =>
+ _points[0];
+
+ ///
+ /// 获取轨迹终点。
+ ///
+ public TrajectoryPoint EndPoint =>
+ _points[_points.Length - 1];
+
+ ///
+ /// 获取轨迹总弧长,单位为m。
+ ///
+ public double TotalLengthMeters =>
+ EndPoint.ArcLengthMeters;
+
+ ///
+ /// 根据当前累计弧长计算到轨迹终点的剩余距离。
+ ///
+ public double GetRemainingDistanceMeters(
+ double arcLengthMeters)
+ {
+ EnsureFinite(
+ arcLengthMeters,
+ nameof(arcLengthMeters));
+
+ if (arcLengthMeters <= 0.0)
+ return TotalLengthMeters;
+
+ if (arcLengthMeters >= TotalLengthMeters)
+ return 0.0;
+
+ return TotalLengthMeters - arcLengthMeters;
+ }
+
+ ///
+ /// 按累计弧长在线性位置、航向、曲率和参考速度之间插值得到轨迹点。
+ ///
+ public TrajectoryPoint SampleAtArcLength(
+ double arcLengthMeters)
+ {
+ EnsureFinite(
+ arcLengthMeters,
+ nameof(arcLengthMeters));
+
+ if (arcLengthMeters <= 0.0)
+ {
+ return StartPoint;
+ }
+
+ if (arcLengthMeters >= TotalLengthMeters)
+ {
+ return EndPoint;
+ }
+
+ var segmentStartIndex =
+ FindSegmentStartIndex(
+ arcLengthMeters);
+ var segmentStart =
+ _points[segmentStartIndex];
+ var segmentEnd =
+ _points[segmentStartIndex + 1];
+ var interpolationRatio =
+ (arcLengthMeters -
+ segmentStart.ArcLengthMeters) /
+ (segmentEnd.ArcLengthMeters -
+ segmentStart.ArcLengthMeters);
+
+ return new TrajectoryPoint(
+ arcLengthMeters,
+ new Pose2D(
+ InterpolationMath.Lerp(
+ segmentStart.PoseInWorld.XMeters,
+ segmentEnd.PoseInWorld.XMeters,
+ interpolationRatio),
+ InterpolationMath.Lerp(
+ segmentStart.PoseInWorld.YMeters,
+ segmentEnd.PoseInWorld.YMeters,
+ interpolationRatio),
+ AngleMath.LerpRadians(
+ segmentStart.PoseInWorld.YawRadians,
+ segmentEnd.PoseInWorld.YawRadians,
+ interpolationRatio)),
+ InterpolationMath.Lerp(
+ segmentStart.CurvaturePerMeter,
+ segmentEnd.CurvaturePerMeter,
+ interpolationRatio),
+ InterpolationMath.Lerp(
+ segmentStart.ReferenceSpeedMetersPerSecond,
+ segmentEnd.ReferenceSpeedMetersPerSecond,
+ interpolationRatio));
+ }
+
+ ///
+ /// 使用二分查找获取包含指定累计弧长的线段起点索引。
+ ///
+ private int FindSegmentStartIndex(
+ double arcLengthMeters)
+ {
+ var lowerIndex = 0;
+ var upperIndex = _points.Length - 1;
+
+ while (upperIndex - lowerIndex > 1)
+ {
+ var middleIndex =
+ lowerIndex +
+ (upperIndex - lowerIndex) / 2;
+
+ if (_points[middleIndex].ArcLengthMeters <=
+ arcLengthMeters)
+ {
+ lowerIndex = middleIndex;
+ }
+ else
+ {
+ upperIndex = middleIndex;
+ }
+ }
+
+ return lowerIndex;
+ }
+
+ ///
+ /// 检查相邻轨迹点是否构成有效的非零长度有序线段。
+ ///
+ private static void ValidateSegment(
+ TrajectoryPoint previous,
+ TrajectoryPoint current,
+ int currentIndex,
+ string parameterName)
+ {
+ if (current.ArcLengthMeters <=
+ previous.ArcLengthMeters)
+ {
+ throw new ArgumentException(
+ $"轨迹点{currentIndex}的累计弧长必须严格大于前一个点。",
+ parameterName);
+ }
+
+ var deltaX =
+ current.PoseInWorld.XMeters -
+ previous.PoseInWorld.XMeters;
+ var deltaY =
+ current.PoseInWorld.YMeters -
+ previous.PoseInWorld.YMeters;
+ var segmentLengthSquared =
+ deltaX * deltaX +
+ deltaY * deltaY;
+ var minimumLengthSquared =
+ MinimumSegmentLengthMeters *
+ MinimumSegmentLengthMeters;
+
+ if (segmentLengthSquared <
+ minimumLengthSquared)
+ {
+ throw new ArgumentException(
+ $"轨迹点{currentIndex}与前一个点的位置过近,无法构成有效投影线段。",
+ parameterName);
+ }
+ }
+
+ ///
+ /// 检查数值是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "轨迹弧长必须是有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Trajectory/TrajectoryPoint.cs b/ClumsyPilot/Trajectory/TrajectoryPoint.cs
new file mode 100644
index 0000000..83dd56c
--- /dev/null
+++ b/ClumsyPilot/Trajectory/TrajectoryPoint.cs
@@ -0,0 +1,102 @@
+using System;
+using MyParking.Shared;
+
+namespace MultiWheelC.Trajectory
+{
+ ///
+ /// 描述按弧长参数化的车体中心参考轨迹点,统一使用SI单位。
+ ///
+ public readonly struct TrajectoryPoint
+ {
+ ///
+ /// 创建包含中心位姿、曲率和速度信息的参考轨迹点。
+ ///
+ public TrajectoryPoint(
+ double arcLengthMeters,
+ Pose2D poseInWorld,
+ double curvaturePerMeter,
+ double referenceSpeedMetersPerSecond)
+ {
+ EnsureFiniteNonNegative(
+ arcLengthMeters,
+ nameof(arcLengthMeters));
+ EnsureFinite(
+ poseInWorld.XMeters,
+ nameof(poseInWorld));
+ EnsureFinite(
+ poseInWorld.YMeters,
+ nameof(poseInWorld));
+ EnsureFinite(
+ poseInWorld.YawRadians,
+ nameof(poseInWorld));
+ EnsureFinite(
+ curvaturePerMeter,
+ nameof(curvaturePerMeter));
+ EnsureFinite(
+ referenceSpeedMetersPerSecond,
+ nameof(referenceSpeedMetersPerSecond));
+ ArcLengthMeters = arcLengthMeters;
+ PoseInWorld = new Pose2D(
+ poseInWorld.XMeters,
+ poseInWorld.YMeters,
+ AngleMath.NormalizeRadians(
+ poseInWorld.YawRadians));
+ CurvaturePerMeter = curvaturePerMeter;
+ ReferenceSpeedMetersPerSecond =
+ referenceSpeedMetersPerSecond;
+ }
+
+ ///
+ /// 获取从轨迹起点累计到当前点的弧长,单位为m。
+ ///
+ public double ArcLengthMeters { get; }
+
+ ///
+ /// 获取车体中心参考坐标系在世界坐标系中的位姿。
+ ///
+ public Pose2D PoseInWorld { get; }
+
+ ///
+ /// 获取车体中心参考轨迹曲率,单位为1/m,左转为正。
+ ///
+ public double CurvaturePerMeter { get; }
+
+ ///
+ /// 获取沿轨迹切线方向的有符号参考速度,单位为m/s。
+ ///
+ public double ReferenceSpeedMetersPerSecond { get; }
+
+ ///
+ /// 检查数值是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "轨迹点参数必须是有限值。");
+ }
+ }
+
+ ///
+ /// 检查数值是否为非负有限值。
+ ///
+ private static void EnsureFiniteNonNegative(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "轨迹累计弧长不能为负数。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Trajectory/TrajectoryProjection.cs b/ClumsyPilot/Trajectory/TrajectoryProjection.cs
new file mode 100644
index 0000000..798ae17
--- /dev/null
+++ b/ClumsyPilot/Trajectory/TrajectoryProjection.cs
@@ -0,0 +1,123 @@
+using System;
+using MyParking.Shared;
+
+namespace MultiWheelC.Trajectory
+{
+ ///
+ /// 保存车体中心投影到二维参考轨迹后得到的只读结果。
+ ///
+ public readonly struct TrajectoryProjection
+ {
+ ///
+ /// 创建包含轨迹进度、参考状态和跟踪误差的投影结果。
+ ///
+ public TrajectoryProjection(
+ int segmentStartIndex,
+ TrajectoryPoint referencePoint,
+ double lateralErrorMeters,
+ double headingErrorRadians,
+ double distanceToTrajectoryMeters,
+ double remainingDistanceMeters)
+ {
+ if (segmentStartIndex < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(segmentStartIndex),
+ "投影线段起点索引不能为负数。");
+ }
+
+ EnsureFinite(
+ lateralErrorMeters,
+ nameof(lateralErrorMeters));
+ EnsureFinite(
+ headingErrorRadians,
+ nameof(headingErrorRadians));
+ EnsureFiniteNonNegative(
+ distanceToTrajectoryMeters,
+ nameof(distanceToTrajectoryMeters));
+ EnsureFiniteNonNegative(
+ remainingDistanceMeters,
+ nameof(remainingDistanceMeters));
+
+ SegmentStartIndex = segmentStartIndex;
+ ReferencePoint = referencePoint;
+ LateralErrorMeters = lateralErrorMeters;
+ HeadingErrorRadians =
+ AngleMath.NormalizeRadians(
+ headingErrorRadians);
+ DistanceToTrajectoryMeters =
+ distanceToTrajectoryMeters;
+ RemainingDistanceMeters =
+ remainingDistanceMeters;
+ }
+
+ ///
+ /// 获取投影所在轨迹线段的起点索引,线段终点索引为该值加1。
+ ///
+ public int SegmentStartIndex { get; }
+
+ ///
+ /// 获取投影位置插值得到的车体中心参考轨迹点。
+ ///
+ public TrajectoryPoint ReferencePoint { get; }
+
+ ///
+ /// 获取有符号横向误差,单位为m,参考轨迹位于车辆左侧时为正。
+ ///
+ public double LateralErrorMeters { get; }
+
+ ///
+ /// 获取参考航向减实际车体航向的最短角差,单位为rad,逆时针为正。
+ ///
+ public double HeadingErrorRadians { get; }
+
+ ///
+ /// 获取车体中心到投影点的欧氏距离,单位为m。
+ ///
+ public double DistanceToTrajectoryMeters { get; }
+
+ ///
+ /// 获取投影位置沿轨迹到终点的剩余弧长,单位为m。
+ ///
+ public double RemainingDistanceMeters { get; }
+
+ ///
+ /// 获取投影位置从轨迹起点累计的弧长,单位为m。
+ ///
+ public double ArcLengthMeters =>
+ ReferencePoint.ArcLengthMeters;
+
+ ///
+ /// 检查数值是否为有限值。
+ ///
+ private static void EnsureFinite(
+ double value,
+ string parameterName)
+ {
+ if (double.IsNaN(value) ||
+ double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "轨迹投影参数必须是有限值。");
+ }
+ }
+
+ ///
+ /// 检查数值是否为非负有限值。
+ ///
+ private static void EnsureFiniteNonNegative(
+ double value,
+ string parameterName)
+ {
+ EnsureFinite(value, parameterName);
+
+ if (value < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "轨迹投影距离不能为负数。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/Trajectory/TrajectoryProjector.cs b/ClumsyPilot/Trajectory/TrajectoryProjector.cs
new file mode 100644
index 0000000..43cc3c5
--- /dev/null
+++ b/ClumsyPilot/Trajectory/TrajectoryProjector.cs
@@ -0,0 +1,219 @@
+using System;
+using MyParking.Shared;
+
+namespace MultiWheelC.Trajectory
+{
+ ///
+ /// 将Detour给出的实际车体中心位姿投影到二维离散参考轨迹。
+ ///
+ public static class TrajectoryProjector
+ {
+ ///
+ /// 在整条轨迹上查找距离实际车体中心最近的线段投影结果。
+ ///
+ public static TrajectoryProjection Project(
+ Trajectory2D trajectory,
+ Pose2D vehiclePoseInWorld)
+ {
+ if (trajectory == null)
+ {
+ throw new ArgumentNullException(
+ nameof(trajectory));
+ }
+
+ EnsureFinitePose(
+ vehiclePoseInWorld,
+ nameof(vehiclePoseInWorld));
+
+ var bestSegmentStartIndex = 0;
+ var bestInterpolationRatio = 0.0;
+ var bestProjectedX = 0.0;
+ var bestProjectedY = 0.0;
+ var bestDistanceSquared =
+ double.PositiveInfinity;
+
+ for (var segmentStartIndex = 0;
+ segmentStartIndex < trajectory.Count - 1;
+ segmentStartIndex++)
+ {
+ var segmentStart =
+ trajectory[segmentStartIndex];
+ var segmentEnd =
+ trajectory[segmentStartIndex + 1];
+
+ var segmentX =
+ segmentEnd.PoseInWorld.XMeters -
+ segmentStart.PoseInWorld.XMeters;
+ var segmentY =
+ segmentEnd.PoseInWorld.YMeters -
+ segmentStart.PoseInWorld.YMeters;
+ var segmentLengthSquared =
+ segmentX * segmentX +
+ segmentY * segmentY;
+
+ var vehicleFromSegmentStartX =
+ vehiclePoseInWorld.XMeters -
+ segmentStart.PoseInWorld.XMeters;
+ var vehicleFromSegmentStartY =
+ vehiclePoseInWorld.YMeters -
+ segmentStart.PoseInWorld.YMeters;
+
+ var interpolationRatio =
+ InterpolationMath.Clamp01(
+ (vehicleFromSegmentStartX * segmentX +
+ vehicleFromSegmentStartY * segmentY) /
+ segmentLengthSquared);
+
+ var projectedX =
+ InterpolationMath.Lerp(
+ segmentStart.PoseInWorld.XMeters,
+ segmentEnd.PoseInWorld.XMeters,
+ interpolationRatio);
+ var projectedY =
+ InterpolationMath.Lerp(
+ segmentStart.PoseInWorld.YMeters,
+ segmentEnd.PoseInWorld.YMeters,
+ interpolationRatio);
+
+ var projectionErrorX =
+ projectedX -
+ vehiclePoseInWorld.XMeters;
+ var projectionErrorY =
+ projectedY -
+ vehiclePoseInWorld.YMeters;
+ var distanceSquared =
+ projectionErrorX * projectionErrorX +
+ projectionErrorY * projectionErrorY;
+
+ if (distanceSquared >= bestDistanceSquared)
+ {
+ continue;
+ }
+
+ bestSegmentStartIndex =
+ segmentStartIndex;
+ bestInterpolationRatio =
+ interpolationRatio;
+ bestProjectedX = projectedX;
+ bestProjectedY = projectedY;
+ bestDistanceSquared = distanceSquared;
+ }
+
+ return BuildProjection(
+ trajectory,
+ vehiclePoseInWorld,
+ bestSegmentStartIndex,
+ bestInterpolationRatio,
+ bestProjectedX,
+ bestProjectedY,
+ bestDistanceSquared);
+ }
+
+ ///
+ /// 根据最近线段和插值比例生成控制器使用的完整投影结果。
+ ///
+ private static TrajectoryProjection BuildProjection(
+ Trajectory2D trajectory,
+ Pose2D vehiclePoseInWorld,
+ int segmentStartIndex,
+ double interpolationRatio,
+ double projectedX,
+ double projectedY,
+ double distanceSquared)
+ {
+ var segmentStart =
+ trajectory[segmentStartIndex];
+ var segmentEnd =
+ trajectory[segmentStartIndex + 1];
+
+ var referenceYawRadians =
+ AngleMath.LerpRadians(
+ segmentStart.PoseInWorld.YawRadians,
+ segmentEnd.PoseInWorld.YawRadians,
+ interpolationRatio);
+ var referenceArcLengthMeters =
+ InterpolationMath.Lerp(
+ segmentStart.ArcLengthMeters,
+ segmentEnd.ArcLengthMeters,
+ interpolationRatio);
+ var referenceCurvaturePerMeter =
+ InterpolationMath.Lerp(
+ segmentStart.CurvaturePerMeter,
+ segmentEnd.CurvaturePerMeter,
+ interpolationRatio);
+ var referenceSpeedMetersPerSecond =
+ InterpolationMath.Lerp(
+ segmentStart.ReferenceSpeedMetersPerSecond,
+ segmentEnd.ReferenceSpeedMetersPerSecond,
+ interpolationRatio);
+
+ var referencePoint =
+ new TrajectoryPoint(
+ referenceArcLengthMeters,
+ new Pose2D(
+ projectedX,
+ projectedY,
+ referenceYawRadians),
+ referenceCurvaturePerMeter,
+ referenceSpeedMetersPerSecond);
+
+ var segmentX =
+ segmentEnd.PoseInWorld.XMeters -
+ segmentStart.PoseInWorld.XMeters;
+ var segmentY =
+ segmentEnd.PoseInWorld.YMeters -
+ segmentStart.PoseInWorld.YMeters;
+ var segmentLength =
+ Math.Sqrt(
+ segmentX * segmentX +
+ segmentY * segmentY);
+
+ // 以轨迹线段的前进方向判断左右:
+ // 从车辆指向参考轨迹的向量位于轨迹左侧时为正。
+ var vehicleToProjectionX =
+ projectedX -
+ vehiclePoseInWorld.XMeters;
+ var vehicleToProjectionY =
+ projectedY -
+ vehiclePoseInWorld.YMeters;
+ var lateralErrorMeters =
+ (segmentX * vehicleToProjectionY -
+ segmentY * vehicleToProjectionX) /
+ segmentLength;
+
+ var headingErrorRadians =
+ AngleMath.ShortestDifferenceRadians(
+ referenceYawRadians,
+ vehiclePoseInWorld.YawRadians);
+
+ return new TrajectoryProjection(
+ segmentStartIndex,
+ referencePoint,
+ lateralErrorMeters,
+ headingErrorRadians,
+ Math.Sqrt(distanceSquared),
+ trajectory.GetRemainingDistanceMeters(
+ referenceArcLengthMeters));
+ }
+
+ ///
+ /// 检查用于投影的实际车体中心位姿是否包含有限数值。
+ ///
+ private static void EnsureFinitePose(
+ Pose2D pose,
+ string parameterName)
+ {
+ if (double.IsNaN(pose.XMeters) ||
+ double.IsInfinity(pose.XMeters) ||
+ double.IsNaN(pose.YMeters) ||
+ double.IsInfinity(pose.YMeters) ||
+ double.IsNaN(pose.YawRadians) ||
+ double.IsInfinity(pose.YawRadians))
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ "用于轨迹投影的车体位姿必须是有限值。");
+ }
+ }
+ }
+}
diff --git a/ClumsyPilot/ref/CommonUsage.dll b/ClumsyPilot/ref/CommonUsage.dll
index effb81e..82a8d1d 100644
Binary files a/ClumsyPilot/ref/CommonUsage.dll and b/ClumsyPilot/ref/CommonUsage.dll differ