feat: observe one full EM direction segment

This commit is contained in:
梁薄云
2026-08-07 08:56:14 +08:00
parent 57d8abb3e1
commit ab3204bfde
6 changed files with 289 additions and 20 deletions
@@ -32,22 +32,23 @@ public sealed class TrajectoryObservationMovementTest : MovementTest
public float MapResolutionMm = 50f; public float MapResolutionMm = 50f;
public double ReplanPeriodSeconds = 0.20d; public double ReplanPeriodSeconds = 0.20d;
public double ObserverPeriodSeconds = 0.05d; public double ObserverPeriodSeconds = 0.05d;
public bool UseFullDirectionSegmentPlanning = true;
public double SolverTimeoutSeconds = 5.0d; public double SolverTimeoutSeconds = 5.0d;
public int MaximumOsqpIterations = 100000; public int MaximumOsqpIterations = 100000;
/// <summary>单次 ST 轨迹覆盖的未来时长,单位 s;默认 2s,不等于观察循环周期。</summary> /// <summary>仅滚动兼容字段;完整方向段模式不用于截断。默认 2s,不等于观察循环周期。</summary>
public double TimeHorizonSeconds = 2d; public double TimeHorizonSeconds = 20d;
/// <summary>Trajectory 相邻 TimeFromStart 时间戳的间隔,单位 s;默认 0.10s,不等于观察循环周期。</summary> /// <summary>Trajectory 相邻 TimeFromStart 时间戳的间隔,单位 s;默认 0.10s,不等于观察循环周期。</summary>
public double OutputTimeStepSeconds = 0.10d; public double OutputTimeStepSeconds = 0.10d;
public double VehicleLengthMeters = 0.80d; public double VehicleLengthMeters = 0.80d;
public double VehicleWidthMeters = 0.60d; public double VehicleWidthMeters = 0.60d;
public double SafetyMarginMeters = 0.05d; public double SafetyMarginMeters = 0.05d;
public double MaximumCurvaturePerMeter = 1d / 1.20d; public double MaximumCurvaturePerMeter = 1d / 1.20d;
public bool EnableWebVisualization = false; public bool EnableWebVisualization = true;
public bool AutoOpenWebVisualization = true; public bool AutoOpenWebVisualization = true;
public int WebVisualizationPort = 0; public int WebVisualizationPort = 0;
public double WebRefreshRateHz = 10d; public double WebRefreshRateHz = 10d;
public int VisualizationHistoryCycleLimit = 60; public int VisualizationHistoryCycleLimit = 60;
public bool EnableNativePainterVisualization = false; public bool EnableNativePainterVisualization = true;
public double DirectionConfirmationSpeedMetersPerSecond = 0.02d; public double DirectionConfirmationSpeedMetersPerSecond = 0.02d;
public int DirectionConfirmationSamples = 3; public int DirectionConfirmationSamples = 3;
public double GearSwitchProjectionToleranceMeters = 0.50d; public double GearSwitchProjectionToleranceMeters = 0.50d;
@@ -76,6 +77,7 @@ public sealed class TrajectoryObservationMovementTest : MovementTest
MapResolutionMillimeters = MapResolutionMm, MapResolutionMillimeters = MapResolutionMm,
ReplanPeriodSeconds = ReplanPeriodSeconds, ReplanPeriodSeconds = ReplanPeriodSeconds,
ObserverPeriodSeconds = ObserverPeriodSeconds, ObserverPeriodSeconds = ObserverPeriodSeconds,
PlanningScope = UseFullDirectionSegmentPlanning ? EmPlanningScope.FullDirectionSegment : EmPlanningScope.RollingHorizon,
SolverTimeoutSeconds = SolverTimeoutSeconds, SolverTimeoutSeconds = SolverTimeoutSeconds,
MaximumOsqpIterations = MaximumOsqpIterations, MaximumOsqpIterations = MaximumOsqpIterations,
TimeHorizonSeconds = TimeHorizonSeconds, TimeHorizonSeconds = TimeHorizonSeconds,
@@ -2,19 +2,21 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using MultiWheelC.TrajectoryPlanning.CoarsePath; using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade; using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade;
using MultiWheelC.TrajectoryPlanning.EMPlanner;
using MultiWheelC.TrajectoryPlanning.Mapping; using MultiWheelC.TrajectoryPlanning.Mapping;
namespace MultiWheelC.TrajectoryPlanning.TrajectoryObservation; namespace MultiWheelC.TrajectoryPlanning.TrajectoryObservation;
public sealed class TrajectoryObservationSettings public sealed class TrajectoryObservationSettings
{ {
public EmPlanningScope PlanningScope { get; set; } = EmPlanningScope.FullDirectionSegment;
public double MapPaddingMeters { get; set; } = 2d; public double MapPaddingMeters { get; set; } = 2d;
public float MapResolutionMillimeters { get; set; } = 50f; public float MapResolutionMillimeters { get; set; } = 50f;
public double ReplanPeriodSeconds { get; set; } = 0.20d; public double ReplanPeriodSeconds { get; set; } = 0.20d;
public double ObserverPeriodSeconds { get; set; } = 0.05d; public double ObserverPeriodSeconds { get; set; } = 0.05d;
public double SolverTimeoutSeconds { get; set; } = 5d; public double SolverTimeoutSeconds { get; set; } = 5d;
public int MaximumOsqpIterations { get; set; } = 100000; public int MaximumOsqpIterations { get; set; } = 100000;
/// <summary>单次 ST 轨迹覆盖的未来时长,单位 s;默认 2 s,不等于观察循环周期。</summary> /// <summary>仅滚动视窗兼容字段;完整方向段模式不用于截断。单次 ST 轨迹覆盖的未来时长,单位 s;默认 2 s,不等于观察循环周期。</summary>
public double TimeHorizonSeconds { get; set; } = 2d; public double TimeHorizonSeconds { get; set; } = 2d;
/// <summary>发布 Trajectory 相邻 TimeFromStart 时间戳的间隔,单位 s;不等于观察循环周期。</summary> /// <summary>发布 Trajectory 相邻 TimeFromStart 时间戳的间隔,单位 s;不等于观察循环周期。</summary>
public double OutputTimeStepSeconds { get; set; } = 0.10d; public double OutputTimeStepSeconds { get; set; } = 0.10d;
@@ -22,7 +24,7 @@ public sealed class TrajectoryObservationSettings
public double VehicleWidthMeters { get; set; } = 0.60d; public double VehicleWidthMeters { get; set; } = 0.60d;
public double SafetyMarginMeters { get; set; } = 0.05d; public double SafetyMarginMeters { get; set; } = 0.05d;
public double MaximumCurvaturePerMeter { get; set; } = 1d / 1.20d; public double MaximumCurvaturePerMeter { get; set; } = 1d / 1.20d;
public bool EnableWebVisualization { get; set; } = false; public bool EnableWebVisualization { get; set; } = true;
public bool AutoOpenWebVisualization { get; set; } = true; public bool AutoOpenWebVisualization { get; set; } = true;
public int WebVisualizationPort { get; set; } = 0; public int WebVisualizationPort { get; set; } = 0;
public double WebRefreshRateHz { get; set; } = 10d; public double WebRefreshRateHz { get; set; } = 10d;
@@ -37,6 +39,7 @@ public sealed class TrajectoryObservationSettings
{ {
var snapshot = new TrajectoryObservationSettings var snapshot = new TrajectoryObservationSettings
{ {
PlanningScope = PlanningScope,
MapPaddingMeters = MapPaddingMeters, MapPaddingMeters = MapPaddingMeters,
MapResolutionMillimeters = MapResolutionMillimeters, MapResolutionMillimeters = MapResolutionMillimeters,
ReplanPeriodSeconds = ReplanPeriodSeconds, ReplanPeriodSeconds = ReplanPeriodSeconds,
@@ -66,6 +69,8 @@ public sealed class TrajectoryObservationSettings
public void Validate() public void Validate()
{ {
if (!Enum.IsDefined(typeof(EmPlanningScope), PlanningScope))
throw new ArgumentOutOfRangeException(nameof(PlanningScope), "Value must be a defined planning scope.");
EnsurePositiveFinite(MapPaddingMeters, nameof(MapPaddingMeters)); EnsurePositiveFinite(MapPaddingMeters, nameof(MapPaddingMeters));
EnsurePositiveFinite(MapResolutionMillimeters, nameof(MapResolutionMillimeters)); EnsurePositiveFinite(MapResolutionMillimeters, nameof(MapResolutionMillimeters));
EnsurePositiveFinite(ReplanPeriodSeconds, nameof(ReplanPeriodSeconds)); EnsurePositiveFinite(ReplanPeriodSeconds, nameof(ReplanPeriodSeconds));
@@ -188,6 +188,7 @@ public sealed class TrajectoryObservationObservation
public sealed class TrajectoryObservationController public sealed class TrajectoryObservationController
{ {
private readonly TrajectoryObservationBootstrapResult bootstrap; private readonly TrajectoryObservationBootstrapResult bootstrap;
private readonly TrajectoryObservationSettings settings;
private readonly EmPlannerConfiguration configuration; private readonly EmPlannerConfiguration configuration;
private readonly IEmPlanningService planningService; private readonly IEmPlanningService planningService;
private readonly TrajectoryObservationSegmentTracker segmentTracker; private readonly TrajectoryObservationSegmentTracker segmentTracker;
@@ -196,6 +197,8 @@ public sealed class TrajectoryObservationController
private EmTrajectory previousTrajectoryForVisualization; private EmTrajectory previousTrajectoryForVisualization;
private readonly string sessionId; private readonly string sessionId;
private long cycleId; private long cycleId;
private int plannedSegmentIndex;
private bool planAttemptedForActiveSegment;
public TrajectoryObservationController(TrajectoryObservationBootstrapResult bootstrap, public TrajectoryObservationController(TrajectoryObservationBootstrapResult bootstrap,
TrajectoryObservationSettings settings, IEmPlanningService planningService, string sessionId) TrajectoryObservationSettings settings, IEmPlanningService planningService, string sessionId)
@@ -207,17 +210,18 @@ public sealed class TrajectoryObservationController
if (planningService == null) throw new ArgumentNullException(nameof(planningService)); if (planningService == null) throw new ArgumentNullException(nameof(planningService));
if (string.IsNullOrWhiteSpace(sessionId)) throw new ArgumentException("A session ID is required.", nameof(sessionId)); if (string.IsNullOrWhiteSpace(sessionId)) throw new ArgumentException("A session ID is required.", nameof(sessionId));
settings.Validate(); TrajectoryObservationSettings snapshot = settings.CreateValidatedSnapshot();
this.settings = snapshot;
configuration = EmPlannerConfiguration.CreateDefault(); configuration = EmPlannerConfiguration.CreateDefault();
configuration.Scheduling.ReplanPeriodSeconds = settings.ReplanPeriodSeconds; configuration.Scheduling.ReplanPeriodSeconds = snapshot.ReplanPeriodSeconds;
configuration.Scheduling.SolverTimeoutSeconds = settings.SolverTimeoutSeconds; configuration.Scheduling.SolverTimeoutSeconds = snapshot.SolverTimeoutSeconds;
configuration.Solver.MaximumOsqpIterations = settings.MaximumOsqpIterations; configuration.Solver.MaximumOsqpIterations = snapshot.MaximumOsqpIterations;
configuration.Scheduling.TimeHorizonSeconds = settings.TimeHorizonSeconds; configuration.Scheduling.TimeHorizonSeconds = snapshot.TimeHorizonSeconds;
configuration.Scheduling.OutputTimeStepSeconds = settings.OutputTimeStepSeconds; configuration.Scheduling.OutputTimeStepSeconds = snapshot.OutputTimeStepSeconds;
this.planningService = planningService; this.planningService = planningService;
coordinator = new EmPlanningCoordinator(planningService); coordinator = new EmPlanningCoordinator(planningService);
executor = new TrajectoryExecutor(configuration); executor = new TrajectoryExecutor(configuration);
segmentTracker = new TrajectoryObservationSegmentTracker(bootstrap.Segments, settings, segmentTracker = new TrajectoryObservationSegmentTracker(bootstrap.Segments, snapshot,
configuration.Longitudinal.StopSpeedToleranceMetersPerSecond); configuration.Longitudinal.StopSpeedToleranceMetersPerSecond);
this.sessionId = sessionId; this.sessionId = sessionId;
} }
@@ -242,6 +246,15 @@ public sealed class TrajectoryObservationController
public bool ShouldStartCycle(DateTimeOffset now) public bool ShouldStartCycle(DateTimeOffset now)
{ {
if (settings.PlanningScope == EmPlanningScope.FullDirectionSegment)
{
if (plannedSegmentIndex != ActiveSegment.SegmentIndex)
{
plannedSegmentIndex = ActiveSegment.SegmentIndex;
planAttemptedForActiveSegment = false;
}
return !planAttemptedForActiveSegment;
}
return coordinator.ShouldStartCycle(now); return coordinator.ShouldStartCycle(now);
} }
@@ -250,6 +263,8 @@ public sealed class TrajectoryObservationController
{ {
if (state == null) throw new ArgumentNullException(nameof(state)); if (state == null) throw new ArgumentNullException(nameof(state));
planAttemptedForActiveSegment = true;
plannedSegmentIndex = ActiveSegment.SegmentIndex;
long currentCycleId = Interlocked.Increment(ref cycleId); long currentCycleId = Interlocked.Increment(ref cycleId);
EmTrajectory previousTrajectory = coordinator.PublishedTrajectory; EmTrajectory previousTrajectory = coordinator.PublishedTrajectory;
var request = new EmPlanningRequest( var request = new EmPlanningRequest(
@@ -257,7 +272,7 @@ public sealed class TrajectoryObservationController
ActiveSegment.SegmentIndex, previousTrajectory, now, now, ActiveSegment.SegmentIndex, previousTrajectory, now, now,
sessionId + "-trajectory-" + currentCycleId, sessionId + "-reference", sessionId + "-trajectory-" + currentCycleId, sessionId + "-reference",
previousTrajectory?.Metadata.TrajectoryId ?? string.Empty, previousTrajectory?.Metadata.TrajectoryId ?? string.Empty,
EmMotionModel.NonholonomicForwardReverse, EmPlanningScope.RollingHorizon); EmMotionModel.NonholonomicForwardReverse, settings.PlanningScope);
return coordinator.PlanLatestAsync(new PlanningCycleInput(request, now), cancellationToken); return coordinator.PlanLatestAsync(new PlanningCycleInput(request, now), cancellationToken);
} }
@@ -272,6 +287,8 @@ public sealed class TrajectoryObservationController
previousTrajectoryForVisualization = coordinator.PublishedTrajectory; previousTrajectoryForVisualization = coordinator.PublishedTrajectory;
coordinator = new EmPlanningCoordinator(planningService); coordinator = new EmPlanningCoordinator(planningService);
executor = new TrajectoryExecutor(configuration); executor = new TrajectoryExecutor(configuration);
plannedSegmentIndex = segmentTracker.State.ActiveSegmentIndex;
planAttemptedForActiveSegment = false;
return true; return true;
} }
@@ -168,6 +168,7 @@ public sealed class TrajectoryObservationStaticSnapshotBuilder
V("行数", "Rows", I(map.Rows), ""), V("行数", "Rows", I(map.Rows), ""),
V("列数", "Cols", I(map.Cols), "")), V("列数", "Cols", I(map.Cols), "")),
Group("可视化与换向确认", Group("可视化与换向确认",
V("规划范围", "PlanningScope", settings.PlanningScope.ToString(), ""),
V("启用网页", "EnableWebVisualization", B(settings.EnableWebVisualization), ""), V("启用网页", "EnableWebVisualization", B(settings.EnableWebVisualization), ""),
V("自动打开网页", "AutoOpenWebVisualization", B(settings.AutoOpenWebVisualization), ""), V("自动打开网页", "AutoOpenWebVisualization", B(settings.AutoOpenWebVisualization), ""),
V("网页端口", "WebVisualizationPort", I(settings.WebVisualizationPort), ""), V("网页端口", "WebVisualizationPort", I(settings.WebVisualizationPort), ""),
@@ -1,6 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq;
using System.Reflection;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath; using MultiWheelC.TrajectoryPlanning.CoarsePath;
@@ -10,6 +13,7 @@ using MultiWheelC.TrajectoryPlanning.Mapping;
using MultiWheelC.TrajectoryPlanning.PathSmoothing; using MultiWheelC.TrajectoryPlanning.PathSmoothing;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade; using MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade;
using MultiWheelC.TrajectoryPlanning.TrajectoryObservation; using MultiWheelC.TrajectoryPlanning.TrajectoryObservation;
using TrajectoryPlanningVisualization;
namespace EMPlannerVerificationHost; namespace EMPlannerVerificationHost;
@@ -31,6 +35,11 @@ internal static class TrajectoryObservationChecks
VerifiesValidNonEmptyObstacleSource(); VerifiesValidNonEmptyObstacleSource();
RejectsObstacleOutsideConfiguredBounds(); RejectsObstacleOutsideConfiguredBounds();
VerifiesObserverTicksWhilePlanningIsDelayed(); VerifiesObserverTicksWhilePlanningIsDelayed();
VerifiesFullDirectionSegmentPlansOncePerActiveSegment();
VerifiesFullDirectionRequestCarriesValidatedScope();
VerifiesFullDirectionPlansAgainOnlyAfterConfirmedTransition();
VerifiesFailedFullPlanDoesNotAutoRetry();
VerifiesStaticSnapshotExportsPlanningScope();
VerifiesSessionLayerCleanupDecisions(); VerifiesSessionLayerCleanupDecisions();
VerifiesGearSwitchWaitStateForWorldPresentation(); VerifiesGearSwitchWaitStateForWorldPresentation();
VerifiesLsAndStUsePublishedTrajectoryData(); VerifiesLsAndStUsePublishedTrajectoryData();
@@ -213,9 +222,15 @@ internal static class TrajectoryObservationChecks
new FixedTrajectoryPlanningService(CreatePublishedTrajectory(effectiveAt)), "config-diagnostic"); new FixedTrajectoryPlanningService(CreatePublishedTrajectory(effectiveAt)), "config-diagnostic");
string text = controller.CreateConfigurationDiagnostic().Text; string text = controller.CreateConfigurationDiagnostic().Text;
MethodInfo effectiveConfigurationMethod = typeof(TrajectoryObservationController).GetMethod(
"CreateEffectiveConfigurationSnapshot", BindingFlags.Instance | BindingFlags.NonPublic)!;
var effectiveConfiguration = (EmPlannerConfiguration)effectiveConfigurationMethod.Invoke(
controller, Array.Empty<object>())!;
string distanceHorizonText = "distanceHorizon=" +
effectiveConfiguration.Scheduling.DistanceHorizonMeters.ToString("F2", CultureInfo.InvariantCulture) + "m";
foreach (string expected in new[] foreach (string expected in new[]
{ {
"planning configuration:", "timeHorizon=3.50s", "distanceHorizon=5.00m", "outputTimeStep=0.10s", "planning configuration:", "timeHorizon=3.50s", distanceHorizonText, "outputTimeStep=0.10s",
"outputFrequency=10.00Hz", "trajectoryKnots=36", "maximumOsqpIterations=54321", "outputFrequency=10.00Hz", "trajectoryKnots=36", "maximumOsqpIterations=54321",
"solverTimeout=1.25s", "replanPeriod=0.25s", "maximumJerkLimitedStopDistance=", "solverTimeout=1.25s", "replanPeriod=0.25s", "maximumJerkLimitedStopDistance=",
"maximumJerkLimitedStopDuration=", "requiredDistanceHorizon=", "maximumJerkLimitedStopDuration=", "requiredDistanceHorizon=",
@@ -316,7 +331,10 @@ internal static class TrajectoryObservationChecks
private static void VerifiesObserverTicksWhilePlanningIsDelayed() private static void VerifiesObserverTicksWhilePlanningIsDelayed()
{ {
DateTimeOffset startedAt = new DateTimeOffset(2026, 8, 4, 3, 0, 0, TimeSpan.Zero); DateTimeOffset startedAt = new DateTimeOffset(2026, 8, 4, 3, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings(); var settings = new TrajectoryObservationSettings
{
PlanningScope = EmPlanningScope.RollingHorizon,
};
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob( CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings, new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings,
Array.Empty<TrajectoryObservationObstacle>(), 0L); Array.Empty<TrajectoryObservationObstacle>(), 0L);
@@ -412,6 +430,186 @@ internal static class TrajectoryObservationChecks
} }
} }
private static void VerifiesFullDirectionSegmentPlansOncePerActiveSegment()
{
DateTimeOffset startedAt = new DateTimeOffset(2026, 8, 7, 0, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings();
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings,
Array.Empty<TrajectoryObservationObstacle>(), 0L);
TrajectoryObservationBootstrapResult bootstrap = new TrajectoryObservationBootstrapper()
.Bootstrap(job, CancellationToken.None);
Verification.True(bootstrap.Succeeded, "one-shot bootstrap succeeds");
EmTrajectory published = CreatePublishedTrajectory(startedAt);
var planningService = new FixedTrajectoryPlanningService(published);
var controller = new TrajectoryObservationController(bootstrap, settings, planningService, "one-shot");
var loop = new TrajectoryObservationLoop(controller);
controller.StartCycle(startedAt,
new VehicleMotionState(new Pose2D(0.10d, 0d, 0d), 0.10d, null, startedAt, 1L),
CancellationToken.None).GetAwaiter().GetResult();
TrajectoryObservationLoopTick? lastTick = null;
for (int index = 0; index < 20; index++)
{
DateTimeOffset now = startedAt.AddSeconds((index + 1) * settings.ObserverPeriodSeconds);
lastTick = loop.Tick(now,
new VehicleMotionState(new Pose2D(0.10d + index * 0.01d, 0d, 0d), 0.10d, null,
now, 2L + index), CancellationToken.None);
}
Verification.Equal(1, planningService.Requests.Count,
"full direction segment is planned only once per active segment");
Verification.True(lastTick != null && !lastTick.PlanningStarted,
"no coordinator-cadence replan after the full segment plan");
Verification.True(lastTick != null && lastTick.Observation != null &&
ReferenceEquals(published, lastTick.Observation.PublishedTrajectory),
"observation continues to publish the frozen full-segment trajectory");
}
private static void VerifiesFullDirectionRequestCarriesValidatedScope()
{
DateTimeOffset effectiveAt = new DateTimeOffset(2026, 8, 7, 1, 0, 0, TimeSpan.Zero);
var source = new TrajectoryObservationSettings
{
PlanningScope = EmPlanningScope.FullDirectionSegment,
};
TrajectoryObservationSettings snapshot = source.CreateValidatedSnapshot();
source.PlanningScope = EmPlanningScope.RollingHorizon;
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), snapshot,
Array.Empty<TrajectoryObservationObstacle>(), 0L);
TrajectoryObservationBootstrapResult bootstrap = new TrajectoryObservationBootstrapper()
.Bootstrap(job, CancellationToken.None);
Verification.True(bootstrap.Succeeded, "scope propagation bootstrap succeeds");
EmTrajectory published = CreatePublishedTrajectory(effectiveAt);
var planningService = new FixedTrajectoryPlanningService(published);
var controller = new TrajectoryObservationController(bootstrap, snapshot, planningService, "scope-check");
snapshot.PlanningScope = EmPlanningScope.RollingHorizon;
var state = new VehicleMotionState(new Pose2D(0.10d, 0d, 0d), 0.10d, null, effectiveAt, 1L);
controller.StartCycle(effectiveAt, state, CancellationToken.None).GetAwaiter().GetResult();
Verification.Equal(EmPlanningScope.FullDirectionSegment, planningService.Requests[0].PlanningScope,
"controller propagates the validated full-direction scope into EM requests");
Verification.True(!controller.ShouldStartCycle(effectiveAt.AddSeconds(1d)),
"full scope blocks coordinator-cadence restart after the first plan");
}
private static void VerifiesFullDirectionPlansAgainOnlyAfterConfirmedTransition()
{
DateTimeOffset t0 = new DateTimeOffset(2026, 8, 7, 2, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings
{
DirectionConfirmationSamples = 1,
};
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings,
Array.Empty<TrajectoryObservationObstacle>(), 0L);
TrajectoryObservationBootstrapResult baseBootstrap = new TrajectoryObservationBootstrapper()
.Bootstrap(job, CancellationToken.None);
Verification.True(baseBootstrap.Succeeded, "one-shot transition bootstrap succeeds");
TrajectoryObservationBootstrapResult bootstrap = TrajectoryObservationBootstrapResult.Success(
baseBootstrap.Job, baseBootstrap.CoarseResult, baseBootstrap.SmoothedPath, CreateDirectionalSegments());
EmTrajectory forwardGearTrajectory = CreateSegmentTrajectory(t0, "one-shot-forward", 0,
TravelDirection.Forward, EmTerminalType.GearSwitch, EmBoundaryType.GearSwitchApproach, 1d);
EmTrajectory reverseTrajectory = CreateSegmentTrajectory(t0.AddSeconds(1d), "one-shot-reverse", 1,
TravelDirection.Reverse, EmTerminalType.Goal, EmBoundaryType.Goal, 0d);
var planningService = new SequenceTrajectoryPlanningService(forwardGearTrajectory, reverseTrajectory,
reverseTrajectory);
var controller = new TrajectoryObservationController(bootstrap, settings, planningService, "one-shot-advance");
var stoppedState = new VehicleMotionState(new Pose2D(1d, 0d, 0d), 0d, null, t0, 1L);
controller.StartCycle(t0, stoppedState, CancellationToken.None).GetAwaiter().GetResult();
Verification.True(!controller.ShouldStartCycle(t0.AddSeconds(1d)),
"no replan is armed before a confirmed segment transition");
controller.TryAdvanceSegment(t0, stoppedState);
controller.TryAdvanceSegment(t0.AddSeconds(0.21d),
new VehicleMotionState(new Pose2D(1d, 0d, 0d), 0d, null, t0.AddSeconds(0.21d), 2L));
Verification.True(!controller.ShouldStartCycle(t0.AddSeconds(1d)),
"stop hold and direction waiting do not reset the one-shot flag");
var reverseState = new VehicleMotionState(new Pose2D(1d, 0d, 0d), -0.03d, null,
t0.AddSeconds(0.22d), 3L);
Verification.True(controller.TryAdvanceSegment(t0.AddSeconds(0.22d), reverseState),
"confirmed N to N+1 transition succeeds");
Verification.True(controller.ShouldStartCycle(t0.AddSeconds(0.23d)),
"one-shot planning is rearmed only after the confirmed transition");
controller.StartCycle(t0.AddSeconds(0.23d), reverseState, CancellationToken.None).GetAwaiter().GetResult();
Verification.Equal(2, planningService.Requests.Count,
"the next active direction segment receives exactly one new plan");
Verification.Equal(1, planningService.Requests[1].SegmentIndex,
"the new full-direction plan targets segment N+1");
Verification.Equal(EmPlanningScope.FullDirectionSegment, planningService.Requests[1].PlanningScope,
"the new full-direction plan keeps the full scope");
}
private static void VerifiesFailedFullPlanDoesNotAutoRetry()
{
DateTimeOffset t0 = new DateTimeOffset(2026, 8, 7, 3, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings();
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings,
Array.Empty<TrajectoryObservationObstacle>(), 0L);
TrajectoryObservationBootstrapResult bootstrap = new TrajectoryObservationBootstrapper()
.Bootstrap(job, CancellationToken.None);
Verification.True(bootstrap.Succeeded, "failed-plan bootstrap succeeds");
var planningService = new FailingTrajectoryPlanningService();
var controller = new TrajectoryObservationController(bootstrap, settings, planningService, "failed-one-shot");
var loop = new TrajectoryObservationLoop(controller);
var state = new VehicleMotionState(new Pose2D(0.10d, 0d, 0d), 0.10d, null, t0, 1L);
loop.Tick(t0, state, CancellationToken.None);
TrajectoryObservationLoopTick? failureTick = null;
bool consumed = SpinWait.SpinUntil(() =>
{
DateTimeOffset now = t0.AddSeconds(0.05d);
var current = new VehicleMotionState(new Pose2D(0.11d, 0d, 0d), 0.10d, null, now, 2L);
failureTick = loop.Tick(now, current, CancellationToken.None);
return failureTick.LatestCycle != null;
}, TimeSpan.FromSeconds(5d));
Verification.True(consumed, "failed full plan is consumed and visible");
Verification.Equal(1, planningService.Requests.Count,
"failed full plan makes exactly one attempt");
Verification.True(failureTick != null && failureTick.LatestCycle != null &&
!failureTick.LatestCycle.Published,
"failed full plan remains visible as unpublished");
TrajectoryObservationLoopTick? lastTick = null;
for (int index = 0; index < 20; index++)
{
DateTimeOffset now = t0.AddSeconds(0.10d + (index + 1) * settings.ObserverPeriodSeconds);
lastTick = loop.Tick(now,
new VehicleMotionState(new Pose2D(0.10d + index * 0.01d, 0d, 0d), 0.10d, null,
now, 3L + index), CancellationToken.None);
}
Verification.Equal(1, planningService.Requests.Count,
"failed full plan does not automatically roll into coordinator-cadence retries");
Verification.True(lastTick != null && lastTick.Observation != null && !lastTick.PlanningStarted,
"observation continues after a failed full plan without starting retries");
}
private static void VerifiesStaticSnapshotExportsPlanningScope()
{
var settings = new TrajectoryObservationSettings();
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings,
Array.Empty<TrajectoryObservationObstacle>(), 0L);
TrajectoryObservationBootstrapResult bootstrap = new TrajectoryObservationBootstrapper()
.Bootstrap(job, CancellationToken.None);
Verification.True(bootstrap.Succeeded, "static scope snapshot bootstrap succeeds");
PlanningVisualizationStaticSnapshot snapshot = new TrajectoryObservationStaticSnapshotBuilder().Build(
bootstrap, EmPlannerConfiguration.CreateDefault(), settings.CreateValidatedSnapshot(), 0L);
Verification.True(snapshot.ConfigurationGroups.SelectMany(x => x.Entries).Any(x =>
x.RawName == "PlanningScope" && x.Value == "FullDirectionSegment"),
"static snapshot exports the validated full-direction scope");
}
private static void VerifiesSessionLayerCleanupDecisions() private static void VerifiesSessionLayerCleanupDecisions()
{ {
Verification.True(!TrajectoryObservationSessionLifecycle.ShouldClearLayers( Verification.True(!TrajectoryObservationSessionLifecycle.ShouldClearLayers(
@@ -523,7 +721,10 @@ internal static class TrajectoryObservationChecks
private static void VerifiesRollingRequestUsesOnePublishedTrajectorySnapshot() private static void VerifiesRollingRequestUsesOnePublishedTrajectorySnapshot()
{ {
DateTimeOffset effectiveAt = new DateTimeOffset(2026, 8, 4, 1, 0, 0, TimeSpan.Zero); DateTimeOffset effectiveAt = new DateTimeOffset(2026, 8, 4, 1, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings(); var settings = new TrajectoryObservationSettings
{
PlanningScope = EmPlanningScope.RollingHorizon,
};
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob( CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings, new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings,
Array.Empty<TrajectoryObservationObstacle>(), 0L); Array.Empty<TrajectoryObservationObstacle>(), 0L);
@@ -610,6 +811,7 @@ internal static class TrajectoryObservationChecks
DateTimeOffset t0 = new DateTimeOffset(2026, 8, 6, 5, 0, 0, TimeSpan.Zero); DateTimeOffset t0 = new DateTimeOffset(2026, 8, 6, 5, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings var settings = new TrajectoryObservationSettings
{ {
PlanningScope = EmPlanningScope.RollingHorizon,
DirectionConfirmationSamples = 1, DirectionConfirmationSamples = 1,
}; };
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob( CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
@@ -669,7 +871,10 @@ internal static class TrajectoryObservationChecks
private static void VerifiesPresentationTextDescribesObservationWithoutSendingCommand() private static void VerifiesPresentationTextDescribesObservationWithoutSendingCommand()
{ {
DateTimeOffset effectiveAt = new DateTimeOffset(2026, 8, 4, 0, 0, 0, TimeSpan.Zero); DateTimeOffset effectiveAt = new DateTimeOffset(2026, 8, 4, 0, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings(); var settings = new TrajectoryObservationSettings
{
PlanningScope = EmPlanningScope.RollingHorizon,
};
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob( CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings, new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings,
Array.Empty<TrajectoryObservationObstacle>(), 0L); Array.Empty<TrajectoryObservationObstacle>(), 0L);
@@ -788,7 +993,10 @@ internal static class TrajectoryObservationChecks
private static void FreezesBootstrapVehicleForRollingRequests() private static void FreezesBootstrapVehicleForRollingRequests()
{ {
DateTimeOffset effectiveAt = new DateTimeOffset(2026, 8, 4, 2, 0, 0, TimeSpan.Zero); DateTimeOffset effectiveAt = new DateTimeOffset(2026, 8, 4, 2, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings(); var settings = new TrajectoryObservationSettings
{
PlanningScope = EmPlanningScope.RollingHorizon,
};
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob( CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings, new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), settings,
Array.Empty<TrajectoryObservationObstacle>(), 0L); Array.Empty<TrajectoryObservationObstacle>(), 0L);
@@ -1026,6 +1234,18 @@ internal static class TrajectoryObservationChecks
} }
} }
private sealed class FailingTrajectoryPlanningService : IEmPlanningService
{
public List<EmPlanningRequest> Requests { get; } = new List<EmPlanningRequest>();
public EmPlanningResult Plan(EmPlanningRequest request, CancellationToken cancellationToken)
{
Requests.Add(request);
return new EmPlanningResult(EmPlanningStatus.CorridorInfeasible, null,
"one-shot failure fixture");
}
}
private static void VerifiesMovementTestKeepsNativePainterLazyAndConditional() private static void VerifiesMovementTestKeepsNativePainterLazyAndConditional()
{ {
string movementTestPath = Path.Combine(Directory.GetCurrentDirectory(), "ClumsyPilot", string movementTestPath = Path.Combine(Directory.GetCurrentDirectory(), "ClumsyPilot",
@@ -1,6 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using System.Text; using System.Text;
using MultiWheelC.TrajectoryPlanning.EMPlanner;
using MultiWheelC.TrajectoryPlanning.TrajectoryObservation; using MultiWheelC.TrajectoryPlanning.TrajectoryObservation;
namespace EMPlannerVerificationHost; namespace EMPlannerVerificationHost;
@@ -13,6 +14,7 @@ internal static class TrajectoryObservationSettingsChecks
VerifiesValidatedSnapshotFreezesVisualizationSettings(); VerifiesValidatedSnapshotFreezesVisualizationSettings();
RejectsInvalidVisualizationSettings(); RejectsInvalidVisualizationSettings();
VerifiesMovementTestMapsEveryFrozenSetting(); VerifiesMovementTestMapsEveryFrozenSetting();
VerifiesMovementTestFullDirectionScopeMapping();
} }
private static void VerifiesDefaults() private static void VerifiesDefaults()
@@ -22,7 +24,9 @@ internal static class TrajectoryObservationSettingsChecks
Verification.NearlyEqual(5d, defaults.SolverTimeoutSeconds, "observer solver timeout default"); Verification.NearlyEqual(5d, defaults.SolverTimeoutSeconds, "observer solver timeout default");
Verification.Equal(100000, defaults.MaximumOsqpIterations, "observer OSQP default"); Verification.Equal(100000, defaults.MaximumOsqpIterations, "observer OSQP default");
Verification.NearlyEqual(2d, defaults.TimeHorizonSeconds, "observer ST horizon default"); Verification.NearlyEqual(2d, defaults.TimeHorizonSeconds, "observer ST horizon default");
Verification.True(!defaults.EnableWebVisualization, "web defaults off"); Verification.Equal(EmPlanningScope.FullDirectionSegment, defaults.PlanningScope,
"observer full-direction-segment scope default");
Verification.True(defaults.EnableWebVisualization, "web defaults on");
Verification.True(defaults.AutoOpenWebVisualization, "web auto-open defaults on"); Verification.True(defaults.AutoOpenWebVisualization, "web auto-open defaults on");
Verification.True(!defaults.EnableNativePainterVisualization, "Painter defaults off"); Verification.True(!defaults.EnableNativePainterVisualization, "Painter defaults off");
Verification.Equal(0, defaults.WebVisualizationPort, "dynamic port default"); Verification.Equal(0, defaults.WebVisualizationPort, "dynamic port default");
@@ -40,6 +44,7 @@ internal static class TrajectoryObservationSettingsChecks
{ {
var source = new TrajectoryObservationSettings var source = new TrajectoryObservationSettings
{ {
PlanningScope = EmPlanningScope.FullDirectionSegment,
EnableWebVisualization = true, EnableWebVisualization = true,
AutoOpenWebVisualization = false, AutoOpenWebVisualization = false,
WebVisualizationPort = 2048, WebVisualizationPort = 2048,
@@ -54,6 +59,7 @@ internal static class TrajectoryObservationSettingsChecks
TrajectoryObservationSettings snapshot = source.CreateValidatedSnapshot(); TrajectoryObservationSettings snapshot = source.CreateValidatedSnapshot();
source.EnableWebVisualization = false; source.EnableWebVisualization = false;
source.PlanningScope = EmPlanningScope.RollingHorizon;
source.AutoOpenWebVisualization = true; source.AutoOpenWebVisualization = true;
source.WebVisualizationPort = 0; source.WebVisualizationPort = 0;
source.WebRefreshRateHz = 12d; source.WebRefreshRateHz = 12d;
@@ -64,6 +70,8 @@ internal static class TrajectoryObservationSettingsChecks
source.GearSwitchProjectionToleranceMeters = 0.7d; source.GearSwitchProjectionToleranceMeters = 0.7d;
source.GearSwitchStopHoldSeconds = 0.4d; source.GearSwitchStopHoldSeconds = 0.4d;
Verification.Equal(EmPlanningScope.FullDirectionSegment, snapshot.PlanningScope,
"snapshot freezes planning scope");
Verification.True(snapshot.EnableWebVisualization, "snapshot freezes web switch"); Verification.True(snapshot.EnableWebVisualization, "snapshot freezes web switch");
Verification.True(!snapshot.AutoOpenWebVisualization, "snapshot freezes web auto-open switch"); Verification.True(!snapshot.AutoOpenWebVisualization, "snapshot freezes web auto-open switch");
Verification.Equal(2048, snapshot.WebVisualizationPort, "snapshot freezes web port"); Verification.Equal(2048, snapshot.WebVisualizationPort, "snapshot freezes web port");
@@ -84,6 +92,7 @@ internal static class TrajectoryObservationSettingsChecks
AssertInvalid(settings => settings.WebVisualizationPort = 1, "low reserved web port"); AssertInvalid(settings => settings.WebVisualizationPort = 1, "low reserved web port");
AssertInvalid(settings => settings.WebVisualizationPort = 1023, "last reserved web port"); AssertInvalid(settings => settings.WebVisualizationPort = 1023, "last reserved web port");
AssertInvalid(settings => settings.WebVisualizationPort = 65536, "overflow web port"); AssertInvalid(settings => settings.WebVisualizationPort = 65536, "overflow web port");
AssertInvalid(settings => settings.PlanningScope = (EmPlanningScope)999, "undefined planning scope");
AssertInvalid(settings => settings.WebRefreshRateHz = 0d, "zero web refresh"); AssertInvalid(settings => settings.WebRefreshRateHz = 0d, "zero web refresh");
AssertInvalid(settings => settings.VisualizationHistoryCycleLimit = 0, "zero history limit"); AssertInvalid(settings => settings.VisualizationHistoryCycleLimit = 0, "zero history limit");
AssertInvalid(settings => settings.DirectionConfirmationSpeedMetersPerSecond = 0d, AssertInvalid(settings => settings.DirectionConfirmationSpeedMetersPerSecond = 0d,
@@ -114,6 +123,21 @@ internal static class TrajectoryObservationSettingsChecks
} }
} }
private static void VerifiesMovementTestFullDirectionScopeMapping()
{
string path = Path.Combine(Directory.GetCurrentDirectory(), "ClumsyPilot", "ParkrobTrajplanner",
"tarjplanner_movementtest", "MovementTest.TrajectoryObservationTest.cs");
string source = new UTF8Encoding(false, true).GetString(File.ReadAllBytes(path));
Verification.True(source.Contains("public bool UseFullDirectionSegmentPlanning = true;"),
"MovementTest exposes the full-direction-segment switch on by default");
Verification.True(source.Contains(
"PlanningScope = UseFullDirectionSegmentPlanning ? EmPlanningScope.FullDirectionSegment : EmPlanningScope.RollingHorizon,"),
"MovementTest maps the full-direction-segment switch into settings");
Verification.True(source.Contains("滚动兼容"),
"MovementTest labels TimeHorizonSeconds as rolling-compatible");
}
private static void AssertInvalid(Action<TrajectoryObservationSettings> mutate, string name) private static void AssertInvalid(Action<TrajectoryObservationSettings> mutate, string name)
{ {
var settings = new TrajectoryObservationSettings(); var settings = new TrajectoryObservationSettings();