using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class ScoThermalPredictorTests
{
private static ScoThermalInput Model=>new(){Verified=true,HeatPercent=90,HeatCapacity=100,HeatDisplayScale=1,
ScoInputUpper=50,OtherInputUpper=2,CoolingPassive=10,CoolingMaximum=10,CoolingScale=1,CoolingHeatLower=0,CoolingHeatUpper=1,
LeaseRemainingSeconds=.2,ResponseDelaySeconds=.01,AfterStopSeconds=.2};
private static void Near(double expected,double actual,double error=1e-7)=>Check.That(Math.Abs(expected-actual)<=error,$"Expected {expected}, got {actual}.");
[Test]public void TurningScoOffRemovesOnlyItsFutureHeatSource()
{
var forecast=ScoThermalPredictor.Predict(Model);
Check.That(forecast.Known&&!forecast.ShouldStop);Near(42,forecast.ScoOnNetRisePercentPerSecond);Near(-8,forecast.ScoOffNetRisePercentPerSecond);
Near(98.82,forecast.ContinueUntilDeadlinePeakPercent);Near(90.42,forecast.ImmediateStopPeakPercent);
Near(87.22,forecast.ImmediateStopEndPercent);Near(97.22,forecast.DeadlineStopEndPercent);
}
[Test]public void ExistingStoredHeatIsNotErasedWhenTheSourceStops()
{
var forecast=ScoThermalPredictor.Predict(Model with{HeatPercent=99,ResponseDelaySeconds=0});
Check.That(forecast.Known&&forecast.ShouldStop);Near(99,forecast.ImmediateStopPeakPercent);
Check.That(forecast.ImmediateStopEndPercent>90&&forecast.ImmediateStopEndPercent<99);
}
[Test]public void OtherEnvironmentalOrUnattributedSourcesRemainAfterScoStops()
{
var forecast=ScoThermalPredictor.Predict(Model with{OtherInputUpper=20});
Check.That(forecast.Known);Near(10,forecast.ScoOffNetRisePercentPerSecond);
Check.That(forecast.ImmediateStopEndPercent>Model.HeatPercent);
}
[Test]public void OtherSourcesCanReach100AfterTheDeadlineHasAlreadyRemovedSco()
{
var forecast=ScoThermalPredictor.Predict(Model with{HeatPercent=98,ScoInputUpper=2,OtherInputUpper=12,
LeaseRemainingSeconds=.2,ResponseDelaySeconds=0,AfterStopSeconds=1});
Check.That(forecast.Known&&forecast.ShouldStop);Near(98.8,forecast.ContinueUntilDeadlinePeakPercent);
Near(100.8,forecast.DeadlineStopPeakPercent);Near(100.4,forecast.ImmediateStopPeakPercent);
Check.Equal("other-source-overheat-after-deadline",forecast.Reason);
}
[Test]public void ThresholdUsesActualLeaseAndDelayRatherThanTwoSecondsOfHeating()
{
var shortLease=ScoThermalPredictor.Predict(Model with{HeatPercent=80,LeaseRemainingSeconds=.02,ResponseDelaySeconds=.005});
Check.That(shortLease.Known&&!shortLease.ShouldStop);Near(81.05,shortLease.ContinueUntilDeadlinePeakPercent);
var nearLimit=ScoThermalPredictor.Predict(Model with{HeatPercent=99});
Check.That(nearLimit.Known&&nearLimit.ShouldStop);Near(.21,nearLimit.StopHorizonSeconds);
}
[Test]public void HudScaleAndHeatCapacityDetermineNativeEnergyConversion()
{
var forecast=ScoThermalPredictor.Predict(Model with{HeatCapacity=289,HeatDisplayScale=1.5});
Check.That(forecast.Known);Near(42*100/(289*1.5),forecast.ScoOnNetRisePercentPerSecond);
Near(-8*100/(289*1.5),forecast.ScoOffNetRisePercentPerSecond);
}
[Test]public void SourceAgeDoesNotDoubleCountHeatWhenTheHeatValueIsCurrent()
{
var current=ScoThermalPredictor.Predict(Model);
var olderSources=ScoThermalPredictor.Predict(Model with{SourceAgeSeconds=.19});
Near(current.ContinueUntilDeadlinePeakPercent,olderSources.ContinueUntilDeadlinePeakPercent);
var olderHeat=ScoThermalPredictor.Predict(Model with{HeatAgeSeconds=.1});
Check.That(olderHeat.ContinueUntilDeadlinePeakPercent>current.ContinueUntilDeadlinePeakPercent);
}
[Test]public void UnknownOrExpiredInputsCannotAuthorizeSco()
{
foreach(var value in new[]{Model with{Verified=false},Model with{SourceAgeSeconds=.201},Model with{HeatAgeSeconds=.201},
Model with{LeaseRemainingSeconds=.251},Model with{LeaseRemainingSeconds=0},Model with{CoolingMaximum=double.NaN},Model with{HeatCapacity=0}})
{var forecast=ScoThermalPredictor.Predict(value);Check.That(!forecast.Known&&forecast.ShouldStop);}
}
[Test]public void QuadraticCoolingUpperEndTracksAnIndependentExactSolution()
{
var forecast=ScoThermalPredictor.Predict(Model with{HeatPercent=80,ScoInputUpper=0,OtherInputUpper=0,CoolingPassive=0,CoolingMaximum=100,
LeaseRemainingSeconds=.2,ResponseDelaySeconds=0,AfterStopSeconds=.2});
// H'=-.01 H^2, H(t)=H0/(1+.01 H0 t). Implicit Euler must remain above the true cooling trajectory.
var exact=80/(1+.01*80*.4);
Check.That(forecast.Known&&!forecast.ShouldStop);Near(80,forecast.ImmediateStopPeakPercent);
Check.That(forecast.ImmediateStopEndPercent>=exact&&forecast.ImmediateStopEndPercent<exact+.2);
}
[Test]public void TimeTo100IsAConservativeLowerBoundUnderTheSuppliedEnvelope()
{
var constant=ScoThermalPredictor.Predict(Model);Near(10d/42,constant.SecondsTo100LowerBound!.Value);
var equilibriumBelowLimit=ScoThermalPredictor.Predict(Model with{ScoInputUpper=0,OtherInputUpper=2,CoolingMaximum=100});
Check.That(equilibriumBelowLimit.Known&&equilibriumBelowLimit.SecondsTo100LowerBound is null);
}
[Test]public void CurrentSimulationStepCannotSpendCoolingThatHasNotRunYet()
{
var model=Model with{HeatPercent=98,ScoInputUpper=100,OtherInputUpper=0,CoolingPassive=100,CoolingMaximum=100,
LeaseRemainingSeconds=.001,ResponseDelaySeconds=.03,AfterStopSeconds=0};
var continuous=ScoThermalPredictor.Predict(model);Check.That(continuous.Known&&!continuous.ShouldStop);Near(98,continuous.ContinueUntilDeadlinePeakPercent);
Check.That(ScoThermalPredictor.TryBoundCurrentSimulationStep(model,.03,out var gross));Near(101,gross);
}
[Test]public void ActualSimulationStepAndCurrentStoredHeatAreIndependentOfUtcLeaseLength()
{
var model=Model with{HeatPercent=97,HeatCapacity=289,HeatDisplayScale=1.5,ScoInputUpper=100,OtherInputUpper=10,LeaseRemainingSeconds=.001};
Check.That(ScoThermalPredictor.TryBoundCurrentSimulationStep(model,.25,out var peak));Near(97+110*.25*100/(289*1.5),peak);
foreach(var dt in new[]{0,double.NaN,-.01,.251})Check.That(!ScoThermalPredictor.TryBoundCurrentSimulationStep(model,dt,out _));
Check.That(!ScoThermalPredictor.TryBoundCurrentSimulationStep(model with{SourceAgeSeconds=.201},.01,out _));
}
}
[TestFixture]
public class ScoThermalSourceLedgerTests
{
private static void Near(double expected,double actual)=>Check.That(Math.Abs(expected-actual)<1e-7,$"Expected {expected}, got {actual}.");
[Test]public void AccountsSourcesInSimulationTimeAndDoesNotCallUnknownInputAmbient()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);
for(ulong index=1;index<=5;index++)
{ledger.ObserveInput(1,"FSD Overcharge",2,true);ledger.ObserveInput(2,"Power source",.4,false);ledger.ObserveInput(0,"",.2,false);ledger.ObserveTick(.02,1000+index*7);}
var value=ledger.Read(1035);Check.That(value.Ready&&value.Sources.Count==3);Near(.1,value.WindowSimulationSeconds);
Near(100,value.ScoInputUpper);Near(30,value.OtherInputUpper);Near(130,value.TotalRequestedEnergyPerSecond);
Check.That(value.Sources.Single(row=>row.SourceId==0).Label=="Unidentified source");
}
[Test]public void FirstPartialIntervalAndNonMonotonicClockCannotCreateAHeatSpike()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveInput(1,"SCO",100,true);ledger.ObserveTick(.01,1000);
Check.That(!ledger.Read(1000).Ready);ledger.ObserveInput(1,"SCO",1,true);ledger.ObserveTick(.1,1100);
Near(10,ledger.Read(1100).ScoInputUpper);
ledger.ObserveTick(.01,1099);Check.That(!ledger.Read(1099).Ready);Near(0,ledger.Read(1099).ScoInputUpper);
}
[Test]public void NewSourceIsIncludedImmediatelyDuringAnIncompleteWindow()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);ledger.ObserveTick(.1,1100);
ledger.ObserveInput(2,"new source",4,false);ledger.ObserveTick(.02,1120);
var value=ledger.Read(1120);Check.That(value.Ready);Near(200,value.OtherInputUpper);
}
[Test]public void AHistoricalPeakExpiresDuringAPartialWindowWithoutWaitingForTheNextBucket()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);
ledger.ObserveInput(1,"SCO",10,true);ledger.ObserveTick(.1,1100);
ledger.ObserveInput(1,"SCO",5,true);ledger.ObserveTick(.1,1200);
ledger.ObserveTick(.1,1300);ledger.ObserveTick(.02,1320);
Near(50,ledger.Read(1320).ScoInputUpper);
}
[Test]public void RetainsNegativeRequestsButNeverUsesThemAsUnverifiedFutureCooling()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);ledger.ObserveInput(3,"negative requested input",-4,false);ledger.ObserveTick(.1,1100);
var value=ledger.Read(1100);Check.That(value.Ready);Near(-40,value.TotalRequestedEnergyPerSecond);Near(0,value.OtherInputUpper);
}
[Test]public void ExpiredSnapshotsAndInvalidDtRequireANewCompleteWindow()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);ledger.ObserveTick(.1,1100);
Check.That(ledger.Read(1300).Ready&&!ledger.Read(1301).Ready);
ledger.ObserveTick(.3,1400);Check.That(!ledger.Read(1400).Ready);ledger.ObserveTick(.01,1410);ledger.ObserveTick(.1,1510);
Check.That(ledger.Read(1510).Ready);
}
}
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class ScoThermalPredictorTests
{
private static ScoThermalInput Model=>new(){Verified=true,HeatPercent=90,HeatCapacity=100,HeatDisplayScale=1,
ScoInputUpper=50,OtherInputUpper=2,CoolingPassive=10,CoolingMaximum=10,CoolingScale=1,CoolingHeatLower=0,CoolingHeatUpper=1,
LeaseRemainingSeconds=.2,ResponseDelaySeconds=.01,AfterStopSeconds=.2};
private static void Near(double expected,double actual,double error=1e-7)=>Check.That(Math.Abs(expected-actual)<=error,$"Expected {expected}, got {actual}.");
[Test]public void TurningScoOffRemovesOnlyItsFutureHeatSource()
{
var forecast=ScoThermalPredictor.Predict(Model);
Check.That(forecast.Known&&!forecast.ShouldStop);Near(42,forecast.ScoOnNetRisePercentPerSecond);Near(-8,forecast.ScoOffNetRisePercentPerSecond);
Near(98.82,forecast.ContinueUntilDeadlinePeakPercent);Near(90.42,forecast.ImmediateStopPeakPercent);
Near(87.22,forecast.ImmediateStopEndPercent);Near(97.22,forecast.DeadlineStopEndPercent);
}
[Test]public void ExistingStoredHeatIsNotErasedWhenTheSourceStops()
{
var forecast=ScoThermalPredictor.Predict(Model with{HeatPercent=99,ResponseDelaySeconds=0});
Check.That(forecast.Known&&forecast.ShouldStop);Near(99,forecast.ImmediateStopPeakPercent);
Check.That(forecast.ImmediateStopEndPercent>90&&forecast.ImmediateStopEndPercent<99);
}
[Test]public void OtherEnvironmentalOrUnattributedSourcesRemainAfterScoStops()
{
var forecast=ScoThermalPredictor.Predict(Model with{OtherInputUpper=20});
Check.That(forecast.Known);Near(10,forecast.ScoOffNetRisePercentPerSecond);
Check.That(forecast.ImmediateStopEndPercent>Model.HeatPercent);
}
[Test]public void OtherSourcesCanReach100AfterTheDeadlineHasAlreadyRemovedSco()
{
var forecast=ScoThermalPredictor.Predict(Model with{HeatPercent=98,ScoInputUpper=2,OtherInputUpper=12,
LeaseRemainingSeconds=.2,ResponseDelaySeconds=0,AfterStopSeconds=1});
Check.That(forecast.Known&&forecast.ShouldStop);Near(98.8,forecast.ContinueUntilDeadlinePeakPercent);
Near(100.8,forecast.DeadlineStopPeakPercent);Near(100.4,forecast.ImmediateStopPeakPercent);
Check.Equal("other-source-overheat-after-deadline",forecast.Reason);
}
[Test]public void ThresholdUsesActualLeaseAndDelayRatherThanTwoSecondsOfHeating()
{
var shortLease=ScoThermalPredictor.Predict(Model with{HeatPercent=80,LeaseRemainingSeconds=.02,ResponseDelaySeconds=.005});
Check.That(shortLease.Known&&!shortLease.ShouldStop);Near(81.05,shortLease.ContinueUntilDeadlinePeakPercent);
var nearLimit=ScoThermalPredictor.Predict(Model with{HeatPercent=99});
Check.That(nearLimit.Known&&nearLimit.ShouldStop);Near(.21,nearLimit.StopHorizonSeconds);
}
[Test]public void HudScaleAndHeatCapacityDetermineNativeEnergyConversion()
{
var forecast=ScoThermalPredictor.Predict(Model with{HeatCapacity=289,HeatDisplayScale=1.5});
Check.That(forecast.Known);Near(42*100/(289*1.5),forecast.ScoOnNetRisePercentPerSecond);
Near(-8*100/(289*1.5),forecast.ScoOffNetRisePercentPerSecond);
}
[Test]public void SourceAgeDoesNotDoubleCountHeatWhenTheHeatValueIsCurrent()
{
var current=ScoThermalPredictor.Predict(Model);
var olderSources=ScoThermalPredictor.Predict(Model with{SourceAgeSeconds=.19});
Near(current.ContinueUntilDeadlinePeakPercent,olderSources.ContinueUntilDeadlinePeakPercent);
var olderHeat=ScoThermalPredictor.Predict(Model with{HeatAgeSeconds=.1});
Check.That(olderHeat.ContinueUntilDeadlinePeakPercent>current.ContinueUntilDeadlinePeakPercent);
}
[Test]public void UnknownOrExpiredInputsCannotAuthorizeSco()
{
foreach(var value in new[]{Model with{Verified=false},Model with{SourceAgeSeconds=.201},Model with{HeatAgeSeconds=.201},
Model with{LeaseRemainingSeconds=.251},Model with{LeaseRemainingSeconds=0},Model with{CoolingMaximum=double.NaN},Model with{HeatCapacity=0}})
{var forecast=ScoThermalPredictor.Predict(value);Check.That(!forecast.Known&&forecast.ShouldStop);}
}
[Test]public void QuadraticCoolingUpperEndTracksAnIndependentExactSolution()
{
var forecast=ScoThermalPredictor.Predict(Model with{HeatPercent=80,ScoInputUpper=0,OtherInputUpper=0,CoolingPassive=0,CoolingMaximum=100,
LeaseRemainingSeconds=.2,ResponseDelaySeconds=0,AfterStopSeconds=.2});
// H'=-.01 H^2, H(t)=H0/(1+.01 H0 t). Implicit Euler must remain above the true cooling trajectory.
var exact=80/(1+.01*80*.4);
Check.That(forecast.Known&&!forecast.ShouldStop);Near(80,forecast.ImmediateStopPeakPercent);
Check.That(forecast.ImmediateStopEndPercent>=exact&&forecast.ImmediateStopEndPercent<exact+.2);
}
[Test]public void TimeTo100IsAConservativeLowerBoundUnderTheSuppliedEnvelope()
{
var constant=ScoThermalPredictor.Predict(Model);Near(10d/42,constant.SecondsTo100LowerBound!.Value);
var equilibriumBelowLimit=ScoThermalPredictor.Predict(Model with{ScoInputUpper=0,OtherInputUpper=2,CoolingMaximum=100});
Check.That(equilibriumBelowLimit.Known&&equilibriumBelowLimit.SecondsTo100LowerBound is null);
}
[Test]public void CurrentSimulationStepCannotSpendCoolingThatHasNotRunYet()
{
var model=Model with{HeatPercent=98,ScoInputUpper=100,OtherInputUpper=0,CoolingPassive=100,CoolingMaximum=100,
LeaseRemainingSeconds=.001,ResponseDelaySeconds=.03,AfterStopSeconds=0};
var continuous=ScoThermalPredictor.Predict(model);Check.That(continuous.Known&&!continuous.ShouldStop);Near(98,continuous.ContinueUntilDeadlinePeakPercent);
Check.That(ScoThermalPredictor.TryBoundCurrentSimulationStep(model,.03,out var gross));Near(101,gross);
}
[Test]public void ActualSimulationStepAndCurrentStoredHeatAreIndependentOfUtcLeaseLength()
{
var model=Model with{HeatPercent=97,HeatCapacity=289,HeatDisplayScale=1.5,ScoInputUpper=100,OtherInputUpper=10,LeaseRemainingSeconds=.001};
Check.That(ScoThermalPredictor.TryBoundCurrentSimulationStep(model,.25,out var peak));Near(97+110*.25*100/(289*1.5),peak);
foreach(var dt in new[]{0,double.NaN,-.01,.251})Check.That(!ScoThermalPredictor.TryBoundCurrentSimulationStep(model,dt,out _));
Check.That(!ScoThermalPredictor.TryBoundCurrentSimulationStep(model with{SourceAgeSeconds=.201},.01,out _));
}
}
[TestFixture]
public class ScoThermalSourceLedgerTests
{
private static void Near(double expected,double actual)=>Check.That(Math.Abs(expected-actual)<1e-7,$"Expected {expected}, got {actual}.");
[Test]public void AccountsSourcesInSimulationTimeAndDoesNotCallUnknownInputAmbient()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);
for(ulong index=1;index<=5;index++)
{ledger.ObserveInput(1,"FSD Overcharge",2,true);ledger.ObserveInput(2,"Power source",.4,false);ledger.ObserveInput(0,"",.2,false);ledger.ObserveTick(.02,1000+index*7);}
var value=ledger.Read(1035);Check.That(value.Ready&&value.Sources.Count==3);Near(.1,value.WindowSimulationSeconds);
Near(100,value.ScoInputUpper);Near(30,value.OtherInputUpper);Near(130,value.TotalRequestedEnergyPerSecond);
Check.That(value.Sources.Single(row=>row.SourceId==0).Label=="Unidentified source");
}
[Test]public void FirstPartialIntervalAndNonMonotonicClockCannotCreateAHeatSpike()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveInput(1,"SCO",100,true);ledger.ObserveTick(.01,1000);
Check.That(!ledger.Read(1000).Ready);ledger.ObserveInput(1,"SCO",1,true);ledger.ObserveTick(.1,1100);
Near(10,ledger.Read(1100).ScoInputUpper);
ledger.ObserveTick(.01,1099);Check.That(!ledger.Read(1099).Ready);Near(0,ledger.Read(1099).ScoInputUpper);
}
[Test]public void NewSourceIsIncludedImmediatelyDuringAnIncompleteWindow()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);ledger.ObserveTick(.1,1100);
ledger.ObserveInput(2,"new source",4,false);ledger.ObserveTick(.02,1120);
var value=ledger.Read(1120);Check.That(value.Ready);Near(200,value.OtherInputUpper);
}
[Test]public void AHistoricalPeakExpiresDuringAPartialWindowWithoutWaitingForTheNextBucket()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);
ledger.ObserveInput(1,"SCO",10,true);ledger.ObserveTick(.1,1100);
ledger.ObserveInput(1,"SCO",5,true);ledger.ObserveTick(.1,1200);
ledger.ObserveTick(.1,1300);ledger.ObserveTick(.02,1320);
Near(50,ledger.Read(1320).ScoInputUpper);
}
[Test]public void RetainsNegativeRequestsButNeverUsesThemAsUnverifiedFutureCooling()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);ledger.ObserveInput(3,"negative requested input",-4,false);ledger.ObserveTick(.1,1100);
var value=ledger.Read(1100);Check.That(value.Ready);Near(-40,value.TotalRequestedEnergyPerSecond);Near(0,value.OtherInputUpper);
}
[Test]public void ExpiredSnapshotsAndInvalidDtRequireANewCompleteWindow()
{
var ledger=new ScoThermalSourceLedger();ledger.ObserveTick(.02,1000);ledger.ObserveTick(.1,1100);
Check.That(ledger.Read(1300).Ready&&!ledger.Read(1301).Ready);
ledger.ObserveTick(.3,1400);Check.That(!ledger.Read(1400).Ready);ledger.ObserveTick(.01,1410);ledger.ObserveTick(.1,1510);
Check.That(ledger.Read(1510).Ready);
}
}