chore: save current workspace progress

This commit is contained in:
梁薄云
2026-08-09 22:13:18 +08:00
parent 650c2ab0e3
commit 2f4fd15e52
449 changed files with 76593 additions and 971 deletions
@@ -17,10 +17,12 @@ internal static class EmPlanningServiceChecks
VerifiesForwardReverseAndBoundarySuccessesAreDeterministic();
VerifiesServicePublishesRollingApproachAndExactStopModes();
VerifiesFullScopePublishesItsRequestScope();
VerifiesFullScopeAdmitsOnlyTheTrueSegmentStart();
VerifiesRequestAndStateFailuresPublishNoTrajectory();
VerifiesProjectionCorridorAndOptimizationFailuresPublishNoTrajectory();
VerifiesNoProgressPublishesNoTrajectory();
VerifiesTimeoutFallbackAndCancellationSemantics();
VerifiesLsAndStShareOneSolveBudget();
VerifiesPublicationFailureAndDebugIsolation();
VerifiesPublicationGateStatusMappings();
}
@@ -123,6 +125,32 @@ internal static class EmPlanningServiceChecks
"full publication reaches the real segment boundary");
}
private static void VerifiesFullScopeAdmitsOnlyTheTrueSegmentStart()
{
EmPlanningRequest uRequest = CreateRequest(TravelDirection.Forward, 0d, false, false,
CreateAllForwardUReferencePath(), CreateMap(false, 12d), EmPlanningScope.FullDirectionSegment);
DateTimeOffset capturedAt = uRequest.RequestedAtUtc;
uRequest = ReplaceState(uRequest, new VehicleMotionState(
new Pose2D(5d, 0.4d, Math.PI), 0d, 0d, capturedAt, 8L));
EmPlanningResult laterBranch = new EmPlanningService(
new ScriptedPipelineSolver(PipelineSolverMode.Success)).Plan(uRequest, CancellationToken.None);
VerifyFailure(laterBranch, EmPlanningStatus.ProjectionFailed,
"FullDirection later U branch is not an admissible segment start");
EmPlanningRequest oppositeHeading = CreateRequest(TravelDirection.Forward, 0d, false, false,
planningScope: EmPlanningScope.FullDirectionSegment);
oppositeHeading = ReplaceState(oppositeHeading, new VehicleMotionState(
new Pose2D(0d, 0d, Math.PI), 0d, 0d, oppositeHeading.RequestedAtUtc, 9L));
EmPlanningResult foldedHeading = new EmPlanningService(
new ScriptedPipelineSolver(PipelineSolverMode.Success)).Plan(oppositeHeading, CancellationToken.None);
VerifyFailure(foldedHeading, EmPlanningStatus.ProjectionFailed,
"FullDirection opposite heading is rejected before tangent slope conversion");
}
private static void ConfigureFiveMeterWindowAndTwoSecondHorizon(EmPlannerConfiguration configuration)
{
configuration.Scheduling.DistanceHorizonMeters = 5d;
@@ -396,6 +424,38 @@ internal static class EmPlanningServiceChecks
VerifyFailure(new EmPlanningService(new ScriptedPipelineSolver(PipelineSolverMode.Success)).Plan(request,
cancellation.Token), EmPlanningStatus.Cancelled, "cancellation");
}
using (var cancellation = new CancellationTokenSource())
{
EmPlanningRequest publicationRequest = CreateRequest(TravelDirection.Forward, 0d, false, false);
publicationRequest.Configuration.Solver.NativeVerbose = true;
var service = new EmPlanningService(new ScriptedPipelineSolver(PipelineSolverMode.Success),
new CancellingPublicationDebugSink(cancellation));
EmPlanningResult cancelledBeforePublication = service.Plan(publicationRequest, cancellation.Token);
VerifyFailure(cancelledBeforePublication, EmPlanningStatus.Cancelled,
"cancellation immediately before service publication");
}
}
private static void VerifiesLsAndStShareOneSolveBudget()
{
EmPlanningRequest request = CreateRequest(TravelDirection.Forward, 0d, false, false);
request.Configuration.Scheduling.SolverTimeoutSeconds = 1d;
request.Configuration.Solver.MaximumOuterIterations = 1;
var solver = new ScriptedPipelineSolver(PipelineSolverMode.Success,
lateralSolveDelay: TimeSpan.FromMilliseconds(200d));
EmPlanningResult result = new EmPlanningService(solver).Plan(request, CancellationToken.None);
Verification.True(result.Status == EmPlanningStatus.Success || result.Status == EmPlanningStatus.SuccessWithFallback,
"shared LS/ST budget test publishes a validated trajectory");
Verification.True(solver.LateralSolveBudgets.Count > 0 && solver.LongitudinalSolveBudgets.Count > 0,
"shared LS/ST budget test reached both optimizers");
Verification.True(solver.LongitudinalSolveBudgets[0] <
solver.LateralSolveBudgets[0] - TimeSpan.FromMilliseconds(100d),
"ST receives only the LS/ST budget remaining after the delayed LS solve");
}
private static void VerifiesNoProgressPublishesNoTrajectory()
@@ -616,6 +676,28 @@ internal static class EmPlanningServiceChecks
new PathSmoothingDiagnostics(metrics, TimeSpan.Zero), new List<PathSmoothingRegionReport>());
}
private static PathSmoothingResult CreateAllForwardUReferencePath()
{
var points = new List<SmoothedPathPoint>
{
new SmoothedPathPoint(0d, 0d, 0d, 0d, 0d, TravelDirection.Forward,
0d, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor),
new SmoothedPathPoint(10d, 0d, 0d, 0d, 10d, TravelDirection.Forward,
0d, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor),
new SmoothedPathPoint(10d, 0.4d, Math.PI, Math.PI, 10.4d, TravelDirection.Forward,
0d, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor),
new SmoothedPathPoint(0d, 0.4d, Math.PI, Math.PI, 20.4d, TravelDirection.Forward,
0d, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor),
};
var segments = new List<SmoothedPathSegment>
{
new SmoothedPathSegment(0, TravelDirection.Forward, 0, points.Count - 1, false, false),
};
var metrics = new PathQualityMetrics(true, 20.4d, 0d, 0d, 0d, 0d, 1d, 0d, 0d, 0d, 0d, 0d);
return PathSmoothingResult.PublishLocalG2(PathSmoothingStatus.Complete, points, segments,
new PathSmoothingDiagnostics(metrics, TimeSpan.Zero), new List<PathSmoothingRegionReport>());
}
private static PlanningGridMap CreateMap(bool blockStart, double halfExtentMeters = 3d)
{
IMapObstacleSource[] sources = blockStart
@@ -651,16 +733,20 @@ internal static class EmPlanningServiceChecks
private readonly PipelineSolverMode mode;
private readonly PlanningGridMap? mapToCorrupt;
private readonly IReadOnlyList<double>? strictFullPrimal;
private readonly TimeSpan lateralSolveDelay;
private int longitudinalCallCount;
public QuadraticProgram? LastLongitudinalProblem { get; private set; }
public List<TimeSpan> LateralSolveBudgets { get; } = new();
public List<TimeSpan> LongitudinalSolveBudgets { get; } = new();
public ScriptedPipelineSolver(PipelineSolverMode mode, PlanningGridMap? mapToCorrupt = null,
IReadOnlyList<double>? strictFullPrimal = null)
IReadOnlyList<double>? strictFullPrimal = null, TimeSpan? lateralSolveDelay = null)
{
this.mode = mode;
this.mapToCorrupt = mapToCorrupt;
this.strictFullPrimal = strictFullPrimal;
this.lateralSolveDelay = lateralSolveDelay.GetValueOrDefault();
}
public QpSolveResult Solve(QuadraticProgram problem, QpSolverSettings settings, IReadOnlyList<double> warmStart,
@@ -671,12 +757,16 @@ internal static class EmPlanningServiceChecks
return Result(QpSolveStatus.SolverUnavailable, Array.Empty<double>());
if (!longitudinal)
{
LateralSolveBudgets.Add(settings.TimeLimit);
if (lateralSolveDelay > TimeSpan.Zero)
Thread.Sleep(lateralSolveDelay);
if (mode == PipelineSolverMode.LateralInfeasible)
return Result(QpSolveStatus.PrimalInfeasible, Array.Empty<double>());
if (mode == PipelineSolverMode.TimeoutWithoutFallback)
return Result(QpSolveStatus.TimeLimit, Array.Empty<double>());
return Result(QpSolveStatus.Solved, new double[problem.VariableCount]);
}
LongitudinalSolveBudgets.Add(settings.TimeLimit);
LastLongitudinalProblem = problem;
if (mode == PipelineSolverMode.LongitudinalInfeasible)
return Result(QpSolveStatus.PrimalInfeasible, Array.Empty<double>());
@@ -928,7 +1018,10 @@ internal static class EmPlanningServiceChecks
if (problem.VariableCount < 7 || (problem.VariableCount + 1) % 4 != 0)
return false;
int knotCount = (problem.VariableCount + 1) / 4;
return problem.ConstraintCount >= 8 * knotCount - 2;
var layout = new LongitudinalVariableLayout(knotCount);
return TryReadFixedVariable(problem, layout.S(0), out _) &&
TryReadFixedVariable(problem, layout.U(0), out _) &&
TryReadFixedVariable(problem, layout.A(0), out _);
}
private static double ReadFixedVariable(QuadraticProgram problem, int variable)
@@ -980,4 +1073,20 @@ internal static class EmPlanningServiceChecks
throw new InvalidOperationException("debug sink failure");
}
}
private sealed class CancellingPublicationDebugSink : IEmPlannerDebugSink
{
private readonly CancellationTokenSource cancellation;
public CancellingPublicationDebugSink(CancellationTokenSource cancellation)
{
this.cancellation = cancellation ?? throw new ArgumentNullException(nameof(cancellation));
}
public void Write(string message)
{
if (string.Equals(message, "world-space publication validation succeeded", StringComparison.Ordinal))
cancellation.Cancel();
}
}
}
@@ -60,3 +60,29 @@ internal sealed class FakeQpSolver : IQpSolver
return _results.Dequeue();
}
}
internal sealed class CancellingQpSolver : IQpSolver
{
private readonly IQpSolver inner;
private readonly CancellationTokenSource cancellation;
private readonly int cancelAfterSolveCount;
private int solveCount;
public CancellingQpSolver(IQpSolver inner, CancellationTokenSource cancellation, int cancelAfterSolveCount = 1)
{
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
this.cancellation = cancellation ?? throw new ArgumentNullException(nameof(cancellation));
if (cancelAfterSolveCount <= 0) throw new ArgumentOutOfRangeException(nameof(cancelAfterSolveCount));
this.cancelAfterSolveCount = cancelAfterSolveCount;
}
public QpSolveResult Solve(QuadraticProgram problem, QpSolverSettings settings, IReadOnlyList<double> warmStart,
CancellationToken cancellationToken)
{
QpSolveResult result = inner.Solve(problem, settings, warmStart, cancellationToken);
solveCount++;
if (solveCount == cancelAfterSolveCount)
cancellation.Cancel();
return result;
}
}
@@ -16,6 +16,8 @@ internal static class LateralIntegrationChecks
{
VerifiesValidatedCandidateSurvivesLaterTimeout();
VerifiesInvalidVectorsAndInaccurateResidualsNeverBecomeFallbacks();
VerifiesCancellationAfterStrictCandidateNeverPublishesFallback();
VerifiesRejectedCandidateAdvancesTheNextLateralLinearization();
VerifiesTrustRegionWarmStartAndOuterIterationLimit();
VerifiesCancellationAndTimeoutWithoutCandidate();
VerifiesLateralPlannerDelegatesToTheSequentialOptimizer();
@@ -150,6 +152,47 @@ internal static class LateralIntegrationChecks
"SolvedInaccurate still requires full independent lateral validation");
}
private static void VerifiesCancellationAfterStrictCandidateNeverPublishesFallback()
{
LateralPlanningInput input = CreateInput();
double[] valid = CreatePrimal(input, 0.02d);
using var cancellation = new CancellationTokenSource();
var solver = new CancellingQpSolver(
new FakeQpSolver(Result(QpSolveStatus.Solved, valid, 10d)), cancellation);
LateralPlanningResult result = new SequentialConvexOptimizer(solver).Optimize(input, cancellation.Token);
Verification.Equal(EmPlanningStatus.Cancelled, result.Status,
"cancellation after a strict LS candidate is never fallback success");
Verification.True(result.Path == null, "cancelled LS result exposes no path");
}
private static void VerifiesRejectedCandidateAdvancesTheNextLateralLinearization()
{
LateralPlanningInput input = CreateInput(0.10d);
double[] strict = CreatePrimal(input, 0.01d);
double[] rejected = CreatePrimal(input, 0.02d);
var solver = new FakeQpSolver(new[]
{
Result(QpSolveStatus.Solved, strict, 10d),
Result(QpSolveStatus.Solved, rejected, 9d),
Result(QpSolveStatus.TimeLimit, Array.Empty<double>(), 9d),
});
LateralPlanningResult result = new SequentialConvexOptimizer(solver).Optimize(input, CancellationToken.None);
Verification.Equal(EmPlanningStatus.SuccessWithFallback, result.Status,
"later timeout preserves the earlier strict candidate");
var layout = new LateralVariableLayout(input.ReferenceStations.Count);
Verification.NearlyEqual(0.02d, solver.WarmStarts[2][layout.L(1)],
"the next LS QP warm-starts from the rejected but parseable candidate");
FindSingleVariableBounds(solver.Problems[2], layout.L(1), out double lower, out double upper);
Verification.NearlyEqual(-0.03d, lower,
"the next LS trust region is centered on the rejected candidate");
Verification.NearlyEqual(0.07d, upper,
"the next LS trust region is centered on the rejected candidate");
}
private static void VerifiesTrustRegionWarmStartAndOuterIterationLimit()
{
LateralPlanningInput input = CreateInput();
@@ -178,7 +221,10 @@ internal static class LateralIntegrationChecks
var limitSolver = new FakeQpSolver(limitResults);
LateralPlanningResult limited = new SequentialConvexOptimizer(limitSolver).Optimize(input, CancellationToken.None);
Verification.Equal(5, limitSolver.SolveCallCount, "outer loop stops after at most five QP calls");
Verification.Equal(EmPlanningStatus.Success, limited.Status, "last feasible candidate succeeds at outer iteration limit");
Verification.Equal(EmPlanningStatus.SuccessWithFallback, limited.Status,
"last strict candidate is reported as fallback at outer iteration limit");
Verification.True(limited.FailureReason.Length > 0,
"outer iteration fallback preserves a non-empty diagnostic");
}
private static void VerifiesCancellationAndTimeoutWithoutCandidate()
@@ -319,7 +365,7 @@ internal static class LateralIntegrationChecks
Verification.True(Math.Abs(leftValues[index] - rightValues[index]) <= 1e-10d, name + " value " + index);
}
private static LateralPlanningInput CreateInput()
private static LateralPlanningInput CreateInput(double maximumVehicleCurvature = 1d)
{
var points = new List<SmoothedPathPoint>
{
@@ -341,7 +387,7 @@ internal static class LateralIntegrationChecks
LengthMeters = 0.1d,
WidthMeters = 0.1d,
SafetyMarginMeters = 0d,
MaximumCurvaturePerMeter = 1d,
MaximumCurvaturePerMeter = maximumVehicleCurvature,
};
return new LateralPlanningInput(segment, corridor,
new FrenetProjection(ReferencePathInterpolator.Interpolate(segment, 0d), 0d, 0d, 0d),
@@ -18,6 +18,7 @@ internal static class LateralModelChecks
VerifiesPlanningInputBoundariesAndDefensiveCopies();
VerifiesLateralResultPublicationContract();
VerifiesNormalizedObjectiveAndHardConstraints();
VerifiesVehicleCurvatureIsAHardQpConstraint();
VerifiesAllNamedCostScales();
VerifiesEmptyHardBoundIntersectionFailsBeforeSolve();
VerifiesFakeSolverCapturesTheNeutralQpBoundary();
@@ -245,6 +246,29 @@ internal static class LateralModelChecks
"Frenet denominator is intersected as a finite hard lateral bound");
}
private static void VerifiesVehicleCurvatureIsAHardQpConstraint()
{
EmPlannerConfiguration configuration = CreateUnitScaleConfiguration();
const double maximumVehicleCurvature = 0.25d;
LateralPlanningInput input = CreateModelInput(EmTerminalType.RollingSafetyStop, configuration,
Array.Empty<double>(), maximumVehicleCurvature: maximumVehicleCurvature);
LateralCandidate linearization = LateralCandidate.Integrate(input.ReferenceStations, 0d, 0d, 0d,
new[] { 0d, 0d });
Verification.True(CreateConstraintBuilder().TryBuild(input, linearization, out QuadraticProgram problem,
out string failureReason), "curvature-constrained QP builds: " + failureReason);
var layout = new LateralVariableLayout(input.ReferenceStations.Count);
Verification.Equal(8 * layout.StationCount - 2, problem.ConstraintCount,
"each lateral station adds one curvature hard-constraint row");
for (int station = 0; station < layout.StationCount; station++)
{
Verification.True(HasBound(problem, new Dictionary<int, double> { { layout.DDL(station), 1d } },
-maximumVehicleCurvature, maximumVehicleCurvature),
"straight-reference curvature is a hard DDL bound at station " + station);
}
}
private static void VerifiesEmptyHardBoundIntersectionFailsBeforeSolve()
{
EmPlannerConfiguration configuration = CreateUnitScaleConfiguration();
@@ -550,6 +574,21 @@ internal static class LateralModelChecks
return false;
}
private static bool HasBound(QuadraticProgram problem, IReadOnlyDictionary<int, double> expected,
double lower, double upper)
{
for (int row = 0; row < problem.ConstraintCount; row++)
{
if (RowMatches(problem.ConstraintMatrix, row, expected) &&
Math.Abs(problem.LowerBounds[row] - lower) <= 1e-12d &&
Math.Abs(problem.UpperBounds[row] - upper) <= 1e-12d)
{
return true;
}
}
return false;
}
private static bool RowMatches(SparseCscMatrix matrix, int targetRow, IReadOnlyDictionary<int, double> expected)
{
var actual = new Dictionary<int, double>();
@@ -554,6 +554,18 @@ internal static class LongitudinalIntegrationChecks
Verification.Equal(0, cancellationSolver.SolveCallCount, "cancelled ST does not call the QP solver");
}
using (var cancellation = new CancellationTokenSource())
{
var solver = new CancellingQpSolver(
new FakeQpSolver(Result(QpSolveStatus.Solved, ToPrimal(valid), 1d)), cancellation);
LongitudinalPlanningResult cancelledAfterStrictCandidate = new SequentialLongitudinalOptimizer(solver).Optimize(
input, cancellation.Token);
Verification.Equal(EmPlanningStatus.Cancelled, cancelledAfterStrictCandidate.Status,
"cancellation after a strict ST candidate is never fallback success");
Verification.True(cancelledAfterStrictCandidate.Candidate == null,
"cancelled ST result exposes no candidate");
}
var infeasibleSolver = new FakeQpSolver(Result(QpSolveStatus.PrimalInfeasible, Array.Empty<double>(), 1d));
LongitudinalPlanningResult infeasible = new SequentialLongitudinalOptimizer(infeasibleSolver).Optimize(input,
CancellationToken.None);
@@ -12,6 +12,7 @@ internal static class TrajectoryChecks
public static void Run()
{
VerifiesForwardFieldsExactTerminalAndHold();
VerifiesSegmentLocalReferenceStationIsPublished();
VerifiesReverseTravelVelocityAndUnwrappedYaw();
VerifiesPublishedListsAreImmutable();
VerifiesRollingTrajectoryHasNoSyntheticStopTail();
@@ -49,6 +50,30 @@ internal static class TrajectoryChecks
"reverse yaw interpolation unwraps across the pi boundary");
}
private static void VerifiesSegmentLocalReferenceStationIsPublished()
{
EmTrajectory trajectory = new EmTrajectoryAssembler().Assemble(
CreatePath(TravelDirection.Forward, 0d, 0d), CreateLongitudinalResult(),
CreateMetadata(TravelDirection.Forward, EmTerminalType.Goal));
EmTrajectoryPoint terminal = trajectory.Points[trajectory.Points.Count - 1];
Verification.NearlyEqual(1d, terminal.SegmentLocalS,
"published SegmentLocalS retains the lateral path reference station");
Verification.NearlyEqual(0.12d, terminal.PathS,
"published PathS remains the longitudinal actual path distance");
ValidationContext context = CreateValidationContext();
EmTrajectory validationTrajectory = CreateValidationTrajectory(TravelDirection.Forward);
EmTrajectoryValidationResult accepted = new EmTrajectoryValidator().Validate(validationTrajectory, context.EmptyMap,
context.Vehicle, context.Configuration, 2, 1d, 0.0055d, EmBoundaryType.Goal);
Verification.True(accepted.IsValid,
"publication validates reference and actual path bounds independently: " + accepted.Message);
EmTrajectoryValidationResult segmentExceeded = new EmTrajectoryValidator().Validate(validationTrajectory,
context.EmptyMap, context.Vehicle, context.Configuration, 2, 0.5d, 0.0055d, EmBoundaryType.Goal);
Verification.Equal(EmTrajectoryValidationFailure.SegmentBoundaryExceeded, segmentExceeded.Failure,
"segment-local reference bound is checked independently of PathS");
}
private static void VerifiesPublishedListsAreImmutable()
{
EmTrajectory trajectory = new EmTrajectoryAssembler().Assemble(
@@ -39,7 +39,8 @@ internal static class TrajectoryObservationChecks
VerifiesObserverTicksWhilePlanningIsDelayed();
VerifiesFullDirectionSegmentPlansOncePerActiveSegment();
VerifiesFullDirectionRequestCarriesValidatedScope();
VerifiesFullDirectionPlansAgainOnlyAfterConfirmedTransition();
VerifiesFullDirectionPreplansAfterStopAndActivatesAfterConfirmation();
VerifiesDeterministicSingleForwardObservationSimulation();
VerifiesFailedFullPlanDoesNotAutoRetry();
VerifiesStaticSnapshotExportsPlanningScope();
VerifiesSessionLayerCleanupDecisions();
@@ -114,7 +115,7 @@ internal static class TrajectoryObservationChecks
Verification.True(source.Contains("[MovementTest(name = \"EM轨迹规划观察闭环测试\")]"),
"observation MovementTest uses required Chinese display name");
Verification.Equal("等待真实档位/方向确认;观察模式不会推进下一方向段",
Verification.Equal("等待真实档位/方向确认;观察模式不会激活下一方向段",
TrajectoryObservationRuntimeState.GearSwitchWaitingNotice,
"observation runtime uses required gear-switch notice");
Verification.True(source.Contains("Console.WriteLine(\"[TrajectoryObserver] \" + text);"),
@@ -180,6 +181,10 @@ internal static class TrajectoryObservationChecks
Verification.True(normalizedSource.Contains(
"VehicleMotionState state = ReadVehicleState();\n DateTimeOffset now = state.CapturedAtUtc;"),
"observer host uses the fresh state snapshot time for each observation tick");
Verification.True(source.Contains("effectiveConfiguration.Frenet.MaximumProjectionDistanceMeters"),
"native observation charts use the EM Frenet projection tolerance rather than map padding");
Verification.True(source.Contains("tick.SegmentState.Phase == TrajectoryObservationSegmentPhase.Completed"),
"observer session terminates after the final direction segment completes");
Verification.NearlyEqual(0.10d, new TrajectoryObservationSettings().OutputTimeStepSeconds,
"observer settings use the ST timestamp-spacing default");
@@ -524,7 +529,7 @@ internal static class TrajectoryObservationChecks
"full scope blocks coordinator-cadence restart after the first plan");
}
private static void VerifiesFullDirectionPlansAgainOnlyAfterConfirmedTransition()
private static void VerifiesFullDirectionPreplansAfterStopAndActivatesAfterConfirmation()
{
DateTimeOffset t0 = new DateTimeOffset(2026, 8, 7, 2, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings
@@ -550,28 +555,90 @@ internal static class TrajectoryObservationChecks
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");
DateTimeOffset stoppedAt = t0.AddSeconds(0.21d);
var heldStoppedState = new VehicleMotionState(new Pose2D(1d, 0d, 0d), 0d, null, stoppedAt, 2L);
controller.TryAdvanceSegment(stoppedAt, heldStoppedState);
Verification.True(controller.ShouldStartCycle(stoppedAt),
"the next full direction segment is planned after the real stop hold, before direction evidence");
PlanningCycleResult pendingCycle = controller.StartCycle(stoppedAt, heldStoppedState, CancellationToken.None)
.GetAwaiter().GetResult();
Verification.True(pendingCycle.Published, "the stopped-state pending segment plan is independently publishable");
Verification.Equal(2, planningService.Requests.Count,
"the next segment is planned exactly once from the stopped state");
Verification.Equal(1, planningService.Requests[1].SegmentIndex,
"the stopped-state pending request targets segment N+1");
Verification.Equal(heldStoppedState.SequenceId, planningService.Requests[1].VehicleState.SequenceId,
"the pending segment request uses the real stopped vehicle state");
Verification.True(planningService.Requests[1].PreviousTrajectory == null,
"a pending direction segment never reuses the old direction trajectory as an EM seed");
Verification.Equal(0, controller.ActiveSegment.SegmentIndex,
"planning N+1 does not replace the old segment before direction confirmation");
Verification.True(ReferenceEquals(forwardGearTrajectory, controller.PublishedTrajectory),
"the old segment trajectory remains active while N+1 waits for confirmation");
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(1, controller.ActiveSegment.SegmentIndex,
"the pending plan becomes active only after direction confirmation");
Verification.True(!controller.ShouldStartCycle(t0.AddSeconds(0.23d)),
"confirmed activation does not trigger a duplicate full-segment plan");
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");
"the pending plan is reused at activation instead of planning N+1 twice");
Verification.Equal(reverseState.CapturedAtUtc, controller.PublishedTrajectory.Metadata.EffectiveAtUtc,
"pending trajectory effective time is rebased to its real activation time");
}
private static void VerifiesDeterministicSingleForwardObservationSimulation()
{
DateTimeOffset t0 = new DateTimeOffset(2026, 8, 8, 0, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings
{
PlanningScope = EmPlanningScope.FullDirectionSegment,
};
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, "deterministic single-forward simulation bootstrap succeeds");
TrajectoryObservationBootstrapResult bootstrap = TrajectoryObservationBootstrapResult.Success(
baseBootstrap.Job, baseBootstrap.CoarseResult, baseBootstrap.SmoothedPath, new[]
{
CreateDirectionalSegment(0, TravelDirection.Forward, 0d, 1d, false,
EmBoundaryType.None, EmBoundaryType.Goal),
});
EmTrajectory trajectory = CreateSingleForwardSimulationTrajectory(t0);
var controller = new TrajectoryObservationController(bootstrap, settings,
new FixedTrajectoryPlanningService(trajectory), "single-forward-simulation");
var loop = new TrajectoryObservationLoop(controller);
double x = 0d;
double heading = 0d;
var initialState = new VehicleMotionState(new Pose2D(x, 0d, heading), 0d, null, t0, 1L);
controller.StartCycle(t0, initialState, CancellationToken.None).GetAwaiter().GetResult();
TrajectoryControlCommand command = controller.Observe(t0, initialState).Command;
TrajectoryObservationLoopTick? finalTick = null;
const double deltaSeconds = 0.10d;
for (int step = 1; step <= 10; step++)
{
x += command.SignedLongitudinalVelocity * Math.Cos(heading) * deltaSeconds;
heading += command.YawRate * deltaSeconds;
DateTimeOffset now = t0.AddSeconds(step * deltaSeconds);
var state = new VehicleMotionState(new Pose2D(x, 0d, heading), command.SignedLongitudinalVelocity,
null, now, step + 1L);
finalTick = loop.Tick(now, state, CancellationToken.None);
command = finalTick.Observation.Command;
}
Verification.NearlyEqual(1d, x, "simulated forward vehicle reaches the real Goal pose from executor commands");
Verification.Equal(TrajectoryObservationSegmentPhase.Completed, finalTick!.SegmentState.Phase,
"simulated forward Goal completes the observation lifecycle");
Verification.NearlyEqual(0d, command.SignedLongitudinalVelocity,
"simulated forward Goal receives the terminal zero-speed command");
}
private static void VerifiesFailedFullPlanDoesNotAutoRetry()
@@ -666,11 +733,24 @@ internal static class TrajectoryObservationChecks
effectiveAt.AddSeconds(1d), trajectory);
Verification.True(atFinal.WaitingAtGearSwitch,
"observer enters gear-switch wait state at final time");
Verification.Equal("等待真实档位/方向确认;观察模式不会推进下一方向段", atFinal.WorldNotice,
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");
var tracker = new TrajectoryObservationSegmentTracker(CreateDirectionalSegments(),
new TrajectoryObservationSettings(), 0.01d);
TrajectoryObservationRuntimeState planningState = TrajectoryObservationRuntimeState.Create(tracker.State);
Verification.True(!planningState.WaitingAtGearSwitch,
"tracker-planning state does not show a gear-switch wait notice merely because planned time elapsed");
tracker.Update(effectiveAt.AddSeconds(1d), new VehicleMotionState(new Pose2D(1d, 0d, 0d), 0d, null,
effectiveAt.AddSeconds(1d), 1L),
CreateSegmentTrajectory(effectiveAt, "runtime-forward", 0, TravelDirection.Forward,
EmTerminalType.GearSwitch, EmBoundaryType.GearSwitchApproach, 1d));
TrajectoryObservationRuntimeState stoppedState = TrajectoryObservationRuntimeState.Create(tracker.State);
Verification.True(stoppedState.WaitingAtGearSwitch,
"actual tracker stop state shows the gear-switch wait notice");
string presentationPath = Path.Combine(Directory.GetCurrentDirectory(), "ClumsyPilot",
"ParkrobTrajplanner", "tarjplanner_movementtest", "TrajectoryObservationPresentation.cs");
string presentationSource = new UTF8Encoding(false, true).GetString(File.ReadAllBytes(presentationPath));
@@ -780,6 +860,7 @@ internal static class TrajectoryObservationChecks
DateTimeOffset t0 = new DateTimeOffset(2026, 8, 6, 4, 0, 0, TimeSpan.Zero);
var settings = new TrajectoryObservationSettings
{
PlanningScope = EmPlanningScope.RollingHorizon,
DirectionConfirmationSamples = 1,
};
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
@@ -1244,6 +1325,20 @@ internal static class TrajectoryObservationChecks
});
}
private static EmTrajectory CreateSingleForwardSimulationTrajectory(DateTimeOffset effectiveAt)
{
var metadata = new EmTrajectoryMetadata("single-forward-simulation", effectiveAt, effectiveAt, 1L,
"single-forward-reference", 1L, string.Empty, 0, TravelDirection.Forward, EmTerminalType.Goal,
EmLongitudinalMode.ExactStopAtBoundary, EmPlanningScope.FullDirectionSegment);
return new EmTrajectory(metadata, new[]
{
new EmTrajectoryPoint(0d, 0d, 0d, 1d, 0d, 0d, 0, 0d, 0d,
TravelDirection.Forward, EmBoundaryType.None, 0d, 0d),
new EmTrajectoryPoint(1d, 0d, 0d, 0d, 1d, 0d, 0, 1d, 1d,
TravelDirection.Forward, EmBoundaryType.Goal, 0d, 0d),
});
}
private sealed class FixedTrajectoryPlanningService : IEmPlanningService
{
private readonly EmTrajectory trajectory;
@@ -17,6 +17,7 @@ internal static class TrajectoryObservationSegmentChecks
RejectsNonTerminalOrMismatchedGearTrajectory();
ResetsConfirmationForInvalidDirectionEvidence();
CompletesOneSegmentWithoutIndexingPastTheEnd();
CompletesSingleForwardGoalTrajectory();
}
private static void RejectsHardcodedActiveSegmentIndex()
@@ -112,6 +113,22 @@ internal static class TrajectoryObservationSegmentChecks
Verification.Equal(0, tracker.State.ActiveSegmentIndex, "completed tracker retains final segment index");
}
private static void CompletesSingleForwardGoalTrajectory()
{
DateTimeOffset t0 = new DateTimeOffset(2026, 8, 6, 3, 30, 0, TimeSpan.Zero);
var tracker = new TrajectoryObservationSegmentTracker(new[]
{
CreateSegment(0, TravelDirection.Forward, 0d, 1d, false, 0d, EmBoundaryType.None, EmBoundaryType.Goal),
}, CreateSettings(), 0.01d);
TrajectoryObservationSegmentUpdate completed = tracker.Update(t0, StateAtSwitch(0d, t0, 1L),
GoalTerminal(t0));
Verification.True(completed.Completed, "single forward Goal trajectory reports completion");
Verification.Equal(TrajectoryObservationSegmentPhase.Completed, tracker.State.Phase,
"single forward Goal reaches completed without requiring a gear-switch terminal");
}
private static void AssertConfirmationReset(DateTimeOffset t0, VehicleMotionState invalidState,
EmTrajectory invalidTrajectory, string name)
{
@@ -196,4 +213,16 @@ internal static class TrajectoryObservationSegmentChecks
direction, EmBoundaryType.GearSwitchApproach, 0d, 0d),
});
}
private static EmTrajectory GoalTerminal(DateTimeOffset effectiveAtUtc)
{
var metadata = new EmTrajectoryMetadata("goal-terminal-" + effectiveAtUtc.Ticks, effectiveAtUtc,
effectiveAtUtc, 1L, "segment-check", 1L, string.Empty, 0, TravelDirection.Forward,
EmTerminalType.Goal, EmLongitudinalMode.ExactStopAtBoundary, EmPlanningScope.FullDirectionSegment);
return new EmTrajectory(metadata, new[]
{
new EmTrajectoryPoint(1d, 0d, 0d, 0d, 0d, 0d, 0, 1d, 1d,
TravelDirection.Forward, EmBoundaryType.Goal, 0d, 0d),
});
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseSystemDrawing>true</UseSystemDrawing>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\ClumsyPilot.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,590 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.Mapping;
using MultiWheelC.TrajectoryPlanning.PathSmoothing;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Test;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
internal static class Program
{
private static readonly byte[] PngSignature = { 137, 80, 78, 71, 13, 10, 26, 10 };
private static int Main(string[] arguments)
{
try
{
if (arguments.Length == 3 && arguments[0] == "--verify-local-g2-diagnostic")
{
VerifyLocalG2Diagnostic(arguments[1], arguments[2]);
Console.WriteLine("Local G2 diagnostic visualization verification completed.");
return 0;
}
if (arguments.Length == 4 && arguments[0] == "--export-local-g2-diagnostic")
{
ExportLocalG2Diagnostic(arguments[1], arguments[2], arguments[3]);
return 0;
}
if (arguments.Length == 3 && arguments[0] == "--export-fixtures")
{
ExportFixtureReports(arguments[1], arguments[2]);
return 0;
}
if (arguments.Length == 2 && arguments[0] == "--export-end-to-end")
{
ExportEndToEndReports(arguments[1]);
return 0;
}
Require(arguments.Length == 1 && File.Exists(arguments[0]), "A fixture path is required.");
Verify(arguments[0]);
Console.WriteLine("PNG verification host completed.");
return 0;
}
catch (Exception exception)
{
Console.Error.WriteLine(exception.ToString());
return 1;
}
}
private static void ExportFixtureReports(string fixturePath, string outputDirectory)
{
Require(File.Exists(fixturePath), "Fixture path was not found.");
PrintReports(new PathSmoothingComparisonDemo().ExportFixtureReports(fixturePath, outputDirectory));
}
private static void ExportEndToEndReports(string outputDirectory)
{
PrintReports(new PathSmoothingComparisonDemo().ExportEndToEndReports(outputDirectory));
}
private static void ExportLocalG2Diagnostic(string fixturePath, string evidencePath, string outputDirectory)
{
Require(File.Exists(fixturePath), "Fixture path was not found.");
Require(File.Exists(evidencePath), "Diagnostic evidence path was not found.");
SmoothingReportExportResult report = new LocalG2DiagnosticVisualizationDemo().Export(
fixturePath,
evidencePath,
outputDirectory);
Require(report.Status == SmoothingReportExportStatus.Success, "Diagnostic report export failed: " + report.Reason);
Console.WriteLine("Diagnostic report=" + outputDirectory);
}
private static void VerifyLocalG2Diagnostic(string fixturePath, string evidencePath)
{
Require(File.Exists(fixturePath), "Fixture path was not found.");
Require(File.Exists(evidencePath), "Diagnostic evidence path was not found.");
string outputDirectory = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-diagnostic-" + Guid.NewGuid().ToString("N"));
string badEvidencePath = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-bad-" + Guid.NewGuid().ToString("N") + ".json");
string rejectedOutputDirectory = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-rejected-" + Guid.NewGuid().ToString("N"));
string alteredFixturePath = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-altered-fixture-" + Guid.NewGuid().ToString("N") + ".json");
string rejectedFixtureOutputDirectory = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-fixture-rejected-" + Guid.NewGuid().ToString("N"));
try
{
SmoothingReportExportResult report = new LocalG2DiagnosticVisualizationDemo().Export(
fixturePath,
evidencePath,
outputDirectory);
Require(report.Status == SmoothingReportExportStatus.Success, "Diagnostic report export failed: " + report.Reason);
Require(report.SvgPaths.Count == 5 && report.PngPaths.Count == 5 && File.Exists(report.CsvPath),
"Diagnostic export must publish five SVGs, five PNGs and one CSV.");
Require(Path.GetFileName(report.SvgPaths[4]) == "05-local-g2-diagnostic-candidate.svg",
"Diagnostic SVG stem is incorrect.");
Require(Path.GetFileName(report.PngPaths[4]) == "05-local-g2-diagnostic-candidate.png",
"Diagnostic PNG stem is incorrect.");
string diagnosticSvg = File.ReadAllText(report.SvgPaths[4]);
Require(diagnosticSvg.Contains("data-series=\"raw\"") && diagnosticSvg.Contains("data-series=\"local-g2-diagnostic\""),
"Diagnostic SVG must contain raw and diagnostic samples.");
Require(diagnosticSvg.Contains("净空拒绝") && diagnosticSvg.Contains("未发布") &&
diagnosticSvg.Contains("严格输出=原始路径"),
"Diagnostic SVG must disclose clearance rejection, publication state, and strict output boundary.");
VerifyDiagnosticPointOverlay(diagnosticSvg, report.PngPaths[6]);
Require(!diagnosticSvg.Contains("violation-cross"),
"Clearance rejection must not be drawn as an obstacle collision.");
Require(File.ReadAllText(report.CsvPath).Contains("LocalG2Quintic,Unchanged"),
"CSV must retain the normal strict Local G2 row.");
for (int index = 0; index < report.PngPaths.Count; index++)
VerifyPng(File.ReadAllBytes(report.PngPaths[index]));
Require(!ContainsTemporaryFiles(outputDirectory), "Diagnostic export must not leave temporary files.");
File.Copy(fixturePath, alteredFixturePath);
File.AppendAllText(alteredFixturePath, " ");
bool alteredFixtureRejected = false;
try
{
new LocalG2DiagnosticVisualizationDemo().Export(alteredFixturePath, evidencePath, rejectedFixtureOutputDirectory);
}
catch (Exception)
{
alteredFixtureRejected = true;
}
Require(alteredFixtureRejected, "Fixture bytes that differ from diagnostic evidence must be rejected.");
Require(!Directory.Exists(rejectedFixtureOutputDirectory),
"Fixture hash mismatch must not create an output directory.");
File.WriteAllText(badEvidencePath, File.ReadAllText(evidencePath).Replace(
"3d05daee5a211b3e7aa0b77193423b5fa07d3135e241a4413be3518fc7efe563",
"0000000000000000000000000000000000000000000000000000000000000000"));
bool badEvidenceRejected = false;
try
{
new LocalG2DiagnosticVisualizationDemo().Export(fixturePath, badEvidencePath, rejectedOutputDirectory);
}
catch (Exception)
{
badEvidenceRejected = true;
}
Require(badEvidenceRejected, "Tampered diagnostic evidence must be rejected.");
Require(!Directory.Exists(rejectedOutputDirectory),
"Rejected diagnostic evidence must not create an output directory.");
}
finally
{
if (File.Exists(alteredFixturePath)) File.Delete(alteredFixturePath);
if (Directory.Exists(rejectedFixtureOutputDirectory)) Directory.Delete(rejectedFixtureOutputDirectory, true);
if (File.Exists(badEvidencePath)) File.Delete(badEvidencePath);
if (Directory.Exists(rejectedOutputDirectory)) Directory.Delete(rejectedOutputDirectory, true);
if (Directory.Exists(outputDirectory)) Directory.Delete(outputDirectory, true);
}
}
private static void PrintReports(IReadOnlyList<PathSmoothingComparisonScenarioResult> scenarios)
{
for (int scenarioIndex = 0; scenarioIndex < scenarios.Count; scenarioIndex++)
{
PathSmoothingComparisonScenarioResult scenario = scenarios[scenarioIndex];
if (scenario.Comparison == null)
{
Console.WriteLine(scenario.ScenarioId + " coarse=" + scenario.CoarsePathStatus + " " + scenario.Diagnostic);
continue;
}
PrintEntry(scenario.ScenarioId, scenario.Comparison.RawPathBaseline);
for (int entryIndex = 0; entryIndex < scenario.Comparison.Entries.Count; entryIndex++)
PrintEntry(scenario.ScenarioId, scenario.Comparison.Entries[entryIndex]);
Console.WriteLine(scenario.ScenarioId + " report=" + scenario.Report.Status + " " + scenario.Report.Reason);
}
}
private static void PrintEntry(string scenarioId, PathSmoothingComparisonEntry entry)
{
Console.WriteLine(string.Format(
CultureInfo.InvariantCulture,
"{0} {1} status={2} length={3:F4} peakKappa={4:F4} clearance={5:F4}",
scenarioId,
entry.IsRawPathBaseline ? "RawPath" : entry.Method.ToString(),
entry.Status,
entry.Metrics.PathLengthMeters,
entry.Metrics.MaximumAbsoluteVehicleCurvaturePerMeter,
entry.Metrics.MinimumBodyClearanceMeters));
}
private static void Verify(string fixturePath)
{
PathSmoothingComparisonRequest request = CreateComparisonRequest();
PathSmoothingComparisonResult comparison = new PathSmoothingComparisonService().Compare(request);
Require(!comparison.IsCancelled, "Fixture comparison was cancelled.");
Require(request.SmoothingRequest.CoarsePath.Count > 1, "Fixture coarse path is too short.");
CoarsePathPoint first = request.SmoothingRequest.CoarsePath[0];
CoarsePathPoint last = request.SmoothingRequest.CoarsePath[request.SmoothingRequest.CoarsePath.Count - 1];
var model = new SmoothingFigureModelBuilder().Build(
comparison,
request.SmoothingRequest.Map,
new Pose2D(first.X, first.Y, first.Heading),
new Pose2D(last.X, last.Y, last.Heading),
"straight",
"Straight");
VerifyObstacleRectanglesAreMerged(fixturePath, comparison, new Pose2D(first.X, first.Y, first.Heading), new Pose2D(last.X, last.Y, last.Heading));
VerifyFourFigureDefinitionContract(model);
VerifyNormalLocalG2Series(model);
VerifyPointOnlyRendererContract(model);
var resolver = new SmoothingFontResolver();
Require(resolver.TryResolve("SimSun", "Times New Roman", out SmoothingFontResolution fonts, out string fontReason),
"Required report fonts are unavailable: " + fontReason);
byte[] png;
using (fonts)
{
Require(fonts.ChineseFamilyName == "SimSun", "Chinese font must resolve to exact SimSun family.");
Require(fonts.LatinFamilyName == "Times New Roman", "Latin font must resolve to exact Times New Roman family.");
Require(resolver.MeasureMixedText(fonts, "粗路径 κ(s) X (m) −π", 9f).Width > 0f,
"Mixed Chinese/Latin sample must have nonempty measured bounds.");
png = new SmoothingPngRenderer().Render(model, fonts);
}
VerifyPng(png);
string outputDirectory = Path.Combine(Path.GetTempPath(), "path-smoothing-png-" + Guid.NewGuid().ToString("N"));
try
{
var exporter = new SmoothingReportExporter();
SmoothingReportExportResult report = exporter.Export(new SmoothingReportExportRequest
{
Model = model,
OutputDirectory = outputDirectory,
FileStem = "straight-report",
});
Require(report.Status == SmoothingReportExportStatus.Success, "Report export failed: " + report.Reason);
Require(report.SvgPaths.Count == 4 && report.PngPaths.Count == 4 && File.Exists(report.CsvPath),
"Successful export must publish four SVGs, four PNGs and one CSV.");
VerifyPublishedFourFigureFiles(report, outputDirectory);
Require(!ContainsTemporaryFiles(outputDirectory), "Successful export must not leave temporary files.");
string unavailableDirectory = Path.Combine(outputDirectory, "missing-font");
SmoothingReportExportResult unavailable = exporter.Export(new SmoothingReportExportRequest
{
Model = model,
OutputDirectory = unavailableDirectory,
FileStem = "must-not-write",
ChineseFontFamilyName = "Missing report font",
LatinFontFamilyName = "Times New Roman",
});
Require(unavailable.Status == SmoothingReportExportStatus.FontUnavailable,
"Missing exact font must report FontUnavailable.");
Require(!Directory.Exists(unavailableDirectory), "Missing-font export must remain atomic and create no output directory.");
}
finally
{
if (Directory.Exists(outputDirectory)) Directory.Delete(outputDirectory, true);
}
}
private static PathSmoothingComparisonRequest CreateComparisonRequest()
{
PlanningMapBuildResult mapResult = new PlanningMapFactory().Create(new PlanningMapRequest
{
Bounds = new MapBoundsMm(0f, 5000f, 0f, 5000f),
ResolutionMm = 50f,
AllowExplicitEmptyMap = true,
});
Require(mapResult.Succeeded && mapResult.Map != null, "PNG verification map must be created.");
var vehicle = new VehicleParameters
{
LengthMeters = 0.20d,
WidthMeters = 0.20d,
SafetyMarginMeters = 0d,
MaximumCurvaturePerMeter = 100d,
};
var coarsePath = new List<CoarsePathPoint>
{
CreatePoint(0.5d, 0.5d, 0d),
CreatePoint(1.0d, 0.5d, 0.5d),
CreatePoint(1.0d, 1.0d, 1.0d),
CreatePoint(1.5d, 1.0d, 1.5d),
};
var segments = new List<PathSegment>
{
new PathSegment(0, TravelDirection.Forward, 0, coarsePath.Count - 1, false, false),
};
return new PathSmoothingComparisonRequest(new PathSmoothingRequest(
coarsePath,
segments,
mapResult.Map,
vehicle,
new PathSmoothingConfiguration()));
}
private static void VerifyFourFigureDefinitionContract(SmoothingFigureModel model)
{
SmoothingFigureSet figureSet = new SmoothingFigureSetBuilder().Build(model);
var stems = new List<string>();
for (int index = 0; index < figureSet.Figures.Count; index++) stems.Add(figureSet.Figures[index].FileStem);
string expected = string.Join(",", new[]
{
"01-coarse-path-overview",
"02-all-paths-comparison",
"03-local-g2-overview",
"04-curvature-comparison",
});
Require(string.Join(",", stems) == expected, "Four-figure report stems must be stable and ordered.");
Require(!figureSet.Figures[1].ShowsMapContext, "All-path comparison must contain only trajectories, axes and legend.");
Require(figureSet.Figures[2].ShowsMapContext && figureSet.Figures[2].Series[0].Opacity < 1d,
"Local G2 figure must retain a faded raw-path map reference.");
Require(figureSet.Figures[0].WorldScalePointsPerMeter > 0d &&
Math.Abs((figureSet.Figures[0].WorldXMaxMeters - figureSet.Figures[0].WorldXMinMeters) /
(figureSet.Figures[0].WorldYMaxMeters - figureSet.Figures[0].WorldYMinMeters) -
figureSet.Figures[0].PlotWidthPoints / figureSet.Figures[0].PlotHeightPoints) < 0.000001d,
"Overhead figures must preserve equal X/Y scale.");
Require(figureSet.Figures[0].LegendYPoints -
(figureSet.Figures[0].PlotYPoints + figureSet.Figures[0].PlotHeightPoints + 31d) >= 8d,
"Legend must leave vertical clearance below the X-axis unit label.");
}
private static void VerifyNormalLocalG2Series(SmoothingFigureModel model)
{
Require(model.Series.Count == 2, "Normal comparison model must contain only raw-path and Local G2 series.");
SmoothingFigureSeries localG2 = null;
int count = 0;
for (int index = 0; index < model.Series.Count; index++)
{
if (model.Series[index].Key != "local-g2") continue;
localG2 = model.Series[index];
count++;
}
Require(count == 1, "Normal comparison model must include exactly one Local G2 series.");
Require(Enum.IsDefined(typeof(PathSmoothingStatus), localG2.Status), "Local G2 must retain a defined smoothing status.");
if (localG2.IsCurveVisible)
Require(localG2.Points.Count >= 2, "Visible Local G2 output must include at least two samples.");
}
private static void VerifyObstacleRectanglesAreMerged(string fixturePath, PathSmoothingComparisonResult comparison, Pose2D start, Pose2D goal)
{
IReadOnlyList<SmoothingScenarioFixture> fixtures = SmoothingScenarioFixtureLoader.LoadAndVerify(fixturePath);
IReadOnlyList<PathSmoothingComparisonRequest> requests = SmoothingScenarioFactory.CreateFixtureRequests(fixturePath);
int rectangleIndex = -1;
for (int index = 0; index < fixtures.Count; index++)
{
if (fixtures[index].Id == "rectangle-detour") { rectangleIndex = index; break; }
}
Require(rectangleIndex >= 0, "Fixture suite must include rectangle-detour for obstacle rendering verification.");
SmoothingFigureModel model = new SmoothingFigureModelBuilder().Build(
comparison, requests[rectangleIndex].SmoothingRequest.Map, start, goal, "obstacle", "obstacle");
Require(model.Obstacles.Count > 0, "Rectangle-detour fixture must produce report obstacles.");
for (int firstIndex = 0; firstIndex < model.Obstacles.Count; firstIndex++)
{
SmoothingFigureObstacle first = model.Obstacles[firstIndex];
for (int secondIndex = firstIndex + 1; secondIndex < model.Obstacles.Count; secondIndex++)
{
SmoothingFigureObstacle second = model.Obstacles[secondIndex];
bool matchingColumn = Math.Abs(first.X - second.X) < 0.0000001d && Math.Abs(first.Width - second.Width) < 0.0000001d;
bool verticallyAdjacent = Math.Abs((first.Y + first.Height) - second.Y) < 0.0000001d ||
Math.Abs((second.Y + second.Height) - first.Y) < 0.0000001d;
Require(!(matchingColumn && verticallyAdjacent), "Adjacent occupied rows must merge into a single obstacle rectangle.");
}
}
}
private static void VerifyPointOnlyRendererContract(SmoothingFigureModel model)
{
SmoothingFigureDefinition figure = new SmoothingFigureSetBuilder().Build(model).Figures[1];
string svg = new SmoothingSvgRenderer().Render(figure);
Require(svg.Contains("class=\"trajectory-point\""), "Trajectory samples must render as discrete SVG point markers.");
Require(!svg.Contains("stroke-dasharray") && !svg.Contains("-path\""), "Trajectory SVG output must not use dashed or joined path strokes.");
Require(svg.Contains("X (m)") && svg.Contains("Y (m)"), "Overhead SVG must label metre coordinate axes.");
}
private static void VerifyDiagnosticPointOverlay(string svg, string pngPath)
{
const string rawHeader = "<g class=\"trajectory-series\" data-series=\"raw\" fill=\"#4D4D4D\" opacity=\"1\">";
const string diagnosticHeader = "<g class=\"trajectory-series\" data-series=\"local-g2-diagnostic\" fill=\"#B1373E\" opacity=\"1\">";
Require(svg.Contains(rawHeader), "Diagnostic raw series must render as opaque gray outer dots.");
Require(svg.Contains(diagnosticHeader), "Diagnostic candidate must render as opaque red inner dots.");
IReadOnlyList<SvgTrajectoryPoint> rawPoints = ReadSvgTrajectoryPoints(svg, "raw");
IReadOnlyList<SvgTrajectoryPoint> diagnosticPoints = ReadSvgTrajectoryPoints(svg, "local-g2-diagnostic");
Require(rawPoints.Count == 88, "Diagnostic SVG must retain the original 88 raw sample centers.");
Require(diagnosticPoints.Count == 88, "Diagnostic SVG must retain the original 88 candidate sample centers.");
for (int index = 0; index < rawPoints.Count; index++)
Require(rawPoints[index].Radius == "2.1", "Diagnostic raw points must use a 2.1 point radius.");
for (int index = 0; index < diagnosticPoints.Count; index++)
Require(diagnosticPoints[index].Radius == "1.35", "Diagnostic candidate points must use a 1.35 point radius.");
var diagnosticIndicesByCenter = new Dictionary<string, int>(StringComparer.Ordinal);
for (int index = 0; index < diagnosticPoints.Count; index++)
diagnosticIndicesByCenter[CenterKey(diagnosticPoints[index])] = index;
int sharedCount = 0;
bool hasNonEndpointSharedCenter = false;
for (int index = 0; index < rawPoints.Count; index++)
{
if (!diagnosticIndicesByCenter.TryGetValue(CenterKey(rawPoints[index]), out int diagnosticIndex)) continue;
sharedCount++;
if (index > 0 && index < rawPoints.Count - 1 && diagnosticIndex > 0 && diagnosticIndex < diagnosticPoints.Count - 1)
hasNonEndpointSharedCenter = true;
}
Require(sharedCount == 81 && hasNonEndpointSharedCenter,
"Diagnostic overlay must retain exact shared non-endpoint raw and candidate centers without coordinate offsets.");
VerifyDiagnosticOverlayPixels(pngPath, rawPoints, diagnosticPoints, diagnosticIndicesByCenter);
}
private static IReadOnlyList<SvgTrajectoryPoint> ReadSvgTrajectoryPoints(string svg, string seriesKey)
{
string marker = "data-series=\"" + seriesKey + "\"";
int seriesMarker = svg.IndexOf(marker, StringComparison.Ordinal);
Require(seriesMarker >= 0, "Diagnostic SVG series is missing: " + seriesKey);
int seriesStart = svg.LastIndexOf("<g", seriesMarker, StringComparison.Ordinal);
int seriesEnd = svg.IndexOf("</g>", seriesMarker, StringComparison.Ordinal);
Require(seriesStart >= 0 && seriesEnd > seriesStart, "Diagnostic SVG series markup is invalid: " + seriesKey);
string series = svg.Substring(seriesStart, seriesEnd - seriesStart);
MatchCollection matches = Regex.Matches(series,
"<circle class=\"trajectory-point\" cx=\"(?<x>[^\"]+)\" cy=\"(?<y>[^\"]+)\" r=\"(?<r>[^\"]+)\"/>",
RegexOptions.CultureInvariant);
var points = new List<SvgTrajectoryPoint>(matches.Count);
for (int index = 0; index < matches.Count; index++)
points.Add(new SvgTrajectoryPoint(matches[index].Groups["x"].Value, matches[index].Groups["y"].Value, matches[index].Groups["r"].Value));
return points;
}
private static void VerifyDiagnosticOverlayPixels(
string pngPath,
IReadOnlyList<SvgTrajectoryPoint> rawPoints,
IReadOnlyList<SvgTrajectoryPoint> diagnosticPoints,
IReadOnlyDictionary<string, int> diagnosticIndicesByCenter)
{
const double pixelsPerPoint = 600d / 72d;
const double annulusRadiusPoints = 1.70d;
using (var bitmap = new Bitmap(pngPath))
{
for (int rawIndex = 1; rawIndex < rawPoints.Count - 1; rawIndex++)
{
SvgTrajectoryPoint raw = rawPoints[rawIndex];
if (!diagnosticIndicesByCenter.TryGetValue(CenterKey(raw), out int diagnosticIndex) ||
diagnosticIndex == 0 || diagnosticIndex == diagnosticPoints.Count - 1) continue;
int centerX = (int)Math.Round(raw.XPoints * pixelsPerPoint, MidpointRounding.AwayFromZero);
int centerY = (int)Math.Round(raw.YPoints * pixelsPerPoint, MidpointRounding.AwayFromZero);
if (!IsDiagnosticRed(bitmap.GetPixel(centerX, centerY))) continue;
for (int angleIndex = 0; angleIndex < 24; angleIndex++)
{
double angle = 2d * Math.PI * angleIndex / 24d;
int annulusX = (int)Math.Round(centerX + Math.Cos(angle) * annulusRadiusPoints * pixelsPerPoint, MidpointRounding.AwayFromZero);
int annulusY = (int)Math.Round(centerY + Math.Sin(angle) * annulusRadiusPoints * pixelsPerPoint, MidpointRounding.AwayFromZero);
if (annulusX < 0 || annulusX >= bitmap.Width || annulusY < 0 || annulusY >= bitmap.Height) continue;
if (IsRawGray(bitmap.GetPixel(annulusX, annulusY))) return;
}
}
}
throw new InvalidOperationException("A shared non-endpoint sample must have a red center and gray annulus.");
}
private static bool IsDiagnosticRed(Color color)
{
return color.R >= 140 && color.G <= 95 && color.B <= 100 && color.R >= color.G + 60;
}
private static bool IsRawGray(Color color)
{
return color.R >= 45 && color.R <= 110 && Math.Abs(color.R - color.G) <= 8 && Math.Abs(color.G - color.B) <= 8;
}
private static string CenterKey(SvgTrajectoryPoint point) { return point.X + "|" + point.Y; }
private readonly struct SvgTrajectoryPoint
{
public SvgTrajectoryPoint(string x, string y, string radius)
{
X = x;
Y = y;
Radius = radius;
XPoints = double.Parse(x, CultureInfo.InvariantCulture);
YPoints = double.Parse(y, CultureInfo.InvariantCulture);
}
public string X { get; }
public string Y { get; }
public string Radius { get; }
public double XPoints { get; }
public double YPoints { get; }
}
private static void VerifyPublishedFourFigureFiles(SmoothingReportExportResult report, string outputDirectory)
{
var expectedStems = new[]
{
"01-coarse-path-overview",
"02-all-paths-comparison",
"03-local-g2-overview",
"04-curvature-comparison",
};
VerifyPublishedPaths(report.SvgPaths, expectedStems, ".svg", outputDirectory);
VerifyPublishedPaths(report.PngPaths, expectedStems, ".png", outputDirectory);
Require(!File.Exists(Path.Combine(outputDirectory, "comparison.svg")) && !File.Exists(Path.Combine(outputDirectory, "comparison.png")),
"Four-figure export must not leave legacy composite comparison images.");
}
private static void VerifyPublishedPaths(IReadOnlyList<string> paths, string[] expectedStems, string extension, string outputDirectory)
{
var actual = new List<string>();
for (int index = 0; index < paths.Count; index++) actual.Add(paths[index]);
Require(actual.Count == expectedStems.Length, "Four-figure export must publish four " + extension + " files.");
for (int index = 0; index < expectedStems.Length; index++)
{
string expected = Path.Combine(outputDirectory, expectedStems[index] + extension);
Require(actual[index] == expected && File.Exists(actual[index]), "Published " + extension + " path must match the stable figure stem.");
}
}
private static CoarsePathPoint CreatePoint(double x, double y, double arcLength)
{
return new CoarsePathPoint(
x, y, 0d, 0d, arcLength, TravelDirection.Forward, 0d, 1d, false, CoarsePathPointSource.Start);
}
private static bool ContainsTemporaryFiles(string directory)
{
foreach (string ignored in Directory.EnumerateFiles(directory, "*.tmp")) return true;
return false;
}
private static void VerifyPng(byte[] png)
{
Require(png != null && png.Length > PngSignature.Length, "PNG output is empty.");
byte[] data = png ?? throw new InvalidOperationException("PNG output is empty.");
for (int index = 0; index < PngSignature.Length; index++)
Require(data[index] == PngSignature[index], "PNG signature is invalid.");
bool sawHeader = false;
bool sawPhysicalResolution = false;
int offset = PngSignature.Length;
while (offset < data.Length)
{
Require(offset + 12 <= data.Length, "PNG chunk header is truncated.");
int length = checked((int)ReadUInt32BigEndian(data, offset));
int dataStart = offset + 8;
int crcStart = checked(dataStart + length);
Require(crcStart + 4 <= data.Length, "PNG chunk data is truncated.");
uint expectedCrc = ReadUInt32BigEndian(data, crcStart);
uint actualCrc = ComputeCrc32(data, offset + 4, length + 4);
Require(expectedCrc == actualCrc, "PNG chunk CRC is invalid.");
string type = System.Text.Encoding.ASCII.GetString(data, offset + 4, 4);
if (type == "IHDR")
{
Require(length == 13, "IHDR length must be 13.");
Require(ReadUInt32BigEndian(data, dataStart) == SmoothingPngRenderer.WidthPixels, "PNG width must be 4296.");
Require(ReadUInt32BigEndian(data, dataStart + 4) == SmoothingPngRenderer.HeightPixels, "PNG height must be 3120.");
sawHeader = true;
}
else if (type == "pHYs")
{
Require(length == 9, "pHYs length must be 9.");
Require(ReadUInt32BigEndian(data, dataStart) == SmoothingPngRenderer.PixelsPerMeter,
"PNG horizontal density must be 23622 pixels/meter.");
Require(ReadUInt32BigEndian(data, dataStart + 4) == SmoothingPngRenderer.PixelsPerMeter,
"PNG vertical density must be 23622 pixels/meter.");
Require(data[dataStart + 8] == 1, "PNG pHYs unit must be meter.");
sawPhysicalResolution = true;
}
offset = crcStart + 4;
}
Require(sawHeader && sawPhysicalResolution, "PNG must contain IHDR and pHYs chunks.");
}
private static uint ReadUInt32BigEndian(byte[] data, int offset)
{
return ((uint)data[offset] << 24) | ((uint)data[offset + 1] << 16) |
((uint)data[offset + 2] << 8) | data[offset + 3];
}
private static uint ComputeCrc32(byte[] data, int offset, int length)
{
uint crc = 0xffffffffu;
for (int index = 0; index < length; index++)
{
crc ^= data[offset + index];
for (int bit = 0; bit < 8; bit++)
crc = (crc & 1u) == 0u ? crc >> 1 : (crc >> 1) ^ 0xedb88320u;
}
return crc ^ 0xffffffffu;
}
private static void Require(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
}
@@ -0,0 +1,28 @@
param(
[string]$FixturePath = (Join-Path $PSScriptRoot '..\ParkrobTrajplanner\PathSmoothing\Test\Fixtures\path-smoothing-fixtures.json'),
[string]$EvidencePath = (Join-Path $PSScriptRoot '..\ParkrobTrajplanner\PathSmoothing\Test\Fixtures\local-g2-diagnostic-single-turn.json'),
[string]$OutputDirectory = (Join-Path $PSScriptRoot '..\obj\path_smoothing_reports\local-g2-single-turn')
)
$ErrorActionPreference = 'Stop'
$clumsyPilotRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
$allowedRoot = [IO.Path]::GetFullPath((Join-Path $clumsyPilotRoot 'obj\path_smoothing_reports'))
$resolvedOutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
$allowedPrefix = $allowedRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if ($resolvedOutputDirectory -ne $allowedRoot -and -not $resolvedOutputDirectory.StartsWith($allowedPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Report output must stay below $allowedRoot"
}
$projectPath = Join-Path $clumsyPilotRoot 'ClumsyPilot.csproj'
$hostProject = Join-Path $PSScriptRoot 'PathSmoothingPngVerificationHost\PathSmoothingPngVerificationHost.csproj'
$resolvedFixturePath = (Resolve-Path -LiteralPath $FixturePath).Path
$resolvedEvidencePath = (Resolve-Path -LiteralPath $EvidencePath).Path
& dotnet build $projectPath --no-restore
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& dotnet run --project $hostProject --no-restore -- --export-local-g2-diagnostic $resolvedFixturePath $resolvedEvidencePath $resolvedOutputDirectory
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Output "Local G2 diagnostic visualization written below $resolvedOutputDirectory"
@@ -0,0 +1,32 @@
param(
[switch]$FixtureOnly,
[string]$FixturePath = (Join-Path $PSScriptRoot '..\ParkrobTrajplanner\PathSmoothing\Test\Fixtures\path-smoothing-fixtures.json'),
[string]$OutputDirectory = (Join-Path $PSScriptRoot '..\obj\path_smoothing_reports')
)
$ErrorActionPreference = 'Stop'
$clumsyPilotRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
$allowedRoot = [IO.Path]::GetFullPath((Join-Path $clumsyPilotRoot 'obj\path_smoothing_reports'))
$resolvedOutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
$allowedPrefix = $allowedRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if ($resolvedOutputDirectory -ne $allowedRoot -and -not $resolvedOutputDirectory.StartsWith($allowedPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Report output must stay below $allowedRoot"
}
$projectPath = Join-Path $clumsyPilotRoot 'ClumsyPilot.csproj'
$hostProject = Join-Path $PSScriptRoot 'PathSmoothingPngVerificationHost\PathSmoothingPngVerificationHost.csproj'
$resolvedFixturePath = (Resolve-Path -LiteralPath $FixturePath).Path
& dotnet build $projectPath --no-restore
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& dotnet run --project $hostProject --no-restore -- --export-fixtures $resolvedFixturePath $resolvedOutputDirectory
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not $FixtureOnly) {
& dotnet run --project $hostProject --no-restore -- --export-end-to-end $resolvedOutputDirectory
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
Write-Output "Path smoothing reports written below $resolvedOutputDirectory"
@@ -0,0 +1,130 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-False($Actual, [string]$Message) {
if ($Actual) { throw $Message }
}
function Assert-Near([double]$Expected, [double]$Actual, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt 0.000001) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function New-TestMap {
$mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$boundsType = $assembly.GetType($mapping + 'MapBoundsMm', $true)
$obstacleType = $assembly.GetType($mapping + 'IMapObstacle', $true)
$rectangleType = $assembly.GetType($mapping + 'AxisAlignedRectangleObstacle', $true)
$sourceType = $assembly.GetType($mapping + 'IMapObstacleSource', $true)
$manualType = $assembly.GetType($mapping + 'ManualObstacleSource', $true)
$requestType = $assembly.GetType($mapping + 'PlanningMapRequest', $true)
$factoryType = $assembly.GetType($mapping + 'PlanningMapFactory', $true)
$bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]3000, [single]0, [single]3000))
$obstacle = [Activator]::CreateInstance($rectangleType, @([single]1500, [single]1550, [single]1200, [single]1800))
$obstacles = [Array]::CreateInstance($obstacleType, 1)
$obstacles.SetValue($obstacle, 0)
$source = [Activator]::CreateInstance($manualType, @('manual', [long]1, $true, $obstacles))
$sources = [Array]::CreateInstance($sourceType, 1)
$sources.SetValue($source, 0)
$request = [Activator]::CreateInstance($requestType)
$request.Bounds = $bounds
$request.ResolutionMm = [single]50
$request.ObstacleSources = $sources
$request.AllowExplicitEmptyMap = $false
$result = [Activator]::CreateInstance($factoryType).Create($request)
Assert-True $result.Succeeded 'Test map must be created.'
Assert-True $result.Map.PlanningReady 'Test map must be ready.'
return $result.Map
}
function New-DiagonalCellMap {
$mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$boundsType = $assembly.GetType($mapping + 'MapBoundsMm', $true)
$obstacleType = $assembly.GetType($mapping + 'IMapObstacle', $true)
$circleType = $assembly.GetType($mapping + 'CircleObstacle', $true)
$sourceType = $assembly.GetType($mapping + 'IMapObstacleSource', $true)
$manualType = $assembly.GetType($mapping + 'ManualObstacleSource', $true)
$requestType = $assembly.GetType($mapping + 'PlanningMapRequest', $true)
$factoryType = $assembly.GetType($mapping + 'PlanningMapFactory', $true)
$bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]3000, [single]0, [single]3000))
$obstacle = [Activator]::CreateInstance($circleType, @([single]1525, [single]1525, [single]0))
$obstacles = [Array]::CreateInstance($obstacleType, 1)
$obstacles.SetValue($obstacle, 0)
$source = [Activator]::CreateInstance($manualType, @('diagonal-cell', [long]1, $true, $obstacles))
$sources = [Array]::CreateInstance($sourceType, 1)
$sources.SetValue($source, 0)
$request = [Activator]::CreateInstance($requestType)
$request.Bounds = $bounds
$request.ResolutionMm = [single]50
$request.ObstacleSources = $sources
$request.AllowExplicitEmptyMap = $false
$result = [Activator]::CreateInstance($factoryType).Create($request)
Assert-True $result.Succeeded 'Diagonal cell map must be created.'
Assert-True $result.Map.PlanningReady 'Diagonal cell map must be ready.'
Assert-True $result.Map.IsOccupied(30, 30) 'Diagonal cell obstacle must occupy its 50mm cell.'
return $result.Map
}
$map = New-TestMap
$vehicle = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.VehicleParameters
$vehicle.LengthMeters = 0.20
$vehicle.WidthMeters = 0.20
$vehicle.SafetyMarginMeters = 0.0
$checker = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Vehicle.FootprintCollisionChecker
$diagonalCellMap = New-DiagonalCellMap
$diagonalCellVehicle = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.VehicleParameters
$diagonalCellVehicle.LengthMeters = 0.10
$diagonalCellVehicle.WidthMeters = 0.10
$diagonalCellVehicle.SafetyMarginMeters = 0.0
$diagonalCellPose = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D(1.299, 1.299, 0.0)
$diagonalCellClearance = 0.0
$diagonalCellSafe = $checker.IsPoseCollisionFree($diagonalCellPose, $diagonalCellMap, $diagonalCellVehicle, 0.20, [ref]$diagonalCellClearance)
Assert-False $diagonalCellSafe 'Expanded diagonal footprint must not bypass an occupied 50mm cell.'
$edgePose = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D(1.35, 1.50, 0.0)
$edgeClearance = 0.0
$edgeSafe = $checker.IsPoseCollisionFree($edgePose, $map, $vehicle, 0.0, [ref]$edgeClearance)
Assert-False $edgeSafe 'Touching an occupied cell must be a collision.'
$farPose = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D(0.50, 0.50, 0.0)
$farClearance = 0.0
$farSafe = $checker.IsPoseCollisionFree($farPose, $map, $vehicle, 0.0, [ref]$farClearance)
Assert-True $farSafe 'Strict distance field clearance must allow the distant pose.'
Assert-True ($farClearance -gt 0.0) 'Distant pose must report positive body clearance.'
$from = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D(1.20, 1.50, 0.0)
$to = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D(1.80, 1.50, 0.0)
$sweepClearance = 0.0
$sweepSafe = $checker.IsSweptMotionCollisionFree($from, $to, $map, $vehicle, 0.50, [ref]$sweepClearance)
Assert-False $sweepSafe 'A swept vehicle must not pass through an occupied cell.'
$gridCenterPose = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D(1.475, 1.475, 0.0)
$gridCenterClearance = 0.0
Assert-False $checker.IsPoseCollisionFree($gridCenterPose, $map, $vehicle, 0.0, [ref]$gridCenterClearance) 'A pose at an occupied grid center must collide.'
$subGridPose = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D(1.481, 1.463, 0.37)
$subGridClearance = 0.0
Assert-False $checker.IsPoseCollisionFree($subGridPose, $map, $vehicle, 0.0, [ref]$subGridClearance) 'An arbitrary sub-grid heading must collide with the thin obstacle.'
$diagonalPose = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D(1.32, 1.50, ([Math]::PI / 4.0))
$diagonalClearance = 0.0
Assert-False $checker.IsPoseCollisionFree($diagonalPose, $map, $vehicle, 0.0, [ref]$diagonalClearance) 'A 45 degree footprint overlap must collide.'
$outsidePose = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.Pose2D(0.05, 0.05, 0.0)
$outsideClearance = 0.0
Assert-False $checker.IsPoseCollisionFree($outsidePose, $map, $vehicle, 0.0, [ref]$outsideClearance) 'Any footprint corner outside the map must collide.'
$curveVehicle = New-Object MultiWheelC.TrajectoryPlanning.CoarsePath.VehicleParameters
$curveVehicle.MaximumCurvaturePerMeter = 0.80
$curveVehicle.MinimumTurningRadiusMeters = 2.0
$maximumCurvature = 0.0
$hasMaximumCurvature = [MultiWheelC.TrajectoryPlanning.CoarsePath.Vehicle.VehicleKinematics]::TryGetMaximumCurvaturePerMeter($curveVehicle, [ref]$maximumCurvature)
Assert-True $hasMaximumCurvature 'Vehicle curvature constraints must be accepted.'
Assert-Near 0.50 $maximumCurvature 'Both curvature constraints must use the conservative minimum.'
Write-Output 'Coarse path collision checks passed.'
@@ -0,0 +1,411 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-Null($Actual, [string]$Message) {
if ($null -ne $Actual) { throw $Message }
}
function Assert-False($Actual, [string]$Message) {
if ($Actual) { throw $Message }
}
function Assert-Near([double]$Expected, [double]$Actual, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt 0.000001) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Throws([scriptblock]$Action, [string]$Message) {
try {
& $Action
}
catch {
return
}
throw $Message
}
function Find-Method($Type, [string]$Name, [Type[]]$ParameterTypes) {
foreach ($candidate in $Type.GetMethods()) {
if ($candidate.Name -ne $Name) { continue }
$parameters = $candidate.GetParameters()
if ($parameters.Length -ne $ParameterTypes.Length) { continue }
$matches = $true
for ($index = 0; $index -lt $parameters.Length; $index++) {
if ($parameters[$index].ParameterType -ne $ParameterTypes[$index]) {
$matches = $false
break
}
}
if ($matches) { return $candidate }
}
return $null
}
function Find-NonPublicStaticMethod($Type, [string]$Name, [Type[]]$ParameterTypes) {
foreach ($candidate in $Type.GetMethods([Reflection.BindingFlags]'Static,NonPublic')) {
if ($candidate.Name -ne $Name) { continue }
$parameters = $candidate.GetParameters()
if ($parameters.Length -ne $ParameterTypes.Length) { continue }
$matches = $true
for ($index = 0; $index -lt $parameters.Length; $index++) {
if ($parameters[$index].ParameterType -ne $ParameterTypes[$index]) { $matches = $false; break }
}
if ($matches) { return $candidate }
}
return $null
}
function New-TestMap {
$mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$boundsType = $assembly.GetType($mapping + 'MapBoundsMm', $true)
$requestType = $assembly.GetType($mapping + 'PlanningMapRequest', $true)
$factoryType = $assembly.GetType($mapping + 'PlanningMapFactory', $true)
$bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]5000, [single]0, [single]5000))
$request = [Activator]::CreateInstance($requestType)
$request.Bounds = $bounds
$request.ResolutionMm = [single]50
$request.AllowExplicitEmptyMap = $true
$result = [Activator]::CreateInstance($factoryType).Create($request)
Assert-True $result.Succeeded 'Test map must be created.'
Assert-True $result.Map.PlanningReady 'Test map must be ready.'
return $result.Map
}
function New-CornerBlockedMap {
$mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$boundsType = $assembly.GetType($mapping + 'MapBoundsMm', $true)
$requestType = $assembly.GetType($mapping + 'PlanningMapRequest', $true)
$factoryType = $assembly.GetType($mapping + 'PlanningMapFactory', $true)
$obstacleType = $assembly.GetType($mapping + 'IMapObstacle', $true)
$circleType = $assembly.GetType($mapping + 'CircleObstacle', $true)
$manualType = $assembly.GetType($mapping + 'ManualObstacleSource', $true)
$sourceType = $assembly.GetType($mapping + 'IMapObstacleSource', $true)
$bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]150, [single]0, [single]150))
$obstacles = [Array]::CreateInstance($obstacleType, 2)
$obstacles.SetValue([Activator]::CreateInstance($circleType, @([single]75, [single]25, [single]0)), 0)
$obstacles.SetValue([Activator]::CreateInstance($circleType, @([single]25, [single]75, [single]0)), 1)
$sources = [Array]::CreateInstance($sourceType, 1)
$sources.SetValue([Activator]::CreateInstance($manualType, @('corner-blocks', [long]1, $true, $obstacles)), 0)
$request = [Activator]::CreateInstance($requestType)
$request.Bounds = $bounds
$request.ResolutionMm = [single]50
$request.ObstacleSources = $sources
$request.AllowExplicitEmptyMap = $false
$result = [Activator]::CreateInstance($factoryType).Create($request)
Assert-True $result.Succeeded 'Corner-blocked map must be created.'
Assert-True $result.Map.PlanningReady 'Corner-blocked map must be ready.'
return $result.Map
}
$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$search = $coarsePath + 'Search.'
$primitiveType = $assembly.GetType($search + 'MotionPrimitive', $true)
$generatorType = $assembly.GetType($search + 'MotionPrimitiveGenerator', $true)
$goalCheckerType = $assembly.GetType($search + 'GoalToleranceChecker', $true)
Assert-True ($primitiveType.GetProperty('ActualLengthMeters') -ne $null) 'MotionPrimitive must expose actual length.'
Assert-True ($primitiveType.GetProperty('Points') -ne $null) 'MotionPrimitive must expose integration points.'
Assert-True ($primitiveType.GetProperty('IsGoalTruncation') -ne $null) 'MotionPrimitive must expose goal truncation state.'
$poseType = $assembly.GetType($coarsePath + 'Pose2D', $true)
$requestType = $assembly.GetType($coarsePath + 'PlanningRequest', $true)
$configurationType = $assembly.GetType($coarsePath + 'HybridAStarConfiguration', $true)
$vehicleType = $assembly.GetType($coarsePath + 'VehicleParameters', $true)
$directionType = $assembly.GetType($coarsePath + 'TravelDirection', $true)
$goalDirectionType = $assembly.GetType($coarsePath + 'GoalDirectionConstraint', $true)
$generate = Find-Method $generatorType 'Generate' @($poseType, [double], $directionType, $requestType)
Assert-True ($generate -ne $null) 'MotionPrimitiveGenerator must expose Generate(start, curvature, direction, request).'
$isSatisfied = Find-Method $goalCheckerType 'IsSatisfied' @($poseType, $poseType, $configurationType, $directionType, $goalDirectionType)
Assert-True ($isSatisfied -ne $null) 'GoalToleranceChecker must expose the planned goal check.'
$map = New-TestMap
$vehicle = [Activator]::CreateInstance($vehicleType)
$vehicle.LengthMeters = 0.20
$vehicle.WidthMeters = 0.20
$vehicle.SafetyMarginMeters = 0.0
$vehicle.MaximumCurvaturePerMeter = 1.0
$config = [Activator]::CreateInstance($configurationType)
$config.PrimitiveLengthMeters = 0.50
$config.IntegrationStepMeters = 0.05
$config.MaximumCollisionCheckStepMeters = 0.025
$config.GoalPositionToleranceMeters = 0.001
$config.GoalHeadingToleranceRadians = 0.001
$config.CurvatureLevelCount = 5
$forward = [Enum]::Parse($directionType, 'Forward')
$reverse = [Enum]::Parse($directionType, 'Reverse')
$anyDirection = [Enum]::Parse($goalDirectionType, 'Any')
$forwardOnly = [Enum]::Parse($goalDirectionType, 'Forward')
$start = [Activator]::CreateInstance($poseType, @(1.0, 1.0, 0.0))
$generator = [Activator]::CreateInstance($generatorType)
$request = [Activator]::CreateInstance($requestType)
$request.Map = $map
$request.Vehicle = $vehicle
$request.Configuration = $config
$request.Goal = [Activator]::CreateInstance($poseType, @(3.0, 1.0, 0.0))
$request.GoalDirection = $anyDirection
$straight = $generate.Invoke($generator, @($start, 0.0, $forward, $request))
Assert-True ($straight -ne $null) 'Straight primitive must be generated.'
Assert-Near 0.50 $straight.ActualLengthMeters 'Straight primitive must use the configured cap.'
Assert-Near 1.50 $straight.End.X 'Straight primitive must use analytic integration.'
Assert-Near 1.00 $straight.End.Y 'Straight primitive must not drift laterally.'
Assert-Near 0.00 $straight.End.Heading 'Straight primitive must keep heading.'
Assert-True ($straight.Points.Count -ge 20) 'Point spacing must honor the collision check step.'
$previous = $start
foreach ($point in $straight.Points) {
$distance = [Math]::Sqrt(($point.X - $previous.X) * ($point.X - $previous.X) + ($point.Y - $previous.Y) * ($point.Y - $previous.Y))
Assert-True ($distance -le 0.025001) 'Point spacing must not exceed the map-safe collision step.'
$previous = $point
}
$curve = $generate.Invoke($generator, @($start, 1.0, $forward, $request))
Assert-True ($curve -ne $null) 'Arc primitive must be generated.'
Assert-Near ([Math]::Sin(0.50) + 1.0) $curve.End.X 'Arc X must use analytic integration.'
Assert-Near (1.0 - [Math]::Cos(0.50) + 1.0) $curve.End.Y 'Arc Y must use analytic integration.'
Assert-Near 0.50 $curve.End.Heading 'Arc heading must use signed distance.'
$reversePrimitive = $generate.Invoke($generator, @($start, 0.0, $reverse, $request))
Assert-True ($reversePrimitive -ne $null) 'Reverse primitive must be generated.'
Assert-Near 0.50 $reversePrimitive.End.X 'Reverse straight primitive must move behind the vehicle.'
$request.Goal = [Activator]::CreateInstance($poseType, @(1.30, 1.0, 0.0))
$truncated = $generate.Invoke($generator, @($start, 0.0, $forward, $request))
Assert-True ($truncated -ne $null) 'Goal-truncated primitive must be generated.'
Assert-Near 0.30 $truncated.ActualLengthMeters 'A 0.30m goal must truncate at its first internal point.'
Assert-True $truncated.IsGoalTruncation 'Truncated primitive must record its goal source.'
Assert-Near 1.30 $truncated.End.X 'Truncated primitive must end at the goal point.'
$request.Goal = $start
$zeroLength = $generate.Invoke($generator, @($start, 0.0, $forward, $request))
Assert-True ($zeroLength -ne $null) 'Start-at-goal must produce a zero-length candidate.'
Assert-Near 0.0 $zeroLength.ActualLengthMeters 'Start-at-goal candidate must have zero length.'
Assert-True $zeroLength.IsGoalTruncation 'Start-at-goal candidate must have goal truncation source.'
$wrapPose = [Activator]::CreateInstance($poseType, @(1.0, 1.0, (-[Math]::PI + 0.0005)))
$wrapGoal = [Activator]::CreateInstance($poseType, @(1.0, 1.0, ([Math]::PI - 0.0005)))
$config.GoalHeadingToleranceRadians = 0.002
Assert-True $isSatisfied.Invoke($null, @($wrapPose, $wrapGoal, $config, $forward, $forwardOnly)) 'Goal heading tolerance must wrap across pi.'
Assert-False $isSatisfied.Invoke($null, @($wrapPose, $wrapGoal, $config, $reverse, $forwardOnly)) 'Goal direction constraint must reject reverse entry.'
$levelsMethod = Find-Method $generatorType 'GetCurvatureLevels' @($vehicleType, $configurationType)
Assert-True ($levelsMethod -ne $null) 'MotionPrimitiveGenerator must expose curvature levels.'
$levels = $levelsMethod.Invoke($generator, @($vehicle, $config))
Assert-True ($levels.Count -eq 5) 'Five configured curvature levels must be generated.'
Assert-Near -1.0 $levels[0] 'First curvature level must be negative maximum.'
Assert-Near 0.0 $levels[2] 'Middle curvature level must be straight.'
Assert-Near 1.0 $levels[4] 'Last curvature level must be positive maximum.'
$adjacent = Find-Method $generatorType 'AreCurvatureLevelsAdjacent' @([int], [int])
Assert-True ($adjacent -ne $null) 'MotionPrimitiveGenerator must expose curvature adjacency.'
Assert-True $adjacent.Invoke($null, @(2, 3)) 'Neighboring curvature levels must be allowed.'
Assert-False $adjacent.Invoke($null, @(2, 4)) 'Non-neighboring curvature levels must be rejected.'
$heapGenericType = $assembly.GetType($search + 'BinaryMinHeap`1', $true)
$heapType = $heapGenericType.MakeGenericType([string])
$heap = [Activator]::CreateInstance($heapType)
$push = Find-Method $heapType 'Push' @([string], [double], [double], [double])
$pop = Find-Method $heapType 'Pop' @()
Assert-True ($push -ne $null) 'BinaryMinHeap must expose Push(item, f, h, g).'
Assert-True ($pop -ne $null) 'BinaryMinHeap must expose Pop().'
$push.Invoke($heap, @('node-a', 8.0, 2.0, 1.0))
$push.Invoke($heap, @('node-b', 8.0, 1.0, 1.0))
$push.Invoke($heap, @('node-c', 8.0, 1.0, 3.0))
$push.Invoke($heap, @('node-d', 7.0, 9.0, 0.0))
$push.Invoke($heap, @('node-e', 8.0, 1.0, 3.0))
Assert-Equal 'node-d' $pop.Invoke($heap, @()) 'Lower F must win.'
Assert-Equal 'node-c' $pop.Invoke($heap, @()) 'Equal F and H must prefer larger G.'
Assert-Equal 'node-e' $pop.Invoke($heap, @()) 'Equal F, H and G must preserve insertion order.'
Assert-Equal 'node-b' $pop.Invoke($heap, @()) 'Equal F must prefer lower H.'
Assert-Equal 'node-a' $pop.Invoke($heap, @()) 'Remaining item must be returned last.'
$firstDeterministicHeap = [Activator]::CreateInstance($heapType)
$secondDeterministicHeap = [Activator]::CreateInstance($heapType)
foreach ($deterministicHeap in @($firstDeterministicHeap, $secondDeterministicHeap)) {
$push.Invoke($deterministicHeap, @('repeat-a', 5.0, 2.0, 1.0))
$push.Invoke($deterministicHeap, @('repeat-b', 5.0, 1.0, 1.0))
$push.Invoke($deterministicHeap, @('repeat-c', 5.0, 1.0, 3.0))
}
$firstDeterministicOrder = @($pop.Invoke($firstDeterministicHeap, @()), $pop.Invoke($firstDeterministicHeap, @()), $pop.Invoke($firstDeterministicHeap, @())) -join ','
$secondDeterministicOrder = @($pop.Invoke($secondDeterministicHeap, @()), $pop.Invoke($secondDeterministicHeap, @()), $pop.Invoke($secondDeterministicHeap, @())) -join ','
Assert-Equal $firstDeterministicOrder $secondDeterministicOrder 'Equivalent heap inputs must have deterministic pop order.'
$calculatorType = $assembly.GetType($search + 'SearchCostCalculator', $true)
$calculator = [Activator]::CreateInstance($calculatorType)
$calculate = Find-Method $calculatorType 'Calculate' @([double], $directionType, [bool], [double], [double], [int], [double], $configurationType)
Assert-True ($calculate -ne $null) 'SearchCostCalculator must expose the planned equivalent-meter calculation.'
$costConfig = [Activator]::CreateInstance($configurationType)
$cost = $calculate.Invoke($calculator, @(2.0, $reverse, $true, 0.5, 1.0, 2, 0.25, $costConfig))
Assert-Near 4.55 $cost 'Reverse, gear, curvature and clearance costs must be accumulated.'
$negativeWeightConfig = [Activator]::CreateInstance($configurationType)
$negativeWeightConfig.CurvatureMagnitudeWeight = -0.01
Assert-Throws { $calculate.Invoke($calculator, @(1.0, $forward, $false, 0.0, 1.0, 0, 1.0, $negativeWeightConfig)) } 'Negative cost weights must be rejected.'
$nonFiniteWeightConfig = [Activator]::CreateInstance($configurationType)
$nonFiniteWeightConfig.ClearanceCostWeight = [double]::NaN
Assert-Throws { $calculate.Invoke($calculator, @(1.0, $forward, $false, 0.0, 1.0, 0, 1.0, $nonFiniteWeightConfig)) } 'Non-finite cost weights must be rejected.'
$dijkstraType = $assembly.GetType($search + 'GridDijkstraHeuristic', $true)
$dijkstraConstructor = $dijkstraType.GetConstructor(@($map.GetType(), [int], [int]))
Assert-True ($dijkstraConstructor -ne $null) 'GridDijkstraHeuristic must expose the planned grid constructor.'
$getCost = Find-Method $dijkstraType 'GetCost' @([int], [int])
Assert-True ($getCost -ne $null) 'GridDijkstraHeuristic must expose GetCost(row, col).'
$openHeuristic = $dijkstraConstructor.Invoke(@($map, 2, 2))
$expectedDiagonalCost = 2.0 * [Math]::Sqrt(2.0) * $map.ResolutionMeters
Assert-Near $expectedDiagonalCost $getCost.Invoke($openHeuristic, @(0, 0)) 'Open-grid diagonal movement must cost sqrt(2) per cell.'
$cornerBlockedMap = New-CornerBlockedMap
$blockedHeuristic = $dijkstraConstructor.Invoke(@($cornerBlockedMap, 1, 1))
Assert-True ([double]::IsPositiveInfinity($getCost.Invoke($blockedHeuristic, @(0, 0)))) 'Diagonal corner cutting through two occupied orthogonal cells must be forbidden.'
$operationBudgetType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Utils.PlanningOperationBudget', $true)
$operationStopReasonType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Utils.PlanningOperationStopReason', $true)
$budgetConstructor = $operationBudgetType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null,
@([Threading.CancellationToken], [TimeSpan]), $null)
Assert-True ($budgetConstructor -ne $null) 'PlanningOperationBudget must expose its internal cancellation and timeout constructor.'
$tryCreateDijkstra = Find-NonPublicStaticMethod $dijkstraType 'TryCreate' @($map.GetType(), [int], [int], $operationBudgetType,
$dijkstraType.MakeByRefType(), $operationStopReasonType.MakeByRefType())
Assert-True ($tryCreateDijkstra -ne $null) 'GridDijkstraHeuristic must expose an internal budget-aware TryCreate method.'
$expiredBudget = $budgetConstructor.Invoke(@([Threading.CancellationToken]::None, [TimeSpan]::Zero))
$dijkstraArguments = [object[]]@($map, 2, 2, $expiredBudget, $null, $null)
$dijkstraCreated = $tryCreateDijkstra.Invoke($null, $dijkstraArguments)
Assert-False $dijkstraCreated 'An expired total budget must stop Dijkstra construction.'
Assert-Null $dijkstraArguments[4] 'Stopped Dijkstra construction must not publish a partial heuristic.'
Assert-Equal 'TimedOut' $dijkstraArguments[5].ToString() 'Expired Dijkstra construction must report timeout.'
$largeBounds = [Activator]::CreateInstance($assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.MapBoundsMm', $true),
@([single]0, [single]40000, [single]0, [single]20000))
$largeMapRequest = [Activator]::CreateInstance($assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapRequest', $true))
$largeMapRequest.Bounds = $largeBounds
$largeMapRequest.ResolutionMm = [single]20
$largeMapRequest.AllowExplicitEmptyMap = $true
$largeMap = [Activator]::CreateInstance($assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapFactory', $true)).Create($largeMapRequest)
Assert-True $largeMap.Succeeded 'Large empty map for Dijkstra cancellation must be created before cancellation starts.'
$midDijkstraCancellation = New-Object Threading.CancellationTokenSource
$midDijkstraBudget = $budgetConstructor.Invoke(@($midDijkstraCancellation.Token, [TimeSpan]::FromSeconds(2)))
$midDijkstraCancellation.CancelAfter(1)
$midDijkstraTimer = [Diagnostics.Stopwatch]::StartNew()
$midDijkstraArguments = [object[]]@($largeMap.Map, 500, 1000, $midDijkstraBudget, $null, $null)
$midDijkstraCreated = $tryCreateDijkstra.Invoke($null, $midDijkstraArguments)
$midDijkstraTimer.Stop()
Assert-False $midDijkstraCreated 'Cancellation during Dijkstra initialization must stop construction.'
Assert-Null $midDijkstraArguments[4] 'Mid-Dijkstra cancellation must not publish a partial heuristic.'
Assert-Equal 'Cancelled' $midDijkstraArguments[5].ToString() 'Mid-Dijkstra cancellation must retain cancellation status.'
Assert-True ($midDijkstraTimer.Elapsed -lt [TimeSpan]::FromSeconds(2)) 'Dijkstra cancellation must return within the configured response bound.'
Write-Output 'Coarse path search primitive checks passed.'
$nodeType = $assembly.GetType($search + 'HybridAStarNode', $true)
$nodeKeyType = $assembly.GetType($search + 'HybridAStarNodeKey', $true)
$searchType = $assembly.GetType($search + 'HybridAStarSearch', $true)
$searchResultType = $assembly.GetType($search + 'HybridAStarSearchResult', $true)
$searchMethod = Find-Method $searchType 'Search' @($requestType, [Threading.CancellationToken])
Assert-True ($searchMethod -ne $null) 'HybridAStarSearch must expose Search(request, cancellationToken).'
Assert-True ($searchResultType.GetProperty('Status') -ne $null) 'Search result must expose planning status.'
Assert-True ($searchResultType.GetProperty('TerminationReason') -ne $null) 'Search result must expose its original termination reason.'
Assert-True ($searchResultType.GetProperty('SuccessNodeIndex') -ne $null) 'Search result must expose the successful node index.'
Assert-True ($searchResultType.GetProperty('ReopenedNodeCount') -ne $null) 'Search result must expose reopened node count.'
Assert-True ($searchResultType.GetProperty('StaleOpenListEntryCount') -ne $null) 'Search result must expose stale Open List entry count.'
Assert-True ($searchResultType.GetProperty('PeakOpenListCount') -ne $null) 'Search result must expose Open List peak count.'
Assert-True ($nodeType.GetProperty('ParentNodeIndex') -ne $null) 'Search node must expose its parent node index.'
Assert-True ($nodeKeyType.GetProperty('HeadingIndex') -ne $null) 'Search node key must contain a heading index.'
Assert-True ($nodeKeyType.GetProperty('Direction') -ne $null) 'Search node key must contain a travel direction.'
Assert-True ($nodeKeyType.GetProperty('CurvatureLevelIndex') -ne $null) 'Search node key must contain a curvature level index.'
$goalCandidateProperty = $nodeType.GetProperty('IsGoalCandidate')
Assert-True ($goalCandidateProperty -ne $null) 'Search node must mark a goal-truncated candidate explicitly.'
$nodeConstructor = $nodeType.GetConstructor(@([int], $nodeKeyType, $poseType, [int], $primitiveType, [double], [double], [double], [bool]))
Assert-True ($nodeConstructor -ne $null) 'Search node must accept the explicit goal-candidate marker.'
$admissionMethod = $searchType.GetMethod('ShouldEnqueueSuccessor', [Reflection.BindingFlags]'NonPublic,Static')
Assert-True ($admissionMethod -ne $null) 'Search must expose its private successor admission policy for reflection regression coverage.'
$candidateStart = [Activator]::CreateInstance($poseType, @(1.000, 1.000, 0.0))
$candidateGoal = [Activator]::CreateInstance($poseType, @(1.024, 1.000, 0.0))
$candidatePoints = [Array]::CreateInstance($poseType, 1)
$candidatePoints.SetValue($candidateGoal, 0)
$candidatePrimitive = [Activator]::CreateInstance($primitiveType, @($candidateStart, $forward, 0.0, 0.024, $candidatePoints, [double[]]@(1.0), $true))
$sharedKey = [Activator]::CreateInstance($nodeKeyType, @(20, 20, 0, $forward, 2))
$normalNode = $nodeConstructor.Invoke(@(10, $sharedKey, $candidateStart, 0, $null, 0.0, 0.250, 0.0, $false))
$goalCandidateNode = $nodeConstructor.Invoke(@(11, $sharedKey, $candidateGoal, 10, $candidatePrimitive, 0.0, 0.300, 0.0, $true))
$dominanceConfiguration = [Activator]::CreateInstance($configurationType)
$dominanceConfiguration.GoalPositionToleranceMeters = 0.001
$dominanceConfiguration.GoalHeadingToleranceRadians = 0.001
Assert-True $candidatePrimitive.IsGoalTruncation 'Regression setup must use a goal-truncated motion primitive.'
Assert-True $normalNode.Key.Equals($goalCandidateNode.Key) 'Regression setup must share one discrete state key.'
Assert-False $isSatisfied.Invoke($null, @($normalNode.Pose, $candidateGoal, $dominanceConfiguration, $forward, $forwardOnly)) 'Lower-G normal node must remain outside the continuous goal tolerance.'
Assert-True $isSatisfied.Invoke($null, @($goalCandidateNode.Pose, $candidateGoal, $dominanceConfiguration, $forward, $forwardOnly)) 'Goal candidate must satisfy the continuous goal tolerance.'
Assert-False $admissionMethod.Invoke($null, @($normalNode, 0.250)) 'Equal-cost normal state must be dominated by the best same-key label.'
Assert-True $admissionMethod.Invoke($null, @($goalCandidateNode, 0.250)) 'Goal candidate must enter Open List even when a lower-G normal state has the same key.'
function New-SearchRequest([object]$Goal, [object]$GoalDirection, [bool]$AllowReverse) {
$searchRequest = [Activator]::CreateInstance($requestType)
$searchRequest.Map = $map
$searchRequest.Start = [Activator]::CreateInstance($poseType, @(1.0, 1.0, 0.0))
$searchRequest.Goal = $Goal
$searchRequest.Vehicle = $vehicle
$searchConfiguration = [Activator]::CreateInstance($configurationType)
$searchConfiguration.PrimitiveLengthMeters = 0.50
$searchConfiguration.IntegrationStepMeters = 0.05
$searchConfiguration.MaximumCollisionCheckStepMeters = 0.025
$searchConfiguration.GoalPositionToleranceMeters = 0.001
$searchConfiguration.GoalHeadingToleranceRadians = 0.001
$searchConfiguration.CurvatureLevelCount = 5
$searchConfiguration.MaximumExpandedNodes = 10000
$searchConfiguration.SearchTimeout = [TimeSpan]::FromSeconds(2.0)
$searchConfiguration.AllowReverse = $AllowReverse
$searchRequest.Configuration = $searchConfiguration
$searchRequest.GoalDirection = $GoalDirection
return $searchRequest
}
$searcher = [Activator]::CreateInstance($searchType)
$searchGoal = [Activator]::CreateInstance($poseType, @(1.20, 1.0, 0.0))
$searchRequest = New-SearchRequest $searchGoal $forwardOnly $false
$searchResult = $searchMethod.Invoke($searcher, @($searchRequest, [Threading.CancellationToken]::None))
Assert-Equal 'Success' $searchResult.Status.ToString() 'Empty map forward search must succeed.'
Assert-True ($null -ne $searchResult.SuccessNodeIndex) 'Success must be reported only with a popped goal candidate node.'
Assert-True ($searchResult.ExpandedNodeCount -gt 0) 'A successful search must expand the selected goal candidate.'
$atGoal = New-SearchRequest $searchRequest.Start $forwardOnly $false
$atGoalResult = $searchMethod.Invoke($searcher, @($atGoal, [Threading.CancellationToken]::None))
Assert-Equal 'Success' $atGoalResult.Status.ToString() 'A zero-length goal candidate must enter and leave the open list successfully.'
Assert-True ($null -ne $atGoalResult.SuccessNodeIndex) 'A zero-length goal candidate must have a node index.'
$cancelledRequest = New-SearchRequest $searchGoal $forwardOnly $false
$cancellationSource = New-Object Threading.CancellationTokenSource
$cancellationSource.Cancel()
$cancelledResult = $searchMethod.Invoke($searcher, @($cancelledRequest, $cancellationSource.Token))
Assert-Equal 'Cancelled' $cancelledResult.Status.ToString() 'Cancellation must be observed before node expansion.'
Assert-True (-not [string]::IsNullOrWhiteSpace($cancelledResult.TerminationReason)) 'Cancelled search must retain a non-empty reason.'
$limitedRequest = New-SearchRequest $searchGoal $forwardOnly $false
$limitedRequest.Configuration.MaximumExpandedNodes = 0
$limitedResult = $searchMethod.Invoke($searcher, @($limitedRequest, [Threading.CancellationToken]::None))
Assert-Equal 'SearchNodeLimitExceeded' $limitedResult.Status.ToString() 'Node limit must be checked before expansion.'
Assert-True (-not [string]::IsNullOrWhiteSpace($limitedResult.TerminationReason)) 'Node-limited search must retain a non-empty reason.'
$timedOutRequest = New-SearchRequest $searchGoal $forwardOnly $false
$timedOutRequest.Configuration.SearchTimeout = [TimeSpan]::Zero
$timedOutResult = $searchMethod.Invoke($searcher, @($timedOutRequest, [Threading.CancellationToken]::None))
Assert-Equal 'SearchTimeout' $timedOutResult.Status.ToString() 'Timeout must be checked before expansion.'
Assert-True (-not [string]::IsNullOrWhiteSpace($timedOutResult.TerminationReason)) 'Timed-out search must retain a non-empty reason.'
Assert-False ($cancelledResult.TerminationReason -eq $limitedResult.TerminationReason) 'Cancelled and node-limited searches must retain different reasons.'
Assert-False ($limitedResult.TerminationReason -eq $timedOutResult.TerminationReason) 'Node-limited and timed-out searches must retain different reasons.'
$internalSearchMethod = $searchType.GetMethods([Reflection.BindingFlags]'Instance,NonPublic') |
Where-Object {
$_.Name -eq 'Search' -and
$_.GetParameters().Length -eq 2 -and
$_.GetParameters()[1].ParameterType -eq $operationBudgetType
} |
Select-Object -First 1
Assert-True ($internalSearchMethod -ne $null) 'Search must retain its internal shared-budget overload.'
$internalErrorResult = $internalSearchMethod.Invoke($searcher, @($searchRequest, $null))
Assert-Equal 'InternalError' $internalErrorResult.Status.ToString() 'A missing internal budget must be mapped to InternalError.'
Assert-True ($internalErrorResult.TerminationReason.Contains('ArgumentNullException')) 'Internal search errors must retain the exception type.'
Write-Output 'Coarse path Hybrid A star search checks passed.'
+122
View File
@@ -0,0 +1,122 @@
$ErrorActionPreference = 'Stop'
function Assert-True([bool]$condition, [string]$message) {
if (-not $condition) { throw $message }
}
function Assert-Match([string]$content, [string]$pattern, [string]$message) {
Assert-True ($content -match $pattern) $message
}
function Assert-NotMatch([string]$content, [string]$pattern, [string]$message) {
Assert-True ($content -notmatch $pattern) $message
}
$sourcePath = Join-Path $PSScriptRoot '..\ParkrobTrajplanner\CoarsePath\Test\MovementTest.CoarsePathTest.cs'
Assert-True (Test-Path -LiteralPath $sourcePath) 'P1 coarse-path UI movement-test source must exist.'
$source = Get-Content -Raw -Encoding UTF8 $sourcePath
Assert-Match $source 'class\s+CoarsePathPlanningTest\s*:\s*MovementTest' 'The UI entry must inherit MovementTest.'
Assert-Match $source 'namespace\s+MultiWheelC\s*;' 'MovementTest entries must use the project discovery namespace.'
Assert-Match $source 'CoarsePathPlanningService' 'The UI must keep one shared planning service for map-cache reuse.'
Assert-Match $source 'Task\.Run\s*\(' 'Planning must run in a background Task.'
Assert-Match $source 'CancellationTokenSource' 'The UI must retain a cancellation source for its active task.'
Assert-Match $source 'override\s+void\s+TestStop\s*\(' 'The UI must implement TestStop cancellation.'
Assert-Match $source 'CoarsePathScenarioFactory\.Create\s*\(' 'The UI must expose fixed scenario entries through the factory.'
Assert-Match $source 'ManualCoarsePathObstacle' 'The manual UI must construct typed manual obstacles.'
Assert-Match $source 'CreateManualObstacleDemo\s*\(' 'The manual UI must submit obstacles through the factory.'
Assert-Match $source 'MaximumManualObstacleCount\s*=\s*20' 'The manual UI must bound obstacle input to 20.'
Assert-Match $source 'ReadManualObstacles\s*\(' 'The manual UI must read the requested obstacle sequence.'
Assert-Match $source 'Interlocked\.Increment\s*\(' 'The manual UI must issue a fresh obstacle snapshot version.'
Assert-Match $source 'Circle\s*\(' 'The manual UI must support circle input.'
Assert-Match $source 'AxisAlignedRectangle\s*\(' 'The manual UI must support axis-aligned rectangle input.'
Assert-Match $source 'UI\.GetInput\s*\(' 'The manual demo must accept user input.'
Assert-Match $source 'ReadPositiveTimeoutInput\s*\(' 'The manual UI must read a finite positive timeout.'
Assert-Match $source 'Configuration\.SearchTimeout\s*=\s*searchTimeout' 'The manual UI must apply the timeout to the current job.'
Assert-Match $source 'TimeSpan\.FromSeconds\s*\(' 'The manual timeout must convert seconds to TimeSpan.'
Assert-Match $source 'timeoutSeconds\s*<=\s*0' 'The manual timeout must reject zero and negative values.'
Assert-Match $source 'getCartLocation\s*\(' 'The manual demo must read the AMR body-center pose.'
Assert-Match $source 'DrawLegend\s*\(' 'The painter output must include a visual legend.'
Assert-Match $source 'PlanningGridMap' 'The painter output must consume the planning grid result.'
Assert-Match $source '\.IsOccupied\s*\(' 'The painter output must draw occupied grid cells.'
Assert-Match $source 'ResolutionMm' 'The painter output must draw the grid at its true resolution.'
Assert-Match $source 'SnapshotId' 'The painter output must show the map snapshot identity.'
Assert-Match $source 'IsGearSwitchPoint' 'The painter output must highlight gear-switch points.'
Assert-Match $source 'GoalPositionToleranceMeters' 'The painter output must draw goal tolerance.'
Assert-True (([regex]::Matches($source, 'PathSearchElapsed')).Count -ge 2) 'The status layer and Toast must both show path-search elapsed time.'
Assert-Match $source 'BuildToastMessage[\s\S]*TerminationReason' 'The failure Toast must include the termination reason.'
Assert-Match $source 'ExpandedNodeCount' 'The status layer must show expanded-node statistics.'
Assert-Match $source 'VehicleKinematics\.TryGetMaximumCurvaturePerMeter' 'The status layer must show the effective turning radius.'
Assert-NotMatch $source 'PlanningMapFactory' 'Movement tests must not build maps directly; use CoarsePathPlanningService.'
Assert-NotMatch $source '\.Result\b' 'The UI must not block on Task.Result.'
Assert-NotMatch $source '\.Wait\s*\(' 'The UI must not block the movement-test thread.'
$runnerStart = $source.IndexOf('internal static class CoarsePathPlanningTestRunner')
Assert-True ($runnerStart -ge 0) 'Shared runner source must exist.'
$runnerSource = $source.Substring($runnerStart)
Assert-Match $runnerSource 'RunScenario[\s\S]*getCartLocation\s*\(' 'Fixed scenarios must read the current AMR pose.'
Assert-Match $runnerSource 'CoarsePathScenarioFactory\.Create\s*\(\s*scenario\s*,' 'Fixed scenarios must use the AMR-aware factory overload.'
Assert-Match $runnerSource 'AMR' 'The runner must expose AMR pose diagnostics.'
Assert-Match $runnerSource 'ArgumentException\("AMR' 'Invalid AMR input must be reported without starting planning.'
Assert-Match $runnerSource 'double\.IsNaN|double\.IsInfinity' 'The runner must reject non-finite AMR coordinates.'
Assert-Match $runnerSource 'DrawStatus\s*\([\s\S]*AmrPoseSnapshot' 'Result status must receive the frozen AMR snapshot.'
$inputFailureStart = $runnerSource.IndexOf('internal static void ShowInputFailure')
$inputFailureEnd = $runnerSource.IndexOf('private static void Finish', $inputFailureStart)
Assert-True ($inputFailureStart -ge 0 -and $inputFailureEnd -gt $inputFailureStart) 'Input failure renderer must be isolated before completion handling.'
$inputFailureSource = $runnerSource.Substring($inputFailureStart, $inputFailureEnd - $inputFailureStart)
Assert-Match $inputFailureSource 'Painter\.DrawText\s*\(' 'Invalid AMR input must be shown on the Painter status layer.'
$readmePath = Join-Path $PSScriptRoot '..\ParkrobTrajplanner\CoarsePath\README.md'
Assert-True (Test-Path -LiteralPath $readmePath) 'CoarsePath README must exist beside its module.'
$readme = Get-Content -Raw -Encoding UTF8 $readmePath
foreach ($requiredText in @(
'CoarsePathPlanningV1',
'CoarsePathPlanningTest',
'CreateManualGoalDemo',
'getCartLocation',
'mm',
'deg',
'rad',
'CancellationTokenSource',
'TestStop',
'NoFeasiblePath',
'IsGearSwitchPoint')) {
Assert-True ($readme.Contains($requiredText)) "P1 README must document $requiredText."
}
$readmeStructure = @(
'File Structure',
'Planning Data Flow',
'Build Status and Stop',
'Coordinates and Units',
'Minimal Call Example',
'Cache and SourceVersion',
'Detailed Usage Guide',
'P1 Manual Tests and Visualization',
'Common Errors',
'First-Version Limits',
'CoarsePathPlanningService.Plan(job, cancellationToken)',
'CoarsePathPlanningJob',
'PlanningGridMap',
'SourceVersion',
'CoarsePathPlanningV1',
'CancellationTokenSource',
'NoFeasiblePath',
'IsGearSwitchPoint',
'CreateManualObstacleDemo',
'ManualCoarsePathObstacle',
'manual-user-input',
'0-20',
'PathSearchElapsed',
'1.20 m',
'Reeds-Shepp',
'AxisAlignedRectangle',
'Create(CoarsePathTestScenario scenario, double amrXMillimeters',
'DetectionHeadingRadians',
'../Map/README.md'
)
foreach ($requiredText in $readmeStructure) {
Assert-True ($readme.Contains($requiredText)) "Restructured CoarsePath README must document $requiredText."
}
Write-Output 'Coarse path P1 UI source checks passed.'
@@ -1,121 +0,0 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-Near([double]$Expected, [double]$Actual, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt 0.000000001) {
throw "$Message Expected=$Expected Actual=$Actual"
}
}
function Assert-Throws([scriptblock]$Action, [string]$Message) {
try {
& $Action
}
catch {
return
}
throw $Message
}
function Get-RequiredType([string]$Name) {
return $assembly.GetType($Name, $true)
}
function Get-RequiredProperty($Type, [string]$Name) {
$property = $Type.GetProperty($Name, [Reflection.BindingFlags]'Instance,Public,NonPublic')
Assert-True ($null -ne $property) ("Missing property: " + $Name)
Assert-True (-not $property.CanWrite) ("Snapshot property must be get-only: " + $Name)
return $property
}
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$algorithms = $root + 'Algorithms.'
$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$snapshotType = Get-RequiredType ($algorithms + 'SmoothingOptionsSnapshot')
$inputType = Get-RequiredType ($algorithms + 'SmoothingAlgorithmInput')
$configurationType = Get-RequiredType ($root + 'PathSmoothingConfiguration')
$requestType = Get-RequiredType ($root + 'PathSmoothingRequest')
$preparedPathType = Get-RequiredType ($root + 'Processing.PreparedPath')
$mapType = Get-RequiredType ($mapping + 'PlanningGridMap')
$vehicleType = Get-RequiredType ($coarsePath + 'VehicleParameters')
$coarsePointType = Get-RequiredType ($coarsePath + 'CoarsePathPoint')
$pathSegmentType = Get-RequiredType ($coarsePath + 'PathSegment')
$snapshotConstructor = $snapshotType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null,
@($configurationType), $null)
Assert-True ($null -ne $snapshotConstructor) 'SmoothingOptionsSnapshot must be created from PathSmoothingConfiguration.'
$inputConstructor = $inputType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null,
@($preparedPathType, $mapType, $vehicleType, [double], [double], $snapshotType), $null)
Assert-True ($null -ne $inputConstructor) 'SmoothingAlgorithmInput must accept the immutable smoothing-options snapshot at its construction boundary.'
$optionNames = @(
'CubicBSplineEndpointTangentScale',
'BezierCornerHeadingThresholdRadians',
'BezierMaximumWindowLengthMeters',
'BezierHandleLengthRatio',
'QuinticKnotSpacingMeters',
'QuinticMinimumKnotSpacingMeters')
$optionProperties = @{}
foreach ($optionName in $optionNames) {
$optionProperties[$optionName] = Get-RequiredProperty $snapshotType $optionName
}
function New-Configuration {
return [Activator]::CreateInstance($configurationType)
}
function New-Snapshot($Configuration) {
return $snapshotConstructor.Invoke(@($Configuration))
}
# Request creation takes a configuration copy. Later mutations to either the source configuration
# or a configuration copy returned by the request must not alter the algorithm snapshot.
$configuration = New-Configuration
$configuration.CubicBSpline.EndpointTangentScale = [double]0.20
$configuration.LocalCubicBezier.CornerHeadingThresholdRadians = [double]0.40
$configuration.LocalCubicBezier.MaximumWindowLengthMeters = [double]0.80
$configuration.LocalCubicBezier.HandleLengthRatio = [double]0.25
$configuration.PiecewiseQuintic.KnotSpacingMeters = [double]0.60
$configuration.PiecewiseQuintic.MinimumKnotSpacingMeters = [double]0.15
$emptyCoarsePath = [Array]::CreateInstance($coarsePointType, 0)
$emptySegments = [Array]::CreateInstance($pathSegmentType, 0)
$request = [Activator]::CreateInstance($requestType, @($emptyCoarsePath, $emptySegments, $null, $null, $configuration))
$configuration.CubicBSpline.EndpointTangentScale = [double]0.90
$requestConfiguration = $request.Configuration
Assert-Near 0.20 $requestConfiguration.CubicBSpline.EndpointTangentScale 'Request configuration must remain independent from source-config mutations.'
$snapshot = New-Snapshot $requestConfiguration
$requestConfiguration.CubicBSpline.EndpointTangentScale = [double]0.70
Assert-Near 0.20 $optionProperties['CubicBSplineEndpointTangentScale'].GetValue($snapshot) 'Algorithm options must remain independent from request-configuration mutations.'
Assert-Near 0.40 $optionProperties['BezierCornerHeadingThresholdRadians'].GetValue($snapshot) 'Snapshot must preserve the request configuration values.'
Assert-Near 0.80 $optionProperties['BezierMaximumWindowLengthMeters'].GetValue($snapshot) 'Snapshot must preserve the request configuration values.'
Assert-Near 0.25 $optionProperties['BezierHandleLengthRatio'].GetValue($snapshot) 'Snapshot must preserve the request configuration values.'
Assert-Near 0.60 $optionProperties['QuinticKnotSpacingMeters'].GetValue($snapshot) 'Snapshot must preserve the request configuration values.'
Assert-Near 0.15 $optionProperties['QuinticMinimumKnotSpacingMeters'].GetValue($snapshot) 'Snapshot must preserve the request configuration values.'
function Assert-InvalidSnapshot([scriptblock]$Mutate, [string]$Message) {
$invalidConfiguration = New-Configuration
& $Mutate $invalidConfiguration
Assert-Throws { New-Snapshot $invalidConfiguration } $Message
}
Assert-InvalidSnapshot { param($c) $c.CubicBSpline.EndpointTangentScale = [double]::NaN } 'Non-finite B-spline tangent scale must be rejected before retry.'
Assert-InvalidSnapshot { param($c) $c.CubicBSpline.EndpointTangentScale = [double]0.0 } 'Non-positive B-spline tangent scale must be rejected before retry.'
Assert-InvalidSnapshot { param($c) $c.LocalCubicBezier.CornerHeadingThresholdRadians = [double]0.0 } 'A zero Bézier heading threshold must be rejected before retry.'
Assert-InvalidSnapshot { param($c) $c.LocalCubicBezier.CornerHeadingThresholdRadians = [Math]::PI + 0.0001 } 'A Bézier heading threshold above pi must be rejected before retry.'
Assert-InvalidSnapshot { param($c) $c.LocalCubicBezier.MaximumWindowLengthMeters = [double]::PositiveInfinity } 'A non-finite Bézier window length must be rejected before retry.'
Assert-InvalidSnapshot { param($c) $c.LocalCubicBezier.HandleLengthRatio = [double]0.0 } 'A non-positive Bézier handle ratio must be rejected before retry.'
Assert-InvalidSnapshot { param($c) $c.PiecewiseQuintic.KnotSpacingMeters = [double]0.0 } 'A non-positive quintic knot spacing must be rejected before retry.'
Assert-InvalidSnapshot { param($c) $c.PiecewiseQuintic.MinimumKnotSpacingMeters = [double]::NaN } 'A non-finite quintic minimum knot spacing must be rejected before retry.'
Assert-InvalidSnapshot { param($c) $c.PiecewiseQuintic.KnotSpacingMeters = [double]0.05; $c.PiecewiseQuintic.MinimumKnotSpacingMeters = [double]0.10 } 'Quintic knot spacing below the configured minimum must be rejected before retry.'
Write-Output 'Path smoothing algorithm-input checks passed.'
@@ -1,319 +0,0 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Near([double]$Expected, [double]$Actual, [double]$Tolerance, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt $Tolerance) {
throw "$Message Expected=$Expected Actual=$Actual Tolerance=$Tolerance"
}
}
function Get-RequiredType([string]$Name) {
return $assembly.GetType($Name, $true)
}
function Get-PropertyValue($Instance, [string]$Name) {
$property = $Instance.GetType().GetProperty($Name, [Reflection.BindingFlags]'Instance,Public,NonPublic')
Assert-True ($null -ne $property) ("Missing property: " + $Name)
return $property.GetValue($Instance)
}
function Assert-PointBitwiseEqual($Expected, $Actual, [string]$Message) {
foreach ($name in @('X', 'Y', 'ArcLength', 'Heading', 'UnwrappedHeading', 'BodyClearance')) {
$expectedBits = [BitConverter]::DoubleToInt64Bits([double]$Expected.$name)
$actualBits = [BitConverter]::DoubleToInt64Bits([double]$Actual.$name)
Assert-Equal $expectedBits $actualBits ($Message + ' ' + $name)
}
Assert-Equal $Expected.IsGearSwitchPoint $Actual.IsGearSwitchPoint ($Message + ' IsGearSwitchPoint')
Assert-Equal $Expected.Source.ToString() $Actual.Source.ToString() ($Message + ' Source')
}
function New-Point(
[double]$X,
[double]$Y,
[double]$ArcLength,
[double]$Heading,
[double]$BodyClearance = 0.10,
[bool]$IsGearSwitch = $false) {
return [Activator]::CreateInstance($pointType, @(
$X, $Y, $ArcLength, $Heading, $Heading, $BodyClearance, $IsGearSwitch, $anchor))
}
function New-DirectionSegment(
[int]$Index,
$Direction,
[object[]]$Points,
[bool]$StartsAtGearSwitch = $false,
[bool]$EndsAtGearSwitch = $false) {
$typedPoints = [Array]::CreateInstance($pointType, $Points.Count)
for ($pointIndex = 0; $pointIndex -lt $Points.Count; $pointIndex++) {
$typedPoints.SetValue($Points[$pointIndex], $pointIndex)
}
return [Activator]::CreateInstance($segmentType, @(
$Index, $Direction, $typedPoints, $StartsAtGearSwitch, $EndsAtGearSwitch))
}
function New-EmptyMap {
$request = [Activator]::CreateInstance($mapRequestType)
$request.Bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]5000, [single]0, [single]5000))
$request.ResolutionMm = [single]50
$request.AllowExplicitEmptyMap = $true
$map = [Activator]::CreateInstance($mapFactoryType).Create($request).Map
Assert-True ($null -ne $map) 'Bézier test must create an explicit empty planning map.'
return $map
}
function New-AlgorithmInput(
[object[]]$Segments,
[double]$ReserveMeters = 0.02,
[double]$CornerThresholdRadians = ([Math]::PI / 18.0),
[double]$MaximumWindowLengthMeters = 0.60,
[double]$HandleLengthRatio = (1.0 / 3.0)) {
$typedSegments = [Array]::CreateInstance($segmentType, $Segments.Count)
for ($index = 0; $index -lt $Segments.Count; $index++) {
$typedSegments.SetValue($Segments[$index], $index)
}
$preparedPath = [Activator]::CreateInstance($preparedPathType, [object[]]@(,$typedSegments))
$vehicle = [Activator]::CreateInstance($vehicleType)
$vehicle.LengthMeters = [double]0.20
$vehicle.WidthMeters = [double]0.20
$vehicle.SafetyMarginMeters = [double]0.0
$vehicle.MaximumCurvaturePerMeter = [double]100.0
$vehicle.MinimumTurningRadiusMeters = [double]0.01
$configuration = [Activator]::CreateInstance($configurationType)
$configuration.LocalCubicBezier.CornerHeadingThresholdRadians = $CornerThresholdRadians
$configuration.LocalCubicBezier.MaximumWindowLengthMeters = $MaximumWindowLengthMeters
$configuration.LocalCubicBezier.HandleLengthRatio = $HandleLengthRatio
$options = $optionsConstructor.Invoke(@($configuration))
return $inputConstructor.Invoke(@($preparedPath, (New-EmptyMap), $vehicle, [double]0.05, $ReserveMeters, $options))
}
function Invoke-Candidate(
[object[]]$Segments,
[double]$ReserveMeters = 0.02,
[double]$CornerThresholdRadians = ([Math]::PI / 18.0),
[double]$MaximumWindowLengthMeters = 0.60,
[double]$HandleLengthRatio = (1.0 / 3.0),
[double]$Strength = 1.0) {
return $smoothMethod.Invoke($smoother, @(
(New-AlgorithmInput $Segments $ReserveMeters $CornerThresholdRadians $MaximumWindowLengthMeters $HandleLengthRatio),
$Strength,
[Threading.CancellationToken]::None))
}
function Invoke-Smoothing(
[object[]]$Segments,
[double]$ReserveMeters = 0.02,
[double]$CornerThresholdRadians = ([Math]::PI / 18.0),
[double]$MaximumWindowLengthMeters = 0.60,
[double]$HandleLengthRatio = (1.0 / 3.0),
[double]$Strength = 1.0) {
$candidate = Invoke-Candidate $Segments $ReserveMeters $CornerThresholdRadians $MaximumWindowLengthMeters $HandleLengthRatio $Strength
Assert-True (Get-PropertyValue $candidate 'Succeeded') 'Bézier smoothing must produce a candidate for the deterministic fixture.'
return @(Get-PropertyValue $candidate 'Segments')
}
function Get-InterpolatedRunCount($Points) {
$runCount = 0
$inRun = $false
foreach ($point in $Points) {
$interpolated = $point.Source.ToString() -eq 'Interpolated'
if ($interpolated -and -not $inRun) { $runCount++ }
$inRun = $interpolated
}
return $runCount
}
function Get-PointAtArcLength($Points, [double]$ArcLength) {
foreach ($point in $Points) {
if ([BitConverter]::DoubleToInt64Bits([double]$point.ArcLength) -eq
[BitConverter]::DoubleToInt64Bits($ArcLength)) {
return $point
}
}
throw "No output point found at local arc length $ArcLength."
}
function Get-FirstInterpolatedPoint($Points) {
foreach ($point in $Points) {
if ($point.Source.ToString() -eq 'Interpolated') { return $point }
}
throw 'Expected an interpolated Bézier point.'
}
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$processing = $root + 'Processing.'
$algorithms = $root + 'Algorithms.'
$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$smootherType = Get-RequiredType ($algorithms + 'LocalCubicBezierSmoother')
$pointType = Get-RequiredType ($processing + 'SmoothingPoint2D')
$segmentType = Get-RequiredType ($processing + 'PreparedDirectionSegment')
$preparedPathType = Get-RequiredType ($processing + 'PreparedPath')
$inputType = Get-RequiredType ($algorithms + 'SmoothingAlgorithmInput')
$optionsType = Get-RequiredType ($algorithms + 'SmoothingOptionsSnapshot')
$configurationType = Get-RequiredType ($root + 'PathSmoothingConfiguration')
$vehicleType = Get-RequiredType ($coarsePath + 'VehicleParameters')
$directionType = Get-RequiredType ($coarsePath + 'TravelDirection')
$sourceType = Get-RequiredType ($root + 'SmoothedPathPointSource')
$candidateStatusType = Get-RequiredType ($algorithms + 'SmoothingCandidateStatus')
$boundsType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.MapBoundsMm'
$mapType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningGridMap'
$mapRequestType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapRequest'
$mapFactoryType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapFactory'
$inputConstructor = $inputType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null,
@($preparedPathType, $mapType, $vehicleType, [double], [double], $optionsType), $null)
Assert-True ($null -ne $inputConstructor) 'Bézier tests must construct algorithm input with immutable option values.'
$optionsConstructor = $optionsType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null, @($configurationType), $null)
Assert-True ($null -ne $optionsConstructor) 'Bézier tests must create immutable option snapshots.'
$smoother = [Activator]::CreateInstance($smootherType, $true)
$smoothMethod = $smootherType.GetMethod('Smooth', [Reflection.BindingFlags]'Instance,Public')
Assert-True ($null -ne $smoothMethod) 'LocalCubicBezierSmoother must implement the internal smoother contract.'
Assert-Equal 'LocalCubicBezier' $smoother.Method.ToString() 'Bézier smoother must identify its public smoothing method.'
$forward = [Enum]::Parse($directionType, 'Forward')
$reverse = [Enum]::Parse($directionType, 'Reverse')
$anchor = [Enum]::Parse($sourceType, 'Anchor')
# A straight path must not create a local Bézier window or alter any sample.
$straightSource = @(
(New-Point 0.0 0.0 0.0 0.0),
(New-Point 0.1 0.0 0.1 0.0),
(New-Point 0.2 0.0 0.2 0.0),
(New-Point 0.3 0.0 0.3 0.0))
$straightOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $straightSource)))[0].Points
Assert-Equal 0 (Get-InterpolatedRunCount $straightOutput) 'A straight line must create no Bézier replacement window.'
Assert-Equal $straightSource.Count $straightOutput.Count 'A straight line must retain its original sample count.'
for ($index = 0; $index -lt $straightSource.Count; $index++) {
Assert-PointBitwiseEqual $straightSource[$index] $straightOutput[$index] 'Straight samples must remain bitwise unchanged.'
}
# One corner is one local replacement: only the corner sample is evaluated while the window endpoints stay fixed.
$cornerSource = @(
(New-Point 0.0 0.0 0.0 0.0),
(New-Point 0.1 0.0 0.1 0.0),
(New-Point 0.2 0.0 0.2 0.0),
(New-Point 0.2 0.1 0.3 ([Math]::PI / 2.0)),
(New-Point 0.2 0.2 0.4 ([Math]::PI / 2.0)))
$cornerOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $cornerSource)))[0].Points
Assert-Equal 1 (Get-InterpolatedRunCount $cornerOutput) 'One corner must produce exactly one contiguous Bézier replacement.'
Assert-Equal $cornerSource.Count $cornerOutput.Count 'One local replacement must preserve the segment sampling topology.'
Assert-True (($cornerOutput[2].X -ne $cornerSource[2].X) -or ($cornerOutput[2].Y -ne $cornerSource[2].Y)) 'The corner sample must be replaced by cubic Bézier geometry.'
Assert-PointBitwiseEqual $cornerSource[1] $cornerOutput[1] 'Bézier entry anchor must remain fixed.'
Assert-PointBitwiseEqual $cornerSource[3] $cornerOutput[3] 'Bézier exit anchor must remain fixed.'
$cornerChordLength = [Math]::Sqrt(
[Math]::Pow($cornerSource[3].X - $cornerSource[1].X, 2.0) +
[Math]::Pow($cornerSource[3].Y - $cornerSource[1].Y, 2.0))
$cornerHandleLength = $cornerChordLength / 3.0
$expectedCornerX =
0.125 * $cornerSource[1].X +
0.375 * ($cornerSource[1].X + $cornerHandleLength) +
0.375 * $cornerSource[3].X +
0.125 * $cornerSource[3].X
$expectedCornerY =
0.125 * $cornerSource[1].Y +
0.375 * $cornerSource[1].Y +
0.375 * ($cornerSource[3].Y - $cornerHandleLength) +
0.125 * $cornerSource[3].Y
$cornerInterpolated = Get-FirstInterpolatedPoint $cornerOutput
Assert-Near $expectedCornerX $cornerInterpolated.X 0.000000000001 'Bézier control handles must use the local endpoint chord length for X geometry.'
Assert-Near $expectedCornerY $cornerInterpolated.Y 0.000000000001 'Bézier control handles must use the local endpoint chord length for Y geometry.'
# Adjacent corner windows touch/overlap and must become one merged cubic replacement, not two sequential fits.
$overlappingSource = @(
(New-Point 0.0 0.0 0.0 0.0),
(New-Point 0.1 0.0 0.1 0.0),
(New-Point 0.2 0.0 0.2 0.0),
(New-Point 0.2 0.1 0.3 ([Math]::PI / 2.0)),
(New-Point 0.3 0.1 0.4 0.0),
(New-Point 0.4 0.1 0.5 0.0))
$overlappingOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $overlappingSource)))[0].Points
Assert-Equal 1 (Get-InterpolatedRunCount $overlappingOutput) 'Touching local corner windows must merge into exactly one Bézier replacement.'
Assert-PointBitwiseEqual $overlappingSource[1] $overlappingOutput[1] 'Merged Bézier entry anchor must remain fixed.'
Assert-PointBitwiseEqual $overlappingSource[4] $overlappingOutput[4] 'Merged Bézier exit anchor must remain fixed.'
Assert-True (($overlappingOutput[2].X -ne $overlappingSource[2].X) -or ($overlappingOutput[2].Y -ne $overlappingSource[2].Y)) 'Merged window must replace the first interior corner sample.'
Assert-True (($overlappingOutput[3].X -ne $overlappingSource[3].X) -or ($overlappingOutput[3].Y -ne $overlappingSource[3].Y)) 'Merged window must replace the second interior corner sample.'
# Safe bounded policy: decline an entire connected set when its merged interval exceeds the cap.
# The two candidate windows below are each 0.20 m, but their merged 0.30 m interval must not
# produce one over-length curve or be split into new unrequested joins.
$overCapMergedOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $overlappingSource)) 0.02 ([Math]::PI / 18.0) 0.20)[0].Points
Assert-Equal 0 (Get-InterpolatedRunCount $overCapMergedOutput) 'An oversized connected Bézier window set must be declined instead of emitting an over-cap replacement.'
for ($index = 0; $index -lt $overlappingSource.Count; $index++) {
Assert-PointBitwiseEqual $overlappingSource[$index] $overCapMergedOutput[$index] 'Declining an oversized connected set must preserve its anchors.'
}
# Samples outside a local window must remain bitwise unchanged rather than be globally re-fit.
$isolatedSource = @(
(New-Point 0.0 0.0 0.0 0.0),
(New-Point 0.1 0.0 0.1 0.0),
(New-Point 0.2 0.0 0.2 0.0),
(New-Point 0.3 0.0 0.3 0.0),
(New-Point 0.3 0.1 0.4 ([Math]::PI / 2.0)),
(New-Point 0.3 0.2 0.5 ([Math]::PI / 2.0)),
(New-Point 0.3 0.3 0.6 ([Math]::PI / 2.0)))
$isolatedOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $isolatedSource)))[0].Points
foreach ($index in @(0, 1, 2, 5, 6)) {
Assert-PointBitwiseEqual $isolatedSource[$index] (Get-PointAtArcLength $isolatedOutput $isolatedSource[$index].ArcLength) 'Samples outside a Bézier window must remain bitwise unchanged.'
}
# Direction segments stay independent; segment endpoints and the gear-switch anchor are fixed.
$reverseSource = @(
(New-Point 0.2 0.2 0.0 ([Math]::PI / 2.0) 0.10 $true),
(New-Point 0.2 0.1 0.1 ([Math]::PI / 2.0)),
(New-Point 0.2 0.0 0.2 ([Math]::PI / 2.0)))
$switchOutput = @(Invoke-Smoothing @(
(New-DirectionSegment 0 $forward $cornerSource $false $true),
(New-DirectionSegment 1 $reverse $reverseSource $true $false)))
Assert-Equal 2 $switchOutput.Count 'Bézier smoothing must retain separate forward and reverse direction segments.'
Assert-True $switchOutput[0].EndsAtGearSwitch 'The forward segment must retain its gear-switch boundary flag.'
Assert-True $switchOutput[1].StartsAtGearSwitch 'The reverse segment must retain its gear-switch boundary flag.'
Assert-PointBitwiseEqual $cornerSource[0] $switchOutput[0].Points[0] 'Segment start endpoint must remain fixed.'
Assert-PointBitwiseEqual $cornerSource[$cornerSource.Count - 1] $switchOutput[0].Points[$switchOutput[0].Points.Count - 1] 'Segment end endpoint must remain fixed.'
Assert-PointBitwiseEqual $reverseSource[0] $switchOutput[1].Points[0] 'Gear-switch point must remain fixed.'
# The immutable options each change only their own local behavior.
$thresholdOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $cornerSource)) 0.02 1.70)[0].Points
Assert-Equal 0 (Get-InterpolatedRunCount $thresholdOutput) 'A non-default heading threshold above the corner angle must suppress only corner detection.'
$windowOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $cornerSource)) 0.02 ([Math]::PI / 18.0) 0.15)[0].Points
Assert-Equal 0 (Get-InterpolatedRunCount $windowOutput) 'A non-default maximum window shorter than the local connection must suppress only that window.'
$shortHandleOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $cornerSource)) 0.02 ([Math]::PI / 18.0) 0.60 0.10)[0].Points
$longHandleOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $cornerSource)) 0.02 ([Math]::PI / 18.0) 0.60 0.60)[0].Points
Assert-Equal 1 (Get-InterpolatedRunCount $shortHandleOutput) 'Changing handle ratio must not change detected window topology.'
Assert-Equal 1 (Get-InterpolatedRunCount $longHandleOutput) 'Changing handle ratio must not change detected window topology.'
$shortHandlePoint = Get-FirstInterpolatedPoint $shortHandleOutput
$longHandlePoint = Get-FirstInterpolatedPoint $longHandleOutput
Assert-True (($shortHandlePoint.X -ne $longHandlePoint.X) -or ($shortHandlePoint.Y -ne $longHandlePoint.Y)) 'A non-default handle ratio must change only the local Bézier geometry.'
# Parameter-matched local arc-length reference comparison must reject excess displacement as retryable and publish no geometry.
$infeasible = Invoke-Candidate @((New-DirectionSegment 0 $forward $cornerSource)) 0.095
Assert-Equal 'RetryableInfeasible' (Get-PropertyValue $infeasible 'Status').ToString() 'Exceeded local arc-length displacement must be retryable, not terminal.'
Assert-True (-not (Get-PropertyValue $infeasible 'Succeeded')) 'An infeasible Bézier curve must not be executable.'
Assert-Equal 0 (Get-PropertyValue $infeasible 'Segments').Count 'A retryable Bézier infeasibility must publish no executable geometry.'
# This nonuniform, offset window evaluates its only interior point at t=0.25 and local s=6.
# A wrong global/index mapping would instead compare to s=5 and accept the 0.50 m clearance;
# the required local-arc reference at s=6 must reject the roughly 0.65 m displacement.
$nonuniformOffsetSource = @(
(New-Point 0.0 0.0 0.0 0.0 0.50),
(New-Point 1.0 0.0 4.0 0.0 0.50),
(New-Point 2.0 0.0 5.0 0.0 0.50),
(New-Point 3.0 0.0 6.0 0.0 0.50),
(New-Point 3.0 1.0 9.0 ([Math]::PI / 2.0) 0.50),
(New-Point 3.0 2.0 20.0 ([Math]::PI / 2.0) 0.50))
$nonuniformOffsetInfeasible = Invoke-Candidate @((New-DirectionSegment 0 $forward $nonuniformOffsetSource)) 0.0 ([Math]::PI / 18.0) 5.0
Assert-Equal 'RetryableInfeasible' (Get-PropertyValue $nonuniformOffsetInfeasible 'Status').ToString() 'A nonuniform offset window must use local arc-length mapping for retryable clearance rejection.'
Assert-True (-not (Get-PropertyValue $nonuniformOffsetInfeasible 'Succeeded')) 'The nonuniform local-arc infeasibility must not be executable.'
Assert-Equal 0 (Get-PropertyValue $nonuniformOffsetInfeasible 'Segments').Count 'The nonuniform local-arc infeasibility must publish no geometry.'
Write-Output 'Path smoothing local cubic Bézier checks passed.'
@@ -1,286 +0,0 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Near([double]$Expected, [double]$Actual, [double]$Tolerance, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt $Tolerance) {
throw "$Message Expected=$Expected Actual=$Actual Tolerance=$Tolerance"
}
}
function Get-RequiredType([string]$Name) {
return $assembly.GetType($Name, $true)
}
function Get-PropertyValue($Instance, [string]$Name) {
$property = $Instance.GetType().GetProperty($Name, [Reflection.BindingFlags]'Instance,Public,NonPublic')
Assert-True ($null -ne $property) ("Missing property: " + $Name)
return $property.GetValue($Instance)
}
function New-Point(
[double]$X,
[double]$Y,
[double]$ArcLength,
[double]$Heading,
[double]$BodyClearance,
[bool]$IsGearSwitch = $false) {
return [Activator]::CreateInstance($pointType, @(
$X, $Y, $ArcLength, $Heading, $Heading, $BodyClearance, $IsGearSwitch, $anchor))
}
function New-DirectionSegment(
[int]$Index,
$Direction,
[object[]]$Points,
[bool]$StartsAtGearSwitch = $false,
[bool]$EndsAtGearSwitch = $false) {
$typedPoints = [Array]::CreateInstance($pointType, $Points.Count)
for ($pointIndex = 0; $pointIndex -lt $Points.Count; $pointIndex++) {
$typedPoints.SetValue($Points[$pointIndex], $pointIndex)
}
return [Activator]::CreateInstance($segmentType, @(
$Index, $Direction, $typedPoints, $StartsAtGearSwitch, $EndsAtGearSwitch))
}
function New-EmptyMap {
$request = [Activator]::CreateInstance($mapRequestType)
$request.Bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]5000, [single]0, [single]5000))
$request.ResolutionMm = [single]50
$request.AllowExplicitEmptyMap = $true
$map = [Activator]::CreateInstance($mapFactoryType).Create($request).Map
Assert-True ($null -ne $map) 'B-spline test must create an explicit empty planning map.'
return $map
}
function New-AlgorithmInput(
[object[]]$Segments,
[double]$ReserveMeters,
[double]$EndpointTangentScale = (1.0 / 3.0)) {
$typedSegments = [Array]::CreateInstance($segmentType, $Segments.Count)
for ($index = 0; $index -lt $Segments.Count; $index++) {
$typedSegments.SetValue($Segments[$index], $index)
}
$preparedPath = [Activator]::CreateInstance($preparedPathType, [object[]]@(,$typedSegments))
$vehicle = [Activator]::CreateInstance($vehicleType)
$vehicle.LengthMeters = [double]0.20
$vehicle.WidthMeters = [double]0.20
$vehicle.SafetyMarginMeters = [double]0.0
$vehicle.MaximumCurvaturePerMeter = [double]100.0
$vehicle.MinimumTurningRadiusMeters = [double]0.01
$configuration = [Activator]::CreateInstance($configurationType)
$configuration.CubicBSpline.EndpointTangentScale = $EndpointTangentScale
$options = $optionsConstructor.Invoke(@($configuration))
return $inputConstructor.Invoke(@($preparedPath, (New-EmptyMap), $vehicle, [double]0.05, $ReserveMeters, $options))
}
function Invoke-Candidate(
[object[]]$Segments,
[double]$ReserveMeters,
[double]$Strength = 1.0,
[double]$EndpointTangentScale = (1.0 / 3.0)) {
return $smoothMethod.Invoke($smoother, @(
(New-AlgorithmInput $Segments $ReserveMeters $EndpointTangentScale), $Strength, [Threading.CancellationToken]::None))
}
function Invoke-Smoothing(
[object[]]$Segments,
[double]$ReserveMeters,
[double]$Strength = 1.0,
[double]$EndpointTangentScale = (1.0 / 3.0)) {
$candidate = Invoke-Candidate $Segments $ReserveMeters $Strength $EndpointTangentScale
Assert-True (Get-PropertyValue $candidate 'Succeeded') 'B-spline smoothing must produce a candidate for the deterministic fixture.'
return @(Get-PropertyValue $candidate 'Segments')
}
function Get-PointDistance($Left, $Right) {
$deltaX = $Left.X - $Right.X
$deltaY = $Left.Y - $Right.Y
return [Math]::Sqrt($deltaX * $deltaX + $deltaY * $deltaY)
}
function Get-DistanceToSegment($Point, $Left, $Right) {
$deltaX = $Right.X - $Left.X
$deltaY = $Right.Y - $Left.Y
$lengthSquared = $deltaX * $deltaX + $deltaY * $deltaY
if ($lengthSquared -le 0.0) { return Get-PointDistance $Point $Left }
$projection = (($Point.X - $Left.X) * $deltaX + ($Point.Y - $Left.Y) * $deltaY) / $lengthSquared
$projection = [Math]::Max(0.0, [Math]::Min(1.0, $projection))
$closest = New-Object PSObject -Property @{
X = $Left.X + $projection * $deltaX
Y = $Left.Y + $projection * $deltaY
}
return Get-PointDistance $Point $closest
}
function Get-DistanceToPolyline($Point, [object[]]$SourcePoints) {
$minimum = [double]::PositiveInfinity
for ($index = 1; $index -lt $SourcePoints.Count; $index++) {
$minimum = [Math]::Min($minimum, (Get-DistanceToSegment $Point $SourcePoints[$index - 1] $SourcePoints[$index]))
}
return $minimum
}
function Get-TravelAngle($Left, $Right) {
return [Math]::Atan2($Right.Y - $Left.Y, $Right.X - $Left.X)
}
function Get-AngleDifference([double]$Left, [double]$Right) {
$difference = $Left - $Right
while ($difference -gt [Math]::PI) { $difference -= 2.0 * [Math]::PI }
while ($difference -lt -[Math]::PI) { $difference += 2.0 * [Math]::PI }
return [Math]::Abs($difference)
}
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$processing = $root + 'Processing.'
$algorithms = $root + 'Algorithms.'
$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$smootherType = Get-RequiredType ($algorithms + 'CubicBSplineSmoother')
$pointType = Get-RequiredType ($processing + 'SmoothingPoint2D')
$segmentType = Get-RequiredType ($processing + 'PreparedDirectionSegment')
$preparedPathType = Get-RequiredType ($processing + 'PreparedPath')
$inputType = Get-RequiredType ($algorithms + 'SmoothingAlgorithmInput')
$optionsType = Get-RequiredType ($algorithms + 'SmoothingOptionsSnapshot')
$interpolatorType = Get-RequiredType ($processing + 'PathReferenceInterpolator')
$configurationType = Get-RequiredType ($root + 'PathSmoothingConfiguration')
$vehicleType = Get-RequiredType ($coarsePath + 'VehicleParameters')
$directionType = Get-RequiredType ($coarsePath + 'TravelDirection')
$sourceType = Get-RequiredType ($root + 'SmoothedPathPointSource')
$boundsType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.MapBoundsMm'
$mapType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningGridMap'
$mapRequestType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapRequest'
$mapFactoryType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapFactory'
$inputConstructor = $inputType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null,
@($preparedPathType, $mapType, $vehicleType, [double], [double], $optionsType), $null)
Assert-True ($null -ne $inputConstructor) 'Algorithm input must carry immutable smoothing options and the minimum clearance reserve for per-anchor movement limits.'
$optionsConstructor = $optionsType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null, @($configurationType), $null)
Assert-True ($null -ne $optionsConstructor) 'B-spline tests must create an immutable options snapshot.'
$interpolateMethod = $interpolatorType.GetMethod('TryInterpolateByArcLength', [Reflection.BindingFlags]'Static,Public,NonPublic')
Assert-True ($null -ne $interpolateMethod) 'PathReferenceInterpolator must expose arc-length interpolation.'
$smoother = [Activator]::CreateInstance($smootherType, $true)
$smoothMethod = $smootherType.GetMethod('Smooth', [Reflection.BindingFlags]'Instance,Public')
Assert-True ($null -ne $smoothMethod) 'CubicBSplineSmoother must implement the internal smoother contract.'
Assert-Equal 'CubicBSpline' $smoother.Method.ToString() 'B-spline smoother must identify its public smoothing method.'
$forward = [Enum]::Parse($directionType, 'Forward')
$reverse = [Enum]::Parse($directionType, 'Reverse')
$anchor = [Enum]::Parse($sourceType, 'Anchor')
# Arc-length interpolation must bracket by local arc rather than by sample index.
$nonUniformPoints = [Array]::CreateInstance($pointType, 4)
$nonUniformPoints.SetValue((New-Point 0.0 0.0 0.000 0.0 1.0), 0)
$nonUniformPoints.SetValue((New-Point 5.0 0.0 0.050 0.1 0.9), 1)
$nonUniformPoints.SetValue((New-Point 10.0 0.0 0.100 0.2 0.8), 2)
$nonUniformPoints.SetValue((New-Point 20.0 0.0 0.125 0.3 0.7), 3)
$interpolateArguments = [object[]]@($nonUniformPoints, [double]0.1125, $null, $null)
Assert-True $interpolateMethod.Invoke($null, $interpolateArguments) 'Arc-length interpolation must accept a target within the final non-uniform interval.'
$arcReference = $interpolateArguments[2]
Assert-Near 15.0 $arcReference.X 0.000000001 'Target arc length 0.1125 must lie halfway through the final 0.100-0.125 interval, independent of point count.'
Assert-Near 0.1125 $arcReference.ArcLength 0.000000001 'Arc-length interpolation must preserve the requested target arc length.'
Assert-Near 0.25 $arcReference.Heading 0.000000001 'Arc-length interpolation must linearly interpolate heading.'
Assert-Near 0.75 $arcReference.BodyClearance 0.000000001 'Arc-length interpolation must linearly interpolate clearance.'
# Straight samples are returned exactly, so a straight is never distorted or densified.
$straightSource = @(
(New-Point 0.0 0.0 0.0 0.0 0.03),
(New-Point 1.0 0.0 1.0 0.0 0.03),
(New-Point 2.0 0.0 2.0 0.0 0.03),
(New-Point 3.0 0.0 3.0 0.0 0.03))
$straightResult = Invoke-Smoothing @((New-DirectionSegment 0 $forward $straightSource)) 0.02
Assert-Equal 1 $straightResult.Count 'A single direction segment must produce exactly one candidate segment.'
Assert-Equal $straightSource.Count $straightResult[0].Points.Count 'A straight must retain its original samples.'
for ($index = 0; $index -lt $straightSource.Count; $index++) {
Assert-Near $straightSource[$index].X $straightResult[0].Points[$index].X 0.0 'Straight X coordinates must remain exact.'
Assert-Near $straightSource[$index].Y $straightResult[0].Points[$index].Y 0.0 'Straight Y coordinates must remain exact.'
}
# A five-anchor corner uses the preprocessor's 0.05 m sampling scale. It must retain exact endpoint poses,
# follow endpoint travel tangents, turn continuously, and stay within the per-anchor clearance reserve radius.
$cornerSource = @(
(New-Point 0.0 0.0 0.0 0.0 0.08),
(New-Point 0.05 0.0 0.05 0.0 0.08),
(New-Point 0.10 0.0 0.10 0.0 0.08),
(New-Point 0.10 0.05 0.15 ([Math]::PI / 2.0) 0.08),
(New-Point 0.10 0.10 0.20 ([Math]::PI / 2.0) 0.08))
$cornerResult = Invoke-Smoothing @((New-DirectionSegment 0 $forward $cornerSource)) 0.02
$cornerPoints = @($cornerResult[0].Points)
Assert-True ($cornerPoints.Count -gt $cornerSource.Count) 'A non-straight B-spline candidate must provide sampled curve geometry.'
$cornerStart = $cornerPoints[0]
$cornerEnd = $cornerPoints[$cornerPoints.Count - 1]
Assert-Near $cornerSource[0].X $cornerStart.X 0.0 'B-spline start X must be exact.'
Assert-Near $cornerSource[0].Y $cornerStart.Y 0.0 'B-spline start Y must be exact.'
Assert-Near $cornerSource[$cornerSource.Count - 1].X $cornerEnd.X 0.0 'B-spline end X must be exact.'
Assert-Near $cornerSource[$cornerSource.Count - 1].Y $cornerEnd.Y 0.0 'B-spline end Y must be exact.'
Assert-Near 0.0 (Get-AngleDifference (Get-TravelAngle $cornerPoints[0] $cornerPoints[1]) 0.0) 0.02 'B-spline start travel tangent must follow the supplied forward heading.'
Assert-Near 0.0 (Get-AngleDifference (Get-TravelAngle $cornerPoints[$cornerPoints.Count - 2] $cornerPoints[$cornerPoints.Count - 1]) ([Math]::PI / 2.0)) 0.02 'B-spline end travel tangent must follow the supplied forward heading.'
for ($index = 2; $index -lt $cornerPoints.Count; $index++) {
$previousAngle = Get-TravelAngle $cornerPoints[$index - 2] $cornerPoints[$index - 1]
$currentAngle = Get-TravelAngle $cornerPoints[$index - 1] $cornerPoints[$index]
Assert-True ((Get-AngleDifference $previousAngle $currentAngle) -lt 0.08) 'B-spline corner samples must turn without a tangent discontinuity.'
}
# Endpoint tangent scale is an immutable option and must influence the clamped B-spline endpoint handle.
$shortHandlePoints = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $cornerSource)) 0.02 1.0 0.20)[0].Points
$longHandlePoints = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $cornerSource)) 0.02 1.0 0.60)[0].Points
Assert-True (($longHandlePoints[2].X - $shortHandlePoints[2].X) -gt 0.0001) 'A custom endpoint tangent scale must change the B-spline start handle and early curve samples.'
# Every evaluated point may deviate only by BodyClearance - reserve, never by raw BodyClearance.
$allowedRadius = 0.06
foreach ($point in $cornerPoints) {
Assert-True ((Get-DistanceToPolyline $point $cornerSource) -le ($allowedRadius + 0.000000001)) 'Every B-spline displacement must stay inside the per-anchor clearance reserve radius.'
}
# A 0.035 m reserve radius must reject this corner by its parameter-matched source reference,
# even though the candidate's nearest-polyline distance is smaller than 0.035 m.
$parameterMatchedReserveCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $cornerSource)) 0.045
Assert-True (-not (Get-PropertyValue $parameterMatchedReserveCandidate 'Succeeded')) 'A medium reserve must reject B-spline geometry that exceeds its parameter-matched movement radius.'
# A tight reserve may not publish an evaluated B-spline that leaves its 0.005 m movement radius.
$tightReserveCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $cornerSource)) 0.075
# A tight endpoint circle that cannot meet the heading=0.2 rad tangent ray must fail, not silently rotate the handle.
$misalignedHeadingSource = @(
(New-Point 0.0 0.0 0.0 0.2 0.08),
(New-Point 0.05 0.0 0.05 0.0 0.08),
(New-Point 0.10 0.0 0.10 0.0 0.08),
(New-Point 0.10 0.05 0.15 ([Math]::PI / 2.0) 0.08),
(New-Point 0.10 0.10 0.20 ([Math]::PI / 2.0) 0.08))
$misalignedHeadingCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $misalignedHeadingSource)) 0.075
$requiredFailures = New-Object System.Collections.Generic.List[string]
if (Get-PropertyValue $tightReserveCandidate 'Succeeded') {
[void]$requiredFailures.Add('A tight reserve published an evaluated candidate outside its permitted movement radius.')
}
if (Get-PropertyValue $misalignedHeadingCandidate 'Succeeded') {
[void]$requiredFailures.Add('A tight reserve silently accepted an endpoint handle that cannot follow the supplied travel tangent.')
}
Assert-Equal 0 $requiredFailures.Count ([string]::Join(' ', $requiredFailures))
# Adjacent direction segments retain their duplicated switch pose and independent topology; no fit may cross the switch.
$reverseSource = @(
(New-Point 0.10 0.10 0.0 ([Math]::PI / 2.0) 0.08 $true),
(New-Point 0.10 0.05 0.05 ([Math]::PI / 2.0) 0.08),
(New-Point 0.10 0.0 0.10 ([Math]::PI / 2.0) 0.08))
$switchResult = Invoke-Smoothing @(
(New-DirectionSegment 0 $forward $cornerSource $false $true),
(New-DirectionSegment 1 $reverse $reverseSource $true $false)) 0.02
Assert-Equal 2 $switchResult.Count 'B-spline smoothing must preserve each direction segment boundary.'
Assert-True $switchResult[0].EndsAtGearSwitch 'The forward segment must retain its gear-switch boundary flag.'
Assert-True $switchResult[1].StartsAtGearSwitch 'The reverse segment must retain its gear-switch boundary flag.'
$switchLeft = $switchResult[0].Points[$switchResult[0].Points.Count - 1]
$switchRight = $switchResult[1].Points[0]
Assert-Near $switchLeft.X $switchRight.X 0.0 'B-spline smoothing must retain the duplicated gear-switch X pose.'
Assert-Near $switchLeft.Y $switchRight.Y 0.0 'B-spline smoothing must retain the duplicated gear-switch Y pose.'
Write-Output 'Path smoothing cubic B-spline checks passed.'
@@ -1,230 +0,0 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Near([double]$Expected, [double]$Actual, [double]$Tolerance, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt $Tolerance) {
throw "$Message Expected=$Expected Actual=$Actual Tolerance=$Tolerance"
}
}
function Get-RequiredType([string]$Name) {
return $assembly.GetType($Name, $true)
}
function New-Map {
$mapRequest = [Activator]::CreateInstance($mapRequestType)
$mapRequest.Bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]5000, [single]0, [single]5000))
$mapRequest.ResolutionMm = [single]50
$mapRequest.AllowExplicitEmptyMap = $true
$map = [Activator]::CreateInstance($mapFactoryType).Create($mapRequest).Map
Assert-True ($null -ne $map) 'Comparison test must create a planning map.'
return $map
}
function New-Vehicle {
$vehicle = [Activator]::CreateInstance($vehicleType)
$vehicle.LengthMeters = [double]0.20
$vehicle.WidthMeters = [double]0.20
$vehicle.SafetyMarginMeters = [double]0.0
$vehicle.MaximumCurvaturePerMeter = [double]100.0
return $vehicle
}
function New-CoarsePoint([double]$X, [double]$Y, [double]$ArcLength) {
return [Activator]::CreateInstance($coarsePointType, @(
$X, $Y, [double]0.0, [double]0.0, $ArcLength, $forward,
[double]0.0, [double]1.0, $false, $coarseAnchor))
}
function New-SmoothingRequest {
$points = [Array]::CreateInstance($coarsePointType, 2)
$points.SetValue((New-CoarsePoint 0.5 0.5 0.0), 0)
$points.SetValue((New-CoarsePoint 1.5 0.5 1.0), 1)
$segments = [Array]::CreateInstance($coarseSegmentType, 1)
$segments.SetValue([Activator]::CreateInstance($coarseSegmentType, @(0, $forward, 0, 1, $false, $false)), 0)
$configuration = [Activator]::CreateInstance($configurationType)
return [Activator]::CreateInstance($smoothingRequestType, @($points, $segments, (New-Map), (New-Vehicle), $configuration))
}
function New-CornerSmoothingRequest {
$points = [Array]::CreateInstance($coarsePointType, 4)
$points.SetValue((New-CoarsePoint 0.5 0.5 0.0), 0)
$points.SetValue((New-CoarsePoint 1.0 0.5 0.5), 1)
$points.SetValue((New-CoarsePoint 1.0 1.0 1.0), 2)
$points.SetValue((New-CoarsePoint 1.5 1.0 1.5), 3)
$segments = [Array]::CreateInstance($coarseSegmentType, 1)
$segments.SetValue([Activator]::CreateInstance($coarseSegmentType, @(0, $forward, 0, 3, $false, $false)), 0)
$configuration = [Activator]::CreateInstance($configurationType)
return [Activator]::CreateInstance($smoothingRequestType, @($points, $segments, (New-Map), (New-Vehicle), $configuration))
}
function New-Metrics(
[double]$VariationEnergy,
[double]$PeakCurvature,
[double]$MinimumClearance,
[double]$LengthChangePercent) {
return [Activator]::CreateInstance($metricsType, @(
$true, [double]1.0, $PeakCurvature, [double]0.0, [double]0.0, $VariationEnergy,
$MinimumClearance, $LengthChangePercent, [double]0.0, [double]0.0, [double]0.0))
}
function New-Timing([double]$MedianMilliseconds) {
[double[]]$samples = @($MedianMilliseconds, $MedianMilliseconds, $MedianMilliseconds, $MedianMilliseconds, $MedianMilliseconds)
return [Activator]::CreateInstance($timingType, @($samples, $true, ''))
}
function New-Entry(
$Method,
[double]$VariationEnergy,
[double]$PeakCurvature,
[double]$MinimumClearance,
[double]$LengthChangePercent,
[double]$MedianMilliseconds) {
[object[]]$arguments = @(
$Method, $successStatus, (New-Metrics $VariationEnergy $PeakCurvature $MinimumClearance $LengthChangePercent),
(New-Timing $MedianMilliseconds), ('synthetic-' + $Method.ToString()), '')
return [Activator]::CreateInstance($entryType, $arguments)
}
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$comparison = $root + 'Comparison.'
$facade = $root + 'Facade.'
$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$comparisonServiceType = Get-RequiredType ($facade + 'PathSmoothingComparisonService')
$comparisonRequestType = Get-RequiredType ($comparison + 'PathSmoothingComparisonRequest')
$comparisonResultType = Get-RequiredType ($comparison + 'PathSmoothingComparisonResult')
$entryType = Get-RequiredType ($comparison + 'PathSmoothingComparisonEntry')
$timingType = Get-RequiredType ($comparison + 'SmoothingTimingSummary')
$rankerType = Get-RequiredType ($comparison + 'SmoothingMethodRanker')
$smoothingRequestType = Get-RequiredType ($root + 'PathSmoothingRequest')
$configurationType = Get-RequiredType ($root + 'PathSmoothingConfiguration')
$metricsType = Get-RequiredType ($root + 'PathQualityMetrics')
$methodType = Get-RequiredType ($root + 'SmoothingMethod')
$statusType = Get-RequiredType ($root + 'PathSmoothingStatus')
$smoothingResultType = Get-RequiredType ($root + 'PathSmoothingResult')
$diagnosticsType = Get-RequiredType ($root + 'PathSmoothingDiagnostics')
$coarsePointType = Get-RequiredType ($coarsePath + 'CoarsePathPoint')
$coarseSegmentType = Get-RequiredType ($coarsePath + 'PathSegment')
$directionType = Get-RequiredType ($coarsePath + 'TravelDirection')
$coarsePointSourceType = Get-RequiredType ($coarsePath + 'CoarsePathPointSource')
$vehicleType = Get-RequiredType ($coarsePath + 'VehicleParameters')
$boundsType = Get-RequiredType ($mapping + 'MapBoundsMm')
$mapRequestType = Get-RequiredType ($mapping + 'PlanningMapRequest')
$mapFactoryType = Get-RequiredType ($mapping + 'PlanningMapFactory')
$forward = [Enum]::Parse($directionType, 'Forward')
$coarseAnchor = [Enum]::Parse($coarsePointSourceType, 'Start')
$cubicBSpline = [Enum]::Parse($methodType, 'CubicBSpline')
$localCubicBezier = [Enum]::Parse($methodType, 'LocalCubicBezier')
$piecewiseQuintic = [Enum]::Parse($methodType, 'PiecewiseQuintic')
$successStatus = [Enum]::Parse($statusType, 'Success')
Assert-True $comparisonServiceType.IsPublic 'Comparison service must be public.'
$compareMethod = $comparisonServiceType.GetMethod('Compare', [Type[]]@($comparisonRequestType, [Threading.CancellationToken]))
Assert-True ($null -ne $compareMethod) 'Comparison service must expose Compare(PathSmoothingComparisonRequest, CancellationToken).'
Assert-Equal $comparisonResultType $compareMethod.ReturnType 'Compare must return PathSmoothingComparisonResult.'
$methods = [Array]::CreateInstance($methodType, 3)
$methods.SetValue($cubicBSpline, 0)
$methods.SetValue($localCubicBezier, 1)
$methods.SetValue($piecewiseQuintic, 2)
$comparisonRequest = [Activator]::CreateInstance($comparisonRequestType, @((New-SmoothingRequest), $methods))
$comparisonService = [Activator]::CreateInstance($comparisonServiceType)
$result = $compareMethod.Invoke($comparisonService, @($comparisonRequest, [Threading.CancellationToken]::None))
Assert-True ($null -ne $result.RawPathBaseline) 'Comparison must publish a separately analyzed raw-path baseline.'
Assert-True $result.RawPathBaseline.IsRawPathBaseline 'Raw baseline must be explicitly marked and excluded from candidates.'
Assert-Equal 3 $result.Entries.Count 'Comparison must contain exactly one entry for every requested method.'
Assert-True ($null -ne $result.RecommendedMethod) 'A comparison with feasible methods must select a recommendation.'
foreach ($entry in $result.Entries) {
Assert-True (-not $entry.IsRawPathBaseline) 'Candidate entries must not be marked as the raw baseline.'
Assert-Equal 5 $entry.Timing.MeasuredElapsedMilliseconds.Count 'Warm-up must be excluded and exactly five measurements retained.'
Assert-True $entry.Timing.IsDeterministic 'Repeated deterministic smoothing geometry must remain eligible for recommendation.'
Assert-True (-not [string]::IsNullOrWhiteSpace($entry.StableGeometryDigest)) 'Every measured candidate must expose a stable geometry digest.'
}
$fromMeasurementsMethod = $timingType.GetMethod('FromMeasurements')
Assert-True ($null -ne $fromMeasurementsMethod) 'Timing summary must analyze the five measured outputs for deterministic geometry.'
$failureFactory = $smoothingResultType.GetMethod('Failure')
$invalidInputStatus = [Enum]::Parse($statusType, 'InvalidInput')
$infeasibleStatus = [Enum]::Parse($statusType, 'Infeasible')
$inconsistentResults = [Array]::CreateInstance($smoothingResultType, 5)
for ($index = 0; $index -lt 5; $index++) {
$status = if ($index -eq 4) { $infeasibleStatus } else { $invalidInputStatus }
[object[]]$failureArguments = New-Object object[] 2
$failureArguments[0] = $status
$failureArguments[1] = [Activator]::CreateInstance($diagnosticsType)
$inconsistentResults.SetValue($failureFactory.Invoke($null, $failureArguments), $index)
}
[object[]]$timingArguments = New-Object object[] 2
$timingArguments[0] = [double[]]@(1.0, 2.0, 3.0, 4.0, 5.0)
$timingArguments[1] = $inconsistentResults
$nonDeterministicTiming = $fromMeasurementsMethod.Invoke($null, $timingArguments)
Assert-True (-not $nonDeterministicTiming.IsDeterministic) 'A status, point-count, segment-count, or digest mismatch must be non-deterministic.'
Assert-True (-not [string]::IsNullOrWhiteSpace($nonDeterministicTiming.Diagnostic)) 'Non-deterministic measurements must publish a stable diagnostic.'
# All published comparison metrics must be normalized against the separately analyzed raw baseline.
$cornerMethods = [Array]::CreateInstance($methodType, 1)
$cornerMethods.SetValue($cubicBSpline, 0)
$cornerRequest = [Activator]::CreateInstance($comparisonRequestType, @((New-CornerSmoothingRequest), $cornerMethods))
$cornerResult = $compareMethod.Invoke($comparisonService, @($cornerRequest, [Threading.CancellationToken]::None))
$cornerEntry = $cornerResult.Entries[0]
Assert-Equal 'Success' $cornerEntry.Status.ToString() 'The unconstrained empty-map corner fixture must produce a B-spline comparison candidate.'
$expectedLengthChange = (($cornerEntry.Path[$cornerEntry.Path.Count - 1].ArcLength - $cornerResult.RawPathBaseline.Metrics.PathLengthMeters) /
$cornerResult.RawPathBaseline.Metrics.PathLengthMeters) * 100.0
Assert-Near $expectedLengthChange $cornerEntry.Metrics.LengthChangePercent 0.000001 'Candidate length change must be normalized relative to the raw baseline.'
# The ranker must apply every public tie-break in order. Each pair ties all prior criteria.
$entryListType = [Collections.Generic.IReadOnlyList``1].MakeGenericType(@($entryType))
$rankMethod = $rankerType.GetMethod('Rank', [Type[]]@($entryListType))
Assert-True ($null -ne $rankMethod) 'SmoothingMethodRanker must expose Rank(IReadOnlyList<PathSmoothingComparisonEntry>).'
function Assert-Rank($ExpectedMethod, [object[]]$Entries, [string]$Message) {
$typedEntries = [Array]::CreateInstance($entryType, $Entries.Count)
for ($index = 0; $index -lt $Entries.Count; $index++) { $typedEntries.SetValue($Entries[$index], $index) }
[object[]]$invokeArguments = New-Object object[] 1
$invokeArguments[0] = $typedEntries
$actual = $rankMethod.Invoke($null, $invokeArguments)
Assert-Equal $ExpectedMethod.ToString() $actual.ToString() $Message
}
Assert-Rank $cubicBSpline @(
(New-Entry $cubicBSpline 1.0 0.5 0.8 5.0 10.0),
(New-Entry $localCubicBezier 2.0 0.1 1.0 1.0 1.0)) 'Variation-energy tie-break must take priority over later criteria.'
Assert-Rank $cubicBSpline @(
(New-Entry $cubicBSpline 1.0 0.2 0.8 5.0 10.0),
(New-Entry $localCubicBezier 1.0 0.3 1.0 1.0 1.0)) 'Peak-curvature tie-break must follow variation energy.'
Assert-Rank $cubicBSpline @(
(New-Entry $cubicBSpline 1.0 0.2 0.9 5.0 10.0),
(New-Entry $localCubicBezier 1.0 0.2 0.8 1.0 1.0)) 'Clearance-loss tie-break must follow peak curvature.'
Assert-Rank $cubicBSpline @(
(New-Entry $cubicBSpline 1.0 0.2 0.9 2.0 10.0),
(New-Entry $localCubicBezier 1.0 0.2 0.9 3.0 1.0)) 'Length-change tie-break must follow clearance loss.'
Assert-Rank $cubicBSpline @(
(New-Entry $cubicBSpline 1.0 0.2 0.9 2.0 5.0),
(New-Entry $localCubicBezier 1.0 0.2 0.9 2.0 6.0)) 'Median elapsed tie-break must be last.'
$cancelSource = [Threading.CancellationTokenSource]::new()
try {
$cancelSource.Cancel()
$cancelled = $compareMethod.Invoke($comparisonService, @($comparisonRequest, $cancelSource.Token))
Assert-True $cancelled.IsCancelled 'Cancellation must stop comparison before subsequent methods start.'
Assert-Equal $null $cancelled.RecommendedMethod 'Cancelled comparison must not make a recommendation.'
}
finally {
$cancelSource.Dispose()
}
Write-Output 'Path smoothing comparison checks passed.'
@@ -1,381 +0,0 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$coarsePathRoot = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$mappingRoot = 'MultiWheelC.TrajectoryPlanning.Mapping.'
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Near([double]$Expected, [double]$Actual, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt 0.000001d) {
throw "$Message Expected=$Expected Actual=$Actual"
}
}
function Assert-Throws([scriptblock]$Action, [string]$Message) {
$threw = $false
try { & $Action }
catch { $threw = $true }
if (-not $threw) { throw $Message }
}
function Assert-ReadOnlyCollection($Collection, [string]$Message) {
$list = [System.Collections.IList]$Collection
Assert-True ($null -ne $list) "$Message The collection must implement IList."
Assert-True $list.IsReadOnly "$Message The collection must report IsReadOnly."
Assert-Throws { $list.Add($null) } "$Message The collection must reject Add."
}
function Get-RequiredType([string]$Name) {
return $assembly.GetType($Name, $true)
}
$configurationType = Get-RequiredType ($root + 'PathSmoothingConfiguration')
$methodType = Get-RequiredType ($root + 'SmoothingMethod')
$statusType = Get-RequiredType ($root + 'PathSmoothingStatus')
$sourceType = Get-RequiredType ($root + 'SmoothedPathPointSource')
$pointType = Get-RequiredType ($root + 'SmoothedPathPoint')
$segmentType = Get-RequiredType ($root + 'SmoothedPathSegment')
$bsplineOptionsType = Get-RequiredType ($root + 'CubicBSplineOptions')
$bezierOptionsType = Get-RequiredType ($root + 'LocalCubicBezierOptions')
$quinticOptionsType = Get-RequiredType ($root + 'PiecewiseQuinticOptions')
$localOptionsType = Get-RequiredType ($root + 'LocalG2QuinticOptions')
$regionStatusType = Get-RequiredType ($root + 'PathSmoothingRegionStatus')
$regionFailureType = Get-RequiredType ($root + 'PathSmoothingRegionFailureReason')
$regionReportType = Get-RequiredType ($root + 'PathSmoothingRegionReport')
$requestType = Get-RequiredType ($root + 'PathSmoothingRequest')
$metricsType = Get-RequiredType ($root + 'PathQualityMetrics')
$diagnosticsType = Get-RequiredType ($root + 'PathSmoothingDiagnostics')
$resultType = Get-RequiredType ($root + 'PathSmoothingResult')
$directionType = Get-RequiredType ($coarsePathRoot + 'TravelDirection')
$coarsePointType = Get-RequiredType ($coarsePathRoot + 'CoarsePathPoint')
$coarseSegmentType = Get-RequiredType ($coarsePathRoot + 'PathSegment')
$mapType = Get-RequiredType ($mappingRoot + 'PlanningGridMap')
$vehicleType = Get-RequiredType ($coarsePathRoot + 'VehicleParameters')
Assert-Equal $true $methodType.IsEnum 'SmoothingMethod must be a public enum.'
Assert-Equal $true $statusType.IsEnum 'PathSmoothingStatus must be a public enum.'
Assert-Equal $true $sourceType.IsEnum 'SmoothedPathPointSource must be a public enum.'
Assert-Equal 'CubicBSpline,LocalCubicBezier,PiecewiseQuintic,LocalG2Quintic' ([string]::Join(',', [Enum]::GetNames($methodType))) 'The Local G2 method must be appended without reordering legacy methods.'
Assert-Equal 'Success,FallbackToCoarsePath,InvalidInput,Infeasible,Failed,Cancelled,Complete,PartialImprovement,NotNeeded,Unchanged' ([string]::Join(',', [Enum]::GetNames($statusType))) 'Local G2 statuses must be appended without reordering legacy statuses.'
Assert-Equal 'Anchor,Interpolated,GearSwitch,CoarsePathFallback,LocalG2Transition' ([string]::Join(',', [Enum]::GetNames($sourceType))) 'Local G2 point source must be appended without reordering legacy sources.'
Assert-Equal 'Improved,RetainedOriginal' ([string]::Join(',', [Enum]::GetNames($regionStatusType))) 'Local G2 region statuses must be stable.'
Assert-Equal 'None,WindowUnavailable,CandidateGenerationFailed,Collision,InsufficientClearance,CurvatureExceeded,CurvatureOvershoot,DeviationExceeded,InsufficientImprovement,VariationCostRegression,GlobalValidationRollback' ([string]::Join(',', [Enum]::GetNames($regionFailureType))) 'Local G2 region failure reasons must be stable.'
$configuration = [Activator]::CreateInstance($configurationType)
Assert-Near 0.05 $configuration.OutputSpacingMeters 'Default output spacing must be 0.05 m.'
Assert-Near 0.025 $configuration.MaximumCollisionCheckStepMeters 'Default collision step must be 0.025 m.'
Assert-Near 0.02 $configuration.MinimumClearanceReserveMeters 'Default clearance reserve must be 0.02 m.'
Assert-Near 1.0 $configuration.SmoothingStrength 'Default smoothing strength must be 1.0.'
Assert-Equal $true $configuration.AllowFallbackToCoarsePath 'Fallback must be enabled by default.'
Assert-Equal 4 $configuration.RetryStrengthScales.Count 'Retry schedule must contain four entries.'
Assert-Near 1.0 $configuration.RetryStrengthScales[0] 'First retry scale must be 1.0.'
Assert-Near 0.75 $configuration.RetryStrengthScales[1] 'Second retry scale must be 0.75.'
Assert-Near 0.50 $configuration.RetryStrengthScales[2] 'Third retry scale must be 0.50.'
Assert-Near 0.25 $configuration.RetryStrengthScales[3] 'Last retry scale must be 0.25.'
for ($index = 1; $index -lt $configuration.RetryStrengthScales.Count; $index++) {
Assert-True ($configuration.RetryStrengthScales[$index] -lt $configuration.RetryStrengthScales[$index - 1]) 'Retry schedule must be strictly decreasing.'
}
Assert-ReadOnlyCollection $configuration.RetryStrengthScales 'Retry schedule must be immutable.'
Assert-Near (1.0 / 3.0) ([Activator]::CreateInstance($bsplineOptionsType)).EndpointTangentScale 'B-spline endpoint tangent default must be one third.'
$bezier = [Activator]::CreateInstance($bezierOptionsType)
Assert-Near ([Math]::PI / 18.0) $bezier.CornerHeadingThresholdRadians 'Bezier corner threshold must be 10 degrees.'
Assert-Near 0.60 $bezier.MaximumWindowLengthMeters 'Bezier window default must be 0.60 m.'
Assert-Near (1.0 / 3.0) $bezier.HandleLengthRatio 'Bezier handle default must be one third.'
$quintic = [Activator]::CreateInstance($quinticOptionsType)
Assert-Near 0.50 $quintic.KnotSpacingMeters 'Quintic knot spacing must be 0.50 m.'
Assert-Near 0.10 $quintic.MinimumKnotSpacingMeters 'Quintic minimum knot spacing must be 0.10 m.'
$local = [Activator]::CreateInstance($localOptionsType)
Assert-Near 0.20 $local.MinimumWindowLengthMeters 'Minimum Local G2 window must be 0.20 m.'
Assert-Near 0.50 $local.PreferredWindowLengthMeters 'Preferred Local G2 window must be 0.50 m.'
Assert-Near 0.80 $local.MaximumWindowLengthMeters 'Maximum Local G2 window must be 0.80 m.'
Assert-Near 0.10 $local.MaximumDeviationMeters 'Maximum Local G2 deviation must be 0.10 m.'
Assert-Near 0.001 $local.AbsoluteCurvatureJumpFloorPerMeter 'Absolute jump floor must be 0.001 1/m.'
Assert-Near 0.05 $local.CurvatureJumpRatioOfMaximum 'Relative jump threshold must be 5 percent.'
Assert-Near 0.20 $local.MinimumPeakGradientImprovementRatio 'Peak improvement must be 20 percent.'
Assert-Near 0.02 $local.MaximumVariationCostRegressionRatio 'Variation cost tolerance must be 2 percent.'
Assert-Equal 12 $local.MaximumCandidatesPerRegion 'At most twelve candidates are allowed.'
$forward = [Enum]::Parse($directionType, 'Forward')
$anchor = [Enum]::Parse($sourceType, 'Anchor')
$point = [Activator]::CreateInstance($pointType, @(
[double]1.25, [double]-2.50, [double]0.30, [double]6.58, [double]4.75,
$forward, [double]0.12, [double]0.12, [double]0.44, $false, $anchor))
Assert-Near 1.25 $point.X 'Smoothed point X must be stored in m.'
Assert-Near -2.50 $point.Y 'Smoothed point Y must be stored in m.'
Assert-Near 0.30 $point.Heading 'Smoothed point heading must be stored in rad.'
Assert-Near 6.58 $point.UnwrappedHeading 'Smoothed point unwrapped heading must be stored in rad.'
Assert-Near 4.75 $point.ArcLength 'Smoothed point arc length must be stored in m.'
Assert-Equal 'Forward' $point.Direction.ToString() 'Smoothed point direction must be preserved.'
Assert-Near 0.12 $point.GeometricCurvature 'Smoothed point geometric curvature must be stored in 1/m.'
Assert-Near 0.12 $point.VehicleCurvature 'Smoothed point vehicle curvature must be stored in 1/m.'
Assert-Near 0.44 $point.BodyClearance 'Smoothed point clearance must be stored in m.'
Assert-Equal $false $point.IsGearSwitchPoint 'Smoothed point gear-switch marker must be preserved.'
Assert-Equal 'Anchor' $point.Source.ToString() 'Smoothed point source must be preserved.'
Assert-True ($pointType.GetProperty('VehicleCurvatureDerivative') -ne $null) 'Smoothed points must expose d-kappa/d-s.'
$pointWithDerivative = [Activator]::CreateInstance($pointType, @(
[double]1.25, [double]-2.50, [double]0.30, [double]6.58, [double]4.75,
$forward, [double]0.12, [double]0.12, [double]0.37, [double]0.44, $false, $anchor))
Assert-Near 0.37 $pointWithDerivative.VehicleCurvatureDerivative 'Smoothed point curvature derivative must be stored in 1/m^2.'
$segmentA = [Activator]::CreateInstance($segmentType, @(0, $forward, 0, 2, $false, $true))
$reverse = [Enum]::Parse($directionType, 'Reverse')
$segmentB = [Activator]::CreateInstance($segmentType, @(1, $reverse, 3, 5, $true, $false))
Assert-Equal 0 $segmentA.SegmentIndex 'First smoothing segment index must be retained.'
Assert-Equal 'Forward' $segmentA.Direction.ToString() 'First smoothing segment direction must be retained.'
Assert-Equal 2 $segmentA.EndIndex 'First smoothing segment end index must be retained.'
Assert-Equal $true $segmentA.EndsAtGearSwitch 'First smoothing segment switch flag must be retained.'
Assert-Equal 1 $segmentB.SegmentIndex 'Second smoothing segment index must be retained.'
Assert-Equal 'Reverse' $segmentB.Direction.ToString() 'Second smoothing segment direction must be retained.'
Assert-Equal $true $segmentB.StartsAtGearSwitch 'Second smoothing segment switch flag must be retained.'
$metrics = [Activator]::CreateInstance($metricsType)
Assert-Equal $false $metrics.IsFeasible 'Default metrics must be infeasible until analysis accepts a candidate.'
Assert-Near 0.0 $metrics.PathLengthMeters 'Default metrics must be zero-valued.'
Assert-Near 0.0 $metrics.MinimumBodyClearanceMeters 'Default metrics must be zero-valued.'
Assert-Near 0.0 $metrics.MaximumAbsoluteVehicleCurvatureDerivativePerSquareMeter 'Default derivative metric must be zero-valued.'
Assert-Near 0.0 $metrics.CurvatureVariationCost 'Curvature variation cost compatibility alias must be available.'
$metricsWithDerivative = [Activator]::CreateInstance($metricsType, @(
$true,
[double]1.0, [double]0.50, [double]0.75, [double]0.0, [double]0.0,
[double]0.0, [double]0.25, [double]0.0, [double]0.0, [double]0.0, [double]0.0))
Assert-Near 0.75 $metricsWithDerivative.MaximumAbsoluteVehicleCurvatureDerivativePerSquareMeter 'Derivative-aware metrics constructor must retain the peak derivative.'
$diagnostics = [Activator]::CreateInstance($diagnosticsType)
Assert-True ($diagnostics.Metrics -ne $null) 'Default diagnostics must provide quality metrics.'
Assert-Equal 0 $diagnostics.RetryCount 'Default diagnostics must have no retries.'
Assert-Near 0.0 $diagnostics.AcceptedStrength 'Default diagnostics must have zero accepted strength.'
$feasibleMetrics = [Activator]::CreateInstance($metricsType, @(
$true,
[double]1.0, [double]0.0, [double]0.0, [double]0.0, [double]0.0,
[double]0.5, [double]0.0, [double]0.0, [double]0.0, [double]0.0))
$feasibleDiagnostics = [Activator]::CreateInstance($diagnosticsType, @(
$feasibleMetrics, [TimeSpan]::Zero, 0, [double]1.0, 'test feasible diagnostics'))
$pointArray = [Array]::CreateInstance($pointType, 1)
$pointArray.SetValue($point, 0)
$segmentArray = [Array]::CreateInstance($segmentType, 2)
$segmentArray.SetValue($segmentA, 0)
$segmentArray.SetValue($segmentB, 1)
$method = [Enum]::Parse($methodType, 'CubicBSpline')
$successMethod = $resultType.GetMethod('Success')
Assert-True ($null -ne $successMethod) 'PathSmoothingResult must expose Success.'
Assert-Throws { $successMethod.Invoke($null, @($method, $pointArray, $segmentArray, $diagnostics)) } 'Success factory must reject diagnostics that are not feasible.'
Assert-Throws { $successMethod.Invoke($null, @($method, $pointArray, $segmentArray, $null)) } 'Success factory must reject null diagnostics.'
Assert-Throws { $successMethod.Invoke($null, @([Enum]::ToObject($methodType, 99), $pointArray, $segmentArray, $feasibleDiagnostics)) } 'Success factory must reject undefined smoothing methods.'
$success = $successMethod.Invoke($null, @($method, $pointArray, $segmentArray, $feasibleDiagnostics))
Assert-Equal 'Success' $success.Status.ToString() 'Success factory must publish Success status.'
Assert-Equal 'CubicBSpline' $success.Method.ToString() 'Success factory must retain the selected method.'
Assert-Equal 1 $success.Path.Count 'Success factory must publish the provided path.'
Assert-Equal 2 $success.Segments.Count 'Success factory must publish the provided segments.'
Assert-ReadOnlyCollection $success.Path 'Success path must be immutable.'
Assert-ReadOnlyCollection $success.Segments 'Success segments must be immutable.'
$pointArray.SetValue($null, 0)
$segmentArray.SetValue($null, 0)
Assert-True ($null -ne $success.Path[0]) 'Success factory must copy path collections.'
Assert-True ($null -ne $success.Segments[0]) 'Success factory must copy segment collections.'
$fallbackMethod = $resultType.GetMethod('Fallback')
Assert-True ($null -ne $fallbackMethod) 'PathSmoothingResult must expose Fallback.'
$fallbackPath = [Array]::CreateInstance($pointType, 1)
$fallbackPath.SetValue($point, 0)
$fallbackSegments = [Array]::CreateInstance($segmentType, 1)
$fallbackSegments.SetValue($segmentA, 0)
Assert-Throws { $fallbackMethod.Invoke($null, @($method, $fallbackPath, $fallbackSegments, $diagnostics)) } 'Fallback factory must reject diagnostics that are not feasible.'
Assert-Throws { $fallbackMethod.Invoke($null, @($method, $fallbackPath, $fallbackSegments, $null)) } 'Fallback factory must reject null diagnostics.'
Assert-Throws { $fallbackMethod.Invoke($null, @([Enum]::ToObject($methodType, 99), $fallbackPath, $fallbackSegments, $feasibleDiagnostics)) } 'Fallback factory must reject undefined smoothing methods.'
$fallback = $fallbackMethod.Invoke($null, @($method, $fallbackPath, $fallbackSegments, $feasibleDiagnostics))
Assert-Equal 'FallbackToCoarsePath' $fallback.Status.ToString() 'Fallback factory must publish an explicit fallback status.'
Assert-Equal 1 $fallback.Path.Count 'Fallback factory must publish a validated fallback path.'
Assert-Equal 0 $fallback.RegionReports.Count 'Legacy fallback results must publish empty immutable region reports.'
$failureMethod = $resultType.GetMethod('Failure')
Assert-True ($null -ne $failureMethod) 'PathSmoothingResult must expose Failure.'
$failed = $failureMethod.Invoke(
$null,
@([Enum]::Parse($statusType, 'InvalidInput'),
[Activator]::CreateInstance($diagnosticsType)))
Assert-Equal 'InvalidInput' $failed.Status.ToString() 'Failure factory must retain failure status.'
Assert-Equal 0 $failed.Path.Count 'Failure must publish no path.'
Assert-Equal 0 $failed.Segments.Count 'Failure must publish no segments.'
Assert-ReadOnlyCollection $failed.Path 'Failure path must be immutable.'
Assert-ReadOnlyCollection $failed.Segments 'Failure segments must be immutable.'
Assert-Throws { $failureMethod.Invoke($null, @([Enum]::Parse($statusType, 'Success'), $diagnostics)) } 'Failure factory must reject Success.'
Assert-Throws { $failureMethod.Invoke($null, @([Enum]::Parse($statusType, 'FallbackToCoarsePath'), $diagnostics)) } 'Failure factory must reject fallback status.'
Assert-Throws { $failureMethod.Invoke($null, @([Enum]::ToObject($statusType, 99), $diagnostics)) } 'Failure factory must reject undefined statuses.'
Assert-Throws { $successMethod.Invoke($null, @($method, [Array]::CreateInstance($pointType, 0), $fallbackSegments, $diagnostics)) } 'Success factory must reject an empty path.'
Assert-Throws { $successMethod.Invoke($null, @($method, $fallbackPath, [Array]::CreateInstance($segmentType, 0), $diagnostics)) } 'Success factory must reject empty segments.'
Assert-Equal 0 $success.RegionReports.Count 'Legacy success results must publish empty immutable region reports.'
Assert-ReadOnlyCollection $success.RegionReports 'Legacy success region reports must be immutable.'
$curvatureJumps = [System.Collections.Generic.List[double]]::new()
$curvatureJumps.Add([double]0.20)
$report = [Activator]::CreateInstance($regionReportType, @(
0, [double]0.0, [double]0.5, $curvatureJumps,
[double]0.5, [double]0.5, [double]0.25, [double]0.25,
1, 0,
[Enum]::Parse($regionStatusType, 'Improved'), [Enum]::Parse($regionFailureType, 'None'),
[double]1.0, [double]0.5, [double]2.0, [double]1.0,
[double]0.05, [double]0.10, [double]0.80))
Assert-ReadOnlyCollection $report.CurvatureJumpsPerMeter 'Region report curvature jumps must be immutable.'
$curvatureJumps[0] = [double]9.99
Assert-Near 0.20 $report.CurvatureJumpsPerMeter[0] 'Region report must copy curvature jumps.'
$retainedReport = [Activator]::CreateInstance($regionReportType, @(
0, [double]0.0, [double]0.5, $curvatureJumps,
[double]0.5, [double]0.5, [double]0.25, [double]0.25,
1, 7,
[Enum]::Parse($regionStatusType, 'RetainedOriginal'), [Enum]::Parse($regionFailureType, 'InsufficientImprovement'),
[double]1.0, [double]1.0, [double]2.0, [double]2.0,
[double]0.0, [double]0.10, [double]0.80))
Assert-Equal -1 $retainedReport.SelectedCandidateIndex 'A region without a selected candidate must publish -1.'
$publishLocalG2Method = $resultType.GetMethod('PublishLocalG2')
Assert-True ($null -ne $publishLocalG2Method) 'PathSmoothingResult must expose PublishLocalG2.'
$reports = [Array]::CreateInstance($regionReportType, 1)
$reports.SetValue($report, 0)
$localG2Method = [Enum]::Parse($methodType, 'LocalG2Quintic')
$complete = [Enum]::Parse($statusType, 'Complete')
$localG2Result = $publishLocalG2Method.Invoke($null, @($complete, $fallbackPath, $fallbackSegments, $feasibleDiagnostics, $reports))
Assert-Equal 'Complete' $localG2Result.Status.ToString() 'PublishLocalG2 must retain Local G2 publication status.'
Assert-Equal 'LocalG2Quintic' $localG2Result.Method.ToString() 'PublishLocalG2 must publish the Local G2 method.'
Assert-Equal 1 $localG2Result.RegionReports.Count 'PublishLocalG2 must publish region reports.'
Assert-ReadOnlyCollection $localG2Result.RegionReports 'Local G2 result region reports must be immutable.'
$reports.SetValue($null, 0)
Assert-True ($null -ne $localG2Result.RegionReports[0]) 'PublishLocalG2 must copy region reports.'
Assert-Throws { $publishLocalG2Method.Invoke($null, @([Enum]::Parse($statusType, 'Success'), $fallbackPath, $fallbackSegments, $feasibleDiagnostics, $reports)) } 'PublishLocalG2 must reject legacy statuses.'
Assert-Throws { $publishLocalG2Method.Invoke($null, @($complete, [Array]::CreateInstance($pointType, 0), $fallbackSegments, $feasibleDiagnostics, $reports)) } 'PublishLocalG2 must reject an empty path.'
$requestConstructor = $requestType.GetConstructor(@(
[System.Collections.Generic.IReadOnlyList``1].MakeGenericType($coarsePointType),
[System.Collections.Generic.IReadOnlyList``1].MakeGenericType($coarseSegmentType),
$mapType,
$vehicleType,
$configurationType))
Assert-True ($null -ne $requestConstructor) 'PathSmoothingRequest must expose the public five-argument constructor.'
$boundsType = Get-RequiredType ($mappingRoot + 'MapBoundsMm')
$mapRequestType = Get-RequiredType ($mappingRoot + 'PlanningMapRequest')
$mapFactoryType = Get-RequiredType ($mappingRoot + 'PlanningMapFactory')
$mapRequest = [Activator]::CreateInstance($mapRequestType)
$mapRequest.Bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]1000, [single]0, [single]1000))
$mapRequest.ResolutionMm = [single]50
$mapRequest.AllowExplicitEmptyMap = $true
$map = [Activator]::CreateInstance($mapFactoryType).Create($mapRequest).Map
Assert-True ($null -ne $map) 'Contract test must create an explicit empty planning map.'
$requestCoarsePath = [Array]::CreateInstance($coarsePointType, 1)
$requestCoarsePath.SetValue([Activator]::CreateInstance($coarsePointType, @(
[double]0.0, [double]0.0, [double]0.0, [double]0.0, [double]0.0,
$forward, [double]0.0, [double]1.0, $false,
[Enum]::Parse((Get-RequiredType ($coarsePathRoot + 'CoarsePathPointSource')), 'Start'))), 0)
$requestSegments = [Array]::CreateInstance($coarseSegmentType, 1)
$requestSegments.SetValue([Activator]::CreateInstance($coarseSegmentType, @(0, $forward, 0, 0, $false, $false)), 0)
$vehicle = [Activator]::CreateInstance($vehicleType)
$vehicle.LengthMeters = [double]0.80
$vehicle.WidthMeters = [double]0.60
$vehicle.SafetyMarginMeters = [double]0.05
$vehicle.MaximumCurvaturePerMeter = [double]0.8333333333333334
$requestConfiguration = [Activator]::CreateInstance($configurationType)
$request = $requestConstructor.Invoke(@($requestCoarsePath, $requestSegments, $map, $vehicle, $requestConfiguration))
Assert-ReadOnlyCollection $request.CoarsePath 'Request coarse path must be immutable.'
Assert-ReadOnlyCollection $request.Segments 'Request segments must be immutable.'
$requestCoarsePath.SetValue($null, 0)
$requestSegments.SetValue($null, 0)
$vehicle.LengthMeters = [double]9.99
$vehicle.WidthMeters = [double]9.99
$vehicle.SafetyMarginMeters = [double]9.99
$vehicle.MaximumCurvaturePerMeter = [double]0.1
$vehicle.MinimumTurningRadiusMeters = [double]9.99
$requestConfiguration.Method = [Enum]::Parse($methodType, 'PiecewiseQuintic')
$requestConfiguration.OutputSpacingMeters = [double]0.99
$requestConfiguration.MaximumCollisionCheckStepMeters = [double]0.99
$requestConfiguration.MinimumClearanceReserveMeters = [double]0.99
$requestConfiguration.SmoothingStrength = [double]0.99
$requestConfiguration.AllowFallbackToCoarsePath = $false
$requestConfiguration.CubicBSpline.EndpointTangentScale = [double]0.99
$requestConfiguration.LocalCubicBezier.CornerHeadingThresholdRadians = [double]0.99
$requestConfiguration.LocalCubicBezier.MaximumWindowLengthMeters = [double]0.99
$requestConfiguration.LocalCubicBezier.HandleLengthRatio = [double]0.99
$requestConfiguration.PiecewiseQuintic.KnotSpacingMeters = [double]0.99
$requestConfiguration.PiecewiseQuintic.MinimumKnotSpacingMeters = [double]0.99
$requestConfiguration.LocalG2Quintic.MinimumWindowLengthMeters = [double]0.99
$requestConfiguration.LocalG2Quintic.PreferredWindowLengthMeters = [double]0.99
$requestConfiguration.LocalG2Quintic.MaximumWindowLengthMeters = [double]0.99
$requestConfiguration.LocalG2Quintic.MaximumDeviationMeters = [double]0.99
$requestConfiguration.LocalG2Quintic.AbsoluteCurvatureJumpFloorPerMeter = [double]0.99
$requestConfiguration.LocalG2Quintic.CurvatureJumpRatioOfMaximum = [double]0.99
$requestConfiguration.LocalG2Quintic.MinimumPeakGradientImprovementRatio = [double]0.99
$requestConfiguration.LocalG2Quintic.MaximumVariationCostRegressionRatio = [double]0.99
$requestConfiguration.LocalG2Quintic.MaximumCandidatesPerRegion = 99
Assert-True ($null -ne $request.CoarsePath[0]) 'Request must copy the coarse-path collection.'
Assert-True ($null -ne $request.Segments[0]) 'Request must copy the segment collection.'
Assert-Near 0.80 $request.Vehicle.LengthMeters 'Request must snapshot vehicle parameters.'
Assert-Near 0.60 $request.Vehicle.WidthMeters 'Request must snapshot vehicle width.'
Assert-Near 0.05 $request.Vehicle.SafetyMarginMeters 'Request must snapshot vehicle safety margin.'
Assert-Near (1.0 / 1.20) $request.Vehicle.MaximumCurvaturePerMeter 'Request must snapshot nullable vehicle curvature.'
Assert-True ($null -eq $request.Vehicle.MinimumTurningRadiusMeters) 'Request must snapshot nullable vehicle turning radius.'
Assert-Equal 'CubicBSpline' $request.Configuration.Method.ToString() 'Request must snapshot smoothing method.'
Assert-Near 0.05 $request.Configuration.OutputSpacingMeters 'Request must snapshot common configuration.'
Assert-Near 0.025 $request.Configuration.MaximumCollisionCheckStepMeters 'Request must snapshot collision configuration.'
Assert-Near 0.02 $request.Configuration.MinimumClearanceReserveMeters 'Request must snapshot clearance configuration.'
Assert-Near 1.0 $request.Configuration.SmoothingStrength 'Request must snapshot smoothing strength.'
Assert-Equal $true $request.Configuration.AllowFallbackToCoarsePath 'Request must snapshot fallback configuration.'
Assert-Near (1.0 / 3.0) $request.Configuration.CubicBSpline.EndpointTangentScale 'Request must snapshot B-spline options.'
Assert-Near ([Math]::PI / 18.0) $request.Configuration.LocalCubicBezier.CornerHeadingThresholdRadians 'Request must snapshot Bezier threshold.'
Assert-Near 0.60 $request.Configuration.LocalCubicBezier.MaximumWindowLengthMeters 'Request must snapshot Bezier window length.'
Assert-Near (1.0 / 3.0) $request.Configuration.LocalCubicBezier.HandleLengthRatio 'Request must snapshot Bezier options.'
Assert-Near 0.50 $request.Configuration.PiecewiseQuintic.KnotSpacingMeters 'Request must snapshot quintic options.'
Assert-Near 0.10 $request.Configuration.PiecewiseQuintic.MinimumKnotSpacingMeters 'Request must snapshot quintic minimum spacing.'
Assert-Near 0.20 $request.Configuration.LocalG2Quintic.MinimumWindowLengthMeters 'Request must snapshot Local G2 minimum window.'
Assert-Near 0.50 $request.Configuration.LocalG2Quintic.PreferredWindowLengthMeters 'Request must snapshot Local G2 preferred window.'
Assert-Near 0.80 $request.Configuration.LocalG2Quintic.MaximumWindowLengthMeters 'Request must snapshot Local G2 maximum window.'
Assert-Near 0.10 $request.Configuration.LocalG2Quintic.MaximumDeviationMeters 'Request must snapshot Local G2 maximum deviation.'
Assert-Near 0.001 $request.Configuration.LocalG2Quintic.AbsoluteCurvatureJumpFloorPerMeter 'Request must snapshot Local G2 absolute jump floor.'
Assert-Near 0.05 $request.Configuration.LocalG2Quintic.CurvatureJumpRatioOfMaximum 'Request must snapshot Local G2 relative jump threshold.'
Assert-Near 0.20 $request.Configuration.LocalG2Quintic.MinimumPeakGradientImprovementRatio 'Request must snapshot Local G2 peak improvement threshold.'
Assert-Near 0.02 $request.Configuration.LocalG2Quintic.MaximumVariationCostRegressionRatio 'Request must snapshot Local G2 variation tolerance.'
Assert-Equal 12 $request.Configuration.LocalG2Quintic.MaximumCandidatesPerRegion 'Request must snapshot Local G2 candidate count.'
$request.Vehicle.WidthMeters = [double]9.99
$request.Vehicle.SafetyMarginMeters = [double]9.99
$request.Vehicle.MinimumTurningRadiusMeters = [double]9.99
$request.Configuration.MaximumCollisionCheckStepMeters = [double]0.99
$request.Configuration.MinimumClearanceReserveMeters = [double]0.99
$request.Configuration.SmoothingStrength = [double]0.99
$request.Configuration.AllowFallbackToCoarsePath = $false
$request.Configuration.LocalCubicBezier.CornerHeadingThresholdRadians = [double]0.99
$request.Configuration.LocalCubicBezier.MaximumWindowLengthMeters = [double]0.99
$request.Configuration.PiecewiseQuintic.MinimumKnotSpacingMeters = [double]0.99
$request.Configuration.LocalG2Quintic.MinimumWindowLengthMeters = [double]0.99
$request.Configuration.LocalG2Quintic.PreferredWindowLengthMeters = [double]0.99
$request.Configuration.LocalG2Quintic.MaximumWindowLengthMeters = [double]0.99
$request.Configuration.LocalG2Quintic.MaximumDeviationMeters = [double]0.99
$request.Configuration.LocalG2Quintic.AbsoluteCurvatureJumpFloorPerMeter = [double]0.99
$request.Configuration.LocalG2Quintic.CurvatureJumpRatioOfMaximum = [double]0.99
$request.Configuration.LocalG2Quintic.MinimumPeakGradientImprovementRatio = [double]0.99
$request.Configuration.LocalG2Quintic.MaximumVariationCostRegressionRatio = [double]0.99
$request.Configuration.LocalG2Quintic.MaximumCandidatesPerRegion = 99
Assert-Near 0.60 $request.Vehicle.WidthMeters 'Request vehicle getter must not expose mutable state.'
Assert-Near 0.05 $request.Vehicle.SafetyMarginMeters 'Request vehicle getter must not expose mutable state.'
Assert-True ($null -eq $request.Vehicle.MinimumTurningRadiusMeters) 'Request vehicle getter must not expose mutable nullable state.'
Assert-Near 0.025 $request.Configuration.MaximumCollisionCheckStepMeters 'Request configuration getter must not expose mutable state.'
Assert-Near 0.02 $request.Configuration.MinimumClearanceReserveMeters 'Request configuration getter must not expose mutable state.'
Assert-Near 1.0 $request.Configuration.SmoothingStrength 'Request configuration getter must not expose mutable state.'
Assert-Equal $true $request.Configuration.AllowFallbackToCoarsePath 'Request configuration getter must not expose mutable state.'
Assert-Near ([Math]::PI / 18.0) $request.Configuration.LocalCubicBezier.CornerHeadingThresholdRadians 'Request configuration getter must not expose mutable Bezier options.'
Assert-Near 0.60 $request.Configuration.LocalCubicBezier.MaximumWindowLengthMeters 'Request configuration getter must not expose mutable Bezier options.'
Assert-Near 0.10 $request.Configuration.PiecewiseQuintic.MinimumKnotSpacingMeters 'Request configuration getter must not expose mutable quintic options.'
Assert-Near 0.20 $request.Configuration.LocalG2Quintic.MinimumWindowLengthMeters 'Request configuration getter must not expose mutable Local G2 minimum window.'
Assert-Near 0.50 $request.Configuration.LocalG2Quintic.PreferredWindowLengthMeters 'Request configuration getter must not expose mutable Local G2 preferred window.'
Assert-Near 0.80 $request.Configuration.LocalG2Quintic.MaximumWindowLengthMeters 'Request configuration getter must not expose mutable Local G2 maximum window.'
Assert-Near 0.10 $request.Configuration.LocalG2Quintic.MaximumDeviationMeters 'Request configuration getter must not expose mutable Local G2 maximum deviation.'
Assert-Near 0.001 $request.Configuration.LocalG2Quintic.AbsoluteCurvatureJumpFloorPerMeter 'Request configuration getter must not expose mutable Local G2 absolute jump floor.'
Assert-Near 0.05 $request.Configuration.LocalG2Quintic.CurvatureJumpRatioOfMaximum 'Request configuration getter must not expose mutable Local G2 relative jump threshold.'
Assert-Near 0.20 $request.Configuration.LocalG2Quintic.MinimumPeakGradientImprovementRatio 'Request configuration getter must not expose mutable Local G2 peak improvement threshold.'
Assert-Near 0.02 $request.Configuration.LocalG2Quintic.MaximumVariationCostRegressionRatio 'Request configuration getter must not expose mutable Local G2 variation tolerance.'
Assert-Equal 12 $request.Configuration.LocalG2Quintic.MaximumCandidatesPerRegion 'Request configuration getter must not expose mutable Local G2 candidate count.'
Write-Output 'Path smoothing contract checks passed.'
@@ -0,0 +1,68 @@
$ErrorActionPreference = 'Stop'
$root = Join-Path $PSScriptRoot '..'
$readmePath = Join-Path $root 'ParkrobTrajplanner\PathSmoothing\README.md'
$demoPath = Join-Path $root 'ParkrobTrajplanner\PathSmoothing\Test\PathSmoothingComparisonDemo.cs'
$runnerPath = Join-Path $PSScriptRoot 'run_path_smoothing_comparison.ps1'
function Assert-True($actual, [string]$message) {
if (-not $actual) { throw $message }
}
Assert-True (Test-Path -LiteralPath $readmePath) 'Path smoothing README is missing.'
Assert-True (Test-Path -LiteralPath $demoPath) 'Path smoothing comparison demo is missing.'
Assert-True (Test-Path -LiteralPath $runnerPath) 'Path smoothing comparison batch runner is missing.'
$readme = Get-Content -Raw -Encoding UTF8 $readmePath
foreach ($section in @(
'Module Overview',
'File Structure',
'Smoothing Data Flow',
'Result Status and Publication Rules',
'Coordinates and Units',
'Minimal Call Example',
'Detailed Usage Guide',
'Fixture Reports and Visualization',
'Common Errors',
'First-Version Limits')) {
Assert-True $readme.Contains($section) "README must contain section: $section"
}
foreach ($requiredText in @(
'Local G2',
'MinimumClearanceReserveMeters',
'PathSmoothingService',
'Complete',
'PartialImprovement',
'NotNeeded',
'Unchanged',
'SimSun',
'Times New Roman',
'01-coarse-path-overview',
'02-all-paths-comparison',
'03-local-g2-overview',
'04-curvature-comparison',
'05-local-g2-diagnostic-candidate')) {
Assert-True $readme.Contains($requiredText) "README must document: $requiredText"
}
$demo = Get-Content -Raw -Encoding UTF8 $demoPath
foreach ($requiredText in @(
'PathSmoothingComparisonDemo',
'PathSmoothingService',
'PathSmoothingComparisonService',
'SmoothingReportExporter',
'SmoothingScenarioFactory',
'PathSmoothingStatus.Complete',
'PathSmoothingStatus.PartialImprovement',
'PathSmoothingStatus.NotNeeded',
'PathSmoothingStatus.Unchanged')) {
Assert-True $demo.Contains($requiredText) "Demo must use: $requiredText"
}
$runner = Get-Content -Raw -Encoding UTF8 $runnerPath
Assert-True ($runner -match '\[switch\]\$FixtureOnly') 'Batch runner must offer -FixtureOnly.'
Assert-True $runner.Contains('obj\path_smoothing_reports') 'Batch runner must write reports below ClumsyPilot/obj/path_smoothing_reports.'
Assert-True $runner.Contains('--export-fixtures') 'Batch runner must export all eight fast fixtures by default.'
Assert-True ($runner -notmatch 'ParkrobTrajplanner\\PathSmoothing\\.*\.(svg|png|csv)') 'Batch runner must never write report files below source directories.'
Write-Output 'Path smoothing LocalG2 documentation checks passed.'
@@ -26,7 +26,7 @@ function Assert-ThrowsMatching([scriptblock]$Action, [string]$ExpectedPattern, [
function Get-RequiredType([string]$Name) { return $assembly.GetType($Name, $true) }
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.Test.'
$comparison = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison.'
$comparison = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison.'
$loaderType = Get-RequiredType ($root + 'SmoothingScenarioFixtureLoader')
$factoryType = Get-RequiredType ($root + 'SmoothingScenarioFactory')
$fixtureType = Get-RequiredType ($root + 'SmoothingScenarioFixture')
@@ -1,140 +0,0 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) { if (-not $Actual) { throw $Message } }
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Near([double]$Expected, [double]$Actual, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt 0.000001) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Get-RequiredType([string]$Name) { return $assembly.GetType($Name, $true) }
function Assert-CoarsePathGeometry($ExpectedPath, $ActualPath, $Map, [string]$ScenarioName) {
Assert-Equal $ExpectedPath.Count $ActualPath.Count "Raw comparison request $ScenarioName must preserve every coarse-path point."
for ($index = 0; $index -lt $ExpectedPath.Count; $index++) {
$expected = $ExpectedPath[$index]
$actual = $ActualPath[$index]
Assert-Near $expected.X $actual.X "Raw comparison request $ScenarioName point $index must preserve X."
Assert-Near $expected.Y $actual.Y "Raw comparison request $ScenarioName point $index must preserve Y."
Assert-Near $expected.Heading $actual.Heading "Raw comparison request $ScenarioName point $index must preserve heading."
Assert-Near $expected.UnwrappedHeading $actual.UnwrappedHeading "Raw comparison request $ScenarioName point $index must preserve unwrapped heading."
Assert-Near $expected.ArcLength $actual.ArcLength "Raw comparison request $ScenarioName point $index must preserve arc length."
Assert-Equal $expected.Direction $actual.Direction "Raw comparison request $ScenarioName point $index must preserve travel direction."
Assert-Near $expected.VehicleCurvature $actual.VehicleCurvature "Raw comparison request $ScenarioName point $index must preserve vehicle curvature."
$expectedClearance = $expected.BodyClearance
if ([double]::IsPositiveInfinity($expectedClearance)) {
$widthMeters = ($Map.Bounds.XMax - $Map.Bounds.XMin) / 1000.0
$heightMeters = ($Map.Bounds.YMax - $Map.Bounds.YMin) / 1000.0
$expectedClearance = [Math]::Sqrt($widthMeters * $widthMeters + $heightMeters * $heightMeters)
}
Assert-Near $expectedClearance $actual.BodyClearance "Raw comparison request $ScenarioName point $index must preserve or normalize clearance for smoothing."
Assert-Equal $expected.IsGearSwitchPoint $actual.IsGearSwitchPoint "Raw comparison request $ScenarioName point $index must preserve gear-switch flag."
Assert-Equal $expected.Source $actual.Source "Raw comparison request $ScenarioName point $index must preserve point source."
}
}
function Assert-SegmentTopology($ExpectedSegments, $ActualPath, $ActualSegments, [string]$Description) {
Assert-Equal $ExpectedSegments.Count $ActualSegments.Count "$Description must preserve segment count."
$expectedStartIndex = 0
for ($index = 0; $index -lt $ActualSegments.Count; $index++) {
$expected = $ExpectedSegments[$index]
$actual = $ActualSegments[$index]
Assert-Equal $index $actual.SegmentIndex "$Description segment $index must retain its stable index."
Assert-Equal $expected.Direction $actual.Direction "$Description segment $index must preserve travel direction."
Assert-Equal $expected.StartsAtGearSwitch $actual.StartsAtGearSwitch "$Description segment $index must preserve start gear-switch topology."
Assert-Equal $expected.EndsAtGearSwitch $actual.EndsAtGearSwitch "$Description segment $index must preserve end gear-switch topology."
Assert-Equal $expectedStartIndex $actual.StartIndex "$Description segment $index must start directly after the prior segment."
Assert-True ($actual.EndIndex -ge $actual.StartIndex -and $actual.EndIndex -lt $ActualPath.Count) "$Description segment $index must cover valid path indices."
Assert-Equal $actual.StartsAtGearSwitch $ActualPath[$actual.StartIndex].IsGearSwitchPoint "$Description segment $index start flag must match its path point."
for ($pointIndex = $actual.StartIndex; $pointIndex -le $actual.EndIndex; $pointIndex++) {
Assert-Equal $actual.Direction $ActualPath[$pointIndex].Direction "$Description segment $index may not contain mixed directions."
}
$hasNext = $index + 1 -lt $ActualSegments.Count
$expectedEndSwitch = $hasNext -and $ActualPath[$actual.EndIndex + 1].IsGearSwitchPoint
Assert-Equal $expectedEndSwitch $actual.EndsAtGearSwitch "$Description segment $index end flag must match the next gear switch."
$expectedStartIndex = $actual.EndIndex + 1
}
Assert-Equal $ActualPath.Count $expectedStartIndex "$Description segments must cover every path point."
}
function Assert-GearSwitchGeometry($ExpectedPath, $ActualPath, [string]$Description) {
$expectedSwitches = @($ExpectedPath | Where-Object { $_.IsGearSwitchPoint })
$actualSwitches = @($ActualPath | Where-Object { $_.IsGearSwitchPoint })
Assert-Equal $expectedSwitches.Count $actualSwitches.Count "$Description must preserve gear-switch count."
for ($index = 0; $index -lt $expectedSwitches.Count; $index++) {
Assert-Near $expectedSwitches[$index].X $actualSwitches[$index].X "$Description gear switch $index must preserve X."
Assert-Near $expectedSwitches[$index].Y $actualSwitches[$index].Y "$Description gear switch $index must preserve Y."
Assert-Near $expectedSwitches[$index].Heading $actualSwitches[$index].Heading "$Description gear switch $index must preserve heading."
Assert-Equal $expectedSwitches[$index].Direction $actualSwitches[$index].Direction "$Description gear switch $index must preserve direction."
}
}
function Assert-RawBaselinePath($ExpectedPath, $ActualPath, [string]$ScenarioName) {
Assert-Equal $ExpectedPath.Count $ActualPath.Count "Raw baseline $ScenarioName must preserve every coarse-path point."
for ($index = 0; $index -lt $ExpectedPath.Count; $index++) {
$expected = $ExpectedPath[$index]
$actual = $ActualPath[$index]
Assert-Near $expected.X $actual.X "Raw baseline $ScenarioName point $index must preserve X."
Assert-Near $expected.Y $actual.Y "Raw baseline $ScenarioName point $index must preserve Y."
Assert-Near $expected.Heading $actual.Heading "Raw baseline $ScenarioName point $index must preserve heading."
Assert-Near $expected.UnwrappedHeading $actual.UnwrappedHeading "Raw baseline $ScenarioName point $index must preserve unwrapped heading."
Assert-Near $expected.ArcLength $actual.ArcLength "Raw baseline $ScenarioName point $index must preserve arc length."
Assert-Equal $expected.Direction $actual.Direction "Raw baseline $ScenarioName point $index must preserve travel direction."
Assert-Near $expected.VehicleCurvature $actual.VehicleCurvature "Raw baseline $ScenarioName point $index must preserve vehicle curvature."
Assert-Equal $expected.IsGearSwitchPoint $actual.IsGearSwitchPoint "Raw baseline $ScenarioName point $index must preserve gear-switch topology."
}
}
$coarse = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$coarseFacade = $coarse + 'Facade.'
$coarseTest = $coarse + 'Test.'
$smoothingTest = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.Test.'
$smoothingFacade = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade.'
$comparison = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison.'
$coarseServiceType = Get-RequiredType ($coarseFacade + 'CoarsePathPlanningService')
$coarseJobType = Get-RequiredType ($coarseFacade + 'CoarsePathPlanningJob')
$coarseResultType = Get-RequiredType ($coarseFacade + 'CoarsePathPlanningJobResult')
$scenarioType = Get-RequiredType ($coarseTest + 'CoarsePathTestScenario')
$coarseFactoryType = Get-RequiredType ($coarseTest + 'CoarsePathScenarioFactory')
$scenarioFactoryType = Get-RequiredType ($smoothingTest + 'SmoothingScenarioFactory')
$comparisonServiceType = Get-RequiredType ($smoothingFacade + 'PathSmoothingComparisonService')
$comparisonRequestType = Get-RequiredType ($comparison + 'PathSmoothingComparisonRequest')
$coarseCreate = $coarseFactoryType.GetMethod('Create', [Type[]]@($scenarioType))
$coarsePlan = $coarseServiceType.GetMethod('Plan', [Type[]]@($coarseJobType, [Threading.CancellationToken]))
$createComparisonRequest = $scenarioFactoryType.GetMethod('CreateEndToEndRequest', [Type[]]@($coarseJobType, $coarseResultType))
$compare = $comparisonServiceType.GetMethod('Compare', [Type[]]@($comparisonRequestType, [Threading.CancellationToken]))
Assert-True ($null -ne $createComparisonRequest) 'Smoothing scenario factory must convert a successful coarse planning job into a comparison request.'
$planner = [Activator]::CreateInstance($coarseServiceType)
$comparisonService = [Activator]::CreateInstance($comparisonServiceType)
foreach ($scenarioName in @('ExplicitEmpty', 'RectangleDetour', 'ManualAndTwoLeg', 'ReverseGearSwitch')) {
$scenario = [Enum]::Parse($scenarioType, $scenarioName)
$job = $coarseCreate.Invoke($null, @($scenario))
$planningTimer = [Diagnostics.Stopwatch]::StartNew()
$planned = $coarsePlan.Invoke($planner, @($job, [Threading.CancellationToken]::None))
$planningTimer.Stop()
Assert-Equal 'Success' $planned.PlanningResult.Status.ToString() "Coarse scenario $scenarioName must succeed before smoothing comparison."
$request = $createComparisonRequest.Invoke($null, @($job, $planned))
Assert-CoarsePathGeometry $planned.PlanningResult.Path $request.SmoothingRequest.CoarsePath $planned.MapResult.Map $scenarioName
$comparisonTimer = [Diagnostics.Stopwatch]::StartNew()
$comparisonResult = $compare.Invoke($comparisonService, @($request, [Threading.CancellationToken]::None))
$comparisonTimer.Stop()
Assert-True (-not $comparisonResult.IsCancelled) "Comparison $scenarioName must not be cancelled."
Assert-Equal 'Success' $comparisonResult.RawPathBaseline.Status.ToString() "Raw baseline $scenarioName must remain a feasible, verified copy of the coarse path."
Assert-RawBaselinePath $planned.PlanningResult.Path $comparisonResult.RawPathBaseline.Path $scenarioName
Assert-SegmentTopology $planned.PlanningResult.Segments $comparisonResult.RawPathBaseline.Path $comparisonResult.RawPathBaseline.Segments "Raw baseline $scenarioName"
Assert-GearSwitchGeometry $planned.PlanningResult.Path $comparisonResult.RawPathBaseline.Path "Raw baseline $scenarioName"
foreach ($entry in $comparisonResult.Entries | Where-Object { $_.Status.ToString() -eq 'Success' }) {
Assert-SegmentTopology $comparisonResult.RawPathBaseline.Segments $entry.Path $entry.Segments "Successful $($entry.Method) $scenarioName"
Assert-GearSwitchGeometry $comparisonResult.RawPathBaseline.Path $entry.Path "Successful $($entry.Method) $scenarioName"
}
$successfulEntryCount = @($comparisonResult.Entries | Where-Object { $_.Status.ToString() -eq 'Success' }).Count
Write-Output ("$scenarioName diagnostics: planning=$([Math]::Round($planningTimer.Elapsed.TotalSeconds, 3))s; comparison=$([Math]::Round($comparisonTimer.Elapsed.TotalSeconds, 3))s; successfulMethods=$successfulEntryCount")
}
Write-Output 'Path smoothing end-to-end integration checks passed.'
@@ -197,16 +197,50 @@ $realCandidates = (Get-InternalMethod $builderType 'Build').Invoke($realBuilder,
Assert-True ($realCandidates.Count -gt 0) 'SingleTurn must build real Local G2 candidates.'
$realEvaluator = New-InternalInstance $evaluatorType
$duplicateFailures = @()
$acceptedSplitCandidates = @()
$maximumAnchorError = 0.0
$maximumConnectionPositionError = 0.0
$maximumTangentDirectionError = 0.0
$maximumCurvatureError = 0.0
foreach ($realCandidate in $realCandidates) {
$evaluation = (Get-InternalMethod $evaluatorType 'Evaluate').Invoke($realEvaluator, @(
$preparedPath, $preparedPath, $region, $realCandidate, $singleTurnRequest, $options, [Threading.CancellationToken]::None))
$preparedPath, $preparedPath, $region, $realCandidate, $singleTurnRequest, $options,
[Threading.CancellationToken]::None))
$failureReason = Get-InternalProperty $evaluation 'FailureReason'
if ($failureReason -eq 2) {
$candidateIndex = Get-InternalProperty $realCandidate 'CandidateIndex'
$duplicateFailures += $candidateIndex
if ($failureReason -eq 2) { $duplicateFailures += (Get-InternalProperty $realCandidate 'CandidateIndex') }
$hasDifferentScale = $false
foreach ($diagnostic in (Get-InternalProperty $realCandidate 'InternalScaleDiagnostics')) {
$anchorError = [double](Get-InternalProperty $diagnostic 'AnchorPositionErrorMeters')
$maximumAnchorError = [Math]::Max($maximumAnchorError, $anchorError)
$maximumConnectionPositionError = [Math]::Max(
$maximumConnectionPositionError,
[double](Get-InternalProperty $diagnostic 'ConnectionPositionErrorMeters'))
$maximumTangentDirectionError = [Math]::Max(
$maximumTangentDirectionError,
[double](Get-InternalProperty $diagnostic 'TangentDirectionErrorRadians'))
$maximumCurvatureError = [Math]::Max(
$maximumCurvatureError,
[double](Get-InternalProperty $diagnostic 'CurvatureErrorPerMeter'))
$incoming = [double](Get-InternalProperty $diagnostic 'IncomingDerivativeScale')
$outgoing = [double](Get-InternalProperty $diagnostic 'OutgoingDerivativeScale')
if ([Math]::Abs($incoming - $outgoing) -gt 1e-10) { $hasDifferentScale = $true }
}
if ((Get-InternalProperty $evaluation 'Accepted') -and $hasDifferentScale) {
$acceptedSplitCandidates += $realCandidate
}
}
Assert-Equal 0 $duplicateFailures.Count `
'SingleTurn builder candidates must not fail evaluator window analysis due to duplicate or degenerate points.'
'SingleTurn candidates must not fail raw-window analysis on coincident boundary points.'
Assert-True ($maximumAnchorError -le 1e-9) `
'Every internal primitive boundary must remain at its original coordinate.'
Assert-True ($maximumConnectionPositionError -le 1e-9) `
'Every real split-scale connection must be position continuous.'
Assert-True ($maximumTangentDirectionError -le 1e-8) `
'Every real split-scale connection must preserve unit tangent direction.'
Assert-True ($maximumCurvatureError -le 1e-8) `
'Every real split-scale connection must preserve geometric curvature.'
Assert-True ($acceptedSplitCandidates.Count -gt 0) `
'SingleTurn must accept a zero-coordinate-offset candidate with different incoming/outgoing scales.'
Write-Output 'Path smoothing Local G2 candidate checks passed.'
@@ -0,0 +1,20 @@
$ErrorActionPreference = 'Stop'
$root = Join-Path $PSScriptRoot '..\ParkrobTrajplanner\PathSmoothing'
$sourcePath = Join-Path $root 'Contracts\SmoothedPathPointSource.cs'
$evidencePath = Join-Path $root 'Test\Fixtures\local-g2-diagnostic-single-turn.json'
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
$source = Get-Content -Raw -Encoding UTF8 $sourcePath
Assert-True ($source -match 'LocalG2Transition\s*=\s*4') `
'LocalG2Transition must retain serialized value 4 for diagnostic evidence compatibility.'
$evidence = Get-Content -Raw -Encoding UTF8 $evidencePath | ConvertFrom-Json
foreach ($point in $evidence.CandidatePoints) {
Assert-True ($point.Source -eq 4) 'Diagnostic evidence points must use the LocalG2Transition serialized value.'
}
Write-Output 'Local G2 diagnostic serialization contract checks passed.'
@@ -0,0 +1,11 @@
param(
[string]$FixturePath = (Join-Path $PSScriptRoot "..\\ParkrobTrajplanner\\PathSmoothing\\Test\\Fixtures\\path-smoothing-fixtures.json"),
[string]$EvidencePath = (Join-Path $PSScriptRoot "..\\ParkrobTrajplanner\\PathSmoothing\\Test\\Fixtures\\local-g2-diagnostic-single-turn.json")
)
$hostProject = Join-Path $PSScriptRoot "PathSmoothingPngVerificationHost\\PathSmoothingPngVerificationHost.csproj"
$resolvedFixturePath = (Resolve-Path -LiteralPath $FixturePath).Path
$resolvedEvidencePath = (Resolve-Path -LiteralPath $EvidencePath).Path
& dotnet run --project $hostProject --no-restore -- --verify-local-g2-diagnostic $resolvedFixturePath $resolvedEvidencePath
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
@@ -0,0 +1,98 @@
param(
[string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'),
[string]$FixturePath = (Join-Path $PSScriptRoot '..\ParkrobTrajplanner\PathSmoothing\Test\Fixtures\path-smoothing-fixtures.json')
)
$ErrorActionPreference = 'Stop'
$newtonsoft = Join-Path $env:USERPROFILE '.nuget\packages\newtonsoft.json\13.0.4\lib\netstandard2.0\Newtonsoft.Json.dll'
if (Test-Path $newtonsoft) { $null = [Reflection.Assembly]::LoadFrom($newtonsoft) }
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) { if (-not $Actual) { throw $Message } }
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Near([double]$Expected, [double]$Actual, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt 0.000000001) {
throw "$Message Expected=$Expected Actual=$Actual"
}
}
function Get-RequiredType([string]$Name) { return $assembly.GetType($Name, $true) }
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$facade = $root + 'Facade.'
$test = $root + 'Test.'
$serviceType = Get-RequiredType ($facade + 'PathSmoothingService')
$requestType = Get-RequiredType ($root + 'PathSmoothingRequest')
$configurationType = Get-RequiredType ($root + 'PathSmoothingConfiguration')
$factoryType = Get-RequiredType ($test + 'SmoothingScenarioFactory')
$service = [Activator]::CreateInstance($serviceType)
$smooth = $serviceType.GetMethod('Smooth', [Type[]]@($requestType, [Threading.CancellationToken]))
$createFixtures = $factoryType.GetMethod('CreateFixtureRequests', [Type[]]@([string]))
Assert-True ($null -ne $smooth) 'Local G2 must use the public service entrypoint.'
$publishedStatuses = @('Complete', 'PartialImprovement', 'NotNeeded', 'Unchanged')
function New-LocalG2Request($BaseRequest, [scriptblock]$Configure = $null) {
$configuration = [Activator]::CreateInstance($configurationType)
if ($null -ne $Configure) { & $Configure $configuration }
return [Activator]::CreateInstance($requestType, @(
$BaseRequest.CoarsePath, $BaseRequest.Segments, $BaseRequest.Map,
$BaseRequest.Vehicle, $configuration))
}
function Invoke-LocalG2($Request, [Threading.CancellationToken]$CancellationToken = [Threading.CancellationToken]::None) {
return $smooth.Invoke($service, @($Request, $CancellationToken))
}
function Assert-PublishedPath($Result, [string]$Name) {
Assert-True ($Result.Path.Count -gt 0) "$Name must publish a complete path."
Assert-Equal 0 $Result.Segments[0].StartIndex "$Name segments must start at zero."
Assert-Equal ($Result.Path.Count - 1) $Result.Segments[-1].EndIndex "$Name segments must cover the path."
Assert-True $Result.Diagnostics.Metrics.IsFeasible "$Name diagnostics must be feasible."
Assert-True ($Result.Diagnostics.Metrics.MinimumBodyClearanceMeters -ge 0.0) "$Name must remain collision-free at the configured 0 m reserve."
}
function Assert-Equivalent($First, $Second, [string]$Name) {
Assert-Equal $First.Status $Second.Status "$Name must retain deterministic status."
Assert-Equal $First.Path.Count $Second.Path.Count "$Name must retain deterministic point count."
Assert-Equal $First.RegionReports.Count $Second.RegionReports.Count "$Name must retain deterministic report count."
for ($index = 0; $index -lt $First.Path.Count; $index++) {
Assert-Near $First.Path[$index].X $Second.Path[$index].X "$Name point $index must retain X."
Assert-Near $First.Path[$index].Y $Second.Path[$index].Y "$Name point $index must retain Y."
}
for ($index = 0; $index -lt $First.RegionReports.Count; $index++) {
Assert-Equal $First.RegionReports[$index].SelectedCandidateIndex $Second.RegionReports[$index].SelectedCandidateIndex "$Name report $index must retain candidate selection."
Assert-Equal $First.RegionReports[$index].Status $Second.RegionReports[$index].Status "$Name report $index must retain status."
}
}
$fixtures = $createFixtures.Invoke($null, @((Resolve-Path $FixturePath).Path))
for ($index = 0; $index -lt $fixtures.Count; $index++) {
$request = New-LocalG2Request $fixtures[$index].SmoothingRequest
$first = Invoke-LocalG2 $request
$second = Invoke-LocalG2 $request
Assert-Equivalent $first $second "Fixture $index"
$status = $first.Status.ToString()
Assert-True ($status -in @('Complete', 'PartialImprovement', 'NotNeeded', 'Unchanged', 'Failed', 'InvalidInput', 'Infeasible')) "Fixture $index must return a defined Local G2 result status."
if ($publishedStatuses -contains $status) {
Assert-PublishedPath $first "Fixture $index"
}
else {
Assert-Equal 0 $first.Path.Count "Unpublished fixture $index must not expose a path."
Assert-Equal 0 $first.Segments.Count "Unpublished fixture $index must not expose segments."
}
}
$cancelSource = [Threading.CancellationTokenSource]::new()
try {
$cancelSource.Cancel()
$cancelled = Invoke-LocalG2 (New-LocalG2Request $fixtures[1].SmoothingRequest) $cancelSource.Token
Assert-Equal 'Cancelled' $cancelled.Status.ToString() 'Cancellation must propagate through Local G2.'
Assert-Equal 0 $cancelled.Path.Count 'A cancelled run must not publish a partial path.'
}
finally {
$cancelSource.Dispose()
}
Write-Output 'Local G2 service integration checks passed.'
@@ -0,0 +1,75 @@
param(
[string]$PathSmoothingRoot = (Join-Path $PSScriptRoot '..\ParkrobTrajplanner\PathSmoothing')
)
$ErrorActionPreference = 'Stop'
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-FileExists([string]$RelativePath) {
Assert-True (Test-Path -LiteralPath (Join-Path $PathSmoothingRoot $RelativePath) -PathType Leaf) "Missing required file: $RelativePath"
}
function Assert-DirectoryExists([string]$RelativePath) {
Assert-True (Test-Path -LiteralPath (Join-Path $PathSmoothingRoot $RelativePath) -PathType Container) "Missing required directory: $RelativePath"
}
function Assert-PathAbsent([string]$RelativePath) {
Assert-True (-not (Test-Path -LiteralPath (Join-Path $PathSmoothingRoot $RelativePath))) "Removed legacy path still exists: $RelativePath"
}
Assert-DirectoryExists 'Contracts'
Assert-DirectoryExists 'Facade'
Assert-DirectoryExists 'LocalG2'
Assert-DirectoryExists 'Processing'
Assert-DirectoryExists 'Validation'
Assert-DirectoryExists 'Output'
Assert-DirectoryExists 'Output\Comparison'
Assert-DirectoryExists 'Output\Visualization'
Assert-DirectoryExists 'Test'
Assert-FileExists 'README.md'
foreach ($legacyPath in @(
'Algorithms',
'Comparison',
'Visualization',
'Contracts\CubicBSplineOptions.cs',
'Contracts\LocalCubicBezierOptions.cs',
'Contracts\PiecewiseQuinticOptions.cs',
'Output\Comparison\SmoothingMethodRanker.cs'
)) {
Assert-PathAbsent $legacyPath
}
$methodSource = Get-Content -Raw -Encoding UTF8 (Join-Path $PathSmoothingRoot 'Contracts\SmoothingMethod.cs')
Assert-True ($methodSource -match 'LocalG2Quintic') 'SmoothingMethod must retain LocalG2Quintic.'
foreach ($legacyMethod in @('CubicBSpline', 'LocalCubicBezier', 'PiecewiseQuintic')) {
Assert-True (-not $methodSource.Contains($legacyMethod)) "SmoothingMethod must not retain $legacyMethod."
}
$configurationSource = Get-Content -Raw -Encoding UTF8 (Join-Path $PathSmoothingRoot 'Contracts\PathSmoothingConfiguration.cs')
foreach ($legacyConfiguration in @('CubicBSpline', 'LocalCubicBezier', 'PiecewiseQuintic', 'SmoothingStrength', 'RetryStrengthScales', 'AllowFallbackToCoarsePath')) {
Assert-True (-not $configurationSource.Contains($legacyConfiguration)) "PathSmoothingConfiguration must not retain $legacyConfiguration."
}
Assert-True ($configurationSource.Contains('MinimumClearanceReserveMeters = 0d;')) 'The requested 0 m clearance-reserve default must remain.'
$readme = Get-Content -Raw -Encoding UTF8 (Join-Path $PathSmoothingRoot 'README.md')
foreach ($heading in @(
'# PathSmoothing',
'Module Overview',
'File Structure',
'Smoothing Data Flow',
'Result Status and Publication Rules',
'Coordinates and Units',
'Minimal Call Example',
'Detailed Usage Guide',
'Fixture Reports and Visualization',
'Common Errors',
'First-Version Limits'
)) {
Assert-True ($readme.Contains($heading)) "README is missing required section: $heading"
}
Write-Output 'PathSmoothing LocalG2-only layout checks passed.'
@@ -0,0 +1,30 @@
param(
[string]$FixturePath = (Join-Path $PSScriptRoot '..\ParkrobTrajplanner\PathSmoothing\Test\Fixtures\path-smoothing-fixtures.json')
)
$ErrorActionPreference = 'Stop'
$hostProject = Join-Path $PSScriptRoot 'PathSmoothingPngVerificationHost\PathSmoothingPngVerificationHost.csproj'
if (-not (Test-Path -LiteralPath $hostProject)) {
throw "Path smoothing PNG verification host was not found: $hostProject"
}
$resolvedFixturePath = (Resolve-Path -LiteralPath $FixturePath).Path
& dotnet run --project $hostProject --no-restore -- $resolvedFixturePath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'verify_path_smoothing_local_g2_diagnostic_visualization.ps1')
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$runnerPath = Join-Path $PSScriptRoot 'run_local_g2_diagnostic_visualization.ps1'
$runnerOutput = Join-Path $PSScriptRoot '..\obj\path_smoothing_reports\local-g2-png-smoke'
& powershell -ExecutionPolicy Bypass -File $runnerPath -OutputDirectory $runnerOutput
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$primaryPng = Join-Path $runnerOutput '05-local-g2-diagnostic-candidate.png'
if (-not (Test-Path -LiteralPath $primaryPng)) {
throw "Local G2 diagnostic runner did not publish $primaryPng"
}
Write-Output 'Path smoothing PNG checks passed.'
@@ -1,338 +0,0 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Near([double]$Expected, [double]$Actual, [double]$Tolerance, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt $Tolerance) {
throw "$Message Expected=$Expected Actual=$Actual Tolerance=$Tolerance"
}
}
function Get-RequiredType([string]$Name) {
return $assembly.GetType($Name, $true)
}
function Get-PropertyValue($Instance, [string]$Name) {
$property = $Instance.GetType().GetProperty($Name, [Reflection.BindingFlags]'Instance,Public,NonPublic')
Assert-True ($null -ne $property) ("Missing property: " + $Name)
return $property.GetValue($Instance)
}
function New-Point(
[double]$X,
[double]$Y,
[double]$ArcLength,
[double]$Heading,
[double]$BodyClearance = 1.0,
[bool]$IsGearSwitch = $false) {
return [Activator]::CreateInstance($pointType, @(
$X, $Y, $ArcLength, $Heading, $Heading, $BodyClearance, $IsGearSwitch, $anchor))
}
function New-DirectionSegment(
[int]$Index,
$Direction,
[object[]]$Points,
[bool]$StartsAtGearSwitch = $false,
[bool]$EndsAtGearSwitch = $false) {
$typedPoints = [Array]::CreateInstance($pointType, $Points.Count)
for ($pointIndex = 0; $pointIndex -lt $Points.Count; $pointIndex++) {
$typedPoints.SetValue($Points[$pointIndex], $pointIndex)
}
return [Activator]::CreateInstance($segmentType, @(
$Index, $Direction, $typedPoints, $StartsAtGearSwitch, $EndsAtGearSwitch))
}
function New-EmptyMap {
$request = [Activator]::CreateInstance($mapRequestType)
$request.Bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]5000, [single]0, [single]5000))
$request.ResolutionMm = [single]50
$request.AllowExplicitEmptyMap = $true
$map = [Activator]::CreateInstance($mapFactoryType).Create($request).Map
Assert-True ($null -ne $map) 'Quintic test must create an explicit empty planning map.'
return $map
}
function New-AlgorithmInput(
[object[]]$Segments,
[double]$ReserveMeters,
[double]$KnotSpacingMeters = 1.0,
[double]$MinimumKnotSpacingMeters = 0.10) {
$typedSegments = [Array]::CreateInstance($segmentType, $Segments.Count)
for ($index = 0; $index -lt $Segments.Count; $index++) {
$typedSegments.SetValue($Segments[$index], $index)
}
$preparedPath = [Activator]::CreateInstance($preparedPathType, [object[]]@(,$typedSegments))
$vehicle = [Activator]::CreateInstance($vehicleType)
$vehicle.LengthMeters = [double]0.20
$vehicle.WidthMeters = [double]0.20
$vehicle.SafetyMarginMeters = [double]0.0
$vehicle.MaximumCurvaturePerMeter = [double]100.0
$vehicle.MinimumTurningRadiusMeters = [double]0.01
$configuration = [Activator]::CreateInstance($configurationType)
$configuration.PiecewiseQuintic.KnotSpacingMeters = $KnotSpacingMeters
$configuration.PiecewiseQuintic.MinimumKnotSpacingMeters = $MinimumKnotSpacingMeters
$options = $optionsConstructor.Invoke(@($configuration))
return $inputConstructor.Invoke(@($preparedPath, (New-EmptyMap), $vehicle, [double]0.05, $ReserveMeters, $options))
}
function Invoke-Candidate(
[object[]]$Segments,
[double]$ReserveMeters = 0.0,
[double]$KnotSpacingMeters = 1.0,
[double]$MinimumKnotSpacingMeters = 0.10) {
return $smoothMethod.Invoke($smoother, @(
(New-AlgorithmInput $Segments $ReserveMeters $KnotSpacingMeters $MinimumKnotSpacingMeters),
[double]1.0, [Threading.CancellationToken]::None))
}
function Invoke-Smoothing(
[object[]]$Segments,
[double]$ReserveMeters = 0.0,
[double]$KnotSpacingMeters = 1.0,
[double]$MinimumKnotSpacingMeters = 0.10) {
$candidate = Invoke-Candidate $Segments $ReserveMeters $KnotSpacingMeters $MinimumKnotSpacingMeters
Assert-True (Get-PropertyValue $candidate 'Succeeded') 'Quintic smoothing must produce a candidate for the deterministic fixture.'
return @(Get-PropertyValue $candidate 'Segments')
}
function Get-PointDistance($Left, $Right) {
$deltaX = $Left.X - $Right.X
$deltaY = $Left.Y - $Right.Y
return [Math]::Sqrt($deltaX * $deltaX + $deltaY * $deltaY)
}
function Get-Reference([object[]]$Source, [double]$ArcLength) {
$typedPoints = [Array]::CreateInstance($pointType, $Source.Count)
for ($index = 0; $index -lt $Source.Count; $index++) { $typedPoints.SetValue($Source[$index], $index) }
$arguments = [object[]]@($typedPoints, $ArcLength, $null, $null)
Assert-True $interpolateMethod.Invoke($null, $arguments) 'Quintic test must resolve every sampled local-arc reference.'
return $arguments[2]
}
function Get-PointAtArcLength([object[]]$Points, [double]$ArcLength) {
foreach ($point in $Points) {
if ([Math]::Abs($point.ArcLength - $ArcLength) -lt 0.000000000001) { return $point }
}
throw "Missing quintic sample at arc length $ArcLength"
}
function Get-EndpointDerivative([object[]]$Samples, [double]$StepMeters, [bool]$AtStart) {
$firstCoefficients = @((-137.0 / 60.0), 5.0, -5.0, (10.0 / 3.0), (-5.0 / 4.0), (1.0 / 5.0))
$x = 0.0
$y = 0.0
for ($index = 0; $index -lt 6; $index++) {
$sampleIndex = if ($AtStart) { $index } else { 5 - $index }
$sign = if ($AtStart) { 1.0 } else { -1.0 }
$x += $firstCoefficients[$index] * $Samples[$sampleIndex].X
$y += $firstCoefficients[$index] * $Samples[$sampleIndex].Y
}
return [PSCustomObject]@{ X = $sign * $x / $StepMeters; Y = $sign * $y / $StepMeters }
}
function Get-EndpointSecondDerivative([object[]]$Samples, [double]$StepMeters, [bool]$AtStart) {
$coefficients = @((15.0 / 4.0), (-77.0 / 6.0), (107.0 / 6.0), -13.0, (61.0 / 12.0), (-5.0 / 6.0))
$x = 0.0
$y = 0.0
for ($index = 0; $index -lt 6; $index++) {
$sampleIndex = if ($AtStart) { $index } else { 5 - $index }
$x += $coefficients[$index] * $Samples[$sampleIndex].X
$y += $coefficients[$index] * $Samples[$sampleIndex].Y
}
return [PSCustomObject]@{ X = $x / ($StepMeters * $StepMeters); Y = $y / ($StepMeters * $StepMeters) }
}
function Get-IntervalSamples([object[]]$Points, [double]$StartArcLength, [double]$EndArcLength, [bool]$FromStart) {
$intervalLength = $EndArcLength - $StartArcLength
$result = @()
for ($index = 0; $index -lt 6; $index++) {
$parameter = if ($FromStart) { $index / 8.0 } else { (3.0 + $index) / 8.0 }
$result += Get-PointAtArcLength $Points ($StartArcLength + $parameter * $intervalLength)
}
return $result
}
function Get-QuinticPositionFromInteriorSamples(
[object[]]$Points,
[double]$StartArcLength,
[double]$EndArcLength,
[double[]]$Parameters,
[double]$TargetParameter) {
$intervalLength = $EndArcLength - $StartArcLength
$x = 0.0
$y = 0.0
for ($index = 0; $index -lt $Parameters.Count; $index++) {
$weight = 1.0
for ($otherIndex = 0; $otherIndex -lt $Parameters.Count; $otherIndex++) {
if ($index -ne $otherIndex) {
$weight *= ($TargetParameter - $Parameters[$otherIndex]) / ($Parameters[$index] - $Parameters[$otherIndex])
}
}
$sample = Get-PointAtArcLength $Points ($StartArcLength + $Parameters[$index] * $intervalLength)
$x += $weight * $sample.X
$y += $weight * $sample.Y
}
return [PSCustomObject]@{ X = $x; Y = $y }
}
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$processing = $root + 'Processing.'
$algorithms = $root + 'Algorithms.'
$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$smootherType = Get-RequiredType ($algorithms + 'PiecewiseQuinticSmoother')
$pointType = Get-RequiredType ($processing + 'SmoothingPoint2D')
$segmentType = Get-RequiredType ($processing + 'PreparedDirectionSegment')
$preparedPathType = Get-RequiredType ($processing + 'PreparedPath')
$inputType = Get-RequiredType ($algorithms + 'SmoothingAlgorithmInput')
$optionsType = Get-RequiredType ($algorithms + 'SmoothingOptionsSnapshot')
$configurationType = Get-RequiredType ($root + 'PathSmoothingConfiguration')
$interpolatorType = Get-RequiredType ($processing + 'PathReferenceInterpolator')
$vehicleType = Get-RequiredType ($coarsePath + 'VehicleParameters')
$directionType = Get-RequiredType ($coarsePath + 'TravelDirection')
$sourceType = Get-RequiredType ($root + 'SmoothedPathPointSource')
$boundsType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.MapBoundsMm'
$mapType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningGridMap'
$mapRequestType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapRequest'
$mapFactoryType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapFactory'
$inputConstructor = $inputType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null,
@($preparedPathType, $mapType, $vehicleType, [double], [double], $optionsType), $null)
Assert-True ($null -ne $inputConstructor) 'Algorithm input must carry immutable quintic options and clearance reserve.'
$optionsConstructor = $optionsType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null, @($configurationType), $null)
Assert-True ($null -ne $optionsConstructor) 'Quintic tests must create immutable options snapshots.'
$interpolateMethod = $interpolatorType.GetMethod('TryInterpolateByArcLength', [Reflection.BindingFlags]'Static,Public,NonPublic')
Assert-True ($null -ne $interpolateMethod) 'PathReferenceInterpolator must expose local-arc interpolation.'
$smoother = [Activator]::CreateInstance($smootherType, $true)
$smoothMethod = $smootherType.GetMethod('Smooth', [Reflection.BindingFlags]'Instance,Public')
Assert-True ($null -ne $smoothMethod) 'PiecewiseQuinticSmoother must implement the internal smoother contract.'
Assert-Equal 'PiecewiseQuintic' $smoother.Method.ToString() 'Quintic smoother must identify its public smoothing method.'
$forward = [Enum]::Parse($directionType, 'Forward')
$reverse = [Enum]::Parse($directionType, 'Reverse')
$anchor = [Enum]::Parse($sourceType, 'Anchor')
# The 1.0 m local-arc knot spacing creates shared knots at s=1 and s=2.
# The generated 1/8 samples allow exact one-sided quintic derivative reconstruction.
$continuitySource = @(
(New-Point 0.00 0.00 0.00 0.00),
(New-Point 0.50 0.00 0.50 0.00),
(New-Point 1.00 0.00 1.00 0.00),
(New-Point 1.00 0.50 1.50 ([Math]::PI / 2.0)),
(New-Point 1.00 1.00 2.00 ([Math]::PI / 2.0)),
(New-Point 1.30 1.30 2.40 ([Math]::PI / 4.0)))
$continuityOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $continuitySource)))[0].Points
foreach ($sharedArcLength in @(1.0, 2.0)) {
$leftSamples = Get-IntervalSamples $continuityOutput ($sharedArcLength - 1.0) $sharedArcLength $false
$rightEnd = if ($sharedArcLength -eq 2.0) { 2.4 } else { $sharedArcLength + 1.0 }
$rightSamples = Get-IntervalSamples $continuityOutput $sharedArcLength $rightEnd $true
$leftPosition = Get-QuinticPositionFromInteriorSamples $continuityOutput ($sharedArcLength - 1.0) $sharedArcLength `
@((2.0 / 8.0), (3.0 / 8.0), (4.0 / 8.0), (5.0 / 8.0), (6.0 / 8.0), (7.0 / 8.0)) 1.0
$rightPosition = Get-QuinticPositionFromInteriorSamples $continuityOutput $sharedArcLength $rightEnd `
@((1.0 / 8.0), (2.0 / 8.0), (3.0 / 8.0), (4.0 / 8.0), (5.0 / 8.0), (6.0 / 8.0)) 0.0
$leftFirst = Get-EndpointDerivative $leftSamples (1.0 / 8.0) $false
$rightFirst = Get-EndpointDerivative $rightSamples (($rightEnd - $sharedArcLength) / 8.0) $true
$leftSecond = Get-EndpointSecondDerivative $leftSamples (1.0 / 8.0) $false
$rightSecond = Get-EndpointSecondDerivative $rightSamples (($rightEnd - $sharedArcLength) / 8.0) $true
Assert-Near $leftPosition.X $rightPosition.X 0.000001 'Shared knot X position must match from both quintic intervals.'
Assert-Near $leftPosition.Y $rightPosition.Y 0.000001 'Shared knot Y position must match from both quintic intervals.'
Assert-Near $leftFirst.X $rightFirst.X 0.000001 'Shared knot X first derivative must be C1.'
Assert-Near $leftFirst.Y $rightFirst.Y 0.000001 'Shared knot Y first derivative must be C1.'
Assert-Near $leftSecond.X $rightSecond.X 0.000001 'Shared knot X second derivative must be C2.'
Assert-Near $leftSecond.Y $rightSecond.Y 0.000001 'Shared knot Y second derivative must be C2.'
}
$firstOutput = $continuityOutput[0]
$lastOutput = $continuityOutput[$continuityOutput.Count - 1]
Assert-Near $continuitySource[0].X $firstOutput.X 0.0 'Quintic start X must remain exact.'
Assert-Near $continuitySource[0].Y $firstOutput.Y 0.0 'Quintic start Y must remain exact.'
Assert-Near $continuitySource[$continuitySource.Count - 1].X $lastOutput.X 0.0 'Quintic end X must remain exact.'
Assert-Near $continuitySource[$continuitySource.Count - 1].Y $lastOutput.Y 0.0 'Quintic end Y must remain exact.'
foreach ($point in $continuityOutput) {
$reference = Get-Reference $continuitySource $point.ArcLength
Assert-True ((Get-PointDistance $point $reference) -le ($reference.BodyClearance + 0.000000000001)) 'Every quintic sample must remain inside its local reference movement bound.'
}
# Separate prepared direction segments must retain their exact duplicated switch pose and topology.
$reverseSource = @(
(New-Point 1.30 1.30 0.00 ([Math]::PI / 4.0) 1.0 $true),
(New-Point 1.30 0.80 0.50 ([Math]::PI / 2.0)),
(New-Point 1.30 0.30 1.00 ([Math]::PI / 2.0)))
$switchOutput = @(Invoke-Smoothing @(
(New-DirectionSegment 0 $forward $continuitySource $false $true),
(New-DirectionSegment 1 $reverse $reverseSource $true $false)))
Assert-Equal 2 $switchOutput.Count 'Quintic smoothing must retain separate direction segments.'
Assert-True $switchOutput[0].EndsAtGearSwitch 'Forward quintic segment must retain its gear-switch boundary flag.'
Assert-True $switchOutput[1].StartsAtGearSwitch 'Reverse quintic segment must retain its gear-switch boundary flag.'
$leftSwitch = $switchOutput[0].Points[$switchOutput[0].Points.Count - 1]
$rightSwitch = $switchOutput[1].Points[0]
Assert-Near $leftSwitch.X $rightSwitch.X 0.0 'Quintic smoothing must preserve switch X exactly.'
Assert-Near $leftSwitch.Y $rightSwitch.Y 0.0 'Quintic smoothing must preserve switch Y exactly.'
# Spacing selects local-arc knots, while a valid minimum spacing does not change that selection.
$oneMeterOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $continuitySource)) 0.0 1.0 0.10)[0].Points
$halfMeterOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $continuitySource)) 0.0 0.50 0.10)[0].Points
$largeMinimumOutput = @(Invoke-Smoothing @((New-DirectionSegment 0 $forward $continuitySource)) 0.0 1.0 0.30)[0].Points
Assert-True ($halfMeterOutput.Count -gt $oneMeterOutput.Count) 'Custom knot spacing must create additional local-arc knot intervals.'
Assert-Equal $oneMeterOutput.Count $largeMinimumOutput.Count 'Valid minimum knot spacing must not change knot selection.'
for ($index = 0; $index -lt $oneMeterOutput.Count; $index++) {
Assert-Near $oneMeterOutput[$index].X $largeMinimumOutput[$index].X 0.000000000001 'Minimum knot spacing must not change valid quintic X geometry.'
Assert-Near $oneMeterOutput[$index].Y $largeMinimumOutput[$index].Y 0.000000000001 'Minimum knot spacing must not change valid quintic Y geometry.'
}
$shortSource = @(
(New-Point 0.00 0.00 0.00 0.00),
(New-Point 0.05 0.00 0.05 0.00))
$shortCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $shortSource)) 0.0 1.0 0.10
Assert-Equal 'Failed' (Get-PropertyValue $shortCandidate 'Status').ToString() 'A segment shorter than the configured minimum knot spacing must fail terminally.'
Assert-True (-not (Get-PropertyValue $shortCandidate 'Succeeded')) 'A degenerate short quintic segment must not be executable.'
Assert-Equal 0 (Get-PropertyValue $shortCandidate 'Segments').Count 'A terminal quintic degeneracy must publish no geometry.'
# Repeated decimal addition must not create a spurious 1e-16 m terminal residual when the
# requested spacing divides the local length exactly. A real 0.005 m remainder remains below
# the 0.01 m minimum and must still fail terminally.
$decimalMultipleSource = @(
(New-Point 0.0 0.0 0.0 0.0),
(New-Point 1.0 0.0 1.0 0.0))
$decimalMultipleCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $decimalMultipleSource)) 0.0 0.10 0.01
Assert-Equal 'Success' (Get-PropertyValue $decimalMultipleCandidate 'Status').ToString() 'Decimal-exact knot multiples must not become a terminal under-minimum residual failure.'
$decimalMultipleOutput = @(Get-PropertyValue $decimalMultipleCandidate 'Segments')[0].Points
Assert-Equal 81 $decimalMultipleOutput.Count 'A 1.0 m segment with 0.1 m spacing must create exactly ten valid quintic intervals.'
Assert-Near 1.0 $decimalMultipleOutput[$decimalMultipleOutput.Count - 1].ArcLength 0.0 'Decimal-multiple knot normalization must retain the exact terminal arc length.'
$meaningfulShortResidualSource = @(
(New-Point 0.0 0.0 0.0 0.0),
(New-Point 1.005 0.0 1.005 0.0))
$meaningfulShortResidualCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $meaningfulShortResidualSource)) 0.0 0.10 0.01
Assert-Equal 'Failed' (Get-PropertyValue $meaningfulShortResidualCandidate 'Status').ToString() 'A genuine 0.005 m terminal residual must remain a terminal spacing failure.'
Assert-Equal 0 (Get-PropertyValue $meaningfulShortResidualCandidate 'Segments').Count 'A genuine under-minimum residual must publish no geometry.'
$smallScaleResidualSource = @(
(New-Point 0.0 0.0 0.0 0.0),
(New-Point 0.000000000000105 0.0 0.000000000000105 0.0))
$smallScaleResidualCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $smallScaleResidualSource)) 0.0 0.00000000000001 0.000000000000006
Assert-Equal 'Failed' (Get-PropertyValue $smallScaleResidualCandidate 'Status').ToString() 'Endpoint normalization tolerance must scale with local arc magnitude and preserve a genuine small residual failure.'
# Local-arc reference mapping, rather than sample index/global distance, must reject this unsafe nonuniform path.
$nonuniformUnsafeSource = @(
(New-Point 0.0 0.0 0.0 0.0 0.50),
(New-Point 1.0 0.0 4.0 0.0 0.50),
(New-Point 2.0 0.0 5.0 0.0 0.50),
(New-Point 3.0 0.0 6.0 0.0 0.50),
(New-Point 3.0 1.0 9.0 ([Math]::PI / 2.0) 0.50),
(New-Point 3.0 2.0 20.0 ([Math]::PI / 2.0) 0.50))
$nonuniformUnsafeCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $nonuniformUnsafeSource)) 0.0 5.0 0.10
Assert-Equal 'RetryableInfeasible' (Get-PropertyValue $nonuniformUnsafeCandidate 'Status').ToString() 'Unsafe nonuniform local-arc quintic movement must be retryable.'
Assert-True (-not (Get-PropertyValue $nonuniformUnsafeCandidate 'Succeeded')) 'Unsafe nonuniform quintic geometry must not be executable.'
Assert-Equal 0 (Get-PropertyValue $nonuniformUnsafeCandidate 'Segments').Count 'Retryable quintic infeasibility must publish no geometry.'
Write-Output 'Path smoothing piecewise quintic checks passed.'
@@ -1,81 +0,0 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-False($Actual, [string]$Message) {
if ($Actual) { throw $Message }
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Near([double]$Expected, [double]$Actual, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt 0.000000001) {
throw "$Message Expected=$Expected Actual=$Actual"
}
}
function Get-RequiredType([string]$Name) {
return $assembly.GetType($Name, $true)
}
function Assert-AttemptedStrengths($Snapshot, [double[]]$Expected, [string]$Message) {
Assert-Equal $Expected.Length $Snapshot.AttemptedStrengths.Count ($Message + ' count')
for ($index = 0; $index -lt $Expected.Length; $index++) {
Assert-Near $Expected[$index] $Snapshot.AttemptedStrengths[$index] ($Message + " index=$index")
}
}
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$algorithms = $root + 'Algorithms.'
$runnerType = Get-RequiredType ($algorithms + 'SmoothingAlgorithmRunner')
$smootherType = Get-RequiredType ($algorithms + 'IPathSmoother')
$candidateType = Get-RequiredType ($algorithms + 'SmoothingCandidate')
$candidateStatusType = Get-RequiredType ($algorithms + 'SmoothingCandidateStatus')
Assert-False $smootherType.IsPublic 'IPathSmoother must remain internal to the algorithm assembly.'
Assert-Equal 3 ([Enum]::GetNames($candidateStatusType).Length) 'Smoothing candidate status must contain only the three defined feasibility states.'
Assert-Equal 'Success' ([Enum]::GetNames($candidateStatusType)[0]) 'Candidate status must expose Success.'
Assert-Equal 'RetryableInfeasible' ([Enum]::GetNames($candidateStatusType)[1]) 'Candidate status must expose RetryableInfeasible.'
Assert-Equal 'Failed' ([Enum]::GetNames($candidateStatusType)[2]) 'Candidate status must expose Failed.'
$retryableFactory = $candidateType.GetMethod('RetryableInfeasible', [Reflection.BindingFlags]'Static,NonPublic')
Assert-True ($null -ne $retryableFactory) 'SmoothingCandidate must create retryable infeasibility without executable geometry.'
$hooksType = $runnerType.GetNestedType('TestHooks', [Reflection.BindingFlags]'Public,NonPublic')
Assert-True ($null -ne $hooksType) 'SmoothingAlgorithmRunner must expose its narrowly scoped nested TestHooks helper.'
$executeMethod = $hooksType.GetMethod('Execute', [Reflection.BindingFlags]'Public,Static')
Assert-True ($null -ne $executeMethod) 'TestHooks must expose deterministic scenario execution for reflection tests.'
function Invoke-Scenario([string]$Scenario) {
return $executeMethod.Invoke($null, @($Scenario))
}
$allRetryable = Invoke-Scenario 'RetryableInfeasible'
Assert-Equal 'Infeasible' $allRetryable.Status 'Exhausted retryable infeasibility must produce an Infeasible runner result.'
Assert-AttemptedStrengths $allRetryable @(1.00, 0.75, 0.50, 0.25) 'Retryable infeasibility must use the finite retry schedule exactly.'
Assert-Equal 0 $allRetryable.AcceptedPathPointCount 'An infeasible runner result must not retain a retryable candidate as an accepted path.'
Assert-Equal 0 $allRetryable.RejectedComparisonCandidatePointCount 'Retryable infeasibility must not retain executable candidate geometry.'
Assert-Equal 4 $allRetryable.FailureCount 'Every retryable attempt must retain its failure reason.'
$accepted = Invoke-Scenario 'AcceptFirst'
Assert-Equal 'Success' $accepted.Status 'The first safe candidate must be accepted.'
Assert-AttemptedStrengths $accepted @(1.00) 'The runner must stop immediately after the first accepted candidate.'
Assert-True ($accepted.AcceptedPathPointCount -gt 0) 'A successful runner result must publish the validated path internally.'
Assert-Equal 0 $accepted.RejectedComparisonCandidatePointCount 'An accepted candidate must not create rejected comparison geometry.'
$terminalFailure = Invoke-Scenario 'TerminalFailed'
Assert-Equal 'Failed' $terminalFailure.Status 'A terminal candidate failure must stop the runner as Failed.'
Assert-AttemptedStrengths $terminalFailure @(1.00) 'Terminal candidate failure must run exactly once.'
Assert-Equal 0 $terminalFailure.RejectedComparisonCandidatePointCount 'A terminal failure must not retain comparison geometry.'
$cancelled = Invoke-Scenario 'CancelBeforeNextAttempt'
Assert-True $cancelled.CancellationPropagated 'Cancellation between attempts must propagate out of the runner.'
Assert-AttemptedStrengths $cancelled @(1.00) 'Cancellation before the next attempt must prevent another smoother call.'
Assert-Equal 0 $cancelled.AcceptedPathPointCount 'A cancelled run must not publish a partial path.'
Write-Output 'Path smoothing retry runner checks passed.'
@@ -1,252 +0,0 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) {
if (-not $Actual) { throw $Message }
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}
function Assert-Near([double]$Expected, [double]$Actual, [double]$Tolerance, [string]$Message) {
if ([Math]::Abs($Expected - $Actual) -gt $Tolerance) {
throw "$Message Expected=$Expected Actual=$Actual Tolerance=$Tolerance"
}
}
function Get-RequiredType([string]$Name) {
return $assembly.GetType($Name, $true)
}
function New-Map([bool]$WithObstacle) {
$mapRequest = [Activator]::CreateInstance($mapRequestType)
$mapRequest.Bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]5000, [single]0, [single]5000))
$mapRequest.ResolutionMm = [single]50
if ($WithObstacle) {
$obstacle = [Activator]::CreateInstance($rectangleType, @([single]900, [single]1100, [single]400, [single]600))
$obstacles = [Array]::CreateInstance($obstacleType, 1)
$obstacles.SetValue($obstacle, 0)
$source = [Activator]::CreateInstance($manualSourceType, @('service-safety-obstacle', [long]1, $true, $obstacles))
$sources = [Array]::CreateInstance($obstacleSourceType, 1)
$sources.SetValue($source, 0)
$mapRequest.ObstacleSources = $sources
}
else {
$mapRequest.AllowExplicitEmptyMap = $true
}
$map = [Activator]::CreateInstance($mapFactoryType).Create($mapRequest).Map
Assert-True ($null -ne $map) 'Service test must create a planning map.'
return $map
}
function New-EmptyMap { return New-Map $false }
function New-CollidingMap { return New-Map $true }
function New-Vehicle {
$vehicle = [Activator]::CreateInstance($vehicleType)
$vehicle.LengthMeters = [double]0.20
$vehicle.WidthMeters = [double]0.20
$vehicle.SafetyMarginMeters = [double]0.0
$vehicle.MaximumCurvaturePerMeter = [double]100.0
return $vehicle
}
function New-CoarsePoint(
[double]$X,
[double]$Y,
[double]$ArcLength,
$Direction,
[double]$BodyClearance = 1.0,
[bool]$IsGearSwitch = $false) {
return [Activator]::CreateInstance($coarsePointType, @(
$X, $Y, [double]0.0, [double]0.0, $ArcLength, $Direction,
[double]0.0, $BodyClearance, $IsGearSwitch, $coarseAnchor))
}
function New-Configuration($Method = $cubicBSpline) {
$configuration = [Activator]::CreateInstance($configurationType)
$configuration.Method = $Method
return $configuration
}
function New-Request([object[]]$Points, $Configuration, $Map = $null) {
if ($null -eq $Map) { $Map = New-EmptyMap }
$typedPoints = [Array]::CreateInstance($coarsePointType, $Points.Count)
for ($index = 0; $index -lt $Points.Count; $index++) {
$typedPoints.SetValue($Points[$index], $index)
}
$segments = [Array]::CreateInstance($coarseSegmentType, 1)
$segments.SetValue([Activator]::CreateInstance($coarseSegmentType, @(
0, $forward, 0, ($Points.Count - 1), $false, $false)), 0)
return [Activator]::CreateInstance($requestType, @($typedPoints, $segments, $Map, (New-Vehicle), $Configuration))
}
function New-StraightRequest($Configuration) {
return New-Request @(
(New-CoarsePoint 0.5 0.5 0.0 $forward),
(New-CoarsePoint 1.5 0.5 1.0 $forward)) $Configuration
}
function New-InfeasibleRequest($Configuration) {
# Zero declared movement clearance makes every non-linear B-spline displacement retryably infeasible,
# while the empty map still permits the independently revalidated coarse-path fallback.
return New-Request @(
(New-CoarsePoint 0.5 0.5 0.0 $forward 0.0),
(New-CoarsePoint 1.0 0.5 0.5 $forward 0.0),
(New-CoarsePoint 1.0 1.0 1.0 $forward 0.0),
(New-CoarsePoint 1.5 1.0 1.5 $forward 0.0)) $Configuration
}
function Invoke-Smooth($Request, [Threading.CancellationToken]$CancellationToken = [Threading.CancellationToken]::None) {
return $smoothMethod.Invoke($service, @($Request, $CancellationToken))
}
function Assert-NoGeometry($Result, [string]$Message) {
Assert-Equal 0 $Result.Path.Count "$Message A non-published result must not expose a path."
Assert-Equal 0 $Result.Segments.Count "$Message A non-published result must not expose segments."
}
function Assert-InvalidInputBeforeRetry($Configuration, [string]$CaseName) {
$result = Invoke-Smooth (New-StraightRequest $Configuration)
Assert-Equal 'InvalidInput' $result.Status.ToString() "$CaseName must be rejected as invalid input."
Assert-NoGeometry $result $CaseName
Assert-Equal 0 $result.Diagnostics.RetryCount "$CaseName must be rejected before any smoothing retry."
Assert-Near 0.0 $result.Diagnostics.AcceptedStrength 0.0 "$CaseName must not accept a smoothing strength."
}
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$facade = $root + 'Facade.'
$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$serviceType = Get-RequiredType ($facade + 'PathSmoothingService')
$requestType = Get-RequiredType ($root + 'PathSmoothingRequest')
$resultType = Get-RequiredType ($root + 'PathSmoothingResult')
$configurationType = Get-RequiredType ($root + 'PathSmoothingConfiguration')
$methodType = Get-RequiredType ($root + 'SmoothingMethod')
$coarsePointType = Get-RequiredType ($coarsePath + 'CoarsePathPoint')
$coarseSegmentType = Get-RequiredType ($coarsePath + 'PathSegment')
$directionType = Get-RequiredType ($coarsePath + 'TravelDirection')
$coarsePointSourceType = Get-RequiredType ($coarsePath + 'CoarsePathPointSource')
$vehicleType = Get-RequiredType ($coarsePath + 'VehicleParameters')
$boundsType = Get-RequiredType ($mapping + 'MapBoundsMm')
$obstacleType = Get-RequiredType ($mapping + 'IMapObstacle')
$rectangleType = Get-RequiredType ($mapping + 'AxisAlignedRectangleObstacle')
$obstacleSourceType = Get-RequiredType ($mapping + 'IMapObstacleSource')
$manualSourceType = Get-RequiredType ($mapping + 'ManualObstacleSource')
$mapRequestType = Get-RequiredType ($mapping + 'PlanningMapRequest')
$mapFactoryType = Get-RequiredType ($mapping + 'PlanningMapFactory')
Assert-True $serviceType.IsPublic 'PathSmoothingService must be public.'
$service = [Activator]::CreateInstance($serviceType)
$smoothMethod = $serviceType.GetMethod('Smooth', [Type[]]@($requestType, [Threading.CancellationToken]))
Assert-True ($null -ne $smoothMethod) 'PathSmoothingService must expose Smooth(PathSmoothingRequest, CancellationToken).'
Assert-Equal $resultType $smoothMethod.ReturnType 'PathSmoothingService Smooth must return PathSmoothingResult.'
$forward = [Enum]::Parse($directionType, 'Forward')
$cubicBSpline = [Enum]::Parse($methodType, 'CubicBSpline')
$localCubicBezier = [Enum]::Parse($methodType, 'LocalCubicBezier')
$piecewiseQuintic = [Enum]::Parse($methodType, 'PiecewiseQuintic')
$coarseAnchor = [Enum]::Parse($coarsePointSourceType, 'Start')
# The public method registry must retain every stable enum-to-algorithm mapping.
foreach ($method in @($cubicBSpline, $localCubicBezier, $piecewiseQuintic)) {
$result = Invoke-Smooth (New-StraightRequest (New-Configuration $method))
Assert-Equal 'Success' $result.Status.ToString() "A valid straight path must succeed for $method."
Assert-Equal $method.ToString() $result.Method.ToString() "The result must retain the selected $method method."
Assert-True ($result.Path.Count -gt 0) "A successful $method result must publish geometry."
Assert-True $result.Diagnostics.Metrics.IsFeasible "A successful $method result must publish feasible diagnostics."
}
# Every configuration scalar is checked before the options snapshot or retry runner starts.
$invalidConfigurationCases = @(
[PSCustomObject]@{ Name = 'NaN output spacing'; Mutate = { param($c) $c.OutputSpacingMeters = [double]::NaN } },
[PSCustomObject]@{ Name = 'zero output spacing'; Mutate = { param($c) $c.OutputSpacingMeters = [double]0.0 } },
[PSCustomObject]@{ Name = 'infinite collision step'; Mutate = { param($c) $c.MaximumCollisionCheckStepMeters = [double]::PositiveInfinity } },
[PSCustomObject]@{ Name = 'zero collision step'; Mutate = { param($c) $c.MaximumCollisionCheckStepMeters = [double]0.0 } },
[PSCustomObject]@{ Name = 'NaN clearance reserve'; Mutate = { param($c) $c.MinimumClearanceReserveMeters = [double]::NaN } },
[PSCustomObject]@{ Name = 'negative clearance reserve'; Mutate = { param($c) $c.MinimumClearanceReserveMeters = [double]-0.01 } },
[PSCustomObject]@{ Name = 'NaN smoothing strength'; Mutate = { param($c) $c.SmoothingStrength = [double]::NaN } },
[PSCustomObject]@{ Name = 'zero smoothing strength'; Mutate = { param($c) $c.SmoothingStrength = [double]0.0 } },
[PSCustomObject]@{ Name = 'NaN B-spline scale'; Mutate = { param($c) $c.CubicBSpline.EndpointTangentScale = [double]::NaN } },
[PSCustomObject]@{ Name = 'zero B-spline scale'; Mutate = { param($c) $c.CubicBSpline.EndpointTangentScale = [double]0.0 } },
[PSCustomObject]@{ Name = 'zero Bezier threshold'; Mutate = { param($c) $c.LocalCubicBezier.CornerHeadingThresholdRadians = [double]0.0 } },
[PSCustomObject]@{ Name = 'over-pi Bezier threshold'; Mutate = { param($c) $c.LocalCubicBezier.CornerHeadingThresholdRadians = [Math]::PI + 0.01 } },
[PSCustomObject]@{ Name = 'NaN Bezier window'; Mutate = { param($c) $c.LocalCubicBezier.MaximumWindowLengthMeters = [double]::NaN } },
[PSCustomObject]@{ Name = 'zero Bezier window'; Mutate = { param($c) $c.LocalCubicBezier.MaximumWindowLengthMeters = [double]0.0 } },
[PSCustomObject]@{ Name = 'infinite Bezier handle scale'; Mutate = { param($c) $c.LocalCubicBezier.HandleLengthRatio = [double]::PositiveInfinity } },
[PSCustomObject]@{ Name = 'zero Bezier handle scale'; Mutate = { param($c) $c.LocalCubicBezier.HandleLengthRatio = [double]0.0 } },
[PSCustomObject]@{ Name = 'NaN quintic knot spacing'; Mutate = { param($c) $c.PiecewiseQuintic.KnotSpacingMeters = [double]::NaN } },
[PSCustomObject]@{ Name = 'zero quintic knot spacing'; Mutate = { param($c) $c.PiecewiseQuintic.KnotSpacingMeters = [double]0.0 } },
[PSCustomObject]@{ Name = 'infinite minimum quintic knot spacing'; Mutate = { param($c) $c.PiecewiseQuintic.MinimumKnotSpacingMeters = [double]::PositiveInfinity } },
[PSCustomObject]@{ Name = 'zero minimum quintic knot spacing'; Mutate = { param($c) $c.PiecewiseQuintic.MinimumKnotSpacingMeters = [double]0.0 } },
[PSCustomObject]@{ Name = 'quintic knot spacing below minimum'; Mutate = { param($c) $c.PiecewiseQuintic.KnotSpacingMeters = [double]0.05; $c.PiecewiseQuintic.MinimumKnotSpacingMeters = [double]0.10 } }
)
foreach ($case in $invalidConfigurationCases) {
$configuration = New-Configuration
& $case.Mutate $configuration
Assert-InvalidInputBeforeRetry $configuration $case.Name
}
$unknownMethodConfiguration = New-Configuration ([Enum]::ToObject($methodType, 99))
Assert-InvalidInputBeforeRetry $unknownMethodConfiguration 'unknown smoothing method'
$invalidCoarseConfiguration = New-Configuration
$invalidCoarseRequest = New-Request @(
(New-CoarsePoint ([double]::NaN) 0.5 0.0 $forward),
(New-CoarsePoint 1.5 0.5 1.0 $forward)) $invalidCoarseConfiguration
$invalidCoarseResult = Invoke-Smooth $invalidCoarseRequest
Assert-Equal 'InvalidInput' $invalidCoarseResult.Status.ToString() 'A non-finite coarse path coordinate must be invalid input.'
Assert-NoGeometry $invalidCoarseResult 'Invalid coarse path'
Assert-Equal 0 $invalidCoarseResult.Diagnostics.RetryCount 'Invalid coarse input must be rejected before retries.'
# A finite, structurally valid coarse path may still be unsafe for the requested map and vehicle.
# It must be rejected before method selection/retries and may never use fallback to publish the unsafe geometry.
$unsafeCoarseResult = Invoke-Smooth (New-Request @(
(New-CoarsePoint 0.5 0.5 0.0 $forward),
(New-CoarsePoint 1.5 0.5 1.0 $forward)) (New-Configuration) (New-CollidingMap))
Assert-Equal 'InvalidInput' $unsafeCoarseResult.Status.ToString() 'A colliding coarse path must be invalid before smoothing starts.'
Assert-NoGeometry $unsafeCoarseResult 'Unsafe coarse path'
Assert-Equal 0 $unsafeCoarseResult.Diagnostics.RetryCount 'Unsafe coarse geometry must be rejected before retry execution.'
$cancelledConfiguration = New-Configuration
$cancellationSource = [Threading.CancellationTokenSource]::new()
$cancellationSource.Cancel()
try {
$cancelledResult = Invoke-Smooth (New-StraightRequest $cancelledConfiguration) $cancellationSource.Token
Assert-Equal 'Cancelled' $cancelledResult.Status.ToString() 'Pre-cancelled smoothing must return the explicit cancellation result.'
Assert-NoGeometry $cancelledResult 'Cancelled smoothing'
Assert-Equal 0 $cancelledResult.Diagnostics.RetryCount 'Cancellation before execution must not start retries.'
}
finally {
$cancellationSource.Dispose()
}
$withoutFallbackConfiguration = New-Configuration
$withoutFallbackConfiguration.AllowFallbackToCoarsePath = $false
$withoutFallbackResult = Invoke-Smooth (New-InfeasibleRequest $withoutFallbackConfiguration)
Assert-Equal 'Infeasible' $withoutFallbackResult.Status.ToString() 'A retryably infeasible candidate without fallback must remain infeasible.'
Assert-NoGeometry $withoutFallbackResult 'Infeasible smoothing without fallback'
Assert-Equal 3 $withoutFallbackResult.Diagnostics.RetryCount 'Infeasible smoothing must exhaust the four configured strengths.'
Assert-Near 0.0 $withoutFallbackResult.Diagnostics.AcceptedStrength 0.0 'Infeasible smoothing must not accept a strength.'
$withFallbackConfiguration = New-Configuration
$withFallbackConfiguration.AllowFallbackToCoarsePath = $true
$withFallbackResult = Invoke-Smooth (New-InfeasibleRequest $withFallbackConfiguration)
Assert-Equal 'FallbackToCoarsePath' $withFallbackResult.Status.ToString() 'A verified coarse path must return explicit fallback status.'
Assert-Equal 'CubicBSpline' $withFallbackResult.Method.ToString() 'Fallback must retain the originally selected method.'
Assert-True ($withFallbackResult.Path.Count -gt 0) 'A verified fallback must publish the revalidated coarse geometry.'
Assert-True $withFallbackResult.Diagnostics.Metrics.IsFeasible 'Fallback must publish feasible shared-geometry diagnostics.'
foreach ($point in $withFallbackResult.Path) {
Assert-Equal 'CoarsePathFallback' $point.Source.ToString() 'Every fallback point must be explicitly labeled as coarse-path fallback.'
}
Assert-Equal $withoutFallbackResult.Diagnostics.RetryCount $withFallbackResult.Diagnostics.RetryCount 'Fallback must preserve retry diagnostics from the failed method.'
Assert-Near $withoutFallbackResult.Diagnostics.AcceptedStrength $withFallbackResult.Diagnostics.AcceptedStrength 0.0 'Fallback must preserve the failed method accepted-strength diagnostic.'
Assert-Equal $withoutFallbackResult.Diagnostics.TerminationReason $withFallbackResult.Diagnostics.TerminationReason 'Fallback must preserve the failed method termination reason.'
Write-Output 'Path smoothing service checks passed.'
@@ -0,0 +1,47 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) { if (-not $Actual) { throw $Message } }
function Assert-Equal($Expected, $Actual, [string]$Message) { if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" } }
$boundsType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.MapBoundsMm', $true)
$gridType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.EnvironmentGridMap', $true)
$circleType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.CircleObstacle', $true)
$rectangleType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.AxisAlignedRectangleObstacle', $true)
$rasterizer = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.MapObstacleRasterizer', $true)
$adapter = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapAdapter', $true)
$bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]105, [single]0, [single]105))
$grid = [Activator]::CreateInstance($gridType, @($bounds, [single]20))
Assert-Equal $true ($grid.IsWorldInBounds([single]104.9, [single]104.9)) 'Last partial cell must be inside.'
Assert-Equal $false ($grid.IsWorldInBounds([single]105, [single]50)) 'XMax must be exclusive.'
Assert-Equal $true ($grid.IsOccupiedWorld([single]-0.1, [single]20)) 'Outside world must be occupied.'
$circle = [Activator]::CreateInstance($circleType, @([single]40, [single]40, [single]0))
$rasterizer.GetMethod('Rasterize').Invoke($null, @($grid, $circle)) | Out-Null
Assert-True ($grid.OccupiedCount -ge 4) 'Circle touching a grid intersection must conservatively occupy adjacent cells.'
$rectangle = [Activator]::CreateInstance($rectangleType, @([single]80, [single]100, [single]80, [single]100))
$rasterizer.GetMethod('Rasterize').Invoke($null, @($grid, $rectangle)) | Out-Null
Assert-True ($grid.IsOccupiedWorld([single]90, [single]90)) 'Rectangle must occupy its intersecting cells.'
$outside = [Activator]::CreateInstance($circleType, @([single]500, [single]500, [single]10))
$before = $grid.OccupiedCount
$rasterizer.GetMethod('Rasterize').Invoke($null, @($grid, $outside)) | Out-Null
Assert-Equal $before $grid.OccupiedCount 'Outside obstacle must not mark cells.'
$planning = $adapter.GetMethod('Create').Invoke($null, @($grid))
Assert-True ($planning.IsOccupiedWorld(0.04, 0.04)) 'Planning map must use metre world coordinates.'
Assert-Equal 0.0 ($planning.GetConservativeObstacleDistanceMeters(0.04, 0.04)) 'Occupied cell clearance must be zero.'
Assert-Equal 0.0 ($planning.GetConservativeObstacleDistanceMeters(-1.0, 0.0)) 'Outside map clearance must be zero.'
$singleBounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]3000, [single]0, [single]3000))
$singleGrid = [Activator]::CreateInstance($gridType, @($singleBounds, [single]50))
$singleCircle = [Activator]::CreateInstance($circleType, @([single]1525, [single]1525, [single]0))
$rasterizer.GetMethod('Rasterize').Invoke($null, @($singleGrid, $singleCircle)) | Out-Null
Assert-Equal 1 $singleGrid.OccupiedCount 'Zero radius circle at a cell center must occupy exactly one cell.'
$singlePlanning = $adapter.GetMethod('Create').Invoke($null, @($singleGrid))
$singleDistance = $singlePlanning.GetConservativeObstacleDistanceMeters(1.275, 1.275)
$singleNearestCellCenterDistance = [Math]::Sqrt(0.25 * 0.25 + 0.25 * 0.25)
Assert-True ((-not [double]::IsNaN($singleDistance)) -and (-not [double]::IsInfinity($singleDistance))) 'Single occupied cell clearance must be finite.'
Assert-True ($singleDistance -le $singleNearestCellCenterDistance) 'Single occupied cell clearance must not exceed nearest occupied cell geometry distance.'
$emptyGrid = [Activator]::CreateInstance($gridType, @($bounds, [single]20))
$emptyPlanning = $adapter.GetMethod('Create').Invoke($null, @($emptyGrid))
Assert-True ([double]::IsPositiveInfinity($emptyPlanning.GetConservativeObstacleDistanceMeters(0.01, 0.01))) 'Empty map clearance must be positive infinity.'
$invalidResolutionRejected = $false
try { [Activator]::CreateInstance($gridType, @($bounds, [single]10)) | Out-Null } catch { $invalidResolutionRejected = $true }
Assert-True $invalidResolutionRejected 'Resolution below 20 mm must fail.'
Write-Output 'Planning map adapter checks passed.'
@@ -0,0 +1,58 @@
param([string]$MapRoot = (Join-Path $PSScriptRoot '..\ParkrobTrajplanner\Map'))
$ErrorActionPreference = 'Stop'
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) {
if (-not $Content.Contains($Expected)) { throw "$Message Missing=$Expected" }
}
function Assert-DocumentationCount([string]$RelativePath, [int]$MinimumCount) {
$path = Join-Path $MapRoot $RelativePath
$content = Get-Content -LiteralPath $path -Raw
$count = [regex]::Matches($content, '/// <summary>').Count
if ($count -lt $MinimumCount) { throw "Insufficient public API documentation in $RelativePath ExpectedAtLeast=$MinimumCount Actual=$count" }
}
$readmePath = Join-Path $MapRoot 'README.md'
if (-not (Test-Path -LiteralPath $readmePath)) { throw "Map README is required: $readmePath" }
$readme = Get-Content -LiteralPath $readmePath -Raw
Assert-Contains $readme 'PlanningMapFactory' 'README must identify the public creation facade.'
Assert-Contains $readme 'IMapObstacleSource' 'README must explain unified obstacle sources.'
Assert-Contains $readme 'PlanningGridMap' 'README must explain the planning snapshot output.'
Assert-Contains $readme 'SourceVersion' 'README must explain cache invalidation versions.'
Assert-Contains $readme 'PlanningMapBuildStatus' 'README must explain explicit map build termination states.'
Assert-Contains $readme 'PNG' 'README must explain debug image output.'
Assert-Contains $readme 'TrapMap' 'README must describe the legacy-map boundary.'
Assert-DocumentationCount 'PlanningMapFactory.cs' 2
Assert-DocumentationCount 'PlanningMapRequest.cs' 5
Assert-DocumentationCount 'PlanningMapBuildResult.cs' 9
Assert-DocumentationCount 'Core\MapBuildRequest.cs' 4
Assert-DocumentationCount 'Core\EnvironmentMapBuildResult.cs' 7
Assert-DocumentationCount 'Core\MapBoundsMm.cs' 12
Assert-DocumentationCount 'Core\EnvironmentGridMap.cs' 13
Assert-DocumentationCount 'Core\EnvironmentMapBuilder.cs' 2
Assert-DocumentationCount 'Obstacles\IMapObstacle.cs' 2
Assert-DocumentationCount 'Obstacles\CircleObstacle.cs' 6
Assert-DocumentationCount 'Obstacles\AxisAlignedRectangleObstacle.cs' 7
Assert-DocumentationCount 'Obstacles\MapObstacleRasterizer.cs' 2
Assert-DocumentationCount 'Sources\IMapObstacleSource.cs' 5
Assert-DocumentationCount 'Sources\ManualObstacleSource.cs' 6
Assert-DocumentationCount 'Sources\TwoLegProjectionInput.cs' 13
Assert-DocumentationCount 'Sources\TwoLegObstacleSource.cs' 6
Assert-DocumentationCount 'Sources\TwoLegObstacleProjector.cs' 2
Assert-DocumentationCount 'Sources\ObstacleProjectionResult.cs' 8
Assert-DocumentationCount 'Sources\ObstacleSourceStatus.cs' 5
Assert-DocumentationCount 'Planning\PlanningGridMap.cs' 15
Assert-DocumentationCount 'Planning\PlanningMapAdapter.cs' 2
Assert-DocumentationCount 'Planning\ObstacleDistanceField.cs' 2
Assert-DocumentationCount 'Planning\EuclideanDistanceTransform.cs' 2
Assert-DocumentationCount 'Planning\PlanningMapCache.cs' 5
Assert-DocumentationCount 'Test\MovementTest.MapTest.cs' 3
Assert-DocumentationCount 'Test\Visualization\PlanningMapImageExportRequest.cs' 3
Assert-DocumentationCount 'Test\Visualization\PlanningMapImageExportResult.cs' 8
Assert-DocumentationCount 'Test\Visualization\PlanningMapImageExporter.cs' 6
Assert-DocumentationCount 'Test\Visualization\PlanningMapImageRenderer.cs' 2
Assert-DocumentationCount 'Test\Visualization\ValidatedPngWriter.cs' 2
Write-Output 'Planning map documentation checks passed.'
@@ -0,0 +1,88 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) { if (-not $Actual) { throw $Message } }
function Assert-Equal($Expected, $Actual, [string]$Message) { if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" } }
function Assert-False($Actual, [string]$Message) { if ($Actual) { throw $Message } }
function Assert-Null($Actual, [string]$Message) { if ($null -ne $Actual) { throw $Message } }
function Find-NonPublicInstanceMethod($Type, [string]$Name, [Type[]]$ParameterTypes) {
foreach ($candidate in $Type.GetMethods([Reflection.BindingFlags]'Instance,NonPublic')) {
if ($candidate.Name -ne $Name) { continue }
$parameters = $candidate.GetParameters()
if ($parameters.Length -ne $ParameterTypes.Length) { continue }
$matches = $true
for ($index = 0; $index -lt $parameters.Length; $index++) {
if ($parameters[$index].ParameterType -ne $ParameterTypes[$index]) { $matches = $false; break }
}
if ($matches) { return $candidate }
}
return $null
}
$ns = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$boundsType = $assembly.GetType($ns + 'MapBoundsMm', $true)
$obstacleType = $assembly.GetType($ns + 'IMapObstacle', $true)
$sourceType = $assembly.GetType($ns + 'IMapObstacleSource', $true)
$circleType = $assembly.GetType($ns + 'CircleObstacle', $true)
$manualType = $assembly.GetType($ns + 'ManualObstacleSource', $true)
$twoLegInputType = $assembly.GetType($ns + 'TwoLegProjectionInput', $true)
$twoLegType = $assembly.GetType($ns + 'TwoLegObstacleSource', $true)
$requestType = $assembly.GetType($ns + 'PlanningMapRequest', $true)
$factoryType = $assembly.GetType($ns + 'PlanningMapFactory', $true)
$mapResultType = $assembly.GetType($ns + 'PlanningMapBuildResult', $true)
$mapBuildStatusType = $assembly.GetType($ns + 'PlanningMapBuildStatus', $true)
$operationBudgetType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Utils.PlanningOperationBudget', $false)
$operationStopReasonType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Utils.PlanningOperationStopReason', $false)
Assert-True ($operationBudgetType -ne $null) 'PlanningOperationBudget must exist for shared planning cancellation and timeout handling.'
Assert-True ($operationStopReasonType -ne $null) 'PlanningOperationStopReason must exist for shared planning cancellation and timeout handling.'
Assert-True $operationStopReasonType.IsEnum 'PlanningOperationStopReason must be an enum.'
foreach ($expectedStopReason in @('None', 'Cancelled', 'TimedOut')) {
Assert-True ($operationStopReasonType.GetEnumNames() -contains $expectedStopReason) "PlanningOperationStopReason must contain $expectedStopReason."
}
Assert-True ($mapResultType.GetProperty('Status') -ne $null) 'PlanningMapBuildResult must expose an explicit build status.'
foreach ($expectedMapStatus in @('Success', 'Failed', 'Cancelled', 'TimedOut')) {
Assert-True ($mapBuildStatusType.GetEnumNames() -contains $expectedMapStatus) "PlanningMapBuildStatus must contain $expectedMapStatus."
}
$bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]2000, [single]0, [single]2000))
$obstacles = [Array]::CreateInstance($obstacleType, 1)
$obstacles.SetValue([Activator]::CreateInstance($circleType, @([single]500, [single]500, [single]100)), 0)
$manual = [Activator]::CreateInstance($manualType, @('manual', [long]1, $true, $obstacles))
$twoLegInput = [Activator]::CreateInstance($twoLegInputType, @($true, [single]1000, [single]1000, [double]([Math]::PI / 2), [single]100, [single]0, [single]-100, [single]0, [single]40, 'test snapshot'))
$twoLeg = [Activator]::CreateInstance($twoLegType, @('two-leg', [long]1, $false, $twoLegInput))
$sources = [Array]::CreateInstance($sourceType, 2); $sources.SetValue($twoLeg, 0); $sources.SetValue($manual, 1)
function New-Request($sourceArray, [bool]$allowEmpty) { $request = [Activator]::CreateInstance($requestType); $request.Bounds = $bounds; $request.ResolutionMm = [single]50; $request.ObstacleSources = $sourceArray; $request.AllowExplicitEmptyMap = $allowEmpty; return $request }
$factory = [Activator]::CreateInstance($factoryType)
$budgetConstructor = $operationBudgetType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null,
@([Threading.CancellationToken], [TimeSpan]), $null)
Assert-True ($budgetConstructor -ne $null) 'PlanningOperationBudget must expose its internal cancellation and timeout constructor.'
$createWithBudget = Find-NonPublicInstanceMethod $factoryType 'Create' @($requestType, $operationBudgetType)
Assert-True ($createWithBudget -ne $null) 'PlanningMapFactory must expose an internal budget-aware Create overload.'
$cancelledSource = New-Object Threading.CancellationTokenSource
$cancelledSource.Cancel()
$cancelledBudget = $budgetConstructor.Invoke(@($cancelledSource.Token, [TimeSpan]::FromSeconds(1)))
$cancelled = $createWithBudget.Invoke($factory, @((New-Request $sources $false), $cancelledBudget))
Assert-Equal 'Cancelled' $cancelled.Status.ToString() 'Cancelled map construction must retain the cancellation status.'
Assert-False $cancelled.Succeeded 'Cancelled map construction must not succeed.'
Assert-Null $cancelled.Map 'Cancelled map construction must not publish a map.'
Assert-Equal 'None' $cancelled.CacheHit.ToString() 'Cancelled map construction must not publish a cache hit.'
$first = $factory.Create((New-Request $sources $false))
Assert-True $first.Succeeded 'Mixed source request must build.'
Assert-True $first.Map.PlanningReady 'Applied source geometry must make the map ready.'
Assert-True ($first.Map.IsOccupiedWorld(1.0, 1.1)) 'TwoLeg must project detection-time local coordinates into world metres.'
$second = $factory.Create((New-Request $sources $false))
Assert-Equal 'Input' $second.CacheHit.ToString() 'Same snapshot must hit complete input cache.'
Assert-True ([object]::ReferenceEquals($first.Map, $second.Map)) 'Same snapshot must return the exact immutable map object.'
$manualVersionTwo = [Activator]::CreateInstance($manualType, @('manual', [long]2, $true, $obstacles))
$sourcesVersionTwo = [Array]::CreateInstance($sourceType, 2); $sourcesVersionTwo.SetValue($manualVersionTwo, 0); $sourcesVersionTwo.SetValue($twoLeg, 1)
$third = $factory.Create((New-Request $sourcesVersionTwo $false))
Assert-Equal 'Occupancy' $third.CacheHit.ToString() 'Version change with equal occupancy must reuse occupancy buffers.'
Assert-True ($third.Map.SnapshotId -gt $first.Map.SnapshotId) 'Occupancy reuse must still issue a fresh snapshot id.'
$emptySources = [Array]::CreateInstance($sourceType, 0)
$emptyBlocked = $factory.Create((New-Request $emptySources $false))
Assert-True (-not $emptyBlocked.Map.PlanningReady) 'Implicit empty map must be blocked.'
$emptyAllowed = $factory.Create((New-Request $emptySources $true))
Assert-True $emptyAllowed.Map.PlanningReady 'Explicit empty map must be accepted.'
$invalidManual = [Activator]::CreateInstance($manualType, @('invalid', [long]0, $true, $null))
$invalidSources = [Array]::CreateInstance($sourceType, 1); $invalidSources.SetValue($invalidManual, 0)
$invalid = $factory.Create((New-Request $invalidSources $false))
Assert-True (-not $invalid.Succeeded) 'Required invalid source must reject the full build.'
Write-Output 'Planning map factory checks passed.'
@@ -0,0 +1,31 @@
param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'
$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))
function Assert-True($Actual, [string]$Message) { if (-not $Actual) { throw $Message } }
$ns = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$boundsType = $assembly.GetType($ns + 'MapBoundsMm', $true)
$sourceType = $assembly.GetType($ns + 'IMapObstacleSource', $true)
$requestType = $assembly.GetType($ns + 'PlanningMapRequest', $true)
$factoryType = $assembly.GetType($ns + 'PlanningMapFactory', $true)
$imageRequestType = $assembly.GetType($ns + 'PlanningMapImageExportRequest', $true)
$exporterType = $assembly.GetType($ns + 'PlanningMapImageExporter', $true)
$mapRequest = [Activator]::CreateInstance($requestType)
$mapRequest.Bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]1000, [single]0, [single]1000))
$mapRequest.ResolutionMm = [single]50
$mapRequest.ObstacleSources = [Array]::CreateInstance($sourceType, 0)
$mapRequest.AllowExplicitEmptyMap = $true
$map = [Activator]::CreateInstance($factoryType).Create($mapRequest).Map
$imageRequest = [Activator]::CreateInstance($imageRequestType)
$imageRequest.Map = $map
$imageRequest.OutputRootDirectory = (Join-Path $PSScriptRoot '..\obj\planning_map_image_test')
$export = $exporterType.GetMethod('ExportIfEnabled').Invoke($null, @($true, $imageRequest))
Assert-True $export.Saved ('Image export failed: ' + $export.Message)
Assert-True (Test-Path -LiteralPath $export.FilePath -PathType Leaf) 'Image output is missing.'
$png = [IO.File]::ReadAllBytes($export.FilePath)
Assert-True ($png.Length -gt 45) 'PNG is too small.'
Assert-True ($png[0] -eq 137 -and $png[1] -eq 80 -and $png[2] -eq 78 -and $png[3] -eq 71) 'PNG signature is invalid.'
Assert-True ([Text.Encoding]::ASCII.GetString($png, 12, 4) -eq 'IHDR') 'PNG must begin with IHDR.'
Assert-True ([Text.Encoding]::ASCII.GetString($png, $png.Length - 8, 4) -eq 'IEND') 'PNG must end with IEND.'
$source = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\ParkrobTrajplanner\Map\Test\Visualization\PlanningMapImageExporter.cs') -Raw -Encoding UTF8
Assert-True ($source -notmatch 'GridMapData|TrapMapVehiclePose|TwoLegDetect') 'New exporter must consume only PlanningGridMap.'
Write-Output 'Planning map image checks passed.'
@@ -0,0 +1,23 @@
param([string]$SourcePath = (Join-Path $PSScriptRoot '..\ParkrobTrajplanner\Map\Test\MovementTest.MapTest.cs'))
$ErrorActionPreference = 'Stop'
$source = Get-Content -LiteralPath $SourcePath -Raw
function Assert-Contains([string]$Expected, [string]$Message) {
if (-not $source.Contains($Expected)) { throw "$Message Missing=$Expected" }
}
Assert-Contains 'EnableTerminalDebugLog' 'MapTest must expose a terminal logging switch.'
Assert-Contains 'SavePng' 'MapTest must expose a PNG export switch.'
$begin = -join [char[]](0x89C4, 0x5212, 0x5730, 0x56FE, 0x521B, 0x5EFA, 0x5F00, 0x59CB)
$sources = -join [char[]](0x969C, 0x788D, 0x7269, 0x6765)
$projection = -join [char[]](0x6295, 0x5F71, 0x7ED3, 0x679C)
$snapshot = -join [char[]](0x5730, 0x56FE, 0x5FEB, 0x7167)
$end = -join [char[]](0x89C4, 0x5212, 0x5730, 0x56FE, 0x521B, 0x5EFA, 0x5B8C, 0x6210)
Assert-Contains 'FormatSourceStatus' 'MapTest must translate projection status for terminal output.'
Assert-Contains 'FormatCacheHit' 'MapTest must translate cache hits for terminal output.'
Assert-Contains 'FormatImageResult' 'MapTest must translate image export results for terminal output.'
if ($source.Contains('PLANNING_MAP_CREATE_BEGIN')) { throw 'MapTest must not expose English terminal log markers.' }
Assert-Contains 'PlanningMapFactory' 'MapTest must continue using the public map facade.'
Write-Output 'Planning map MovementTest configuration checks passed.'