fix: complete EM observation final review

This commit is contained in:
梁薄云
2026-08-04 17:33:14 +08:00
parent d0e673b573
commit bd7170b611
7 changed files with 655 additions and 50 deletions
@@ -6,6 +6,7 @@ using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade;
using MultiWheelC.TrajectoryPlanning.EMPlanner;
using MultiWheelC.TrajectoryPlanning.Mapping;
using MultiWheelC.TrajectoryPlanning.PathSmoothing;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade;
using MultiWheelC.TrajectoryPlanning.TrajectoryObservation;
@@ -19,8 +20,14 @@ internal static class TrajectoryObservationChecks
VerifiesObservationSourceHasNoActuatorCalls();
VerifiesObservationSourceUsesRequiredOperatorText();
VerifiesOperatorDocumentationUsesExactUiEntry();
VerifiesMovementTestVehicleInputsAndSettingsSnapshot();
RejectsInvalidObservationSettings();
VerifiesStartGoalBoundsUseOnlyConfiguredPadding();
VerifiesValidNonEmptyObstacleSource();
RejectsObstacleOutsideConfiguredBounds();
VerifiesObserverTicksWhilePlanningIsDelayed();
VerifiesSessionLayerCleanupDecisions();
VerifiesGearSwitchWaitStateForWorldPresentation();
VerifiesLsAndStUsePublishedTrajectoryData();
VerifiesPresentationTextDescribesObservationWithoutSendingCommand();
VerifiesLsPresentationUsesPathSOnHorizontalAxis();
@@ -61,8 +68,9 @@ internal static class TrajectoryObservationChecks
Verification.True(source.Contains("[MovementTest(name = \"EM轨迹规划观察闭环测试\")]"),
"observation MovementTest uses required Chinese display name");
Verification.True(source.Contains("\"等待真实档位/方向确认;观察模式不会推进下一方向段\""),
"observation MovementTest uses required gear-switch notice");
Verification.Equal("等待真实档位/方向确认;观察模式不会推进下一方向段",
TrajectoryObservationRuntimeState.GearSwitchWaitingNotice,
"observation runtime uses required gear-switch notice");
}
private static void VerifiesOperatorDocumentationUsesExactUiEntry()
@@ -81,6 +89,82 @@ internal static class TrajectoryObservationChecks
"observation README has the exact UI entry");
}
private static void VerifiesMovementTestVehicleInputsAndSettingsSnapshot()
{
string movementTestSource = Path.Combine(Directory.GetCurrentDirectory(), "ClumsyPilot",
"ParkrobTrajplanner", "tarjplanner_movementtest", "MovementTest.TrajectoryObservationTest.cs");
string source = new UTF8Encoding(false, true).GetString(File.ReadAllBytes(movementTestSource));
Verification.True(source.Contains("public double VehicleLengthMeters = 0.80d;"),
"observer MovementTest exposes vehicle length with Task-1 default");
Verification.True(source.Contains("public double VehicleWidthMeters = 0.60d;"),
"observer MovementTest exposes vehicle width with Task-1 default");
Verification.True(source.Contains("public double SafetyMarginMeters = 0.05d;"),
"observer MovementTest exposes safety margin with Task-1 default");
Verification.True(source.Contains("public double MaximumCurvaturePerMeter = 1d / 1.20d;"),
"observer MovementTest exposes maximum curvature with Task-1 default");
Verification.True(source.Contains("VehicleLengthMeters = VehicleLengthMeters,"),
"observer MovementTest copies vehicle length into settings");
Verification.True(source.Contains("VehicleWidthMeters = VehicleWidthMeters,"),
"observer MovementTest copies vehicle width into settings");
Verification.True(source.Contains("SafetyMarginMeters = SafetyMarginMeters,"),
"observer MovementTest copies safety margin into settings");
Verification.True(source.Contains("MaximumCurvaturePerMeter = MaximumCurvaturePerMeter,"),
"observer MovementTest copies maximum curvature into settings");
string normalizedSource = source.Replace("\r\n", "\n");
Verification.True(normalizedSource.Contains(
"VehicleMotionState state = ReadVehicleState();\n DateTimeOffset now = state.CapturedAtUtc;"),
"observer host uses the fresh state snapshot time for each observation tick");
var configured = new TrajectoryObservationSettings
{
VehicleLengthMeters = 1.10d,
VehicleWidthMeters = 0.70d,
SafetyMarginMeters = 0.08d,
MaximumCurvaturePerMeter = 0.55d,
};
TrajectoryObservationSettings snapshot = configured.CreateValidatedSnapshot();
configured.VehicleLengthMeters = 9.10d;
configured.VehicleWidthMeters = 9.20d;
configured.SafetyMarginMeters = 9.30d;
configured.MaximumCurvaturePerMeter = 9.40d;
Verification.NearlyEqual(1.10d, snapshot.VehicleLengthMeters,
"observer settings snapshot freezes vehicle length");
Verification.NearlyEqual(0.70d, snapshot.VehicleWidthMeters,
"observer settings snapshot freezes vehicle width");
Verification.NearlyEqual(0.08d, snapshot.SafetyMarginMeters,
"observer settings snapshot freezes safety margin");
Verification.NearlyEqual(0.55d, snapshot.MaximumCurvaturePerMeter,
"observer settings snapshot freezes maximum curvature");
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 0d, 0d), snapshot,
Array.Empty<TrajectoryObservationObstacle>(), 0L);
AssertVehicleSnapshot(job.Vehicle, 1.10d, 0.70d, 0.08d, 0.55d, null,
"observer configured bootstrap vehicle");
}
private static void RejectsInvalidObservationSettings()
{
AssertInvalidSetting(settings => settings.MapPaddingMeters = 0d, "map padding");
AssertInvalidSetting(settings => settings.MapResolutionMillimeters = float.NaN, "map resolution");
AssertInvalidSetting(settings => settings.ReplanPeriodSeconds = 0d, "replan period");
AssertInvalidSetting(settings => settings.ObserverPeriodSeconds = double.PositiveInfinity,
"observer period");
AssertInvalidSetting(settings => settings.VehicleLengthMeters = 0d, "vehicle length");
AssertInvalidSetting(settings => settings.VehicleWidthMeters = double.NaN, "vehicle width");
AssertInvalidSetting(settings => settings.SafetyMarginMeters = 0d, "safety margin");
AssertInvalidSetting(settings => settings.MaximumCurvaturePerMeter = double.PositiveInfinity,
"maximum curvature");
}
private static void AssertInvalidSetting(Action<TrajectoryObservationSettings> mutate, string name)
{
var settings = new TrajectoryObservationSettings();
mutate(settings);
Verification.True(Throws(settings.Validate), "observer rejects invalid " + name);
}
private static void VerifiesStartGoalBoundsUseOnlyConfiguredPadding()
{
var settings = new TrajectoryObservationSettings
@@ -99,6 +183,29 @@ internal static class TrajectoryObservationChecks
Verification.NearlyEqual(50d, job.MapRequest.ResolutionMm, "observer map resolution");
}
private static void VerifiesValidNonEmptyObstacleSource()
{
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
new Pose2D(0d, 0d, 0d), new Pose2D(1d, 1d, 0d), new TrajectoryObservationSettings(),
new[] { TrajectoryObservationObstacle.Circle(500d, 500d, 100d) }, 23L);
Verification.True(!job.MapRequest.AllowExplicitEmptyMap,
"observer non-empty obstacle map is not explicitly empty");
Verification.Equal(1, job.MapRequest.ObstacleSources.Count,
"observer non-empty map has exactly one obstacle source");
IMapObstacleSource source = job.MapRequest.ObstacleSources[0];
Verification.Equal("trajectory-observer-manual", source.SourceId,
"observer manual obstacle source ID");
Verification.Equal(23L, source.SourceVersion,
"observer manual obstacle source version");
Verification.True(source.IsRequired, "observer manual obstacle source is required");
ObstacleProjectionResult projection = source.ProjectToWorld();
Verification.Equal(ObstacleSourceStatus.Applied, projection.Status,
"observer valid manual obstacle source applies");
Verification.Equal(1, projection.Obstacles.Count,
"observer valid manual obstacle source preserves geometry");
}
private static void RejectsObstacleOutsideConfiguredBounds()
{
Verification.True(Throws(() => TrajectoryObservationSetupFactory.CreateBootstrapJob(
@@ -107,6 +214,144 @@ internal static class TrajectoryObservationChecks
"observer obstacle outside configured bounds");
}
private static void VerifiesObserverTicksWhilePlanningIsDelayed()
{
DateTimeOffset startedAt = new DateTimeOffset(2026, 8, 4, 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, "observer delayed-planner bootstrap succeeds");
EmTrajectory published = CreatePublishedTrajectory(startedAt);
var planningService = new DelayedTrajectoryPlanningService(published, 2);
try
{
var controller = new TrajectoryObservationController(
bootstrap, settings, planningService, "cadence-check");
var loop = new TrajectoryObservationLoop(controller);
var firstState = new VehicleMotionState(
new Pose2D(0.10d, 0d, 0d), 0.10d, null, startedAt, 10L);
TrajectoryObservationLoopTick firstTick = loop.Tick(startedAt, firstState, CancellationToken.None);
Verification.True(firstTick.ShouldLog,
"observer first cadence tick is eligible for session-guarded logging");
Verification.Equal(10L, firstTick.Observation.VehicleState.SequenceId,
"observer first tick uses first fresh state");
TrajectoryObservationLoopTick? initialPublicationTick = null;
bool initiallyPublished = SpinWait.SpinUntil(() =>
{
var currentState = new VehicleMotionState(
new Pose2D(0.15d, 0d, 0d), 0.15d, null,
startedAt.AddSeconds(0.10d), 11L);
initialPublicationTick = loop.Tick(startedAt.AddSeconds(0.10d), currentState,
CancellationToken.None);
return !initialPublicationTick.PlanningInFlight;
}, TimeSpan.FromSeconds(5d));
Verification.True(initiallyPublished && initialPublicationTick != null &&
ReferenceEquals(published, initialPublicationTick.Observation.PublishedTrajectory),
"observer establishes a published trajectory before delayed rolling planning");
DateTimeOffset replanAt = startedAt.AddSeconds(settings.ReplanPeriodSeconds);
var replanState = new VehicleMotionState(
new Pose2D(0.20d, 0d, 0d), 0.20d, null, replanAt, 12L);
TrajectoryObservationLoopTick replanTick = loop.Tick(replanAt, replanState,
CancellationToken.None);
Verification.True(replanTick.PlanningInFlight,
"observer delayed rolling planning remains in flight after replan tick");
Verification.True(planningService.WaitUntilEntered(TimeSpan.FromSeconds(5d)),
"observer delayed planner enters planning service");
DateTimeOffset secondAt = replanAt.AddSeconds(settings.ObserverPeriodSeconds);
var secondState = new VehicleMotionState(
new Pose2D(0.25d, 0d, 0d), 0.25d, null, secondAt, 13L);
TrajectoryObservationLoopTick secondTick = loop.Tick(secondAt, secondState, CancellationToken.None);
Verification.True(secondTick.PlanningInFlight,
"observer second tick does not wait for delayed planning");
Verification.True(secondTick.ShouldLog,
"observer second cadence tick is eligible for session-guarded logging");
Verification.True(ReferenceEquals(published, secondTick.Observation.PublishedTrajectory),
"observer keeps observing the existing publication during delayed rolling planning");
Verification.Equal(13L, secondTick.Observation.VehicleState.SequenceId,
"observer second tick uses second fresh state");
Verification.Equal(secondAt, secondTick.Observation.ObservedAtUtc,
"observer second tick observes at its own time");
planningService.Release();
TrajectoryObservationLoopTick? completedTick = null;
bool completed = SpinWait.SpinUntil(() =>
{
var currentState = new VehicleMotionState(
new Pose2D(0.30d, 0d, 0d), 0.30d, null,
replanAt.AddSeconds(0.10d), 14L);
completedTick = loop.Tick(replanAt.AddSeconds(0.10d), currentState,
CancellationToken.None);
return !completedTick.PlanningInFlight;
}, TimeSpan.FromSeconds(5d));
Verification.True(completed, "observer delayed planning completes deterministically");
Verification.True(completedTick != null, "observer delayed planning produces a completion tick");
TrajectoryObservationLoopTick finalTick = completedTick!;
Verification.True(finalTick.LatestCycle != null && finalTick.LatestCycle.Published,
"observer delayed planning result is consumed without a continuation");
Verification.Equal(14L, finalTick.Observation.VehicleState.SequenceId,
"observer post-plan observation does not reuse pre-plan state");
Verification.Equal(replanAt.AddSeconds(0.10d), finalTick.Observation.ObservedAtUtc,
"observer post-plan observation does not reuse pre-plan time");
}
finally
{
planningService.Release();
}
}
private static void VerifiesSessionLayerCleanupDecisions()
{
Verification.True(!TrajectoryObservationSessionLifecycle.ShouldClearLayers(
TrajectoryObservationSessionEndReason.BootstrapFailure),
"observer bootstrap failure preserves the diagnostic world view");
Verification.True(TrajectoryObservationSessionLifecycle.ShouldClearLayers(
TrajectoryObservationSessionEndReason.RuntimeFault),
"observer runtime fault clears all painter layers");
Verification.True(TrajectoryObservationSessionLifecycle.ShouldClearLayers(
TrajectoryObservationSessionEndReason.Cancellation),
"observer cancellation clears all painter layers");
}
private static void VerifiesGearSwitchWaitStateForWorldPresentation()
{
DateTimeOffset effectiveAt = new DateTimeOffset(2026, 8, 4, 4, 0, 0, TimeSpan.Zero);
EmTrajectory trajectory = CreatePublishedTrajectory(effectiveAt, EmTerminalType.GearSwitch);
TrajectoryObservationRuntimeState beforeFinal = TrajectoryObservationRuntimeState.Create(
effectiveAt.AddSeconds(0.99d), trajectory);
Verification.True(!beforeFinal.WaitingAtGearSwitch,
"observer does not paint gear-switch wait state before final time");
Verification.Equal(string.Empty, beforeFinal.WorldNotice,
"observer has no gear-switch world notice before final time");
TrajectoryObservationRuntimeState atFinal = TrajectoryObservationRuntimeState.Create(
effectiveAt.AddSeconds(1d), trajectory);
Verification.True(atFinal.WaitingAtGearSwitch,
"observer enters gear-switch wait state at final time");
Verification.Equal("等待真实档位/方向确认;观察模式不会推进下一方向段", atFinal.WorldNotice,
"observer exposes the exact gear-switch state to the world painter");
Verification.Equal(0, trajectory.Metadata.SegmentIndex,
"observer gear-switch wait state remains on segment zero");
string presentationPath = Path.Combine(Directory.GetCurrentDirectory(), "ClumsyPilot",
"ParkrobTrajplanner", "tarjplanner_movementtest", "TrajectoryObservationPresentation.cs");
string presentationSource = new UTF8Encoding(false, true).GetString(File.ReadAllBytes(presentationPath));
Verification.True(presentationSource.Contains(
"worldPainter.DrawText(Color.OrangeRed, runtimeState.WorldNotice"),
"observer world painter draws the exact runtime wait state");
}
private static void VerifiesLsAndStUsePublishedTrajectoryData()
{
DateTimeOffset effectiveAt = new DateTimeOffset(2026, 8, 4, 0, 0, 0, TimeSpan.Zero);
@@ -333,10 +578,11 @@ internal static class TrajectoryObservationChecks
new ReferenceBoundary(0, 2d, EmBoundaryType.Goal, 12d), 10d);
}
private static EmTrajectory CreatePublishedTrajectory(DateTimeOffset effectiveAt)
private static EmTrajectory CreatePublishedTrajectory(DateTimeOffset effectiveAt,
EmTerminalType terminalType = EmTerminalType.Goal)
{
var metadata = new EmTrajectoryMetadata("observer-published", effectiveAt, effectiveAt, 1L,
"observer-reference", 1L, string.Empty, 0, TravelDirection.Forward, EmTerminalType.Goal);
"observer-reference", 1L, string.Empty, 0, TravelDirection.Forward, terminalType);
return new EmTrajectory(metadata, new[]
{
new EmTrajectoryPoint(0.25d, 0.10d, 0d, 0.20d, 0d, 0d, 0, 0.25d, 4d,
@@ -364,6 +610,41 @@ internal static class TrajectoryObservationChecks
}
}
private sealed class DelayedTrajectoryPlanningService : IEmPlanningService
{
private readonly EmTrajectory trajectory;
private readonly int delayedCall;
private readonly ManualResetEventSlim entered = new ManualResetEventSlim(false);
private readonly ManualResetEventSlim release = new ManualResetEventSlim(false);
private int callCount;
public DelayedTrajectoryPlanningService(EmTrajectory trajectory, int delayedCall)
{
this.trajectory = trajectory;
this.delayedCall = delayedCall;
}
public EmPlanningResult Plan(EmPlanningRequest request, CancellationToken cancellationToken)
{
if (Interlocked.Increment(ref callCount) == delayedCall)
{
entered.Set();
release.Wait(cancellationToken);
}
return new EmPlanningResult(EmPlanningStatus.Success, trajectory, string.Empty);
}
public bool WaitUntilEntered(TimeSpan timeout)
{
return entered.Wait(timeout);
}
public void Release()
{
release.Set();
}
}
private static bool Throws(Action action)
{
try { action(); return false; }