chore: save current workspace progress
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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.'
|
||||
@@ -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.'
|
||||
Reference in New Issue
Block a user