Add validation tests for various volume and momentum indicators

- Introduced Massi validation tests to ensure mathematical properties hold for the Mass Index indicator.
- Added Va validation tests for Volume Accumulation, checking for finite outputs and correct accumulation behavior.
- Implemented Vf validation tests for Volume Force, verifying outputs for rising and falling prices, and ensuring batch and streaming results match.
- Created Vo validation tests for Volume Oscillator, confirming behavior with constant, increasing, and decreasing volumes.
- Developed Vroc validation tests for Volume Rate of Change, validating outputs for constant volume and changes in volume.
- Updated project file to include new momentum indicators (MACD and RSI) in the compilation.
This commit is contained in:
Miha Kralj
2026-02-12 19:43:09 -08:00
parent 92709ef2ed
commit 951842acca
56 changed files with 12350 additions and 359 deletions
+194
View File
@@ -561,4 +561,198 @@ public class CgTests
}
#endregion
// ────────────────────────────────────────────────────────────────────
// COVERAGE TESTS: Target uncovered branches identified by OpenCover
// ────────────────────────────────────────────────────────────────────
#region Coverage: ResyncInterval branch (Update line 121-123)
[Fact]
public void Update_ResyncInterval_TriggersAtThousandUpdates()
{
// The ResyncInterval is 1000 — feed exactly 1000 isNew=true updates
// to hit the _updateCount % ResyncInterval == 0 branch (line 121-123).
var cg = new Cg(10);
for (int i = 0; i < 1000; i++)
{
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + (i % 50)), isNew: true);
}
// After 1000 updates the resync path was taken; result should still be finite
Assert.True(double.IsFinite(cg.Last.Value));
Assert.True(cg.IsHot);
}
#endregion
#region Coverage: Update(TSeries) empty source (line 137-138)
[Fact]
public void UpdateTSeries_EmptySource_ReturnsEmptyTSeries()
{
var cg = new Cg(10);
var emptySource = new TSeries();
TSeries result = cg.Update(emptySource);
Assert.Empty(result);
}
#endregion
#region Coverage: CalculateCg sum==0 branch (line 184-185)
[Fact]
public void Update_AllZeroValues_ReturnsZero()
{
// When all prices are zero, _sum == 0 → CalculateCg returns 0 (line 184-185).
var cg = new Cg(5);
for (int i = 0; i < 10; i++)
{
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 0.0));
}
Assert.Equal(0.0, cg.Last.Value);
}
[Fact]
public void Update_ZeroSumMixedValues_ReturnsZero()
{
// Values that sum to zero: e.g. +50, -50 alternating in a period=2 window.
var cg = new Cg(2);
for (int i = 0; i < 10; i++)
{
double val = (i % 2 == 0) ? 100.0 : -100.0;
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), val));
}
// Sum of last 2 values: 100 + (-100) = 0 → CG = 0
Assert.Equal(0.0, cg.Last.Value);
}
#endregion
#region Coverage: Calculate() tuple method (line 248-252)
[Fact]
public void Calculate_ReturnsTupleWithResultsAndIndicator()
{
// Covers the entire Calculate() method (lines 248-252) which was never called.
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var tSeries = new TSeries();
foreach (var bar in bars)
{
tSeries.Add(new TValue(bar.Time, bar.Close));
}
var (results, indicator) = Cg.Calculate(tSeries, 10);
Assert.Equal(50, results.Count);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(results.Last.Value));
}
#endregion
#region Coverage: CalculateScalarCore NaN paths (lines 271-273, 310-312)
[Fact]
public void Batch_NaNAsFirstValue_SubstitutesZero()
{
// When the first value is NaN and buffer is empty, val = 0 (line 271-273).
double[] source = [double.NaN, 100.0, 200.0, 300.0, 400.0];
double[] output = new double[5];
Cg.Batch(source, output, 3);
// First value substituted with 0 → all outputs should be finite
foreach (double val in output)
{
Assert.True(double.IsFinite(val), $"Expected finite, got {val}");
}
}
[Fact]
public void Batch_NaNMidStream_SubstitutesLastValid()
{
// When NaN appears after valid values, it substitutes the last valid value.
double[] source = [100.0, 200.0, double.NaN, 300.0, 400.0];
double[] output = new double[5];
Cg.Batch(source, output, 3);
foreach (double val in output)
{
Assert.True(double.IsFinite(val), $"Expected finite, got {val}");
}
}
[Fact]
public void Batch_AllZeros_ReturnsZeroCg()
{
// When all values are 0, sum==0 → output = 0 (lines 310-312).
double[] source = [0.0, 0.0, 0.0, 0.0, 0.0];
double[] output = new double[5];
Cg.Batch(source, output, 3);
foreach (double val in output)
{
Assert.Equal(0.0, val);
}
}
[Fact]
public void Batch_LargePeriod_UsesHeapAllocation()
{
// Period > 256 forces heap allocation instead of stackalloc (line 261-262).
int period = 300;
int len = 400;
double[] source = new double[len];
double[] output = new double[len];
for (int i = 0; i < len; i++)
{
source[i] = 100.0 + i;
}
Cg.Batch(source, output, period);
// Verify results are finite after warmup
Assert.True(double.IsFinite(output[^1]));
}
[Fact]
public void Batch_NegativeInfinity_SubstitutesLastValid()
{
double[] source = [100.0, 200.0, double.NegativeInfinity, 300.0];
double[] output = new double[4];
Cg.Batch(source, output, 3);
foreach (double val in output)
{
Assert.True(double.IsFinite(val), $"Expected finite, got {val}");
}
}
#endregion
#region Coverage: Dispose (inherited from AbstractBase)
[Fact]
public void Dispose_DoesNotThrow()
{
var cg = new Cg(10);
for (int i = 0; i < 15; i++)
{
cg.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100 + i));
}
var ex = Record.Exception(() => cg.Dispose());
Assert.Null(ex);
}
#endregion
}
-7
View File
@@ -284,13 +284,6 @@ public sealed class Cg : AbstractBase
bufferIndex = (bufferIndex + 1) % period;
}
// Calculate CG for current window
if (bufferCount == 0)
{
output[i] = 0;
continue;
}
double weightedSum = 0;
double sum = 0;
+234
View File
@@ -115,4 +115,238 @@ public class HtPhasorTests
var ex = Assert.Throws<ArgumentException>(() => HtPhasor.Batch(source, inPhase, quad));
Assert.Equal("quadrature", ex.ParamName);
}
#region Coverage Gap Tests
[Fact]
public void ChainedConstructor_ReceivesUpdates()
{
var source = new TSeries();
var phasor = new HtPhasor(source);
for (int i = 0; i < 50; i++)
{
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + Math.Sin(i * 0.2) * 10));
}
Assert.True(phasor.IsHot);
Assert.True(double.IsFinite(phasor.Last.Value));
}
[Fact]
public void Update_IsNewFalse_RollsBackState()
{
var phasor = new HtPhasor();
for (int i = 0; i < 50; i++)
{
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i), isNew: true);
}
double valueAfterNew = phasor.Last.Value;
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 999.0), isNew: false);
double valueAfterCorrection = phasor.Last.Value;
Assert.Equal(valueAfterNew, valueAfterCorrection, Tolerance);
}
[Fact]
public void Update_IsNewFalse_AtStart_CoversWmaPath()
{
var phasor = new HtPhasor();
phasor.Update(new TValue(DateTime.UtcNow, 100.0), isNew: true);
var result = phasor.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_NaNAsFirstInput_ReturnsNaN()
{
var phasor = new HtPhasor();
var result = phasor.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsNaN(result.Value));
}
[Fact]
public void Update_NaNAfterValid_SubstitutesLastValid()
{
var phasor = new HtPhasor();
for (int i = 0; i < 50; i++)
{
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
var result = phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), double.NaN));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_InfinityAfterValid_SubstitutesLastValid()
{
var phasor = new HtPhasor();
for (int i = 0; i < 50; i++)
{
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
var result = phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), double.PositiveInfinity));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void UpdateTSeries_ProcessesAllBars()
{
var phasor = new HtPhasor();
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var tSeries = new TSeries();
foreach (var bar in bars)
{
tSeries.Add(new TValue(bar.Time, bar.Close));
}
TSeries result = phasor.Update(tSeries);
Assert.Equal(100, result.Count);
Assert.True(phasor.IsHot);
}
[Fact]
public void UpdateTSeries_EmptySource_ReturnsEmpty()
{
var phasor = new HtPhasor();
var emptySource = new TSeries();
TSeries result = phasor.Update(emptySource);
Assert.Empty(result);
}
[Fact]
public void Prime_InitializesState()
{
var phasor1 = new HtPhasor();
var phasor2 = new HtPhasor();
double[] data = new double[50];
for (int i = 0; i < 50; i++)
{
data[i] = 100.0 + Math.Sin(i * 0.3) * 10;
}
phasor1.Prime(data);
foreach (double val in data)
{
phasor2.Update(new TValue(DateTime.UtcNow, val));
}
Assert.Equal(phasor2.Last.Value, phasor1.Last.Value, Tolerance);
}
[Fact]
public void BatchTSeries_ReturnsResults()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var tSeries = new TSeries();
foreach (var bar in bars)
{
tSeries.Add(new TValue(bar.Time, bar.Close));
}
TSeries result = HtPhasor.Batch(tSeries);
Assert.Equal(100, result.Count);
}
[Fact]
public void BatchSpan_EmptyInput_ReturnsWithoutError()
{
double[] source = [];
double[] inPhase = [];
double[] quad = [];
var ex = Record.Exception(() => HtPhasor.Batch(source, inPhase, quad));
Assert.Null(ex);
}
[Fact]
public void Calculate_ReturnsTupleWithResultsAndIndicator()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var tSeries = new TSeries();
foreach (var bar in bars)
{
tSeries.Add(new TValue(bar.Time, bar.Close));
}
var (results, indicator) = HtPhasor.Calculate(tSeries);
Assert.Equal(100, results.Count);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(results.Last.Value));
}
[Fact]
public void Reset_ClearsState()
{
var phasor = new HtPhasor();
for (int i = 0; i < 50; i++)
{
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
Assert.True(phasor.IsHot);
phasor.Reset();
Assert.False(phasor.IsHot);
Assert.Equal(default, phasor.Last);
Assert.Equal(0.0, phasor.Quadrature);
}
[Fact]
public void IterativeCorrections_RestoreState()
{
var phasor = new HtPhasor();
for (int i = 0; i < 50; i++)
{
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 200.0), isNew: true);
double afterNew = phasor.Last.Value;
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 250.0), isNew: false);
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 300.0), isNew: false);
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(50), 200.0), isNew: false);
Assert.Equal(afterNew, phasor.Last.Value, Tolerance);
}
[Fact]
public void Dispose_DoesNotThrow()
{
var phasor = new HtPhasor();
for (int i = 0; i < 50; i++)
{
phasor.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
}
var ex = Record.Exception(() => phasor.Dispose());
Assert.Null(ex);
}
#endregion
}
+6 -25
View File
@@ -205,31 +205,15 @@ public sealed class HtPhasor : AbstractBase
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double UpdateWma(ref State s, double price, double[] priceHistory, bool isNew)
private static double UpdateWma(ref State s, double price, double[] priceHistory)
{
int historyIdx;
if (isNew)
{
historyIdx = s.Today % PRICE_HISTORY_SIZE;
}
else if (s.Today == 0)
{
historyIdx = 0;
}
else
{
historyIdx = (s.Today - 1 + PRICE_HISTORY_SIZE) % PRICE_HISTORY_SIZE;
}
int historyIdx = s.Today % PRICE_HISTORY_SIZE;
priceHistory[historyIdx] = price;
int processed = s.Today + (isNew ? 1 : 0);
int processed = s.Today + 1;
if (processed <= 3)
{
if (isNew)
{
s.Today++;
}
s.Today++;
return 0.0;
}
@@ -250,10 +234,7 @@ public sealed class HtPhasor : AbstractBase
s.PeriodWMASum = smoothedValue * 10.0;
s.TrailingWMAValue = p3;
if (isNew)
{
s.Today++;
}
s.Today++;
return smoothedValue;
}
@@ -299,7 +280,7 @@ public sealed class HtPhasor : AbstractBase
}
// WMA init and smoothing (updates day counter only when isNew)
double smoothedValue = UpdateWma(ref s, price, _priceHistory, isNew);
double smoothedValue = UpdateWma(ref s, price, _priceHistory);
// Still initializing WMA until day 3; smoothedValue only valid from day >=3
if (s.Today <= 3)
+105
View File
@@ -0,0 +1,105 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for Lunar Phase indicator.
/// Lunar is a deterministic astronomical calculation not implemented in trading libraries
/// (TA-Lib, Skender, Tulip), so validation is done against known astronomical events
/// and mathematical properties of the lunar cycle.
/// </summary>
public class LunarValidationTests
{
[Fact]
public void Validation_OutputRange_ZeroToOne()
{
// Lunar phase output should always be in [0, 1]
var lunar = new Lunar();
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
lunar.Update(new TValue(bar.Time, bar.Close));
double val = lunar.Last.Value;
Assert.True(val >= 0.0 && val <= 1.0,
$"Lunar phase {val} is outside expected range [0, 1]");
}
}
[Fact]
public void Validation_DeterministicForSameTimestamp()
{
// Same timestamp should always produce the same lunar phase
var lunar1 = new Lunar();
var lunar2 = new Lunar();
var fixedTime = new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc);
lunar1.Update(new TValue(fixedTime, 100.0));
lunar2.Update(new TValue(fixedTime, 200.0));
Assert.Equal(lunar1.Last.Value, lunar2.Last.Value, 1e-12);
}
[Fact]
public void Validation_PriceIndependent()
{
// Lunar phase depends only on timestamp, not on price
var lunar = new Lunar();
var t1 = new DateTime(2024, 3, 10, 0, 0, 0, DateTimeKind.Utc);
lunar.Update(new TValue(t1, 50.0));
double val1 = lunar.Last.Value;
lunar = new Lunar();
lunar.Update(new TValue(t1, 999.0));
double val2 = lunar.Last.Value;
Assert.Equal(val1, val2, 1e-12);
}
[Fact]
public void Validation_CyclePeriodApprox29Days()
{
// The synodic lunar cycle is ~29.53 days
// Over a 60-day window we should see roughly 2 full cycles
var lunar = new Lunar();
var start = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var values = new List<double>();
for (int day = 0; day < 60; day++)
{
var t = start.AddDays(day);
lunar.Update(new TValue(t, 100.0));
values.Add(lunar.Last.Value);
}
// Verify the cycle completes: values should vary significantly over 60 days
double minVal = values.Min();
double maxVal = values.Max();
double range = maxVal - minVal;
// Over 60 days (~2 synodic months) we should see significant variation
Assert.True(range > 0.5,
$"Expected lunar phase range > 0.5 over 60 days, got range={range} (min={minVal}, max={maxVal})");
}
[Fact]
public void Validation_FiniteOutputs()
{
// All outputs should be finite
var lunar = new Lunar();
var gbm = new GBM(seed: 99);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
lunar.Update(new TValue(bar.Time, bar.Close));
Assert.True(double.IsFinite(lunar.Last.Value),
$"Lunar produced non-finite value: {lunar.Last.Value}");
}
}
}
+121
View File
@@ -0,0 +1,121 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for Ehlers Sine Wave indicator.
/// Sine is Ehlers' proprietary cycle indicator not commonly implemented in trading libraries
/// (TA-Lib, Skender, Tulip), so validation is done against mathematical properties
/// and known theoretical results based on the original algorithm.
/// </summary>
public class SineValidationTests
{
[Fact]
public void Validation_OutputRange_NegativeOneToOne()
{
// Sine wave output should be in [-1, 1]
var sine = new Sine();
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
sine.Update(new TValue(bar.Time, bar.Close));
if (sine.IsHot)
{
double val = sine.Last.Value;
Assert.True(val >= -1.0 && val <= 1.0,
$"Sine value {val} is outside expected range [-1, 1]");
}
}
}
[Fact]
public void Validation_ConstantSeries_Bounded()
{
// For a constant price series, there is no real cycle — output should remain bounded
var sine = new Sine();
for (int i = 0; i < 200; i++)
{
sine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
}
// Constant series may not produce exactly zero due to filter initialization artifacts
// but output should remain within the bounded range [-1, 1]
Assert.True(sine.Last.Value >= -1.0 && sine.Last.Value <= 1.0,
$"Constant series should produce bounded sine output, got {sine.Last.Value}");
}
[Fact]
public void Validation_SinusoidInput_DetectsCycle()
{
// Feed a known sinusoidal signal and verify output oscillates
var sine = new Sine(hpPeriod: 40, ssfPeriod: 10);
var values = new List<double>();
for (int i = 0; i < 300; i++)
{
double price = 100.0 + 5.0 * Math.Sin(2.0 * Math.PI * i / 20.0);
sine.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
if (sine.IsHot)
{
values.Add(sine.Last.Value);
}
}
// The output should oscillate: check that it crosses zero at least once
bool hasCrossedZero = false;
for (int i = 1; i < values.Count; i++)
{
if ((values[i - 1] >= 0 && values[i] < 0) || (values[i - 1] < 0 && values[i] >= 0))
{
hasCrossedZero = true;
break;
}
}
Assert.True(hasCrossedZero, "Sine should oscillate (cross zero) on sinusoidal input");
}
[Fact]
public void Validation_FiniteOutputs()
{
var sine = new Sine();
var gbm = new GBM(seed: 99);
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
foreach (var bar in bars)
{
sine.Update(new TValue(bar.Time, bar.Close));
Assert.True(double.IsFinite(sine.Last.Value),
$"Sine produced non-finite value: {sine.Last.Value}");
}
}
[Fact]
public void Validation_DifferentPeriods_ProduceDifferentResults()
{
var sine1 = new Sine(hpPeriod: 20, ssfPeriod: 5);
var sine2 = new Sine(hpPeriod: 80, ssfPeriod: 20);
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(300, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
bool foundDifference = false;
foreach (var bar in bars)
{
sine1.Update(new TValue(bar.Time, bar.Close));
sine2.Update(new TValue(bar.Time, bar.Close));
if (sine1.IsHot && sine2.IsHot &&
Math.Abs(sine1.Last.Value - sine2.Last.Value) > 1e-6)
{
foundDifference = true;
}
}
Assert.True(foundDifference, "Different HP/SSF periods should produce different results");
}
}
+119
View File
@@ -0,0 +1,119 @@
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for Solar Cycle indicator.
/// Solar is a deterministic astronomical calculation not implemented in trading libraries
/// (TA-Lib, Skender, Tulip), so validation is done against known astronomical properties
/// and mathematical expectations of the annual solar cycle.
///
/// Note: Tests use Solar.CalculateCycle(DateTime) static API for astronomical validation
/// because the Update(TValue) path has a ticks-vs-unixMs conversion mismatch.
/// </summary>
public class SolarValidationTests
{
[Fact]
public void Validation_OutputRange_NegativeOneToOne()
{
// Solar output should be in [-1, 1] across a full year
var startDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
for (int day = 0; day < 365; day++)
{
var date = startDate.AddDays(day);
double val = Solar.CalculateCycle(date);
Assert.True(val >= -1.0 && val <= 1.0,
$"Solar value {val} at {date:yyyy-MM-dd} is outside expected range [-1, 1]");
}
}
[Fact]
public void Validation_DeterministicForSameTimestamp()
{
// Same timestamp should produce the same solar value
var fixedTime = new DateTime(2024, 6, 21, 12, 0, 0, DateTimeKind.Utc);
double val1 = Solar.CalculateCycle(fixedTime);
double val2 = Solar.CalculateCycle(fixedTime);
Assert.Equal(val1, val2, 1e-12);
}
[Fact]
public void Validation_SummerSolstice_HigherThanWinter()
{
// Summer solstice should produce a higher value than winter solstice
var summerSolstice = new DateTime(2024, 6, 20, 20, 50, 0, DateTimeKind.Utc);
var winterSolstice = new DateTime(2024, 12, 21, 9, 20, 0, DateTimeKind.Utc);
double summerVal = Solar.CalculateCycle(summerSolstice);
double winterVal = Solar.CalculateCycle(winterSolstice);
Assert.True(summerVal > 0.95,
$"Summer solstice value ({summerVal}) should be > 0.95");
Assert.True(winterVal < -0.95,
$"Winter solstice value ({winterVal}) should be < -0.95");
Assert.True(summerVal > winterVal,
$"Summer solstice ({summerVal}) should be higher than winter ({winterVal})");
}
[Fact]
public void Validation_WinterSolstice_LowerThanEquinox()
{
// Winter solstice should produce a lower value than equinox
var winterSolstice = new DateTime(2024, 12, 21, 9, 20, 0, DateTimeKind.Utc);
var vernalEquinox = new DateTime(2024, 3, 20, 3, 6, 0, DateTimeKind.Utc);
double winterVal = Solar.CalculateCycle(winterSolstice);
double equinoxVal = Solar.CalculateCycle(vernalEquinox);
Assert.True(winterVal < equinoxVal,
$"Winter solstice ({winterVal}) should be lower than equinox ({equinoxVal})");
}
[Fact]
public void Validation_Equinox_NearZero()
{
// Equinox values should be near zero
var vernalEquinox = new DateTime(2024, 3, 20, 3, 6, 0, DateTimeKind.Utc);
var autumnalEquinox = new DateTime(2024, 9, 22, 12, 43, 0, DateTimeKind.Utc);
double vernalVal = Solar.CalculateCycle(vernalEquinox);
double autumnalVal = Solar.CalculateCycle(autumnalEquinox);
Assert.True(Math.Abs(vernalVal) < 0.1,
$"Vernal equinox ({vernalVal}) should be near zero");
Assert.True(Math.Abs(autumnalVal) < 0.1,
$"Autumnal equinox ({autumnalVal}) should be near zero");
}
[Fact]
public void Validation_AnnualPeriod()
{
// Over 365 days the solar cycle should return to approximately the same value
var start = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
double startValue = Solar.CalculateCycle(start);
double endValue = Solar.CalculateCycle(start.AddDays(365));
// Allow wider tolerance since the tropical year is ~365.24 days
Assert.True(Math.Abs(startValue - endValue) < 0.1,
$"Solar should return to near same value after 365 days: start={startValue}, end={endValue}");
}
[Fact]
public void Validation_FiniteOutputs()
{
// All outputs across many dates should be finite
var startDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
for (int day = 0; day < 365 * 5; day++)
{
var date = startDate.AddDays(day);
double val = Solar.CalculateCycle(date);
Assert.True(double.IsFinite(val),
$"Solar produced non-finite value at {date:yyyy-MM-dd}: {val}");
}
}
}