2026-08-04 16:43:29 +08:00
|
|
|
|
using System;
|
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
|
using System.Diagnostics;
|
|
|
|
|
|
using System.Globalization;
|
|
|
|
|
|
using System.Threading;
|
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
|
using ClumsyCore;
|
|
|
|
|
|
using ClumsyCore.DTools;
|
|
|
|
|
|
using ClumsyCore.Interfaces;
|
|
|
|
|
|
using ClumsyCore.Pilot;
|
|
|
|
|
|
using CommonUsage.Chassis;
|
|
|
|
|
|
using FundamentalLib;
|
|
|
|
|
|
using MDCSToolBox.Clumsy.Pilot;
|
|
|
|
|
|
using MultiWheelC.TrajectoryPlanning.CoarsePath;
|
|
|
|
|
|
using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade;
|
|
|
|
|
|
using MultiWheelC.TrajectoryPlanning.EMPlanner;
|
|
|
|
|
|
using MultiWheelC.TrajectoryPlanning.TrajectoryObservation;
|
2026-08-06 11:48:46 +08:00
|
|
|
|
using TrajectoryPlanningVisualization;
|
2026-08-04 16:43:29 +08:00
|
|
|
|
|
|
|
|
|
|
namespace MultiWheelC;
|
|
|
|
|
|
|
2026-08-04 16:51:13 +08:00
|
|
|
|
[MovementTest(name = "EM轨迹规划观察闭环测试")]
|
2026-08-04 16:43:29 +08:00
|
|
|
|
public sealed class TrajectoryObservationMovementTest : MovementTest
|
|
|
|
|
|
{
|
|
|
|
|
|
private const int MaximumManualObstacleCount = 20;
|
|
|
|
|
|
private static long nextObstacleSnapshotVersion;
|
|
|
|
|
|
|
|
|
|
|
|
public double GoalXmm = double.NaN;
|
|
|
|
|
|
public double GoalYmm = double.NaN;
|
|
|
|
|
|
public double GoalYawDeg = 0d;
|
|
|
|
|
|
public double MapPaddingMeters = 2d;
|
|
|
|
|
|
public float MapResolutionMm = 50f;
|
|
|
|
|
|
public double ReplanPeriodSeconds = 0.20d;
|
|
|
|
|
|
public double ObserverPeriodSeconds = 0.05d;
|
2026-08-07 08:56:14 +08:00
|
|
|
|
public bool UseFullDirectionSegmentPlanning = true;
|
2026-08-06 10:16:48 +08:00
|
|
|
|
public double SolverTimeoutSeconds = 5.0d;
|
|
|
|
|
|
public int MaximumOsqpIterations = 100000;
|
2026-08-07 08:56:14 +08:00
|
|
|
|
/// <summary>仅滚动兼容字段;完整方向段模式不用于截断。默认 2s,不等于观察循环周期。</summary>
|
|
|
|
|
|
public double TimeHorizonSeconds = 20d;
|
2026-08-05 13:39:18 +08:00
|
|
|
|
/// <summary>Trajectory 相邻 TimeFromStart 时间戳的间隔,单位 s;默认 0.10s,不等于观察循环周期。</summary>
|
|
|
|
|
|
public double OutputTimeStepSeconds = 0.10d;
|
2026-08-04 17:33:14 +08:00
|
|
|
|
public double VehicleLengthMeters = 0.80d;
|
|
|
|
|
|
public double VehicleWidthMeters = 0.60d;
|
|
|
|
|
|
public double SafetyMarginMeters = 0.05d;
|
|
|
|
|
|
public double MaximumCurvaturePerMeter = 1d / 1.20d;
|
2026-08-07 08:56:14 +08:00
|
|
|
|
public bool EnableWebVisualization = true;
|
2026-08-06 10:16:48 +08:00
|
|
|
|
public bool AutoOpenWebVisualization = true;
|
|
|
|
|
|
public int WebVisualizationPort = 0;
|
|
|
|
|
|
public double WebRefreshRateHz = 10d;
|
|
|
|
|
|
public int VisualizationHistoryCycleLimit = 60;
|
2026-08-07 08:56:14 +08:00
|
|
|
|
public bool EnableNativePainterVisualization = true;
|
2026-08-06 10:16:48 +08:00
|
|
|
|
public double DirectionConfirmationSpeedMetersPerSecond = 0.02d;
|
|
|
|
|
|
public int DirectionConfirmationSamples = 3;
|
|
|
|
|
|
public double GearSwitchProjectionToleranceMeters = 0.50d;
|
|
|
|
|
|
public double GearSwitchStopHoldSeconds = 0.20d;
|
2026-08-04 16:43:29 +08:00
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
|
|
var settings = new TrajectoryObservationSettings
|
|
|
|
|
|
{
|
|
|
|
|
|
MapPaddingMeters = MapPaddingMeters,
|
|
|
|
|
|
MapResolutionMillimeters = MapResolutionMm,
|
|
|
|
|
|
ReplanPeriodSeconds = ReplanPeriodSeconds,
|
|
|
|
|
|
ObserverPeriodSeconds = ObserverPeriodSeconds,
|
2026-08-07 08:56:14 +08:00
|
|
|
|
PlanningScope = UseFullDirectionSegmentPlanning ? EmPlanningScope.FullDirectionSegment : EmPlanningScope.RollingHorizon,
|
2026-08-05 11:39:45 +08:00
|
|
|
|
SolverTimeoutSeconds = SolverTimeoutSeconds,
|
2026-08-05 12:03:28 +08:00
|
|
|
|
MaximumOsqpIterations = MaximumOsqpIterations,
|
2026-08-05 13:39:18 +08:00
|
|
|
|
TimeHorizonSeconds = TimeHorizonSeconds,
|
|
|
|
|
|
OutputTimeStepSeconds = OutputTimeStepSeconds,
|
2026-08-04 17:33:14 +08:00
|
|
|
|
VehicleLengthMeters = VehicleLengthMeters,
|
|
|
|
|
|
VehicleWidthMeters = VehicleWidthMeters,
|
|
|
|
|
|
SafetyMarginMeters = SafetyMarginMeters,
|
|
|
|
|
|
MaximumCurvaturePerMeter = MaximumCurvaturePerMeter,
|
2026-08-06 10:16:48 +08:00
|
|
|
|
EnableWebVisualization = EnableWebVisualization,
|
|
|
|
|
|
AutoOpenWebVisualization = AutoOpenWebVisualization,
|
|
|
|
|
|
WebVisualizationPort = WebVisualizationPort,
|
|
|
|
|
|
WebRefreshRateHz = WebRefreshRateHz,
|
|
|
|
|
|
VisualizationHistoryCycleLimit = VisualizationHistoryCycleLimit,
|
|
|
|
|
|
EnableNativePainterVisualization = EnableNativePainterVisualization,
|
|
|
|
|
|
DirectionConfirmationSpeedMetersPerSecond = DirectionConfirmationSpeedMetersPerSecond,
|
|
|
|
|
|
DirectionConfirmationSamples = DirectionConfirmationSamples,
|
|
|
|
|
|
GearSwitchProjectionToleranceMeters = GearSwitchProjectionToleranceMeters,
|
|
|
|
|
|
GearSwitchStopHoldSeconds = GearSwitchStopHoldSeconds,
|
2026-08-04 16:43:29 +08:00
|
|
|
|
};
|
2026-08-04 17:33:14 +08:00
|
|
|
|
settings = settings.CreateValidatedSnapshot();
|
2026-08-04 16:43:29 +08:00
|
|
|
|
TimeSpan observerPeriod = TimeSpan.FromSeconds(settings.ObserverPeriodSeconds);
|
|
|
|
|
|
IReadOnlyList<TrajectoryObservationObstacle> obstacles = ReadManualObstacles();
|
|
|
|
|
|
long obstacleSnapshotVersion = obstacles.Count == 0
|
|
|
|
|
|
? 0L
|
|
|
|
|
|
: Interlocked.Increment(ref nextObstacleSnapshotVersion);
|
|
|
|
|
|
var goal = new Pose2D(goalXMillimeters / 1000d, goalYMillimeters / 1000d,
|
|
|
|
|
|
goalYawDegrees * Math.PI / 180d);
|
|
|
|
|
|
|
|
|
|
|
|
TrajectoryObservationMovementTestRunner.Start(
|
|
|
|
|
|
goal, settings, observerPeriod, obstacles, obstacleSnapshotVersion);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
|
{
|
|
|
|
|
|
TrajectoryObservationMovementTestRunner.ShowInputFailure(exception);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public override void TestStop()
|
|
|
|
|
|
{
|
|
|
|
|
|
TrajectoryObservationMovementTestRunner.Stop();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static IReadOnlyList<TrajectoryObservationObstacle> ReadManualObstacles()
|
|
|
|
|
|
{
|
|
|
|
|
|
int count = ReadBoundedIntegerInput("EM观察手动障碍物数量(0-20)", 0, MaximumManualObstacleCount);
|
|
|
|
|
|
var obstacles = new List<TrajectoryObservationObstacle>(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);
|
|
|
|
|
|
if (value <= 0d)
|
|
|
|
|
|
throw new ArgumentOutOfRangeException(nameof(prompt), "输入必须是正数:" + 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 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 TrajectoryObservationMovementTestRunner
|
|
|
|
|
|
{
|
|
|
|
|
|
private const string StatusChannel = "TrajectoryObserver";
|
|
|
|
|
|
private const string ObserveOnlyNotice = "OBSERVE_ONLY: no chassis command is sent.";
|
|
|
|
|
|
private static readonly object SessionSync = new object();
|
|
|
|
|
|
|
|
|
|
|
|
private static CancellationTokenSource activeCancellation;
|
|
|
|
|
|
private static Task activeTask;
|
2026-08-06 11:48:46 +08:00
|
|
|
|
private static TrajectoryObservationVisualizationPublisher activeWebPublisher;
|
|
|
|
|
|
private static TrajectoryObservationPresentation activePresentation;
|
2026-08-07 15:51:31 +08:00
|
|
|
|
private static TrajectoryObservationReportWriter activeReportWriter;
|
2026-08-04 16:43:29 +08:00
|
|
|
|
private static long nextSessionId;
|
|
|
|
|
|
private static long activeSessionId;
|
|
|
|
|
|
private static long stateSequence;
|
|
|
|
|
|
|
|
|
|
|
|
internal static void Start(Pose2D goal, TrajectoryObservationSettings settings, TimeSpan observerPeriod,
|
|
|
|
|
|
IReadOnlyList<TrajectoryObservationObstacle> obstacles, long obstacleSnapshotVersion)
|
|
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
long sessionId;
|
2026-08-07 15:51:31 +08:00
|
|
|
|
TrajectoryObservationReportWriter reportWriter = null;
|
2026-08-04 16:43:29 +08:00
|
|
|
|
lock (SessionSync)
|
|
|
|
|
|
{
|
|
|
|
|
|
previousCancellation = activeCancellation;
|
|
|
|
|
|
activeCancellation = cancellation;
|
|
|
|
|
|
activeTask = null;
|
|
|
|
|
|
sessionId = ++nextSessionId;
|
|
|
|
|
|
activeSessionId = sessionId;
|
2026-08-06 11:48:46 +08:00
|
|
|
|
StopActiveVisualizationNoLock();
|
|
|
|
|
|
activePresentation = settings.EnableNativePainterVisualization ? new TrajectoryObservationPresentation() : null;
|
2026-08-07 15:51:31 +08:00
|
|
|
|
reportWriter = new TrajectoryObservationReportWriter(sessionId);
|
|
|
|
|
|
activeReportWriter = reportWriter;
|
2026-08-04 16:43:29 +08:00
|
|
|
|
PrintStatus("Starting frozen observation session " +
|
|
|
|
|
|
sessionId.ToString(CultureInfo.InvariantCulture) + ".");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
CancelWithoutWaiting(previousCancellation);
|
|
|
|
|
|
|
|
|
|
|
|
Task task = Task.Run(() => RunSessionAsync(sessionId, goal, settings, observerPeriod,
|
|
|
|
|
|
obstacles, obstacleSnapshotVersion, cancellation.Token), cancellation.Token);
|
|
|
|
|
|
lock (SessionSync)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (activeSessionId == sessionId) activeTask = task;
|
|
|
|
|
|
}
|
2026-08-07 15:51:31 +08:00
|
|
|
|
_ = task.ContinueWith(completed => Finish(sessionId, cancellation, completed, reportWriter), TaskScheduler.Default);
|
2026-08-04 16:43:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
internal static void Stop()
|
|
|
|
|
|
{
|
|
|
|
|
|
CancellationTokenSource cancellation;
|
|
|
|
|
|
lock (SessionSync)
|
|
|
|
|
|
{
|
|
|
|
|
|
cancellation = activeCancellation;
|
|
|
|
|
|
activeCancellation = null;
|
|
|
|
|
|
activeTask = null;
|
|
|
|
|
|
activeSessionId = 0L;
|
2026-08-06 11:48:46 +08:00
|
|
|
|
StopActiveVisualizationNoLock();
|
2026-08-04 16:43:29 +08:00
|
|
|
|
PrintStatus("Observation stop requested; all observer layers were cleared.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
CancelWithoutWaiting(cancellation);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
internal static void ShowInputFailure(Exception exception)
|
|
|
|
|
|
{
|
|
|
|
|
|
string message = exception == null ? "Unknown input error." : exception.Message;
|
|
|
|
|
|
PrintStatus("Observation session did not start: " + message);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static async Task RunSessionAsync(long sessionId, Pose2D goal,
|
|
|
|
|
|
TrajectoryObservationSettings settings, TimeSpan observerPeriod,
|
|
|
|
|
|
IReadOnlyList<TrajectoryObservationObstacle> obstacles, long obstacleSnapshotVersion,
|
|
|
|
|
|
CancellationToken token)
|
|
|
|
|
|
{
|
|
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
|
|
VehicleMotionState initialState = ReadVehicleState();
|
2026-08-07 15:51:31 +08:00
|
|
|
|
LogIfCurrent(sessionId, "initial state: pose=(" + Format(initialState.Pose.X) + ", " +
|
|
|
|
|
|
Format(initialState.Pose.Y) + ", " + Format(initialState.Pose.Heading) + ") m/rad, signed speed=" +
|
|
|
|
|
|
initialState.SignedLongitudinalSpeedMetersPerSecond.ToString("R", CultureInfo.InvariantCulture) + " m/s, acceleration=" +
|
|
|
|
|
|
(initialState.LongitudinalAccelerationMetersPerSecondSquared.HasValue
|
|
|
|
|
|
? Format(initialState.LongitudinalAccelerationMetersPerSecondSquared.Value)
|
|
|
|
|
|
: "unavailable") + " m/s2, stateSequence=" +
|
|
|
|
|
|
initialState.SequenceId.ToString(CultureInfo.InvariantCulture));
|
2026-08-04 16:43:29 +08:00
|
|
|
|
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
|
|
|
|
|
|
initialState.Pose, goal, settings, obstacles, obstacleSnapshotVersion);
|
|
|
|
|
|
|
|
|
|
|
|
var bootstrapTimer = Stopwatch.StartNew();
|
|
|
|
|
|
TrajectoryObservationBootstrapResult bootstrap = new TrajectoryObservationBootstrapper()
|
|
|
|
|
|
.Bootstrap(job, token);
|
|
|
|
|
|
bootstrapTimer.Stop();
|
|
|
|
|
|
if (!bootstrap.Succeeded)
|
|
|
|
|
|
{
|
2026-08-05 09:59:52 +08:00
|
|
|
|
DrawIfCurrent(sessionId, bootstrap, null, null, null,
|
|
|
|
|
|
new TrajectoryObservationDiagnostic("bootstrap failed: " + bootstrap.FailureReason));
|
2026-08-04 16:43:29 +08:00
|
|
|
|
LogIfCurrent(sessionId, "Planning bootstrap failed after " +
|
|
|
|
|
|
bootstrapTimer.Elapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) +
|
|
|
|
|
|
" ms: " + bootstrap.FailureReason);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LogIfCurrent(sessionId, CreateBootstrapStatus(sessionId, bootstrap, settings, obstacles.Count,
|
|
|
|
|
|
bootstrapTimer.Elapsed));
|
|
|
|
|
|
var controller = new TrajectoryObservationController(bootstrap, settings,
|
|
|
|
|
|
new EmPlanningService(new OsqpNativeSolver()),
|
|
|
|
|
|
"trajectory-observer-" + sessionId.ToString(CultureInfo.InvariantCulture));
|
2026-08-05 15:32:37 +08:00
|
|
|
|
LogIfCurrent(sessionId, controller.CreateConfigurationDiagnostic().Text);
|
2026-08-07 15:51:31 +08:00
|
|
|
|
LogIfCurrent(sessionId, CreateSettingsDiagnostic(settings));
|
|
|
|
|
|
EmPlannerConfiguration effectiveConfiguration = controller.CreateEffectiveConfigurationSnapshot();
|
|
|
|
|
|
double stopSpeedTolerance = effectiveConfiguration.Longitudinal.StopSpeedToleranceMetersPerSecond;
|
|
|
|
|
|
LogIfCurrent(sessionId, "static start check: initialSignedSpeed=" +
|
|
|
|
|
|
initialState.SignedLongitudinalSpeedMetersPerSecond.ToString("R", CultureInfo.InvariantCulture) +
|
|
|
|
|
|
", stopSpeedTolerance=" + stopSpeedTolerance.ToString("R", CultureInfo.InvariantCulture) +
|
|
|
|
|
|
", treatedAsStopped=" +
|
|
|
|
|
|
(Math.Abs(initialState.SignedLongitudinalSpeedMetersPerSecond) <= stopSpeedTolerance)
|
|
|
|
|
|
.ToString(CultureInfo.InvariantCulture));
|
2026-08-04 17:33:14 +08:00
|
|
|
|
var observationLoop = new TrajectoryObservationLoop(controller);
|
2026-08-06 11:48:46 +08:00
|
|
|
|
TrajectoryObservationVisualizationPublisher webPublisher = StartWebVisualizationIfEnabled(
|
|
|
|
|
|
sessionId, bootstrap, controller, settings, obstacleSnapshotVersion);
|
|
|
|
|
|
var dynamicSnapshotBuilder = new TrajectoryObservationDynamicSnapshotBuilder();
|
|
|
|
|
|
long visualizationSequence = 0L;
|
2026-08-04 16:43:29 +08:00
|
|
|
|
bool gearSwitchWaitingPrinted = false;
|
|
|
|
|
|
|
|
|
|
|
|
while (true)
|
|
|
|
|
|
{
|
|
|
|
|
|
await Task.Delay(observerPeriod, token).ConfigureAwait(false);
|
|
|
|
|
|
VehicleMotionState state = ReadVehicleState();
|
2026-08-04 17:33:14 +08:00
|
|
|
|
DateTimeOffset now = state.CapturedAtUtc;
|
|
|
|
|
|
TrajectoryObservationLoopTick tick = observationLoop.Tick(now, state, token);
|
|
|
|
|
|
TrajectoryObservationObservation observation = tick.Observation;
|
2026-08-05 09:57:38 +08:00
|
|
|
|
TrajectoryObservationDiagnostic diagnostic = TrajectoryObservationDiagnostics.Create(
|
|
|
|
|
|
tick.LatestCycle, tick.LatestPlanningElapsed, tick.PlanningInFlight,
|
|
|
|
|
|
observation.PublishedTrajectory);
|
2026-08-06 11:48:46 +08:00
|
|
|
|
DirectionSegmentView segment = controller.ActiveSegment;
|
2026-08-04 16:43:29 +08:00
|
|
|
|
TrajectoryObservationCharts charts = observation.PublishedTrajectory == null
|
|
|
|
|
|
? null
|
|
|
|
|
|
: TrajectoryObservationCharts.Build(observation.PublishedTrajectory, segment,
|
|
|
|
|
|
settings.MapPaddingMeters);
|
2026-08-04 17:33:14 +08:00
|
|
|
|
TrajectoryObservationRuntimeState runtimeState = TrajectoryObservationRuntimeState.Create(
|
|
|
|
|
|
now, observation.PublishedTrajectory);
|
2026-08-05 09:59:52 +08:00
|
|
|
|
DrawIfCurrent(sessionId, bootstrap, observation, charts, runtimeState, diagnostic);
|
2026-08-06 11:48:46 +08:00
|
|
|
|
webPublisher?.TryPublish(now, () => dynamicSnapshotBuilder.Build(
|
|
|
|
|
|
Interlocked.Increment(ref visualizationSequence), tick, controller.ActiveSegment,
|
2026-08-07 09:28:55 +08:00
|
|
|
|
controller.PreviousTrajectoryForVisualization, bootstrap.Vehicle,
|
|
|
|
|
|
controller.CreateEffectiveConfigurationSnapshot()));
|
2026-08-04 17:33:14 +08:00
|
|
|
|
if (runtimeState.WaitingAtGearSwitch && !gearSwitchWaitingPrinted)
|
2026-08-04 16:43:29 +08:00
|
|
|
|
{
|
2026-08-04 17:33:14 +08:00
|
|
|
|
LogIfCurrent(sessionId, runtimeState.WorldNotice);
|
2026-08-04 16:43:29 +08:00
|
|
|
|
gearSwitchWaitingPrinted = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 09:57:38 +08:00
|
|
|
|
if (tick.PlanningStarted)
|
|
|
|
|
|
LogIfCurrent(sessionId, "planning status=pending");
|
|
|
|
|
|
if (tick.PlanningCompleted)
|
2026-08-07 15:51:31 +08:00
|
|
|
|
{
|
2026-08-05 09:57:38 +08:00
|
|
|
|
LogIfCurrent(sessionId, diagnostic.Text);
|
2026-08-07 15:51:31 +08:00
|
|
|
|
if (observation.PublishedTrajectory != null && charts != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
AppendReportIfCurrent(sessionId,
|
|
|
|
|
|
TrajectoryObservationDiagnostics.CreateCurveReport(charts, observation.PublishedTrajectory).Text);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-04 16:43:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static VehicleMotionState ReadVehicleState()
|
|
|
|
|
|
{
|
|
|
|
|
|
var location = DetourInterface.getCartLocation();
|
|
|
|
|
|
if (location == null) throw new InvalidOperationException("Live localization is unavailable.");
|
|
|
|
|
|
if (BasicPilotBase.Chassis == null) throw new InvalidOperationException("Live chassis read interface is unavailable.");
|
|
|
|
|
|
var speed = BasicPilotBase.Chassis.GetCarSpeed(true);
|
|
|
|
|
|
return new VehicleMotionState(
|
|
|
|
|
|
new Pose2D(location.x / 1000d, location.y / 1000d, location.th * Math.PI / 180d),
|
|
|
|
|
|
speed.Vx, null, DateTimeOffset.UtcNow, Interlocked.Increment(ref stateSequence));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string CreateBootstrapStatus(long sessionId, TrajectoryObservationBootstrapResult bootstrap,
|
|
|
|
|
|
TrajectoryObservationSettings settings, int obstacleCount, TimeSpan elapsed)
|
|
|
|
|
|
{
|
|
|
|
|
|
float widthMillimeters = bootstrap.Map.Bounds.XMax - bootstrap.Map.Bounds.XMin;
|
|
|
|
|
|
float heightMillimeters = bootstrap.Map.Bounds.YMax - bootstrap.Map.Bounds.YMin;
|
|
|
|
|
|
return "Session " + sessionId.ToString(CultureInfo.InvariantCulture) + " bootstrap succeeded in " +
|
|
|
|
|
|
elapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) + " ms.\n" +
|
|
|
|
|
|
"start=(" + Format(bootstrap.Job.Start.X) + ", " + Format(bootstrap.Job.Start.Y) + ", " +
|
|
|
|
|
|
Format(bootstrap.Job.Start.Heading) + ") m/rad; goal=(" + Format(bootstrap.Job.Goal.X) + ", " +
|
|
|
|
|
|
Format(bootstrap.Job.Goal.Y) + ", " + Format(bootstrap.Job.Goal.Heading) + ") m/rad.\n" +
|
|
|
|
|
|
"map=" + Format(widthMillimeters) + " x " + Format(heightMillimeters) + " mm, resolution=" +
|
|
|
|
|
|
Format(settings.MapResolutionMillimeters) + " mm, padding=" + Format(settings.MapPaddingMeters) +
|
|
|
|
|
|
" m, obstacles=" + obstacleCount.ToString(CultureInfo.InvariantCulture) + ".\n" +
|
|
|
|
|
|
"replan=" + Format(settings.ReplanPeriodSeconds) + " s, observer=" +
|
|
|
|
|
|
Format(settings.ObserverPeriodSeconds) + " s, segment index=0.";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string CreateTickStatus(long sessionId, TrajectoryObservationObservation observation,
|
|
|
|
|
|
TrajectoryObservationCharts charts, PlanningCycleResult latestCycle, TimeSpan latestPlanningElapsed,
|
|
|
|
|
|
bool waitingAtGearSwitch)
|
|
|
|
|
|
{
|
|
|
|
|
|
string planning = latestCycle == null
|
|
|
|
|
|
? "not started"
|
|
|
|
|
|
: latestCycle.Result.Status + ", published=" + latestCycle.Published +
|
|
|
|
|
|
", version=" + latestCycle.Version.ToString(CultureInfo.InvariantCulture);
|
|
|
|
|
|
string selected = observation.SelectedPoint == null
|
|
|
|
|
|
? "selected trajectory point unavailable"
|
|
|
|
|
|
: "selected t=" + Format(observation.SelectedPoint.TimeFromStart) +
|
|
|
|
|
|
" s, path-S=" + Format(observation.SelectedPoint.PathS) + " m";
|
|
|
|
|
|
string loggedCommand = observation.Command == null
|
|
|
|
|
|
? "logged command unavailable"
|
|
|
|
|
|
: "logged command only: signed speed=" + Format(observation.Command.SignedLongitudinalVelocity) +
|
|
|
|
|
|
" m/s, yaw rate=" + Format(observation.Command.YawRate) + " rad/s, direction=" +
|
|
|
|
|
|
observation.Command.Direction + ", request direction change=" +
|
|
|
|
|
|
observation.Command.RequestDirectionChange;
|
|
|
|
|
|
string terminal = observation.PublishedTrajectory == null
|
|
|
|
|
|
? "trajectory unavailable"
|
|
|
|
|
|
: "trajectory=" + observation.PublishedTrajectory.Metadata.TrajectoryId + ", terminal=" +
|
|
|
|
|
|
observation.PublishedTrajectory.Metadata.TerminalType + ", segment index=" +
|
|
|
|
|
|
observation.PublishedTrajectory.Metadata.SegmentIndex.ToString(CultureInfo.InvariantCulture);
|
|
|
|
|
|
string projectionFailures = charts == null
|
|
|
|
|
|
? "unavailable"
|
|
|
|
|
|
: charts.FailedProjectionCount.ToString(CultureInfo.InvariantCulture);
|
2026-08-04 17:33:14 +08:00
|
|
|
|
string waiting = waitingAtGearSwitch
|
|
|
|
|
|
? "\n" + TrajectoryObservationRuntimeState.GearSwitchWaitingNotice
|
|
|
|
|
|
: string.Empty;
|
2026-08-04 16:43:29 +08:00
|
|
|
|
|
|
|
|
|
|
return "Session " + sessionId.ToString(CultureInfo.InvariantCulture) + ".\n" +
|
|
|
|
|
|
"pose=(" + Format(observation.VehicleState.Pose.X) + ", " +
|
|
|
|
|
|
Format(observation.VehicleState.Pose.Y) + ", " + Format(observation.VehicleState.Pose.Heading) +
|
|
|
|
|
|
") m/rad, actual longitudinal speed=" +
|
|
|
|
|
|
Format(observation.VehicleState.SignedLongitudinalSpeedMetersPerSecond) + " m/s.\n" +
|
|
|
|
|
|
"planning=" + planning + ", latest replan elapsed=" +
|
|
|
|
|
|
latestPlanningElapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) + " ms.\n" +
|
|
|
|
|
|
terminal + ".\n" + selected + ".\n" + loggedCommand + ".\n" +
|
|
|
|
|
|
"LS projection failures=" + projectionFailures + "." + waiting;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 11:48:46 +08:00
|
|
|
|
private static TrajectoryObservationVisualizationPublisher StartWebVisualizationIfEnabled(long sessionId,
|
|
|
|
|
|
TrajectoryObservationBootstrapResult bootstrap, TrajectoryObservationController controller,
|
|
|
|
|
|
TrajectoryObservationSettings settings, long obstacleSnapshotVersion)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!settings.EnableWebVisualization) return null;
|
|
|
|
|
|
|
|
|
|
|
|
var publisher = new TrajectoryObservationVisualizationPublisher(settings,
|
|
|
|
|
|
new PlanningVisualizationSessionSink(), message => LogIfCurrent(sessionId, message));
|
|
|
|
|
|
PlanningVisualizationSessionInfo sessionInfo;
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var staticSnapshot = new TrajectoryObservationStaticSnapshotBuilder().Build(bootstrap,
|
|
|
|
|
|
controller.CreateEffectiveConfigurationSnapshot(), settings, obstacleSnapshotVersion);
|
|
|
|
|
|
sessionInfo = publisher.Start(staticSnapshot);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
|
{
|
|
|
|
|
|
publisher.Disable(exception);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (sessionInfo == null)
|
|
|
|
|
|
return null;
|
|
|
|
|
|
if (!TryAttachWebPublisher(sessionId, publisher))
|
|
|
|
|
|
{
|
|
|
|
|
|
publisher.Stop();
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LogIfCurrent(sessionId, "网页可视化地址(含会话令牌):" + sessionInfo.Uri.AbsoluteUri);
|
|
|
|
|
|
if (settings.AutoOpenWebVisualization)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
Process.Start(new ProcessStartInfo
|
|
|
|
|
|
{
|
|
|
|
|
|
FileName = sessionInfo.Uri.AbsoluteUri,
|
|
|
|
|
|
UseShellExecute = true,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
|
{
|
|
|
|
|
|
LogIfCurrent(sessionId, "无法自动打开网页;服务继续运行,请手动访问:" +
|
|
|
|
|
|
sessionInfo.Uri.AbsoluteUri + ";" + exception.GetType().Name + ":" + exception.Message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return publisher;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static bool TryAttachWebPublisher(long sessionId,
|
|
|
|
|
|
TrajectoryObservationVisualizationPublisher publisher)
|
|
|
|
|
|
{
|
|
|
|
|
|
lock (SessionSync)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (activeSessionId != sessionId) return false;
|
|
|
|
|
|
activeWebPublisher = publisher;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 16:43:29 +08:00
|
|
|
|
private static void DrawIfCurrent(long sessionId, TrajectoryObservationBootstrapResult bootstrap,
|
2026-08-04 17:33:14 +08:00
|
|
|
|
TrajectoryObservationObservation observation, TrajectoryObservationCharts charts,
|
2026-08-05 09:59:52 +08:00
|
|
|
|
TrajectoryObservationRuntimeState runtimeState, TrajectoryObservationDiagnostic diagnostic)
|
2026-08-04 16:43:29 +08:00
|
|
|
|
{
|
|
|
|
|
|
lock (SessionSync)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (activeSessionId != sessionId) return;
|
2026-08-06 11:48:46 +08:00
|
|
|
|
TrajectoryObservationPresentation presentation = activePresentation;
|
|
|
|
|
|
if (presentation == null) return;
|
2026-08-05 09:59:52 +08:00
|
|
|
|
string diagnosticText = diagnostic == null ? string.Empty : diagnostic.Text;
|
2026-08-06 11:48:46 +08:00
|
|
|
|
presentation.DrawWorld(bootstrap, observation, runtimeState, diagnosticText);
|
|
|
|
|
|
presentation.DrawLs(charts, diagnosticText);
|
|
|
|
|
|
presentation.DrawSt(charts, diagnosticText);
|
2026-08-04 16:43:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static void LogIfCurrent(long sessionId, string message)
|
|
|
|
|
|
{
|
|
|
|
|
|
lock (SessionSync)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (activeSessionId != sessionId) return;
|
|
|
|
|
|
PrintStatus(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 15:51:31 +08:00
|
|
|
|
private static void AppendReportIfCurrent(long sessionId, string text)
|
|
|
|
|
|
{
|
|
|
|
|
|
lock (SessionSync)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (activeSessionId != sessionId) return;
|
|
|
|
|
|
activeReportWriter?.Append(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 16:43:29 +08:00
|
|
|
|
private static void PrintStatus(string message)
|
|
|
|
|
|
{
|
2026-08-05 09:57:38 +08:00
|
|
|
|
string text = ObserveOnlyNotice + "\n" + message;
|
|
|
|
|
|
Hedingben.ToastText(text, StatusChannel);
|
|
|
|
|
|
Console.WriteLine("[TrajectoryObserver] " + text);
|
2026-08-07 15:51:31 +08:00
|
|
|
|
activeReportWriter?.Append(message);
|
2026-08-04 16:43:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 15:51:31 +08:00
|
|
|
|
private static void Finish(long sessionId, CancellationTokenSource cancellation, Task completed,
|
|
|
|
|
|
TrajectoryObservationReportWriter reportWriter)
|
2026-08-04 16:43:29 +08:00
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
completed.GetAwaiter().GetResult();
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
|
{
|
2026-08-04 17:33:14 +08:00
|
|
|
|
ClearAndLogRuntimeFaultIfCurrent(sessionId, exception);
|
2026-08-04 16:43:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
finally
|
|
|
|
|
|
{
|
|
|
|
|
|
lock (SessionSync)
|
|
|
|
|
|
{
|
2026-08-07 15:51:31 +08:00
|
|
|
|
if (ReferenceEquals(activeReportWriter, reportWriter))
|
|
|
|
|
|
activeReportWriter = null;
|
2026-08-04 16:43:29 +08:00
|
|
|
|
if (activeSessionId == sessionId)
|
|
|
|
|
|
{
|
|
|
|
|
|
activeTask = null;
|
|
|
|
|
|
activeCancellation = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-07 15:51:31 +08:00
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
string reportPath = reportWriter.Save();
|
|
|
|
|
|
Console.WriteLine("[TrajectoryObserver] report=" + reportPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
|
{
|
|
|
|
|
|
Console.WriteLine("[TrajectoryObserver] report write failed: " + exception.Message);
|
|
|
|
|
|
}
|
2026-08-04 16:43:29 +08:00
|
|
|
|
cancellation.Dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 15:51:31 +08:00
|
|
|
|
private static string CreateSettingsDiagnostic(TrajectoryObservationSettings settings)
|
|
|
|
|
|
{
|
|
|
|
|
|
return "observation settings: planningScope=" + settings.PlanningScope +
|
|
|
|
|
|
", webVisualization=" + settings.EnableWebVisualization +
|
|
|
|
|
|
", nativePainter=" + settings.EnableNativePainterVisualization +
|
|
|
|
|
|
", outputTimeStep=" + Format(settings.OutputTimeStepSeconds) + "s" +
|
|
|
|
|
|
", observerPeriod=" + Format(settings.ObserverPeriodSeconds) + "s" +
|
|
|
|
|
|
", solverTimeout=" + Format(settings.SolverTimeoutSeconds) + "s" +
|
|
|
|
|
|
", maximumOsqpIterations=" + settings.MaximumOsqpIterations.ToString(CultureInfo.InvariantCulture);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 17:33:14 +08:00
|
|
|
|
private static void ClearAndLogRuntimeFaultIfCurrent(long sessionId, Exception exception)
|
|
|
|
|
|
{
|
|
|
|
|
|
lock (SessionSync)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (activeSessionId != sessionId) return;
|
2026-08-06 11:48:46 +08:00
|
|
|
|
StopActiveVisualizationNoLock();
|
2026-08-04 17:33:14 +08:00
|
|
|
|
PrintStatus("Observation session failed; all observer layers were cleared: " +
|
|
|
|
|
|
exception.GetType().Name + ": " + exception.Message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 11:48:46 +08:00
|
|
|
|
private static void StopActiveVisualizationNoLock()
|
|
|
|
|
|
{
|
|
|
|
|
|
TrajectoryObservationVisualizationPublisher publisher = activeWebPublisher;
|
|
|
|
|
|
activeWebPublisher = null;
|
|
|
|
|
|
publisher?.Stop();
|
|
|
|
|
|
|
|
|
|
|
|
TrajectoryObservationPresentation presentation = activePresentation;
|
|
|
|
|
|
activePresentation = null;
|
|
|
|
|
|
presentation?.ClearAll();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 16:43:29 +08:00
|
|
|
|
private static void CancelWithoutWaiting(CancellationTokenSource cancellation)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (cancellation == null) return;
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
cancellation.Cancel();
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (ObjectDisposedException)
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string Format(double value)
|
|
|
|
|
|
{
|
|
|
|
|
|
return value.ToString("F3", CultureInfo.InvariantCulture);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|