diff --git a/Core/Utils/RandomUtil.cs b/Core/Utils/RandomUtil.cs index 50a9ebf2..5a99eddc 100644 --- a/Core/Utils/RandomUtil.cs +++ b/Core/Utils/RandomUtil.cs @@ -9,6 +9,12 @@ public class RandomUtil { private readonly Random _random = new(); + /// + /// The IEEE-754 standard for double-precision floating-point numbers limits the number of digits (including both + /// integer + fractional parts) to about 15–17 significant digits. 15 is a safe upper bound, so we'll use that. + /// + public const int MaxSignificantDigits = 15; + /// /// Generates a random integer between the specified minimum and maximum values, inclusive. /// @@ -39,7 +45,7 @@ public class RandomUtil } /// - /// Generates a random floating-point number within the specified range. + /// Generates a random floating-point number within the specified range ~6-9 digits (4 bytes). /// /// The minimum value of the range (inclusive). /// The maximum value of the range (exclusive). @@ -48,6 +54,17 @@ public class RandomUtil { return (float)GetSecureRandomNumber() * (max - min) + min; } + + /// + /// Generates a random floating-point number within the specified range ~15-17 digits (8 bytes). + /// + /// The minimum value of the range (inclusive). + /// The maximum value of the range (exclusive). + /// A random floating-point number between `min` (inclusive) and `max` (exclusive). + public double GetDouble(double min, double max) + { + return GetSecureRandomNumber() * (max - min) + min; + } /// /// Generates a random boolean value. @@ -110,9 +127,9 @@ public class RandomUtil } /// - /// Returns a random string from the provided collection of strings. + /// Returns a random type T from the provided collection of type T. /// - /// + /// The collection to get the random element from /// The type of elements in the collection. /// A random element from the collection. /// This was formerly getArrayValue() in the node server @@ -132,6 +149,134 @@ public class RandomUtil { return GetCollectionValue(dictionary.Keys); } + + /// + /// Gets a random val from the given dictionary + /// + /// The dictionary from which to retrieve a value. + /// Type of key + /// Type of Value + /// A random TVal representing one of the values of the dictionary. + public TVal GetVal(Dictionary dictionary) where TKey : notnull + { + return GetCollectionValue(dictionary.Values); + } + + /// + /// Generates a normally distributed random number using the Box-Muller transform. + /// + /// The mean (μ) of the normal distribution. + /// The standard deviation (σ) of the normal distribution. + /// The current attempt count to generate a valid number (default is 0). + /// A normally distributed random number. + /// + /// This function uses the Box-Muller transform to generate a normally distributed random number. + /// If the generated number is less than 0, it will recursively attempt to generate a valid number up to 100 times. + /// If it fails to generate a valid number after 100 attempts, it will return a random float between 0.01 and twice the mean. + /// + public double GetNormallyDistributedRandomNumber(double mean, double sigma, int attempt = 0) + { + double u, v; + + do + { + u = GetSecureRandomNumber(); + } while (u == 0); + + do + { + v = GetSecureRandomNumber(); + } while (v == 0); + + // Apply the Box-Muller transform + var w = Math.Sqrt(-2.0 * Math.Log(u)) * Math.Cos(2.0 * Math.PI * v); + var valueDrawn = mean + w * sigma; + + // Check if the generated value is valid + if (valueDrawn < 0) + { + return attempt > 100 + ? GetDouble(0.01f, mean * 2f) + : GetNormallyDistributedRandomNumber(mean, sigma, attempt + 1); + } + + return valueDrawn; + } + + /// + /// Generates a random integer between the specified range. + /// + /// The lower bound of the range (inclusive). + /// The upper bound of the range (exclusive). If not provided, the range will be from 0 to `low`. + /// A random integer within the specified range. + public int RandInt(int low, int? high) + { + // Return a random integer from 0 to low if high is not provided + if (high is null) + { + return _random.Next(0, low); + } + + // Return low directly when low and high are equal + return low == high + ? low + : _random.Next(low, (int)high); + } + + /// + /// Generates a random number between two given values with optional precision. + /// + /// The first value to determine the range. + /// The second value to determine the range. If not provided, 0 is used. + /// + /// The number of decimal places to round the result to. Must be a positive integer between 0 + /// and MaxSignificantDigits(15), inclusive. If not provided, precision is determined by the input values. + /// + /// + public double RandNum(double val1, double val2 = 0, byte? precision = null) + { + if (!double.IsFinite(val1) || !double.IsFinite(val2)) + { + throw new ArgumentException("RandNum() parameters 'value1' and 'value2' must be finite numbers."); + } + + // Determine the range + var min = Math.Min(val1, val2); + var max = Math.Max(val1, val2); + + // Validate and adjust precision + if (precision is not null) + { + if (precision > MaxSignificantDigits) + { + throw new ArgumentOutOfRangeException( + nameof(precision), "Must be less than 16"); + } + + // Calculate the number of whole-number digits in the maximum absolute value of the range + var maxAbsoluteValue = Math.Max(Math.Abs(min), Math.Abs(max)); + var wholeNumberDigits = (int)Math.Floor(Math.Log10(maxAbsoluteValue)) + 1; + + var maxAllowedPrecision = Math.Max(0, MaxSignificantDigits - wholeNumberDigits); + + if (precision > maxAllowedPrecision) + { + throw new ArgumentException( + $"RandNum() precision of {precision} exceeds the allowable precision ({maxAllowedPrecision}) for the given values." + ); + } + } + + var result = GetSecureRandomNumber() * (max - min) + min; + + // Determine effective precision + var maxPrecision = Math.Max(GetNumberPrecision(val1), GetNumberPrecision(val2)); + var effectivePrecision = precision ?? maxPrecision; + + var factor = Math.Pow(2, effectivePrecision); + + return Math.Round(result * factor) / factor; + } /// /// Generates a secure random number between 0 (inclusive) and 1 (exclusive). @@ -158,8 +303,6 @@ public class RandomUtil const ulong maxInt = 1UL << 48; - Console.WriteLine(integer); - return (double)Math.Abs(integer) / maxInt; } @@ -168,8 +311,12 @@ public class RandomUtil /// /// The number to analyze. /// The number of decimal places, or 0 if none exist. - private static int GetNumberPrecision(double num) + public int GetNumberPrecision(double num) { - return num.ToString().Split('.')[1]?.Length ?? 0; + var parts = num.ToString($"G{MaxSignificantDigits}").Split('.'); + + return parts.Length > 1 + ? parts[1].Length + : 0; } } \ No newline at end of file diff --git a/UnitTests/Tests/Utils/RandomUtilTests.cs b/UnitTests/Tests/Utils/RandomUtilTests.cs index 81a91517..be9b3041 100644 --- a/UnitTests/Tests/Utils/RandomUtilTests.cs +++ b/UnitTests/Tests/Utils/RandomUtilTests.cs @@ -17,7 +17,7 @@ public sealed class RandomUtilTests if (result < 0 || result > 10) { - Assert.Fail($"GetInt() out of range. Expected range [0, 10] but was {result}."); + Assert.Fail($"GetInt(0, 10) out of range. Expected range [0, 10] but was {result}."); } } } @@ -32,7 +32,7 @@ public sealed class RandomUtilTests if (result < 1 || result > 9) { - Assert.Fail($"GetInt() out of range. Expected range [1, 9] but was {result}."); + Assert.Fail($"GetInt(10) out of range. Expected range [1, 9] but was {result}."); } } } @@ -47,7 +47,7 @@ public sealed class RandomUtilTests if (result < 0f || result >= 9f) { - Assert.Fail($"GetFloat() out of range. Expected range [0.0f, 8.99f] but was {result}."); + Assert.Fail($"GetFloat(0f, 10f) out of range. Expected range [0.0f, 9.999f] but was {result}."); } } } @@ -62,7 +62,7 @@ public sealed class RandomUtilTests expected, result, 0.0001f, - $"GetPercentOfValue() out of range. Expected: {expected}. Actual: {result}."); + $"GetPercentOfValue(45.5f, 100f) out of range. Expected: {expected}. Actual: {result}."); } [TestMethod] @@ -75,7 +75,7 @@ public sealed class RandomUtilTests expected, result, 0.0001f, - $"ReduceValueByPercent() out of range. Expected: {expected}. Actual: {result}."); + $"ReduceValueByPercent(100f, 45.5f) out of range. Expected: {expected}. Actual: {result}."); } [TestMethod] @@ -89,7 +89,7 @@ public sealed class RandomUtilTests Assert.AreEqual( expectedTrue, resultTrue, - $"GetChance100() out of range. Expected: {expectedTrue}. Actual: {resultTrue}."); + $"GetChance100(100f) out of range. Expected: {expectedTrue}. Actual: {resultTrue}."); } for (var i = 0; i < 100; i++) @@ -100,7 +100,67 @@ public sealed class RandomUtilTests Assert.AreEqual( expectedFalse, resultFalse, - $"GetChance100() out of range. Expected: {expectedFalse}. Actual: {resultFalse}."); + $"GetChance100(0f) out of range. Expected: {expectedFalse}. Actual: {resultFalse}."); + } + } + + // TODO: Missing methods between these two + + [TestMethod] + public void RandIntTest() + { + for (var i = 0; i < 100; i++) + { + var result = _randomUtil.RandInt(0, 10); + + if (result < 0 || result > 9) + { + Assert.Fail($"RandInt(0, 10) out of range. Expected range [0, 9] but was {result}."); + } + } + + for (var i = 0; i < 100; i++) + { + var result = _randomUtil.RandInt(10, null); + + if (result < 0 || result > 9) + { + Assert.Fail($"RandInt(10, null) out of range. Expected range [0, 9] but was {result}."); + } + } + } + + [TestMethod] + public void RandNumTest() + { + for (var i = 0; i < 100; i++) + { + var result = _randomUtil.RandNum(0, 10); + + if (result < 0 || result > 9) + { + Assert.Fail($"RandNum(0, 10) out of range. Expected range [0, 9.999d] but was {result}."); + } + + if (_randomUtil.GetNumberPrecision(result) > RandomUtil.MaxSignificantDigits) + { + Assert.Fail($"RandNum(0, 10) precision of {result} exceeds the allowable precision ({RandomUtil.MaxSignificantDigits}) for the given values."); + } + } + + for (var i = 0; i < 100; i++) + { + var result = _randomUtil.RandNum(10); + + if (result < 0 || result > 9) + { + Assert.Fail($"RandNum(10) out of range. Expected range [0, 9.999d] but was {result}."); + } + + if (_randomUtil.GetNumberPrecision(result) > RandomUtil.MaxSignificantDigits) + { + Assert.Fail($"RandNum(10) precision of {result} exceeds the allowable precision ({RandomUtil.MaxSignificantDigits}) for the given values."); + } } } } \ No newline at end of file