using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; using EMPlannerVerificationHost; using MultiWheelC.TrajectoryPlanning.CoarsePath; using MultiWheelC.TrajectoryPlanning.CoarsePath.Vehicle; using MultiWheelC.TrajectoryPlanning.PathSmoothing; namespace MultiWheelC.TrajectoryPlanning.EMPlanner; internal static class LateralIntegrationChecks { public static void Run() { VerifiesValidatedCandidateSurvivesLaterTimeout(); VerifiesInvalidVectorsAndInaccurateResidualsNeverBecomeFallbacks(); VerifiesTrustRegionWarmStartAndOuterIterationLimit(); VerifiesCancellationAndTimeoutWithoutCandidate(); VerifiesLateralPlannerDelegatesToTheSequentialOptimizer(); } public static void RunRealOsqp() { foreach (LateralScenario scenario in CreateRealOsqpScenarios()) { LateralPlanningResult first = new LateralPlanner(new OsqpNativeSolver()).Plan(scenario.Input, CancellationToken.None); LateralPlanningResult second = new LateralPlanner(new OsqpNativeSolver()).Plan(scenario.Input, CancellationToken.None); VerifyRealScenarioResult(scenario, first); VerifyRealScenarioResult(scenario, second); VerifyDeterministicResult(scenario.Name, first, second); if (scenario.RequiresSeedConnectedInterval) { for (int index = 0; index < first.Path.Points.Count; index++) Verification.True(first.Path.Points[index].L <= -0.05d + 1e-10d, scenario.Name + " remains in the seed-connected obstacle corridor"); } } } public static void RunRealOsqpInCleanPluginBundle() { string pluginDirectory = Path.Combine(Path.GetTempPath(), "em-planner-lateral-real-" + Guid.NewGuid().ToString("N")); try { Directory.CreateDirectory(pluginDirectory); foreach (string sourcePath in Directory.GetFiles(AppContext.BaseDirectory)) File.Copy(sourcePath, Path.Combine(pluginDirectory, Path.GetFileName(sourcePath)), false); string nativeSource = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "ClumsyPilot", "ThirdParty", "OSQP", "win-x64", "osqp.dll")); Verification.True(File.Exists(nativeSource), "pinned OSQP DLL is available for the real lateral bundle"); File.Copy(nativeSource, Path.Combine(pluginDirectory, "osqp.dll"), false); var startInfo = new ProcessStartInfo { FileName = Path.Combine(pluginDirectory, "EMPlannerVerificationHost.exe"), Arguments = "lateral-real-osqp-probe", WorkingDirectory = pluginDirectory, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }; using (var process = new Process { StartInfo = startInfo }) { process.Start(); string standardOutput = process.StandardOutput.ReadToEnd(); string standardError = process.StandardError.ReadToEnd(); process.WaitForExit(); if (process.ExitCode != 0 || standardOutput.IndexOf("PASS lateral-real-osqp", StringComparison.Ordinal) < 0) { throw new InvalidOperationException("Real lateral OSQP clean-plugin probe exited " + process.ExitCode + ": " + standardError + standardOutput); } } } finally { if (Directory.Exists(pluginDirectory)) Directory.Delete(pluginDirectory, true); } } private static void VerifiesValidatedCandidateSurvivesLaterTimeout() { LateralPlanningInput input = CreateInput(); double[] valid = CreatePrimal(input, 0.02d); var solver = new FakeQpSolver(new[] { Result(QpSolveStatus.Solved, valid, 10d), Result(QpSolveStatus.TimeLimit, Array.Empty(), 10d), }); LateralPlanningResult result = new SequentialConvexOptimizer(solver).Optimize(input, CancellationToken.None); Verification.Equal(EmPlanningStatus.SuccessWithFallback, result.Status, "timeout after an independently validated candidate returns fallback success"); LateralPath fallbackPath = result.Path ?? throw new InvalidOperationException("Fallback path was not returned."); Verification.True(fallbackPath.IsIndependentlyValidated, "fallback path remains independently validated"); Verification.NearlyEqual(0.02d, fallbackPath.Points[1].L, "first valid candidate remains the fallback path"); } private static void VerifiesInvalidVectorsAndInaccurateResidualsNeverBecomeFallbacks() { LateralPlanningInput input = CreateInput(); double[] valid = CreatePrimal(input, 0.02d); double[] invalid = CreatePrimal(input, 0.40d); var solver = new FakeQpSolver(new[] { Result(QpSolveStatus.Solved, valid, 10d), Result(QpSolveStatus.Solved, invalid, 9d), Result(QpSolveStatus.TimeLimit, Array.Empty(), 9d), }); LateralPlanningResult preserved = new SequentialConvexOptimizer(solver).Optimize(input, CancellationToken.None); Verification.Equal(EmPlanningStatus.SuccessWithFallback, preserved.Status, "invalid solved vector does not discard an earlier fallback"); Verification.NearlyEqual(0.02d, preserved.Path.Points[1].L, "invalid solved vector does not replace the fallback candidate"); var inaccurateResidual = new FakeQpSolver(new[] { Result(QpSolveStatus.SolvedInaccurate, valid, 10d, 2e-5d, 0d), Result(QpSolveStatus.TimeLimit, Array.Empty(), 10d), }); LateralPlanningResult rejectedResidual = new SequentialConvexOptimizer(inaccurateResidual).Optimize(input, CancellationToken.None); Verification.Equal(EmPlanningStatus.SolverTimedOut, rejectedResidual.Status, "SolvedInaccurate above strict residual threshold is rejected"); Verification.True(ReferenceEquals(null, rejectedResidual.Path), "rejected inaccurate result does not publish a path"); var inaccurateGeometry = new FakeQpSolver(new[] { Result(QpSolveStatus.SolvedInaccurate, invalid, 10d, 0d, 0d), Result(QpSolveStatus.TimeLimit, Array.Empty(), 10d), }); LateralPlanningResult rejectedGeometry = new SequentialConvexOptimizer(inaccurateGeometry).Optimize(input, CancellationToken.None); Verification.Equal(EmPlanningStatus.SolverTimedOut, rejectedGeometry.Status, "SolvedInaccurate still requires full independent lateral validation"); } private static void VerifiesTrustRegionWarmStartAndOuterIterationLimit() { LateralPlanningInput input = CreateInput(); var trustSolver = new FakeQpSolver(new[] { Result(QpSolveStatus.Solved, CreatePrimal(input, 0.02d), 10d), Result(QpSolveStatus.TimeLimit, Array.Empty(), 10d), }); new SequentialConvexOptimizer(trustSolver).Optimize(input, CancellationToken.None); var layout = new LateralVariableLayout(input.ReferenceStations.Count); FindSingleVariableBounds(trustSolver.Problems[0], layout.L(1), out double initialLower, out double initialUpper); FindSingleVariableBounds(trustSolver.Problems[1], layout.L(1), out double nextLower, out double nextUpper); Verification.NearlyEqual(-0.05d, initialLower, "initial trust-region lower bound"); Verification.NearlyEqual(0.05d, initialUpper, "initial trust-region upper bound"); Verification.NearlyEqual(-0.03d, nextLower, "trust region is centered on previous iterate"); Verification.NearlyEqual(0.07d, nextUpper, "trust region never exceeds 0.05m around previous iterate"); Verification.Equal(layout.VariableCount, trustSolver.WarmStarts[1].Count, "next QP receives the complete previous primal warm start"); Verification.NearlyEqual(0.02d, trustSolver.WarmStarts[1][layout.L(1)], "warm start retains the prior lateral iterate"); var limitResults = new List(); for (int index = 1; index <= 5; index++) limitResults.Add(Result(QpSolveStatus.Solved, CreatePrimal(input, 0.02d * index), 100d - index)); 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"); } private static void VerifiesCancellationAndTimeoutWithoutCandidate() { LateralPlanningInput input = CreateInput(); var cancellationSolver = new FakeQpSolver(Result(QpSolveStatus.Solved, CreatePrimal(input, 0d), 1d)); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); LateralPlanningResult cancelled = new SequentialConvexOptimizer(cancellationSolver).Optimize(input, cancellation.Token); Verification.Equal(EmPlanningStatus.Cancelled, cancelled.Status, "cancellation before a solver call is cancelled"); Verification.Equal(0, cancellationSolver.SolveCallCount, "cancelled solve does not invoke the solver"); var timeoutSolver = new FakeQpSolver(Result(QpSolveStatus.TimeLimit, Array.Empty(), 1d)); LateralPlanningResult timeout = new SequentialConvexOptimizer(timeoutSolver).Optimize(input, CancellationToken.None); Verification.Equal(EmPlanningStatus.SolverTimedOut, timeout.Status, "timeout without a feasible candidate is solver timed out"); Verification.True(ReferenceEquals(null, timeout.Path), "timeout without candidate does not publish a path"); } private static void VerifiesLateralPlannerDelegatesToTheSequentialOptimizer() { LateralPlanningInput input = CreateInput(); double[] zero = CreatePrimal(input, 0d); var solver = new FakeQpSolver(new[] { Result(QpSolveStatus.Solved, zero, 1d), Result(QpSolveStatus.Solved, zero, 1d), }); LateralPlanningResult result = new LateralPlanner(solver).Plan(input, CancellationToken.None); Verification.Equal(EmPlanningStatus.Success, result.Status, "lateral planner returns SQP success"); } private static IReadOnlyList CreateRealOsqpScenarios() { return new[] { CreateScenario("straight-empty-forward", TravelDirection.Forward, 0d, EmTerminalType.Goal, -0.3d, 0.3d, 0d, false), CreateScenario("straight-empty-reverse", TravelDirection.Reverse, 0d, EmTerminalType.Goal, -0.3d, 0.3d, 0d, false), CreateScenario("gentle-curve", TravelDirection.Forward, 0.05d, EmTerminalType.Goal, -0.3d, 0.3d, 0d, false), CreateScenario("static-obstacle-narrowing", TravelDirection.Forward, 0d, EmTerminalType.RollingSafetyStop, -0.3d, -0.05d, -0.10d, true), CreateScenario("gear-switch-terminal", TravelDirection.Forward, 0d, EmTerminalType.GearSwitch, -0.3d, 0.3d, 0d, false), CreateScenario("rolling-terminal", TravelDirection.Forward, 0d, EmTerminalType.RollingSafetyStop, -0.3d, 0.3d, 0d, false), }; } private static LateralScenario CreateScenario(string name, TravelDirection direction, double geometricCurvature, EmTerminalType terminal, double corridorMinimum, double corridorMaximum, double seedL, bool requiresSeedConnectedInterval) { double[] stations = { 0d, 0.5d, 1d, 1.5d, 2d }; var points = new List(stations.Length); var intervals = new List(stations.Length); var seed = new List(requiresSeedConnectedInterval ? stations.Length : 0); for (int index = 0; index < stations.Length; index++) { double referenceS = stations[index]; double travelYaw = geometricCurvature * referenceS; double x = Math.Abs(geometricCurvature) <= 1e-12d ? referenceS : Math.Sin(travelYaw) / geometricCurvature; double y = Math.Abs(geometricCurvature) <= 1e-12d ? 0d : (1d - Math.Cos(travelYaw)) / geometricCurvature; double vehicleYaw = direction == TravelDirection.Forward ? travelYaw : travelYaw - Math.PI; points.Add(new SmoothedPathPoint(x, y, vehicleYaw, vehicleYaw, referenceS, direction, geometricCurvature, direction == TravelDirection.Forward ? geometricCurvature : -geometricCurvature, 0d, 1d, false, SmoothedPathPointSource.Anchor)); intervals.Add(new LateralInterval(referenceS, corridorMinimum, corridorMaximum, seedL)); } var segment = new DirectionSegmentView(0, direction, points, new ReferenceBoundary(0, 0d, EmBoundaryType.None, 0d), new ReferenceBoundary(0, 2d, terminal == EmTerminalType.GearSwitch ? EmBoundaryType.GearSwitchApproach : EmBoundaryType.Goal, 2d), 0d); if (requiresSeedConnectedInterval) { for (int index = 0; index < stations.Length; index++) seed.Add(new FrenetProjection(ReferencePathInterpolator.Interpolate(segment, stations[index]), seedL, 0d, 0d)); } EmPlannerConfiguration configuration = EmPlannerConfiguration.CreateDefault(); configuration.Scheduling.SolverTimeoutSeconds = 1d; configuration.Validation.SpatialToleranceMeters = 1e-5d; configuration.Validation.KinematicTolerance = 1e-5d; var vehicle = new VehicleParameters { LengthMeters = 0.1d, WidthMeters = 0.1d, SafetyMarginMeters = 0d, MaximumCurvaturePerMeter = 1d, }; return new LateralScenario(name, new LateralPlanningInput(segment, new StaticCorridor(intervals), new FrenetProjection(ReferencePathInterpolator.Interpolate(segment, 0d), seedL, 0d, 0d), terminal, vehicle, configuration, seed), requiresSeedConnectedInterval); } private static void VerifyRealScenarioResult(LateralScenario scenario, LateralPlanningResult result) { Verification.True(result.Status == EmPlanningStatus.Success || result.Status == EmPlanningStatus.SuccessWithFallback, scenario.Name + " is solved or has a documented fallback: " + result.FailureReason); LateralPath path = result.Path ?? throw new InvalidOperationException(scenario.Name + " returned no lateral path."); Verification.True(path.IsIndependentlyValidated, scenario.Name + " path is independently validated"); Verification.Equal(scenario.Input.ReferenceStations.Count, path.Points.Count, scenario.Name + " point count"); double maximumCurvature = scenario.Input.Vehicle.MaximumCurvaturePerMeter.GetValueOrDefault(); Verification.True(maximumCurvature > 0d, scenario.Name + " has a maximum vehicle curvature"); for (int index = 0; index < path.Points.Count; index++) { LateralPathPoint point = path.Points[index]; LateralInterval interval = scenario.Input.Corridor.Stations[index]; Verification.True(point.L >= interval.MinimumL - 1e-10d && point.L <= interval.MaximumL + 1e-10d, scenario.Name + " remains in corridor at station " + index); Verification.True(Math.Abs(point.VehicleCurvature) <= maximumCurvature + 1e-10d, scenario.Name + " respects vehicle curvature at station " + index); } Verification.NearlyEqual(scenario.Input.ReferenceStations[scenario.Input.ReferenceStations.Count - 1], path.Points[path.Points.Count - 1].ReferenceS, scenario.Name + " ends at exact ReferenceS anchor"); } private static void VerifyDeterministicResult(string name, LateralPlanningResult first, LateralPlanningResult second) { Verification.Equal(first.Status, second.Status, name + " deterministic status"); LateralPath firstPath = first.Path ?? throw new InvalidOperationException(name + " first path was missing."); LateralPath secondPath = second.Path ?? throw new InvalidOperationException(name + " second path was missing."); Verification.Equal(firstPath.Points.Count, secondPath.Points.Count, name + " deterministic point count"); for (int index = 0; index < firstPath.Points.Count; index++) ComparePoint(firstPath.Points[index], secondPath.Points[index], name + " deterministic point " + index); } private static void ComparePoint(LateralPathPoint left, LateralPathPoint right, string name) { double[] leftValues = { left.ReferenceS, left.PathS, left.L, left.DL, left.DDL, left.DDDL, left.X, left.Y, left.VehicleYaw, left.GeometricCurvature, left.VehicleCurvature, left.VehicleCurvatureDerivative, }; double[] rightValues = { right.ReferenceS, right.PathS, right.L, right.DL, right.DDL, right.DDDL, right.X, right.Y, right.VehicleYaw, right.GeometricCurvature, right.VehicleCurvature, right.VehicleCurvatureDerivative, }; for (int index = 0; index < leftValues.Length; index++) Verification.True(Math.Abs(leftValues[index] - rightValues[index]) <= 1e-10d, name + " value " + index); } private static LateralPlanningInput CreateInput() { var points = new List { Point(0d, 0d), Point(1d, 1d), Point(2d, 2d), }; var segment = new DirectionSegmentView(0, TravelDirection.Forward, points, new ReferenceBoundary(0, 0d, EmBoundaryType.None, 0d), new ReferenceBoundary(0, 2d, EmBoundaryType.Goal, 2d), 0d); var corridor = new StaticCorridor(new[] { new LateralInterval(0d, -0.3d, 0.3d, 0d), new LateralInterval(1d, -0.3d, 0.3d, 0d), new LateralInterval(2d, -0.3d, 0.3d, 0d), }); var vehicle = new VehicleParameters { LengthMeters = 0.1d, WidthMeters = 0.1d, SafetyMarginMeters = 0d, MaximumCurvaturePerMeter = 1d, }; return new LateralPlanningInput(segment, corridor, new FrenetProjection(ReferencePathInterpolator.Interpolate(segment, 0d), 0d, 0d, 0d), EmTerminalType.Goal, vehicle, EmPlannerConfiguration.CreateDefault(), Array.Empty()); } private static SmoothedPathPoint Point(double x, double pathS) { return new SmoothedPathPoint(x, 0d, 0d, 0d, pathS, TravelDirection.Forward, 0d, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor); } private static QpSolveResult Result(QpSolveStatus status, IReadOnlyList primal, double objective, double primalResidual = 0d, double dualResidual = 0d) { return new QpSolveResult(status, primal, objective, primalResidual, dualResidual, 1, TimeSpan.Zero, status.ToString(), string.Empty); } private static double[] CreatePrimal(LateralPlanningInput input, double middleL) { var layout = new LateralVariableLayout(input.ReferenceStations.Count); double c = 6d * middleL; var primal = new double[layout.VariableCount]; primal[layout.L(0)] = 0d; primal[layout.L(1)] = middleL; primal[layout.L(2)] = 0d; primal[layout.DL(0)] = 0d; primal[layout.DL(1)] = 0d; primal[layout.DL(2)] = 0d; primal[layout.DDL(0)] = c; primal[layout.DDL(1)] = -c; primal[layout.DDL(2)] = c; primal[layout.DDDL(0)] = -2d * c; primal[layout.DDDL(1)] = 2d * c; return primal; } private static void FindSingleVariableBounds(QuadraticProgram problem, int variable, out double lower, out double upper) { for (int row = 0; row < problem.ConstraintCount; row++) { int matchingEntries = 0; double coefficient = 0d; for (int column = 0; column < problem.ConstraintMatrix.ColumnCount; column++) { for (int index = problem.ConstraintMatrix.ColumnPointers[column]; index < problem.ConstraintMatrix.ColumnPointers[column + 1]; index++) { if (problem.ConstraintMatrix.RowIndices[index] == row) { matchingEntries++; if (column == variable) coefficient = problem.ConstraintMatrix.Values[index]; } } } if (matchingEntries == 1 && Math.Abs(coefficient - 1d) <= 1e-12d && Math.Abs(problem.LowerBounds[row] - problem.UpperBounds[row]) > 1e-12d) { lower = problem.LowerBounds[row]; upper = problem.UpperBounds[row]; return; } } throw new InvalidOperationException("Expected single-variable lateral trust-region row was not found."); } private sealed class LateralScenario { public LateralScenario(string name, LateralPlanningInput input, bool requiresSeedConnectedInterval) { Name = name; Input = input; RequiresSeedConnectedInterval = requiresSeedConnectedInterval; } public string Name { get; } public LateralPlanningInput Input { get; } public bool RequiresSeedConnectedInterval { get; } } }