using XFEEDToolBox.Core.Navigation; using XFEExtension.NetCore.XUnit.Attributes; using System.Text.Json; namespace XFEEDToolBox.Test; [TestFixture] public sealed class ScoBrakingPredictorTests { private static ScoExitEstimateInput ExitInput=>new() { OriginalParametersVerified=true,ForwardServoDomainVerified=true,OrdinaryArrivalControllerVerified=true, OriginalBrakeScalingMode=2,RemainingDistanceMetres=10000,CurrentSpeedMetresPerSecond=400, CurrentOrdinarySpeedLimitMetresPerSecond=40,OriginalBaseMaximumSpeedMetresPerSecond=1000, OriginalBaseBrakeAcceleration=150,OriginalResponseTimeSeconds=1,OriginalJerkMetresPerSecondCubed=100, OriginalForwardTargetBiasMetresPerSecond=10,ForwardThrottle=.75,StopResponseSecondsUpper=0, ActiveAccelerationUpper=0,EstimatedCoolingAndAssistSettleSeconds=1.5,MaximumPhysicsStepSeconds=.01, AdditionalResponseMarginSeconds=.1,MinimumOrdinaryHandoffSpeedMetresPerSecond=5, OriginalArrivalWindowSeconds=6,RelativeDistanceMargin=.01,DistanceUncertaintyMetres=2,SafeDropDistanceMetres=1 }; private static ScoServoParameters Servo=>new() { PinnedSourceDomainVerified=true,BaseMaximumSpeedMetresPerSecond=1000,BaseForwardAcceleration=75, BaseBrakeAcceleration=150,ResponseTimeSeconds=1,JerkMetresPerSecondCubed=10000, ForwardTargetBiasMetresPerSecond=10,ForwardThrottle=.75 }; private static ScoServoStepInput ServoStep=>new() { SpeedMetresPerSecond=400,OrdinaryPhysicsMaximumSpeedMetresPerSecond=40,PreviousServoAccelerationNorm=1000, FlightAssistBlend=1,Cooling=false,CoolingDragPerSecond=.36,NativeStepSeconds=.01 }; [Test]public void EstimatedBudgetIsExplicitlyUnqualifiedAndGivesCoolingNoBrakeCredit() { var first=ScoBrakingPredictor.EstimateExit(ExitInput); var longer=ScoBrakingPredictor.EstimateExit(ExitInput with{EstimatedCoolingAndAssistSettleSeconds=2}); Check.That(first.Known&&first.Estimated&&!first.QualifiedForControl&&!first.CoolingDragUsed&&first.RequiresActualOrdinaryHandoff); Check.That(Math.Abs(longer.EstimatedStopDistanceMetres-first.EstimatedStopDistanceMetres-200)<1e-9); Check.That(first.EstimatedStopDistanceMetres<400*13); Check.That(!ScoBrakingPredictor.EstimateExit(ExitInput with{RemainingDistanceMetres=first.EstimatedStopDistanceMetres}).RemainingDistanceExceedsEstimate); } [Test]public void NativeModeTwoLowerLawDoesNotUseAnUnconditionalQuarterBrake() { var result=ScoBrakingPredictor.StepOriginalServo(Servo,ServoStep); var clamp=150*.4*Math.Sqrt(.4/.04); Check.That(result.Known&&!result.QualifiedForControl); Check.That(Math.Abs(result.AppliedAcceleration+clamp)<1e-10); Check.Equal(32.5d,result.TargetSpeedMetresPerSecond); Check.Equal(.5d,result.EffectiveResponseTimeSeconds); Check.That(Math.Abs(result.NextSpeedMetresPerSecond-(400-clamp*.01))<1e-10); } [Test]public void OriginalJerkRampsFromTheActualPriorNormPerPhysicsStep() { var parameters=Servo with{JerkMetresPerSecondCubed=100}; var first=ScoBrakingPredictor.StepOriginalServo(parameters,ServoStep with{PreviousServoAccelerationNorm=0,NativeStepSeconds=.1}); var next=ScoBrakingPredictor.StepOriginalServo(parameters,ServoStep with{PreviousServoAccelerationNorm=first.NextServoAccelerationNorm,SpeedMetresPerSecond=first.NextSpeedMetresPerSecond,NativeStepSeconds=.1}); Check.Equal(10d,first.NextServoAccelerationNorm);Check.Equal(399d,first.NextSpeedMetresPerSecond); Check.Equal(20d,next.NextServoAccelerationNorm);Check.Equal(397d,next.NextSpeedMetresPerSecond); } [Test]public void CoolingLambdaIsNeitherMultipliedByBlendNorPresentWhenBlendIsZero() { var original=ScoBrakingPredictor.StepOriginalServo(Servo,ServoStep); var mixed=ScoBrakingPredictor.StepOriginalServo(Servo,ServoStep with{FlightAssistBlend=.5,Cooling=true}); var zero=ScoBrakingPredictor.StepOriginalServo(Servo,ServoStep with{FlightAssistBlend=0,Cooling=true}); Check.That(Math.Abs(mixed.AppliedAcceleration-(original.AppliedAcceleration*.5-.36*400))<1e-10); Check.Equal(0d,zero.AppliedAcceleration);Check.Equal(400d,zero.NextSpeedMetresPerSecond); } [Test]public void BudgetMaximisesOverHandoffsAndStopsTheOverspeedDomainAtItsFloor() { foreach(var window in new[]{2d,6d,10d}) { var value=ExitInput with{OriginalArrivalWindowSeconds=window}; var result=ScoBrakingPredictor.EstimateExit(value);Check.That(result.Known); // Independent small-step lower-law integration; no predictor roots // or path primitive are reused. Evaluate every possible handoff. var v=value.CurrentSpeedMetresPerSecond;var distance=0d;var t=0d;var maximum=window*v; const double dt=.0001; while(v>result.ServoDomainFloorMetresPerSecond) { var a=Math.Min(result.DecayRatePerSecond*v,value.OriginalJerkMetresPerSecondCubed*t); var next=Math.Max(result.ServoDomainFloorMetresPerSecond,v-a*dt); distance+=(v+next)*.5*dt;t+=dt;v=next; maximum=Math.Max(maximum,distance+window*v); } var analytic=result.OverspeedBudgetDistanceMetres+result.OrdinaryArrivalEnvelopeMetres; Check.That(Math.Abs(analytic-maximum)<.03); Check.That(result.BudgetHandoffSpeedMetresPerSecond>=result.ServoDomainFloorMetresPerSecond-1e-9); Check.That(result.BudgetHandoffSeconds<=result.DomainFloorSeconds); } } [Test]public void BiasFloorAndServoLimitedRateAreDerivedRatherThanFitted() { var original=ScoBrakingPredictor.EstimateExit(ExitInput);Check.That(original.Known); Check.That(Math.Abs(original.DecayRatePerSecond-.15)<1e-12); Check.That(Math.Abs(original.ServoDomainFloorMetresPerSecond-25)<1e-12); var stronger=ScoBrakingPredictor.EstimateExit(ExitInput with{OriginalBaseBrakeAcceleration=500}); Check.That(stronger.Known);Check.Equal(.125d,stronger.DecayRatePerSecond);Check.Equal(20d,stronger.ServoDomainFloorMetresPerSecond); } [Test]public void MissingExitSourceGatesAndInvalidParametersNeverAuthoriseAnEstimate() { foreach(var input in new[]{ExitInput with{OriginalParametersVerified=false},ExitInput with{ForwardServoDomainVerified=false}, ExitInput with{OrdinaryArrivalControllerVerified=false},ExitInput with{OriginalBrakeScalingMode=1}, ExitInput with{CurrentSpeedMetresPerSecond=1000},ExitInput with{CurrentOrdinarySpeedLimitMetresPerSecond=400}, ExitInput with{OriginalJerkMetresPerSecondCubed=double.NaN}, ExitInput with{CurrentOrdinarySpeedLimitMetresPerSecond=1},ExitInput with{ForwardThrottle=1}, ExitInput with{EstimatedCoolingAndAssistSettleSeconds=double.PositiveInfinity}}) { var result=ScoBrakingPredictor.EstimateExit(input); Check.That(!result.Known&&!result.QualifiedForControl&&!result.RemainingDistanceExceedsEstimate); } Check.That(!ScoBrakingPredictor.StepOriginalServo(Servo with{PinnedSourceDomainVerified=false},ServoStep).Known); Check.That(!ScoBrakingPredictor.StepOriginalServo(Servo,ServoStep with{SpeedMetresPerSecond=20,FlightAssistBlend=.5}).Known); } private static ScoExitEstimateObservation Observation=>new() { FreshBindingAndSourceGatesVerified=true,ElapsedSeconds=2,SpeedMetresPerSecond=350,OrdinarySpeedLimitMetresPerSecond=40, NativeStepSeconds=.005,FlightAssistBlend=1,ScoState=0,AllowedSpeedResidualFraction=0,AllowedSpeedResidualMetresPerSecond=0 }; [Test]public void ActualOrdinaryHandoffEndsTheModelEvenWhenTheCapHasRisen() { var forecast=ScoBrakingPredictor.EstimateExit(ExitInput); var check=ScoBrakingPredictor.CheckExitEstimate(ExitInput,forecast,Observation with{SpeedMetresPerSecond=100,OrdinarySpeedLimitMetresPerSecond=110}); Check.That(check.WithinEstimatedDomain&&check.OrdinaryHandoffReady&&!check.QualifiedForControl); Check.Equal("actual-ordinary-cap-handoff",check.Reason); } [Test]public void SettleDeadlineIdentityDomainAndBrakeResidualAreOnlineFallbackReasons() { var forecast=ScoBrakingPredictor.EstimateExit(ExitInput); foreach(var observation in new[]{Observation with{FreshBindingAndSourceGatesVerified=false}, Observation with{ScoState=3},Observation with{FlightAssistBlend=.9},Observation with{NativeStepSeconds=.02}, Observation with{ElapsedSeconds=5,SpeedMetresPerSecond=400}, Observation with{OrdinarySpeedLimitMetresPerSecond=20},Observation with{OrdinarySpeedLimitMetresPerSecond=1001}, Observation with{ElapsedSeconds=1000}}) { var check=ScoBrakingPredictor.CheckExitEstimate(ExitInput,forecast,observation); Check.That(!check.WithinEstimatedDomain&&!check.OrdinaryHandoffReady); } } [Test]public void RecordedCurveKeepsSparsePhysicsStepsSeparateFromElapsedTime() { using var doc=ReadExitFixture();var frames=doc.RootElement.GetProperty("frames").EnumerateArray().Select(x=>x.EnumerateArray().Select(v=>v.GetDouble()).ToArray()).ToArray(); const double c=299792458;var integral=0d; for(var i=1;ix[8])<1); // NOT 7.935 seconds of simulated history. Check.That(Math.Abs(integral-actual)/actual<.002); Check.That(Math.Abs(actual/c-215.34685571495913)<1e-9); Check.That(!doc.RootElement.GetProperty("qualifiedForControl").GetBoolean()); } [Test]public void OriginalScalarOffReplayAgreesWithTheIndependentObservedCurveWithinItsStatedLimits() { using var doc=ReadExitFixture();var root=doc.RootElement; var frames=root.GetProperty("frames").EnumerateArray().Select(x=>x.EnumerateArray().Select(v=>v.GetDouble()).ToArray()).ToArray(); var first=Array.FindIndex(frames,x=>x[5]==0);const double c=299792458; var parameters=new ScoServoParameters { PinnedSourceDomainVerified=true,BaseMaximumSpeedMetresPerSecond=root.GetProperty("baseMaximumSpeed").GetDouble(), BaseBrakeAcceleration=root.GetProperty("baseBrake").GetDouble(),BaseForwardAcceleration=root.GetProperty("baseForward").GetDouble(), ResponseTimeSeconds=root.GetProperty("baseResponseTime").GetDouble(),JerkMetresPerSecondCubed=root.GetProperty("laterObservedJerk").GetDouble(), ForwardTargetBiasMetresPerSecond=root.GetProperty("laterObservedBias").GetDouble(),ForwardThrottle=root.GetProperty("throttle").GetDouble() }; // Supplied FUTURE cap, and an initially saturated servo norm hypothesis. // These are a retrospective source-equation check, not live calibration. var speed=frames[first][1];var norm=frames[first][7];var distance=0d;var error=0d; for(var i=first+1;iJsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory,"Fixtures","sco-exit-observed.json"))); // Isolated mathematical fixture, not a claimed calibration of any live ship. private static ScoBrakingInput Input=>new() { OriginalParametersVerified=true,OrdinaryArrivalControllerVerified=true,OverspeedBrakeBoundVerified=true, RemainingDistanceMetres=10000,CurrentSpeedMetresPerSecond=400,OriginalBaseMaximumSpeedMetresPerSecond=1000, OrdinarySpeedLimitUpperMetresPerSecond=40,OriginalBaseOverspeedBrakeAcceleration=200, OriginalBrakeScalingMode=2,OriginalOrdinaryBrakeScaleLower=.04,OriginalExtraBrakeFactorLower=.25,OriginalArrivalWindowSeconds=6, StopResponseSecondsUpper=0,ActiveAccelerationMetresPerSecondSquaredUpper=0,PositionUncertaintyMetres=0,SafeDropDistanceMetres=0 }; [Test]public void ExactOverspeedIntegralAgreesWithIndependentNumericalOriginalLaw() { var value=Input;var result=ScoBrakingPredictor.Predict(value);Check.That(result.Known); // Simpson integration in velocity space uses the actual original // scale formula, rather than the predictor's analytic primitive. var intervals=10000;var h=(value.CurrentSpeedMetresPerSecond-value.OrdinarySpeedLimitUpperMetresPerSecond)/intervals; var distance=0d;var seconds=0d;var scale=value.OrdinarySpeedLimitUpperMetresPerSecond/value.OriginalBaseMaximumSpeedMetresPerSecond; for(var i=0;i<=intervals;i++) { var speed=value.OrdinarySpeedLimitUpperMetresPerSecond+i*h; var ratio=speed/value.OriginalBaseMaximumSpeedMetresPerSecond; var originalScaled=value.OriginalBaseOverspeedBrakeAcceleration*Math.Max(scale,ratio*Math.Max(1,Math.Sqrt(ratio/scale))); var deceleration=value.OriginalExtraBrakeFactorLower*originalScaled; var weight=i==0||i==intervals?1:i%2==0?2:4; distance+=weight*speed/deceleration;seconds+=weight/deceleration; } distance*=h/3;seconds*=h/3; Check.That(Math.Abs(distance-result.OverspeedDistanceMetres)first.OverspeedDistanceMetres); var slow=ScoBrakingPredictor.Predict(Input with{CurrentSpeedMetresPerSecond=20}); Check.Equal(0d,slow.OverspeedDistanceMetres);Check.Equal(120d,slow.OrdinaryArrivalEnvelopeMetres); } [Test]public void RealResponseDelayAccountsForDistanceAndFurtherScoAccelerationOnlyDuringThatDelay() { var baseline=ScoBrakingPredictor.Predict(Input); var delayed=ScoBrakingPredictor.Predict(Input with{StopResponseSecondsUpper=.25,ActiveAccelerationMetresPerSecondSquaredUpper=80,PositionUncertaintyMetres=3,SafeDropDistanceMetres=2}); Check.Equal(420d,delayed.SpeedAtOriginalStopMetresPerSecond);Check.Equal(102.5d,delayed.ResponseDistanceMetres); Check.That(delayed.StopDistanceMetres>baseline.StopDistanceMetres+107.5); Check.That(ScoBrakingPredictor.Predict(Input with{RemainingDistanceMetres=delayed.StopDistanceMetres-1,StopResponseSecondsUpper=.25,ActiveAccelerationMetresPerSecondSquaredUpper=80,PositionUncertaintyMetres=3,SafeDropDistanceMetres=2}).ContinueOvercharge==false); } [Test]public void SourceOrBrakeResponseGapsCannotAuthorizeALaterStop() { foreach(var value in new[]{Input with{OriginalParametersVerified=false},Input with{OrdinaryArrivalControllerVerified=false},Input with{OverspeedBrakeBoundVerified=false}, Input with{OriginalBrakeScalingMode=3},Input with{OrdinarySpeedLimitUpperMetresPerSecond=1001},Input with{OriginalBaseOverspeedBrakeAcceleration=0}, Input with{OriginalExtraBrakeFactorLower=.251},Input with{CurrentSpeedMetresPerSecond=double.NaN},Input with{StopResponseSecondsUpper=double.PositiveInfinity}}) {var result=ScoBrakingPredictor.Predict(value);Check.That(!result.Known&&!result.ContinueOvercharge&&double.IsNaN(result.StopDistanceMetres));} } [Test]public void ConstantAndSquareRootNativeScalingModesRespectTheirDifferentBrakeStrength() { var linear=ScoBrakingPredictor.Predict(Input with{OriginalBrakeScalingMode=0}); var root=ScoBrakingPredictor.Predict(Input with{OriginalBrakeScalingMode=1}); Check.That(linear.Known&&root.Known);Check.Equal(39600d,linear.OverspeedDistanceMetres);Check.Equal(7920d,root.OverspeedDistanceMetres); } [Test]public void AProviderSnapshotCannotReplaceALowerScaleBoundForModesThatLoseBrakingNearGravity() { var current=ScoBrakingPredictor.Predict(Input with{OriginalBrakeScalingMode=0}); var weaker=ScoBrakingPredictor.Predict(Input with{OriginalBrakeScalingMode=0,OriginalOrdinaryBrakeScaleLower=.01}); Check.Equal(current.OverspeedDistanceMetres*4,weaker.OverspeedDistanceMetres); Check.That(!ScoBrakingPredictor.Predict(Input with{OriginalBrakeScalingMode=0,OriginalOrdinaryBrakeScaleLower=double.NaN}).Known); Check.That(!ScoBrakingPredictor.Predict(Input with{OriginalBrakeScalingMode=1,OriginalOrdinaryBrakeScaleLower=.041}).Known); // Mode 2 gets stronger when the ordinary limit falls. Its upper // speed limit is already the conservative scale for the integral. Check.That(ScoBrakingPredictor.Predict(Input with{OriginalOrdinaryBrakeScaleLower=double.NaN}).Known); } [Test]public void LightSecondScaleAndTinyOverspeedDoNotLoseTheFiniteBrakeDistance() { const double c=299792458; var large=ScoBrakingPredictor.Predict(Input with{CurrentSpeedMetresPerSecond=500*c,OriginalBaseMaximumSpeedMetresPerSecond=2000*c, OrdinarySpeedLimitUpperMetresPerSecond=100*c,OriginalBaseOverspeedBrakeAcceleration=400*c}); Check.That(large.Known&&double.IsFinite(large.StopDistanceMetres)&&large.OverspeedDistanceMetres>0); var small=ScoBrakingPredictor.Predict(Input with{CurrentSpeedMetresPerSecond=40.000000001}); Check.That(small.Known&&small.OverspeedDistanceMetres>0&&small.OverspeedDistanceMetres<1e-6); } }