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.'