using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using ClumsyCore;
using ClumsyCore.Interfaces;
using ClumsyCore.Pilot;
using CommonUsage.Chassis;
using MDCSToolBox.Clumsy.Pilot;
using MultiWheelC.Trajectory;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade;
using MultiWheelC.TrajectoryPlanning.EMPlanner;
using MultiWheelC.TrajectoryPlanning.TrajectoryObservation;
using MyParking.Shared;
using TrajectoryPlanningVisualization;
using PlanningPose2D = MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D;
namespace MultiWheelC;
/// 规划一次并冻结首个 EM 方向段,再使用现有几何控制器完成实车状态反馈闭环。
[MovementTest(name = "EM闭环测试")]
public sealed class EmClosedLoopMovementTest : MovementTest
{
private const int MaximumManualObstacleCount = 20;
private static long nextObstacleSnapshotVersion;
public double GoalXmm = double.NaN;
public double GoalYmm = double.NaN;
public double GoalYawDeg;
public double MapPaddingMeters = 2d;
public float MapResolutionMm = 50f;
public double SolverTimeoutSeconds = 5d;
public int MaximumOsqpIterations = 100000;
public double OutputTimeStepSeconds = 0.10d;
public double VehicleLengthMeters = 0.80d;
public double VehicleWidthMeters = 0.60d;
public double SafetyMarginMeters = 0.05d;
public double MaximumCurvaturePerMeter = 1d / 1.20d;
public bool EnableWebVisualization = true;
public bool AutoOpenWebVisualization = true;
public int WebVisualizationPort;
public double WebRefreshRateHz = 10d;
public int VisualizationHistoryCycleLimit = 60;
public bool EnableNativePainterVisualization = true;
public double MaximumCommandSpeedMetersPerSecond = 1.00d;
public double MaximumDistanceToTrajectoryMeters = 0.30d;
public double ExecutionTimeoutSeconds = 120d;
public float WheelAlignmentToleranceDegrees = 2f;
/// 读取冻结输入并启动一次规划、一次控制器接管的后台会话。
public override void Test()
{
try
{
double goalXMillimeters = GoalXmm;
double goalYMillimeters = GoalYmm;
double goalYawDegrees = GoalYawDeg;
if (!IsFinite(goalXMillimeters) || !IsFinite(goalYMillimeters))
{
goalXMillimeters = ReadFiniteInput("EM闭环终点 X(世界 mm)");
goalYMillimeters = ReadFiniteInput("EM闭环终点 Y(世界 mm)");
goalYawDegrees = ReadFiniteInput("EM闭环终点航向(世界 deg)");
}
EnsureFinite(goalXMillimeters, nameof(GoalXmm));
EnsureFinite(goalYMillimeters, nameof(GoalYmm));
EnsureFinite(goalYawDegrees, nameof(GoalYawDeg));
EnsurePositive(MaximumCommandSpeedMetersPerSecond,
nameof(MaximumCommandSpeedMetersPerSecond));
EnsurePositive(MaximumDistanceToTrajectoryMeters,
nameof(MaximumDistanceToTrajectoryMeters));
EnsurePositive(ExecutionTimeoutSeconds, nameof(ExecutionTimeoutSeconds));
EnsurePositive(WheelAlignmentToleranceDegrees,
nameof(WheelAlignmentToleranceDegrees));
var settings = new TrajectoryObservationSettings
{
PlanningScope = EmPlanningScope.FullDirectionSegment,
MapPaddingMeters = MapPaddingMeters,
MapResolutionMillimeters = MapResolutionMm,
SolverTimeoutSeconds = SolverTimeoutSeconds,
MaximumOsqpIterations = MaximumOsqpIterations,
OutputTimeStepSeconds = OutputTimeStepSeconds,
VehicleLengthMeters = VehicleLengthMeters,
VehicleWidthMeters = VehicleWidthMeters,
SafetyMarginMeters = SafetyMarginMeters,
MaximumCurvaturePerMeter = MaximumCurvaturePerMeter,
EnableWebVisualization = EnableWebVisualization,
AutoOpenWebVisualization = AutoOpenWebVisualization,
WebVisualizationPort = WebVisualizationPort,
WebRefreshRateHz = WebRefreshRateHz,
VisualizationHistoryCycleLimit = VisualizationHistoryCycleLimit,
EnableNativePainterVisualization = EnableNativePainterVisualization,
}.CreateValidatedSnapshot();
IReadOnlyList obstacles = ReadManualObstacles();
long obstacleSnapshotVersion = obstacles.Count == 0
? 0L
: Interlocked.Increment(ref nextObstacleSnapshotVersion);
var goal = new PlanningPose2D(
goalXMillimeters / 1000d,
goalYMillimeters / 1000d,
goalYawDegrees * Math.PI / 180d);
EmClosedLoopMovementTestRunner.Start(goal, settings, obstacles,
obstacleSnapshotVersion, MaximumCommandSpeedMetersPerSecond,
MaximumDistanceToTrajectoryMeters, ExecutionTimeoutSeconds,
WheelAlignmentToleranceDegrees);
}
catch (Exception exception)
{
EmClosedLoopMovementTestRunner.ShowFailure("测试未启动", exception);
}
}
/// 取消尚未完成的规划并停止正在运行的控制任务。
public override void TestStop()
{
EmClosedLoopMovementTestRunner.Stop();
}
private static IReadOnlyList ReadManualObstacles()
{
int count = ReadBoundedIntegerInput("EM闭环手动障碍物数量(0-20)",
0, MaximumManualObstacleCount);
var obstacles = new List(count);
for (int index = 0; index < count; index++)
{
string label = "障碍物 " + (index + 1).ToString(CultureInfo.InvariantCulture);
int kind = ReadBoundedIntegerInput(label + " 类型(1=圆形,2=轴对齐矩形)", 1, 2);
double centerXMillimeters = ReadFiniteInput(label + " 中心 X(世界 mm)");
double centerYMillimeters = ReadFiniteInput(label + " 中心 Y(世界 mm)");
if (kind == 1)
{
double radiusMillimeters = ReadPositiveFiniteInput(label + " 半径(mm)");
obstacles.Add(TrajectoryObservationObstacle.Circle(
centerXMillimeters, centerYMillimeters, radiusMillimeters));
}
else
{
double lengthXMillimeters = ReadPositiveFiniteInput(label + " X 方向长度(mm)");
double widthYMillimeters = ReadPositiveFiniteInput(label + " Y 方向宽度(mm)");
obstacles.Add(TrajectoryObservationObstacle.Rectangle(
centerXMillimeters - lengthXMillimeters / 2d,
centerXMillimeters + lengthXMillimeters / 2d,
centerYMillimeters - widthYMillimeters / 2d,
centerYMillimeters + widthYMillimeters / 2d));
}
}
return obstacles;
}
private static int ReadBoundedIntegerInput(string prompt, int minimum, int maximum)
{
object raw = UI.GetInput(prompt);
string text = Convert.ToString(raw, CultureInfo.CurrentCulture);
int value;
if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.CurrentCulture, out value) &&
!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
{
throw new ArgumentException("输入必须是整数:" + prompt);
}
if (value < minimum || value > maximum)
throw new ArgumentOutOfRangeException(nameof(prompt), "输入超出允许范围:" + prompt);
return value;
}
private static double ReadPositiveFiniteInput(string prompt)
{
double value = ReadFiniteInput(prompt);
EnsurePositive(value, prompt);
return value;
}
private static double ReadFiniteInput(string prompt)
{
object raw = UI.GetInput(prompt);
string text = Convert.ToString(raw, CultureInfo.CurrentCulture);
double value;
if (!double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out value) &&
!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value))
{
throw new ArgumentException("输入必须是有限数字:" + prompt);
}
EnsureFinite(value, prompt);
return value;
}
private static void EnsurePositive(double value, string name)
{
EnsureFinite(value, name);
if (value <= 0d)
throw new ArgumentOutOfRangeException(name, "输入必须是正数:" + name);
}
private static void EnsureFinite(double value, string name)
{
if (!IsFinite(value))
throw new ArgumentException("输入必须是有限数字:" + name);
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
}
/// 拥有单次规划和单个控制任务的会话生命周期,并保证停止操作幂等。
internal static class EmClosedLoopMovementTestRunner
{
private static readonly object SessionSync = new object();
private static CancellationTokenSource activeCancellation;
private static Task activeTask;
private static DriveTask activeDriveTask;
private static TrajectoryObservationPresentation activePresentation;
private static TrajectoryObservationVisualizationPublisher activeWebPublisher;
private static long nextSessionId;
private static long activeSessionId;
private static long stateSequence;
internal static void Start(PlanningPose2D goal, TrajectoryObservationSettings settings,
IReadOnlyList obstacles, long obstacleSnapshotVersion,
double maximumCommandSpeedMetersPerSecond,
double maximumDistanceToTrajectoryMeters,
double executionTimeoutSeconds,
float wheelAlignmentToleranceDegrees)
{
if (goal == null) throw new ArgumentNullException(nameof(goal));
if (settings == null) throw new ArgumentNullException(nameof(settings));
if (obstacles == null) throw new ArgumentNullException(nameof(obstacles));
var cancellation = new CancellationTokenSource();
CancellationTokenSource previousCancellation;
DriveTask previousDriveTask;
TrajectoryObservationPresentation previousPresentation;
TrajectoryObservationVisualizationPublisher previousWebPublisher;
long sessionId;
lock (SessionSync)
{
previousCancellation = activeCancellation;
previousDriveTask = activeDriveTask;
previousPresentation = activePresentation;
previousWebPublisher = activeWebPublisher;
activeCancellation = cancellation;
activeDriveTask = null;
activePresentation = null;
activeWebPublisher = null;
activeTask = null;
activeSessionId = sessionId = ++nextSessionId;
}
Cancel(previousCancellation);
StopDriveTask(previousDriveTask);
ClearPresentation(previousPresentation);
StopWebPublisher(previousWebPublisher);
PrintStatus("会话 " + sessionId.ToString(CultureInfo.InvariantCulture) +
" 已启动;车辆运动前将只规划一次。");
Task task = Task.Run(() => RunSessionAsync(sessionId, goal, settings,
obstacles, obstacleSnapshotVersion, maximumCommandSpeedMetersPerSecond,
maximumDistanceToTrajectoryMeters, executionTimeoutSeconds,
wheelAlignmentToleranceDegrees, cancellation.Token), cancellation.Token);
lock (SessionSync)
{
if (activeSessionId == sessionId)
activeTask = task;
}
_ = task.ContinueWith(completed => Finish(sessionId, cancellation, completed),
TaskScheduler.Default);
}
internal static void Stop()
{
CancellationTokenSource cancellation;
DriveTask driveTask;
TrajectoryObservationPresentation presentation;
TrajectoryObservationVisualizationPublisher webPublisher;
lock (SessionSync)
{
cancellation = activeCancellation;
driveTask = activeDriveTask;
presentation = activePresentation;
webPublisher = activeWebPublisher;
activeCancellation = null;
activeDriveTask = null;
activePresentation = null;
activeWebPublisher = null;
activeTask = null;
activeSessionId = 0L;
}
Cancel(cancellation);
StopDriveTask(driveTask);
ClearPresentation(presentation);
StopWebPublisher(webPublisher);
PrintStatus("已请求取消规划并停止控制任务。");
}
internal static void ShowFailure(string context, Exception exception)
{
PrintStatus(context + ":" + (exception == null ? "未知错误" : exception.Message));
}
private static async Task RunSessionAsync(long sessionId, PlanningPose2D goal,
TrajectoryObservationSettings settings,
IReadOnlyList obstacles,
long obstacleSnapshotVersion,
double maximumCommandSpeedMetersPerSecond,
double maximumDistanceToTrajectoryMeters,
double executionTimeoutSeconds,
float wheelAlignmentToleranceDegrees,
CancellationToken token)
{
token.ThrowIfCancellationRequested();
VehicleMotionState initialState = ReadPlanningState();
using var planningDeadline = new TrajectoryObservationPlanningDeadline(
TimeSpan.FromSeconds(settings.SolverTimeoutSeconds), token);
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
initialState.Pose, goal, settings, obstacles, obstacleSnapshotVersion);
TrajectoryObservationBootstrapResult bootstrap =
new TrajectoryObservationBootstrapper().Bootstrap(job, planningDeadline, token);
if (!bootstrap.Succeeded)
throw new InvalidOperationException(bootstrap.FailureReason);
var controller = new TrajectoryObservationController(
bootstrap, settings, new EmPlanningService(new OsqpNativeSolver()),
"em-closed-loop-" + sessionId.ToString(CultureInfo.InvariantCulture));
PlanningCycleResult cycle = await controller.StartCycle(
initialState.CapturedAtUtc, initialState, planningDeadline, token).ConfigureAwait(false);
if (!cycle.Published || controller.PublishedTrajectory == null)
{
string reason = cycle.Result == null ? string.Empty : cycle.Result.FailureReason;
throw new InvalidOperationException(string.IsNullOrWhiteSpace(reason)
? cycle.Diagnostic
: reason);
}
EmTrajectory emTrajectory = controller.PublishedTrajectory;
Trajectory2D controlTrajectory = new EmControlTrajectoryAdapter().Create(emTrajectory);
TrajectoryObservationVisualizationPublisher webPublisher = StartWebVisualization(
sessionId, bootstrap, controller, settings, obstacleSnapshotVersion, token);
TrajectoryObservationPresentation presentation = settings.EnableNativePainterVisualization
? new TrajectoryObservationPresentation()
: null;
if (!TryRegisterPresentation(sessionId, presentation, token))
{
ClearPresentation(presentation);
token.ThrowIfCancellationRequested();
return;
}
UpdateVisualization(sessionId, presentation, bootstrap, controller, emTrajectory,
initialState, cycle, "规划已冻结,尚未向底盘下发控制指令。");
PrintStatus("规划完成:EM点数=" + emTrajectory.Points.Count.ToString(CultureInfo.InvariantCulture) +
",控制点数=" + controlTrajectory.Count.ToString(CultureInfo.InvariantCulture) +
",长度=" + controlTrajectory.TotalLengthMeters.ToString("F3", CultureInfo.InvariantCulture) +
" m,方向=" + emTrajectory.Metadata.Direction +
",终端=" + emTrajectory.Metadata.TerminalType + "。");
token.ThrowIfCancellationRequested();
EmClosedLoopWheelSafety.RequireWheelsForward(wheelAlignmentToleranceDegrees);
var movement = new TrajectoryTrackingMovement
{
Trajectory = controlTrajectory,
MaximumCommandSpeedMetersPerSecond = maximumCommandSpeedMetersPerSecond,
MaximumDistanceToTrajectoryMeters = maximumDistanceToTrajectoryMeters,
ExecutionTimeoutSeconds = executionTimeoutSeconds,
CycleObserver = control => UpdatePresentationFromControlCycle(
sessionId, presentation, bootstrap, controller, emTrajectory, cycle, control),
};
var driveTask = new DriveTask(movement.Get());
if (!TryRegisterDriveTask(sessionId, driveTask, token))
{
StopDriveTask(driveTask);
token.ThrowIfCancellationRequested();
return;
}
try
{
PrintStatus("冻结轨迹已交给现有控制器,开始单方向段闭环跟踪。");
driveTask.Wait();
if (emTrajectory.Metadata.TerminalType == EmTerminalType.GearSwitch)
PrintStatus("已在换向边界停车;本测试不启动下一方向段。");
else
PrintStatus("首个方向段已完成并停车。");
}
finally
{
StopDriveTask(driveTask);
ClearDriveTask(sessionId, driveTask);
}
}
private static void UpdatePresentationFromControlCycle(long sessionId,
TrajectoryObservationPresentation presentation,
TrajectoryObservationBootstrapResult bootstrap,
TrajectoryObservationController planningController,
EmTrajectory trajectory,
PlanningCycleResult cycle,
MultiWheelC.Control.Execution.ParkingGeometricController control)
{
if (control == null || !control.LastVehicleState.HasValue)
return;
var state = control.LastVehicleState.Value;
var planningState = new VehicleMotionState(
new PlanningPose2D(state.PoseInWorld.XMeters, state.PoseInWorld.YMeters,
state.PoseInWorld.YawRadians),
state.TwistInBody.VxMetersPerSecond, null, DateTimeOffset.UtcNow,
Interlocked.Increment(ref stateSequence));
string diagnostic = "现有控制器正在执行冻结轨迹";
if (control.LastCommand.HasValue)
{
var command = control.LastCommand.Value;
diagnostic += ":底盘速度=" +
command.SpeedMetersPerSecond.ToString("F3", CultureInfo.InvariantCulture) +
" m/s,前GCP=" +
(command.FrontAngleRadians * 180d / Math.PI).ToString("F2", CultureInfo.InvariantCulture) +
" deg,后GCP=" +
(command.RearAngleRadians * 180d / Math.PI).ToString("F2", CultureInfo.InvariantCulture) + " deg。";
}
UpdateVisualization(sessionId, presentation, bootstrap, planningController,
trajectory, planningState, cycle, diagnostic);
}
private static void UpdateVisualization(long sessionId,
TrajectoryObservationPresentation presentation,
TrajectoryObservationBootstrapResult bootstrap,
TrajectoryObservationController controller,
EmTrajectory trajectory,
VehicleMotionState state,
PlanningCycleResult cycle,
string diagnostic)
{
lock (SessionSync)
{
if (activeSessionId != sessionId)
return;
var observation = new TrajectoryObservationObservation(
DateTimeOffset.UtcNow, state, trajectory, null, null, null);
DirectionSegmentView segment = controller.ActiveSegment;
TrajectoryObservationCharts charts = TrajectoryObservationCharts.Build(
trajectory, segment,
controller.CreateEffectiveConfigurationSnapshot().Frenet.MaximumProjectionDistanceMeters);
if (presentation != null && ReferenceEquals(activePresentation, presentation))
{
presentation.DrawWorld(bootstrap, observation,
TrajectoryObservationRuntimeState.Create(DateTimeOffset.UtcNow, trajectory), diagnostic);
presentation.DrawLs(charts, diagnostic);
presentation.DrawSt(charts, diagnostic);
}
TrajectoryObservationVisualizationPublisher publisher = activeWebPublisher;
if (publisher != null)
{
var tick = new TrajectoryObservationLoopTick(observation, cycle, TimeSpan.Zero,
false, false, true, false, controller.SegmentState);
publisher.TryPublish(observation.ObservedAtUtc, () =>
{
PlanningVisualizationDynamicSnapshot source =
new TrajectoryObservationDynamicSnapshotBuilder().Build(
Interlocked.Increment(ref stateSequence), tick, segment,
controller.PreviousTrajectoryForVisualization, bootstrap.Vehicle,
controller.CreateEffectiveConfigurationSnapshot());
return new PlanningVisualizationDynamicSnapshot(source.Sequence,
source.ObservedAtUtc, "EM闭环控制中", source.ActiveSegmentIndex,
source.ActiveDirection, source.VehiclePose, source.DynamicPolylines,
source.DynamicMarkers, source.Charts, source.StatusValues, source.CycleSummary);
});
}
}
}
private static TrajectoryObservationVisualizationPublisher StartWebVisualization(
long sessionId, TrajectoryObservationBootstrapResult bootstrap,
TrajectoryObservationController controller, TrajectoryObservationSettings settings,
long obstacleSnapshotVersion, CancellationToken token)
{
if (!settings.EnableWebVisualization)
return null;
var publisher = new TrajectoryObservationVisualizationPublisher(settings,
new PlanningVisualizationSessionSink(), PrintStatus);
try
{
PlanningVisualizationStaticSnapshot source =
new TrajectoryObservationStaticSnapshotBuilder().Build(bootstrap,
controller.CreateEffectiveConfigurationSnapshot(), settings, obstacleSnapshotVersion);
var closedLoopSnapshot = new PlanningVisualizationStaticSnapshot("EM闭环测试",
source.WorldBounds, source.OccupancyGrid, source.StaticPolylines,
source.StaticMarkers, source.DirectionSegments, source.ConfigurationGroups);
PlanningVisualizationSessionInfo info = publisher.Start(closedLoopSnapshot);
if (info == null)
return null;
lock (SessionSync)
{
if (activeSessionId != sessionId || token.IsCancellationRequested)
{
publisher.Stop();
return null;
}
activeWebPublisher = publisher;
}
PrintStatus("网页可视化地址(含会话令牌):" + info.Uri.AbsoluteUri);
if (settings.AutoOpenWebVisualization)
{
Process.Start(new ProcessStartInfo
{
FileName = info.Uri.AbsoluteUri,
UseShellExecute = true,
});
}
return publisher;
}
catch (Exception exception)
{
publisher.Disable(exception);
PrintStatus("网页可视化未启动:" + exception.Message);
return null;
}
}
private static bool TryRegisterPresentation(long sessionId,
TrajectoryObservationPresentation presentation, CancellationToken token)
{
if (presentation == null)
return true;
lock (SessionSync)
{
if (activeSessionId != sessionId || activeCancellation == null ||
activeCancellation.IsCancellationRequested || token.IsCancellationRequested)
return false;
activePresentation = presentation;
return true;
}
}
private static VehicleMotionState ReadPlanningState()
{
var location = DetourInterface.getCartLocation();
if (location == null)
throw new InvalidOperationException("实时定位不可用。");
if (BasicPilotBase.Chassis == null)
throw new InvalidOperationException("实时底盘读接口不可用。");
var speed = BasicPilotBase.Chassis.GetCarSpeed(true);
return new VehicleMotionState(
new PlanningPose2D(location.x / 1000d, location.y / 1000d,
location.th * Math.PI / 180d),
speed.Vx, null, DateTimeOffset.UtcNow,
Interlocked.Increment(ref stateSequence));
}
private static bool TryRegisterDriveTask(long sessionId, DriveTask driveTask,
CancellationToken token)
{
lock (SessionSync)
{
if (activeSessionId != sessionId || activeCancellation == null ||
activeCancellation.IsCancellationRequested || token.IsCancellationRequested)
{
return false;
}
activeDriveTask = driveTask;
return true;
}
}
private static void ClearDriveTask(long sessionId, DriveTask driveTask)
{
lock (SessionSync)
{
if (activeSessionId == sessionId && ReferenceEquals(activeDriveTask, driveTask))
activeDriveTask = null;
ClearPresentation(activePresentation);
activePresentation = null;
StopWebPublisher(activeWebPublisher);
activeWebPublisher = null;
}
}
private static void Finish(long sessionId, CancellationTokenSource cancellation,
Task completed)
{
lock (SessionSync)
{
if (activeSessionId == sessionId)
{
activeCancellation = null;
activeDriveTask = null;
activeTask = null;
activeSessionId = 0L;
}
}
if (completed.IsFaulted)
{
Exception failure = completed.Exception == null
? null
: completed.Exception.GetBaseException();
ShowFailure("闭环会话失败并已停车", failure);
}
else if (completed.IsCanceled)
{
PrintStatus("闭环会话已取消。");
}
cancellation.Dispose();
}
private static void Cancel(CancellationTokenSource cancellation)
{
if (cancellation == null)
return;
try
{
cancellation.Cancel();
}
catch (ObjectDisposedException)
{
}
}
private static void StopDriveTask(DriveTask driveTask)
{
if (driveTask == null)
return;
try
{
driveTask.Stop();
}
catch (Exception exception)
{
ShowFailure("停止控制任务失败", exception);
}
}
private static void ClearPresentation(TrajectoryObservationPresentation presentation)
{
if (presentation == null)
return;
try
{
presentation.ClearAll();
}
catch (Exception exception)
{
ShowFailure("清理闭环可视化失败", exception);
}
}
private static void StopWebPublisher(TrajectoryObservationVisualizationPublisher publisher)
{
if (publisher == null)
return;
try
{
publisher.Stop();
}
catch (Exception exception)
{
ShowFailure("停止闭环网页可视化失败", exception);
}
}
private static void PrintStatus(string message)
{
Console.WriteLine("[EM闭环测试] " + message);
}
}
/// 在控制器接管底盘前只读检查四个舵轮是否已经与车体前向对齐。
internal static class EmClosedLoopWheelSafety
{
internal static void RequireWheelsForward(float toleranceDegrees)
{
var chassis = PilotDefinition.Chassis as MultiWheelChassis;
if (chassis == null)
throw new InvalidOperationException("当前底盘不是 MultiWheelChassis。");
var adapter = new MultiWheelChassisAdapter(chassis, PilotDefinition.Self.CarNum);
double toleranceRadians = AngleMath.DegreesToRadians(toleranceDegrees);
if (!adapter.AreParallelWheelsAligned(0d, toleranceRadians))
{
throw new InvalidOperationException(
"四个舵轮尚未与车头方向一致,控制器未接管底盘。");
}
}
}