From f4038cb5abf484a5faf27f8042ce47522c39d1f2 Mon Sep 17 00:00:00 2001 From: Matthew Layton Date: Wed, 23 Apr 2025 00:29:13 +0100 Subject: [PATCH] Added new numerics features, including `SetScale` extension methods for `System.Decimal`, and `GenericMath.Pow10`. --- Directory.Build.props | 6 +- .../GenericMathPow10Tests.cs | 94 ++++++++++++++++ .../NumericsExtensionsTests.cs | 101 ++++++++++++++++++ OnixLabs.Numerics/GenericMath.cs | 32 +++++- OnixLabs.Numerics/NumericsExtensions.cs | 67 ++++++++++++ OnixLabs.Playground/Program.cs | 53 +++++++++ 6 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 OnixLabs.Numerics.UnitTests/GenericMathPow10Tests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 9ca331a..62f19cd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,8 @@ + 12.1.0 + 12.1.0 + 12.1.0 net8.0;net9.0 13 enable @@ -8,8 +11,5 @@ en Copyright © ONIXLabs 2020 https://github.com/onix-labs/onixlabs-dotnet - 12.0.0 - 12.0.0 - 12.0.0 diff --git a/OnixLabs.Numerics.UnitTests/GenericMathPow10Tests.cs b/OnixLabs.Numerics.UnitTests/GenericMathPow10Tests.cs new file mode 100644 index 0000000..09cb894 --- /dev/null +++ b/OnixLabs.Numerics.UnitTests/GenericMathPow10Tests.cs @@ -0,0 +1,94 @@ +// Copyright 2020-2025 ONIXLabs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using Xunit; + +namespace OnixLabs.Numerics.UnitTests; + +public sealed class GenericMathPow10Tests +{ + [Theory(DisplayName = "GenericMath.Pow10 should produce the expected result (Int32)")] + [InlineData(0, 1)] + [InlineData(1, 10)] + [InlineData(2, 100)] + [InlineData(3, 1000)] + [InlineData(4, 10000)] + [InlineData(5, 100000)] + [InlineData(6, 1000000)] + [InlineData(9, 1000000000)] + public void GenericMathPow10ShouldProduceExpectedResultInt32(int exponent, int expected) + { + // Given / When + int result = GenericMath.Pow10(exponent); + + // Then + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "GenericMath.Pow10 should produce the expected result (Int64)")] + [InlineData(0, 1L)] + [InlineData(1, 10L)] + [InlineData(2, 100L)] + [InlineData(10, 10000000000L)] + [InlineData(15, 1000000000000000L)] + public void GenericMathPow10ShouldProduceExpectedResultInt64(int exponent, long expected) + { + // Given / When + long result = GenericMath.Pow10(exponent); + + // Then + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "GenericMath.Pow10 should produce the expected result (Double)")] + [InlineData(0, 1.0)] + [InlineData(3, 1000.0)] + [InlineData(6, 1e6)] + [InlineData(9, 1e9)] + [InlineData(15, 1e15)] + public void GenericMathPow10ShouldProduceExpectedResultDouble(int exponent, double expected) + { + // Given / When + double result = GenericMath.Pow10(exponent); + + // Then + Assert.Equal(expected, result, precision: 10); + } + + [Theory(DisplayName = "GenericMath.Pow10 should produce the expected result (Decimal)")] + [InlineData(0, "1")] + [InlineData(1, "10")] + [InlineData(5, "100000")] + [InlineData(10, "10000000000")] + public void GenericMathPow10ShouldProduceExpectedResultDecimal(int exponent, string expectedStr) + { + // Given / When + decimal expected = decimal.Parse(expectedStr); + decimal result = GenericMath.Pow10(exponent); + + // Then + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "GenericMath.Pow10 should throw ArgumentException when the exponent is negative.")] + public void GenericMathPow10ShouldThrowArgumentExceptionWhenExponentNegative() + { + // Given / When + Exception exception = Assert.Throws(() => GenericMath.Pow10(-1)); + + // Then + Assert.Contains("Exponent must be greater", exception.Message); + } +} diff --git a/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs b/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs index 4fc4ee4..3d408c0 100644 --- a/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs +++ b/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs @@ -80,6 +80,107 @@ public void DecimalGetUnscaledValueShouldProduceExpectedResultValueAndScale(int Assert.Equal(expected, actual); } + [Theory(DisplayName = "Decimal.SetScale should preserve or pad when scale is less or equal")] + [InlineData("123.0", 2, "123.00")] + [InlineData("123.00", 2, "123.00")] + [InlineData("123.000", 2, "123.00")] + [InlineData("0.0", 2, "0.00")] + [InlineData("123.12", 2, "123.12")] + public void DecimalSetScaleShouldPreserveOrPadWhenScaleIsLessOrEqual(string inputStr, int scale, string expectedStr) + { + // Given + decimal input = decimal.Parse(inputStr); + decimal expected = decimal.Parse(expectedStr); + + // When + decimal result = input.SetScale(scale); + + // Then + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "Decimal.SetScale should truncate when no precision loss")] + [InlineData("123.1200", 2, "123.12")] + [InlineData("0.1000", 1, "0.1")] + [InlineData("999.0000", 3, "999.000")] + public void DecimalSetScaleShouldTruncateWhenNoPrecisionLoss(string inputStr, int scale, string expectedStr) + { + // Given + decimal input = decimal.Parse(inputStr); + decimal expected = decimal.Parse(expectedStr); + + // When + decimal result = input.SetScale(scale); + + // Then + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "Decimal.SetScale should throw when truncation would lose precision")] + [InlineData("123.456", 2)] + [InlineData("1.001", 2)] + [InlineData("0.123456789", 5)] + public void DecimalSetScaleShouldThrowWhenTruncationWouldLosePrecision(string inputStr, int scale) + { + // Given + decimal input = decimal.Parse(inputStr); + + // When / Then + Assert.Throws(() => input.SetScale(scale)); + } + + [Theory(DisplayName = "Decimal.SetScale(rounding) should apply correct rounding")] + [InlineData("123.456", 2, MidpointRounding.AwayFromZero, "123.46")] + [InlineData("123.454", 2, MidpointRounding.AwayFromZero, "123.45")] + [InlineData("123.455", 2, MidpointRounding.ToZero, "123.45")] + [InlineData("123.455", 2, MidpointRounding.ToEven, "123.46")] + [InlineData("0.125", 2, MidpointRounding.ToEven, "0.12")] + [InlineData("0.135", 2, MidpointRounding.ToEven, "0.14")] + public void DecimalSetScaleWithRoundingShouldApplyCorrectRounding(string inputStr, int scale, MidpointRounding mode, string expectedStr) + { + // Given + decimal input = decimal.Parse(inputStr); + decimal expected = decimal.Parse(expectedStr); + + // When + decimal result = input.SetScale(scale, mode); + + // Then + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "Decimal.SetScale(rounding) should truncate when no precision loss")] + [InlineData("123.0000", 2, MidpointRounding.AwayFromZero, "123.00")] + [InlineData("1.000", 1, MidpointRounding.ToEven, "1.0")] + public void DecimalSetScaleWithRoundingShouldTruncateWhenNoPrecisionLoss(string inputStr, int scale, MidpointRounding mode, string expectedStr) + { + // Given + decimal input = decimal.Parse(inputStr); + decimal expected = decimal.Parse(expectedStr); + + // When + decimal result = input.SetScale(scale, mode); + + // Then + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "Decimal.SetScale should throw when scale is negative")] + public void DecimalSetScaleShouldThrowWhenScaleIsNegative() + { + // Given / When / Then + Exception exception = Assert.Throws(() => 123.45m.SetScale(-1)); + Assert.Contains("Scale must be greater than, or equal to zero.", exception.Message); + } + + [Fact(DisplayName = "Decimal.SetScale(rounding) should throw when scale is negative")] + public void DecimalSetScaleWithRoundingShouldThrowWhenScaleIsNegative() + { + // Given / When / Then + Exception exception = Assert.Throws(() => 123.45m.SetScale(-1, MidpointRounding.AwayFromZero)); + Assert.Contains("Scale must be greater than, or equal to zero.", exception.Message); + } + [Theory(DisplayName = "INumber.IsBetween should produce the expected result")] [InlineData(0, 0, 0, true)] [InlineData(0, 0, 1, true)] diff --git a/OnixLabs.Numerics/GenericMath.cs b/OnixLabs.Numerics/GenericMath.cs index cd25140..25971b4 100644 --- a/OnixLabs.Numerics/GenericMath.cs +++ b/OnixLabs.Numerics/GenericMath.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Globalization; using System.Numerics; @@ -41,7 +42,7 @@ public static BigInteger Factorial(T value) where T : IBinaryInteger { Require(value >= T.Zero, "Value must be greater than or equal to zero."); - if(value <= T.One) return BigInteger.One; + if (value <= T.One) return BigInteger.One; BigInteger result = BigInteger.One; @@ -71,4 +72,33 @@ public static int IntegerLength(T value) where T : INumberBase /// The underlying type. /// Returns the minimum and maximum values from the specified left-hand and right-hand values. public static (T Min, T Max) MinMax(T left, T right) where T : INumber => (T.Min(left, right), T.Max(left, right)); + + /// + /// Computes 10 raised to the power of a non-negative integer exponent using exponentiation by squaring, for any numeric type implementing . + /// + /// The numeric type. Must implement . + /// The exponent to raise 10 to. Must be greater than or equal to zero. + /// A value of type equal to 10 raised to the power of . + /// if is less than zero. + public static T Pow10(int exponent) where T : INumber + { + Require(exponent >= 0, "Exponent must be greater than, or equal to zero.", nameof(exponent)); + + if (exponent == 0) + return T.One; + + T result = T.One; + T baseValue = T.CreateChecked(10); + + while (exponent > 0) + { + if ((exponent & 1) == 1) + result *= baseValue; + + baseValue *= baseValue; + exponent >>= 1; + } + + return result; + } } diff --git a/OnixLabs.Numerics/NumericsExtensions.cs b/OnixLabs.Numerics/NumericsExtensions.cs index 3b9f94f..46435c0 100644 --- a/OnixLabs.Numerics/NumericsExtensions.cs +++ b/OnixLabs.Numerics/NumericsExtensions.cs @@ -51,6 +51,73 @@ public static BigInteger GetUnscaledValue(this decimal value) return decimal.IsPositive(value) ? result : -result; } + /// + /// Sets the scale (number of digits after the decimal point) of the current value. + /// + /// If the scale of the current value is less than the specified scale, then the scale will be padded with zeroes. + /// If the scale of the current value is greater that the specified scale, then the scale will be truncated, + /// provided that there is no loss of precision; otherwise, will be thrown. + /// + /// + /// The decimal value to adjust. + /// The desired, non-negative scale. + /// A new with the exact specified scale. + /// if reducing the scale would result in a loss of precision. + /// if is negative. + public static decimal SetScale(this decimal value, int scale) + { + Require(scale >= 0, "Scale must be greater than, or equal to zero.", nameof(scale)); + + if (value.Scale == scale) + return value; + + if (value.Scale < scale) + { + decimal factor = GenericMath.Pow10(scale - value.Scale); + return value * factor / factor; + } + + decimal pow10 = GenericMath.Pow10(scale); + decimal truncated = Math.Truncate(value * pow10) / pow10; + + if (value == truncated) + return truncated; + + throw new InvalidOperationException($"Cannot reduce scale without losing precision: {value}"); + } + + /// + /// Sets the scale (number of digits after the decimal point) of the current value. + /// + /// If the scale of the current value is less than the specified scale, then the scale will be padded with zeroes. + /// If the scale of the current value is greater that the specified scale, then the scale will be truncated, + /// provided that there is no loss of precision; otherwise, the value is rounded using the specified mode. + /// + /// + /// The decimal value to adjust. + /// The desired scale (number of decimal digits). Must be non-negative. + /// The rounding strategy to apply if the scale must be reduced with precision loss. + /// A new with the exact specified scale. + /// if is negative. + public static decimal SetScale(this decimal value, int scale, MidpointRounding mode) + { + Require(scale >= 0, "Scale must be greater than, or equal to zero.", nameof(scale)); + + if (value.Scale == scale) + return value; + + if (value.Scale < scale) + { + decimal factor = GenericMath.Pow10(scale - value.Scale); + return value * factor / factor; + } + + decimal pow10 = GenericMath.Pow10(scale); + decimal truncated = Math.Truncate(value * pow10) / pow10; + + return value == truncated ? truncated : Math.Round(value, scale, mode); + } + /// /// Gets the current value as an unscaled integer. /// diff --git a/OnixLabs.Playground/Program.cs b/OnixLabs.Playground/Program.cs index d629839..c03417f 100644 --- a/OnixLabs.Playground/Program.cs +++ b/OnixLabs.Playground/Program.cs @@ -12,11 +12,64 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; + namespace OnixLabs.Playground; internal static class Program { private static void Main() { + decimal value = 100.13m; + decimal round = value.SetScale(2); + + Console.WriteLine(round); + } + + public static decimal SetScale(this decimal value, int scale) + { + Require(scale >= 0, "Scale must be greater than, or equal to zero."); + + // Count actual decimal places + int actualScale = GetDecimalPlaces(value); + + if (actualScale == scale) + { + return value; + } + + if (actualScale < scale) + { + // Pad with zeroes by scaling and descaling + decimal factor = Pow10(scale - actualScale); + return value * factor / factor; + } + + else // actualScale > scale + { + // Check if the digits beyond the desired scale are zero + decimal factor = Pow10(actualScale - scale); + decimal remainder = (value * factor) % 1; + + if (remainder != 0) + throw new InvalidOperationException($"Cannot reduce scale without losing precision: {value}"); + + return Math.Truncate(value * Pow10(scale)) / Pow10(scale); + } + } + + private static int GetDecimalPlaces(decimal value) + { + int[] bits = decimal.GetBits(value); + byte scale = (byte)((bits[3] >> 16) & 0x7F); + return scale; + } + + private static decimal Pow10(int exp) + { + decimal result = 1m; + for (int i = 0; i < exp; i++) + result *= 10; + return result; } }