diff --git a/Source/RandomTools.Core/Options/Delay/DelayOptions.cs b/Source/RandomTools.Core/Options/Delay/DelayOptions.cs index 6ac2251..bba45ba 100644 --- a/Source/RandomTools.Core/Options/Delay/DelayOptions.cs +++ b/Source/RandomTools.Core/Options/Delay/DelayOptions.cs @@ -97,7 +97,6 @@ public Normal WithAutoFit(double minimum, double maximum) public override void Validate() { base.Validate(); - EnsureFinite(Mean); EnsureFinite(StandardDeviation); @@ -117,12 +116,10 @@ public override void Validate() } } - public override bool Equals(Normal? other) - { - return base.Equals(other) && - other.Mean == Mean && - other.StandardDeviation == StandardDeviation; - } + public override bool Equals(Normal? other) => + base.Equals(other) && + DoubleComparer.Equals(other.Mean, Mean) && + DoubleComparer.Equals(other.StandardDeviation, StandardDeviation); public override int GetHashCode() => HashCode.Combine(Minimum, Maximum, TimeUnit, Mean, StandardDeviation); @@ -156,7 +153,6 @@ public Triangular WithMode(double value) public override void Validate() { base.Validate(); - EnsureFinite(Mode); // Check that the mode lies within the defined [Minimum, Maximum] range @@ -173,11 +169,9 @@ public override void Validate() /// /// Another instance. /// True if equal, false otherwise. - public override bool Equals(Triangular? other) - { - return base.Equals(other) && - other.Mode == Mode; - } + public override bool Equals(Triangular? other) => + base.Equals(other) && + DoubleComparer.Equals(other.Mode, Mode); /// /// Returns a hash code for the current instance. @@ -310,7 +304,6 @@ public Polynomial WithReverse(bool value) public override void Validate() { base.Validate(); - EnsureFinite(Power); if (Power < 0.0) @@ -321,12 +314,10 @@ public override void Validate() } /// - public override bool Equals(Polynomial? other) - { - return base.Equals(other) && - other.Power == Power && - other.Reverse == Reverse; - } + public override bool Equals(Polynomial? other) => + base.Equals(other) && + DoubleComparer.Equals(other.Power, Power) && + other.Reverse == Reverse; /// public override int GetHashCode() => @@ -394,7 +385,6 @@ public override void Validate() { // Validate base numeric fields (Minimum/Maximum) base.Validate(); - EnsureFinite(AlphaValue); EnsureFinite(BetaValue); @@ -416,12 +406,10 @@ public override void Validate() /// /// Other instance to compare. /// if all relevant fields are equal; otherwise . - public override bool Equals(Beta? other) - { - return base.Equals(other) && - other.AlphaValue == AlphaValue && - other.BetaValue == BetaValue; - } + public override bool Equals(Beta? other) => + base.Equals(other) && + DoubleComparer.Equals(other.AlphaValue, AlphaValue) && + DoubleComparer.Equals(other.BetaValue, BetaValue); /// /// Computes a hash code based on range, time unit, and Beta distribution parameters. @@ -489,11 +477,9 @@ public override void Validate() } } - public override bool Equals(Sequence? other) - { - return base.Equals(other) && - other.Values.SequenceEqual(Values); - } + public override bool Equals(Sequence? other) => + base.Equals(other) && + other.Values.SequenceEqual(Values); public override int GetHashCode() => HashCode.Combine(Minimum, Maximum, TimeUnit, Values); diff --git a/Source/RandomTools.Core/Options/Delay/DelayOptionsBase.cs b/Source/RandomTools.Core/Options/Delay/DelayOptionsBase.cs index 78138ef..98d43ff 100644 --- a/Source/RandomTools.Core/Options/Delay/DelayOptionsBase.cs +++ b/Source/RandomTools.Core/Options/Delay/DelayOptionsBase.cs @@ -12,6 +12,17 @@ namespace RandomTools.Core.Options.Delay public abstract class DelayOptionsBase : IOptionsBase, IEquatable where TDelayOptions : DelayOptionsBase { + /// + /// Provides a default equality comparer for values. + /// + /// Used for comparing all numeric fields in options classes, such as , + /// , and other derived fields like Mode. + /// Note that this comparer can compare non-finite values (NaN, Infinity), + /// so validation via is still required to enforce finiteness. + /// + /// + protected static EqualityComparer DoubleComparer => EqualityComparer.Default; + /// /// Minimum value of the delay range. /// Can be negative, zero, or positive. @@ -127,11 +138,9 @@ public virtual bool Equals(TDelayOptions? other) if (other is null) return false; - var comparer = EqualityComparer.Default; - return - comparer.Equals(other.Minimum, Minimum) && - comparer.Equals(other.Maximum, Maximum) && + DoubleComparer.Equals(other.Minimum, Minimum) && + DoubleComparer.Equals(other.Maximum, Maximum) && other.TimeUnit == TimeUnit; } diff --git a/Source/RandomTools.Core/RandomTool.cs b/Source/RandomTools.Core/RandomTool.cs index b379ec0..cb6b100 100644 --- a/Source/RandomTools.Core/RandomTool.cs +++ b/Source/RandomTools.Core/RandomTool.cs @@ -368,7 +368,7 @@ public static NormalDelay InMilliseconds(double mean, double stdDev, double min, /// Same as /// but uses seconds as the time unit. /// - public static NormalDelay Seconds(double mean, double stdDev, double min, double max) + public static NormalDelay InSeconds(double mean, double stdDev, double min, double max) { var options = new DelayOptions.Normal() .WithTimeUnit(TimeUnit.Second) @@ -410,7 +410,7 @@ public static NormalDelay InMilliseconds(double mean, double stdDev, (double Min /// Tuple-based overload for seconds. /// public static NormalDelay InSeconds(double mean, double stdDev, (double Min, double Max) range) => - Seconds(mean, stdDev, range.Min, range.Max); + InSeconds(mean, stdDev, range.Min, range.Max); /// /// Tuple-based overload for minutes. @@ -420,33 +420,31 @@ public static NormalDelay InMinutes(double mean, double stdDev, (double Min, dou } /// - /// Factory class for generating and caching instances. + /// Provides methods to obtain instances with caching to reuse identical configurations. /// - /// The Bates distribution represents the arithmetic mean of N independent uniform samples - /// within a configured minimum and maximum range. This factory provides convenient methods - /// to obtain a for different time units while caching instances - /// for reuse. + /// A Bates delay represents the arithmetic mean of samples independent uniform values + /// within the specified minimum and maximum range. /// /// - /// All configuration validation is performed by . + /// All configuration is validated via . /// /// public static class Bates { /// - /// Thread-safe cache for storing instances keyed by their options. - /// Ensures that multiple requests with identical configuration return the same instance. + /// Thread-safe cache mapping to instances. + /// Ensures that repeated requests with the same options return the same instance. /// private static readonly ConcurrentDictionary sCache = new(); /// - /// Returns a cached configured with the specified minimum, maximum, - /// number of samples, and time unit. + /// Returns a cached configured with the specified range, number of samples, and time unit. /// - /// The minimum delay value. - /// The maximum delay value. - /// Number of uniform samples to average for the Bates distribution. - /// Time unit in which the delay will be expressed. + /// Minimum delay value. + /// Maximum delay value. + /// Number of uniform samples to average. + /// Time unit for the delay. + /// A instance with the requested configuration. public static BatesDelay For(double minimum, double maximum, int samples, TimeUnit unit) { var options = new DelayOptions.Bates() @@ -455,24 +453,23 @@ public static BatesDelay For(double minimum, double maximum, int samples, TimeUn .WithMaximum(maximum) .WithSamples(samples); - return sCache.GetOrAdd(options, - _ => new BatesDelay(options)); + return sCache.GetOrAdd(options, _ => new BatesDelay(options)); } /// - /// Returns a cached configured for millisecond delays. + /// Returns a cached configured for milliseconds. /// public static BatesDelay InMilliseconds(double minimum, double maximum, int samples) => For(minimum, maximum, samples, TimeUnit.Millisecond); /// - /// Returns a cached configured for second delays. + /// Returns a cached configured for seconds. /// public static BatesDelay InSeconds(double minimum, double maximum, int samples) => For(minimum, maximum, samples, TimeUnit.Second); /// - /// Returns a cached configured for minute delays. + /// Returns a cached configured for minutes. /// public static BatesDelay InMinutes(double minimum, double maximum, int samples) => For(minimum, maximum, samples, TimeUnit.Minute); diff --git a/Source/RandomTools.Tests/BatesDelayTests.cs b/Source/RandomTools.Tests/BatesDelayTests.cs index 992641b..980522a 100644 --- a/Source/RandomTools.Tests/BatesDelayTests.cs +++ b/Source/RandomTools.Tests/BatesDelayTests.cs @@ -17,12 +17,12 @@ public void SetUp() } [Test, Combinatorial] - public void When_Sampling_MilliSeconds( - [Values( 1_000.0, 5_000.0, 10_000.0)] double min, + public void BatesDelays_Milliseconds_ShouldMatchRangeMeanAndOrder( + [Values(1_000.0, 5_000.0, 10_000.0)] double min, [Values(15_000.0, 20_000.0, 25_000.0)] double max, [Values(1, 2, 3, 4, 5, 10, 25, 50, 100)] int samples) { - double expectedMean = (min + max) / 2.0; + double expMean = (min + max) / 2.0; var delay = RandomTool.Delay.Bates.InMilliseconds(min, max, samples); for (int i = 0; i < Iterations; i++) @@ -35,20 +35,23 @@ public void When_Sampling_MilliSeconds( _delays.Add(next); } - var stats = Statistics.Analyze(_delays.Select(x => x.TotalMilliseconds)); - double SEM = Statistics.StandardErrorOfMean(stats.StandardDeviation, stats.Count); + var (Mean, Variance, StandardDeviation, Count) = Statistics.Analyze(_delays.Select(x => x.TotalMilliseconds)); + double SEM = Statistics.StandardErrorOfMean(StandardDeviation, Count); double delta = Statistics.GetConfidenceDelta(ConfidenceLevel.Confidence999, SEM); - stats.Mean.Should().BeApproximately(expectedMean, delta); + Mean.Should().BeApproximately(expMean, delta); + + double nHat = Statistics.EstimateBatesOrder(Variance, (min, max)); + nHat.Should().BeApproximately(samples, samples * 0.1); } [Test, Combinatorial] - public void When_Sampling_Minutes( + public void BatesDelays_Minutes_ShouldMatchRangeMeanAndOrder( [Values(0.5, 1.5, 2.0)] double min, [Values(3.0, 3.5, 4.0)] double max, [Values(1, 2, 3, 4, 5, 10, 25, 50, 100)] int samples) { - double expectedMean = (min + max) / 2.0; + double expMean = (min + max) / 2.0; var delay = RandomTool.Delay.Bates.InMinutes(min, max, samples); for (int i = 0; i < Iterations; i++) @@ -61,20 +64,23 @@ public void When_Sampling_Minutes( _delays.Add(next); } - var stats = Statistics.Analyze(_delays.Select(x => x.TotalMinutes)); - double SEM = Statistics.StandardErrorOfMean(stats.StandardDeviation, stats.Count); + var (Mean, Variance, StandardDeviation, Count) = Statistics.Analyze(_delays.Select(x => x.TotalMinutes)); + double SEM = Statistics.StandardErrorOfMean(StandardDeviation, Count); double delta = Statistics.GetConfidenceDelta(ConfidenceLevel.Confidence999, SEM); - stats.Mean.Should().BeApproximately(expectedMean, delta); + Mean.Should().BeApproximately(expMean, delta); + + double nHat = Statistics.EstimateBatesOrder(Variance, (min, max)); + nHat.Should().BeApproximately(samples, samples * 0.1); } [Test, Combinatorial] - public void When_Sampling_Seconds( + public void BatesDelays_Seconds_ShouldMatchRangeMeanAndOrder( [Values(05.0, 10.0, 15.0)] double min, [Values(20.0, 25.0, 30.0)] double max, [Values(1, 2, 3, 4, 5, 10, 25, 50, 100)] int samples) { - double expectedMean = (min + max) / 2.0; + double expMean = (min + max) / 2.0; var delay = RandomTool.Delay.Bates.InSeconds(min, max, samples); for (int i = 0; i < Iterations; i++) @@ -87,11 +93,14 @@ public void When_Sampling_Seconds( _delays.Add(next); } - var stats = Statistics.Analyze(_delays.Select(x => x.TotalSeconds)); - double SEM = Statistics.StandardErrorOfMean(stats.StandardDeviation, stats.Count); + var (Mean, Variance, StandardDeviation, Count) = Statistics.Analyze(_delays.Select(x => x.TotalSeconds)); + double SEM = Statistics.StandardErrorOfMean(StandardDeviation, Count); double delta = Statistics.GetConfidenceDelta(ConfidenceLevel.Confidence999, SEM); - stats.Mean.Should().BeApproximately(expectedMean, delta); + Mean.Should().BeApproximately(expMean, delta); + + double nHat = Statistics.EstimateBatesOrder(Variance, (min, max)); + nHat.Should().BeApproximately(samples, samples * 0.1); } } -} +} \ No newline at end of file diff --git a/Source/RandomTools.Tests/Keywords.cs b/Source/RandomTools.Tests/Keywords.cs index c700b98..9343c4f 100644 --- a/Source/RandomTools.Tests/Keywords.cs +++ b/Source/RandomTools.Tests/Keywords.cs @@ -8,5 +8,6 @@ internal static class Keywords public static string Minimum => nameof(Minimum); public static string Maximum => nameof(Maximum); public static string Samples => nameof(Samples); + public static string Mode => nameof(Mode); } } diff --git a/Source/RandomTools.Tests/Options/BatesOptionsFixture.cs b/Source/RandomTools.Tests/Options/BatesOptionsFixture.cs index feeebf4..0db6449 100644 --- a/Source/RandomTools.Tests/Options/BatesOptionsFixture.cs +++ b/Source/RandomTools.Tests/Options/BatesOptionsFixture.cs @@ -206,16 +206,16 @@ public void When_Used_As_Dictionary_Key_Should_Return_Correct_Value() bool exists = dict.TryGetValue(keyToLookup, out object? actualValue); exists.Should().BeTrue(); - actualValue.Should().BeSameAs(expectedValue); + actualValue.Should().Be(expectedValue); } [Test] [TestCaseSource(typeof(ValuesProvider), nameof(ValuesProvider.TimeUnits))] public void When_Valid_Options_Provided_Should_Not_Throw_On_Validate(TimeUnit unit) { - const double min = 300.0; - const double max = 600.0; - const int samples = 5; + double min = CoreTools.NextDouble(250, 500); + double max = min + CoreTools.NextDouble(200, 400); + int samples = CoreTools.NextInt(5, 10); var options = new DelayOptions.Bates() .WithTimeUnit(unit) diff --git a/Source/RandomTools.Tests/Options/TriangularOptionsFixture.cs b/Source/RandomTools.Tests/Options/TriangularOptionsFixture.cs index 98af4d7..f0b49dc 100644 --- a/Source/RandomTools.Tests/Options/TriangularOptionsFixture.cs +++ b/Source/RandomTools.Tests/Options/TriangularOptionsFixture.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using RandomTools.Core; using RandomTools.Core.Exceptions; using RandomTools.Core.Options.Delay; @@ -7,6 +8,20 @@ namespace RandomTools.Tests.Options [TestFixture] public class TriangularOptionsFixture { + [Test] + public void When_Unconfigured_Should_Throw_On_Validate() + { + var options = new DelayOptions.Triangular(); + + var ex = options.Invoking(opt => opt.Validate()) + .Should() + .Throw() + .Which; + + ex.Options.Should().Be(options).And.NotBeSameAs(options); + ex.Message.Should().Contain(ExceptionMessages.RangeIsTooShort); + } + [Test] [TestCaseSource(typeof(ValuesProvider), nameof(ValuesProvider.NonFinite))] public void When_Minimum_Is_Not_Finite_Should_Throw_On_Validate(double value) @@ -19,8 +34,8 @@ public void When_Minimum_Is_Not_Finite_Should_Throw_On_Validate(double value) .Throw() .Which; - ex.Options.Should().BeSameAs(options); - ex.Message.Should().Contain($"({value})"); + ex.Options.Should().Be(options).And.NotBeSameAs(options); + ex.Message.Should().ContainAll(Keywords.Minimum, ExceptionMessages.Format(value, true)); } [Test] @@ -35,8 +50,8 @@ public void When_Maximum_Is_Not_Finite_Should_Throw_On_Validate(double value) .Throw() .Which; - ex.Options.Should().BeSameAs(options); - ex.Message.Should().Contain($"({value})"); + ex.Options.Should().Be(options).And.NotBeSameAs(options); + ex.Message.Should().ContainAll(Keywords.Maximum, ExceptionMessages.Format(value, true)); } [Test] @@ -44,6 +59,8 @@ public void When_Maximum_Is_Not_Finite_Should_Throw_On_Validate(double value) public void When_Mode_Is_Not_Finite_Should_Throw_On_Validate(double value) { var options = new DelayOptions.Triangular() + .WithMinimum(100.0) + .WithMaximum(200.0) .WithMode(value); var ex = options.Invoking(opt => opt.Validate()) @@ -51,8 +68,111 @@ public void When_Mode_Is_Not_Finite_Should_Throw_On_Validate(double value) .Throw() .Which; - ex.Options.Should().BeSameAs(options); - ex.Message.Should().Contain($"({value})"); + ex.Options.Should().Be(options).And.NotBeSameAs(options); + ex.Message.Should().ContainAll(Keywords.Mode, ExceptionMessages.Format(value, true)); + } + + [Test] + public void When_Mode_Is_LessThan_Minimum_Should_Throw_On_Validate() + { + double min = Math.Round(CoreTools.NextDouble(400.0, 800.0)); + double max = Math.Round(CoreTools.NextDouble(1200.0, 1600.0)); + double mode = Math.Round(min - 100.0); + + var options = new DelayOptions.Triangular() + .WithTimeUnit(TimeUnit.Millisecond) + .WithMinimum(min) + .WithMaximum(max) + .WithMode(mode); + + var ex = options.Invoking(opt => opt.Validate()) + .Should() + .Throw() + .Which; + + ex.Options.Should().Be(options).And.NotBeSameAs(options); + ex.Message.Should().ContainAll( + Keywords.Mode, + ExceptionMessages.Format(mode, true), + ExceptionMessages.Format(min, false), + ExceptionMessages.Format(max, false)); + } + + [Test] + public void When_Mode_Is_GreaterThan_Maximum_Should_Throw_On_Validate() + { + double min = Math.Round(CoreTools.NextDouble(200.0, 500.0)); + double max = Math.Round(CoreTools.NextDouble(800.0, 1200.0)); + double mode = Math.Round(max + 100.0); + + var options = new DelayOptions.Triangular() + .WithTimeUnit(TimeUnit.Millisecond) + .WithMinimum(min) + .WithMaximum(max) + .WithMode(mode); + + var ex = options.Invoking(opt => opt.Validate()) + .Should() + .Throw() + .Which; + + ex.Options.Should().Be(options).And.NotBeSameAs(options); + ex.Message.Should().ContainAll( + Keywords.Mode, + ExceptionMessages.Format(mode, true), + ExceptionMessages.Format(min, false), + ExceptionMessages.Format(max, false)); + } + + [Test] + public void When_Used_As_Dictionary_Key_Should_Return_Correct_Value() + { + const double min = 850.0; + const double max = 1450.0; + const double mode = 1200.0; + + var options = new DelayOptions.Triangular() + .WithTimeUnit(TimeUnit.Millisecond) + .WithMinimum(min) + .WithMaximum(max) + .WithMode(mode); + + var keyToLookup = (DelayOptions.Triangular) options.Clone(); + var expectedValue = new object(); + + var dict = new Dictionary + { + [options] = expectedValue + }; + + bool exists = dict.TryGetValue(keyToLookup, out object? actualValue); + + exists.Should().BeTrue(); + actualValue.Should().Be(expectedValue); + } + + [Test] + [TestCase(0.0), TestCase(0.1)] + [TestCase(0.2), TestCase(0.3)] + [TestCase(0.4), TestCase(0.5)] + [TestCase(0.6), TestCase(0.7)] + [TestCase(0.8), TestCase(0.9)] + [TestCase(1.0)] + public void When_Valid_Options_Provided_Should_Not_Throw_On_Validate(double modeFactor) + { + double min = Math.Round(CoreTools.NextDouble(600.0, 900.0)); + double max = Math.Round(CoreTools.NextDouble(1200.0, 1600.0)); + double mode = Math.Round(Math.FusedMultiplyAdd(modeFactor, max - min, min)); + + var options = new DelayOptions.Triangular() + .WithTimeUnit(TimeUnit.Millisecond) + .WithMinimum(min) + .WithMaximum(max) + .WithMode(mode); + + options.Invoking(opt => opt.Validate()) + .Should() + .NotThrow(); } } } diff --git a/Source/RandomTools.Tests/Statistics.cs b/Source/RandomTools.Tests/Statistics.cs index 1e4d4f1..a3754d7 100644 --- a/Source/RandomTools.Tests/Statistics.cs +++ b/Source/RandomTools.Tests/Statistics.cs @@ -110,6 +110,20 @@ public static double StandardErrorOfMean(double stdDev, int sampleCount) return stdDev / Math.Sqrt(sampleCount); } + + /// + /// Estimates the order n of a Bates distribution from the sample variance. + /// + /// Variance of the Bates-distributed sample. + /// Min and Max of the underlying uniform distribution. + /// Estimated number of uniform variables averaged (n). + public static double EstimateBatesOrder(double variance, (double Min, double Max) bounds) + { + double range = bounds.Max - bounds.Min; + double n = range * range / (12 * variance); + + return n; + } } ///