From 8562e53521b27598a36bccff0aa2440fdc61a774 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sat, 24 Oct 2026 19:14:39 +0100 Subject: [PATCH 1/2] feat: add quantity comparisons and numerical helpers --- .../TypeSystem/QuantityTests.cs | 113 ++++++++++++++++++ .../TypeSystem/Quantity.cs | 38 ++++++ 2 files changed, 151 insertions(+) diff --git a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs index 06ded5e..e27c681 100644 --- a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs +++ b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs @@ -44,6 +44,119 @@ namespace DimensionalQuantities.Tests.TypeSystem; public sealed class QuantityTests { + [Theory] + [InlineData(2, 3, true, false, true, false)] + [InlineData(3, 2, false, true, false, true)] + [InlineData(-2, -2, false, false, true, true)] + [InlineData(double.PositiveInfinity, 3, false, true, false, true)] + public void RelationalOperatorsCompareMatchingDimensions(double leftValue, double rightValue, bool less, bool greater, bool lessOrEqual, bool greaterOrEqual) + { + Current left = Current.FromSiBaseUnit(leftValue); + Current right = Current.FromSiBaseUnit(rightValue); + + Assert.Multiple( + () => Assert.Equal(less, left < right), + () => Assert.Equal(greater, left > right), + () => Assert.Equal(lessOrEqual, left <= right), + () => Assert.Equal(greaterOrEqual, left >= right)); + } + + [Fact] + public void RelationalOperatorsReturnFalseForNanOnEitherSide() + { + Current nan = Current.FromSiBaseUnit(double.NaN); + Current finite = Current.FromSiBaseUnit(1); + + Assert.Multiple( + () => Assert.False(nan < finite), + () => Assert.False(nan > finite), + () => Assert.False(nan <= finite), + () => Assert.False(nan >= finite), + () => Assert.False(finite < nan), + () => Assert.False(finite > nan), + () => Assert.False(finite <= nan), + () => Assert.False(finite >= nan)); + } + + [Theory] + [InlineData(-3, 3)] + [InlineData(3, 3)] + [InlineData(double.NegativeInfinity, double.PositiveInfinity)] + public void AbsReturnsQuantityWithAbsoluteSiMagnitude(double value, double expectedValue) + { + Current original = Current.FromSiBaseUnit(value); + Current result = Current.Abs(original); + Current expected = Current.FromSiBaseUnit(expectedValue); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(2, 3, 2, 3)] + [InlineData(3, 2, 2, 3)] + [InlineData(-2, -2, -2, -2)] + public void MinAndMaxReturnMatchingDimension(double leftValue, double rightValue, double expectedMinValue, double expectedMaxValue) + { + Current left = Current.FromSiBaseUnit(leftValue); + Current right = Current.FromSiBaseUnit(rightValue); + Current minResult = Current.Min(left, right); + Current maxResult = Current.Max(left, right); + Current expectedMin = Current.FromSiBaseUnit(expectedMinValue); + Current expectedMax = Current.FromSiBaseUnit(expectedMaxValue); + + Assert.Multiple( + () => Assert.Equal(expectedMin, minResult), + () => Assert.Equal(expectedMax, maxResult)); + } + + [Theory] + [InlineData(-2, 0, 10, 0)] + [InlineData(5, 0, 10, 5)] + [InlineData(12, 0, 10, 10)] + public void ClampReturnsQuantityWithinBounds(double value, double minValue, double maxValue, double expectedValue) + { + Current original = Current.FromSiBaseUnit(value); + Current min = Current.FromSiBaseUnit(minValue); + Current max = Current.FromSiBaseUnit(maxValue); + Current result = Current.Clamp(original, min, max); + Current expected = Current.FromSiBaseUnit(expectedValue); + + Assert.Equal(expected, result); + } + + [Fact] + public void ClampRejectsReversedBounds() + { + Current original = Current.FromSiBaseUnit(5); + Current min = Current.FromSiBaseUnit(10); + Current max = Current.FromSiBaseUnit(0); + + Assert.Throws(() => Current.Clamp(original, min, max)); + } + + [Fact] + public void HelpersFollowDoubleNanAndSignedZeroBehavior() + { + Current nan = Current.FromSiBaseUnit(double.NaN); + Current finite = Current.FromSiBaseUnit(1); + Current negativeZero = Current.FromSiBaseUnit(-0.0); + Current positiveZero = Current.FromSiBaseUnit(0.0); + Current absResult = Current.Abs(negativeZero); + Current clampResult = Current.Clamp(negativeZero, negativeZero, positiveZero); + Current nanMinimumResult = Current.Clamp(Current.FromSiBaseUnit(2), nan, finite); + Current nanMaximumResult = Current.Clamp(Current.FromSiBaseUnit(-1), positiveZero, nan); + + Assert.Multiple( + () => Assert.True(double.IsNaN(Current.Abs(nan).AsSiBaseUnit())), + () => Assert.True(double.IsNaN(Current.Min(nan, finite).AsSiBaseUnit())), + () => Assert.True(double.IsNaN(Current.Max(finite, nan).AsSiBaseUnit())), + () => Assert.True(double.IsNaN(Current.Clamp(nan, negativeZero, finite).AsSiBaseUnit())), + () => Assert.Equal(finite, nanMinimumResult), + () => Assert.Equal(positiveZero, nanMaximumResult), + () => Assert.Equal(0L, BitConverter.DoubleToInt64Bits(absResult.AsSiBaseUnit())), + () => Assert.Equal(long.MinValue, BitConverter.DoubleToInt64Bits(clampResult.AsSiBaseUnit()))); + } + [Theory] [InlineData(6, 2, 3)] [InlineData(-6, 2, -3)] diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index 058b867..e5aef34 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -16,6 +16,14 @@ public readonly record struct Quantity public static Quantity operator *(double scalar, Quantity quantity) => new(scalar * quantity.ValueInSiBaseUnit); public static Quantity operator /(Quantity quantity, double scalar) => new(quantity.ValueInSiBaseUnit / scalar); public static double operator /(Quantity left, Quantity right) => left.ValueInSiBaseUnit / right.ValueInSiBaseUnit; + /// Compares two quantities of the same dimension by their SI magnitudes. + public static bool operator <(Quantity left, Quantity right) => left.ValueInSiBaseUnit < right.ValueInSiBaseUnit; + /// Compares two quantities of the same dimension by their SI magnitudes. + public static bool operator >(Quantity left, Quantity right) => left.ValueInSiBaseUnit > right.ValueInSiBaseUnit; + /// Compares two quantities of the same dimension by their SI magnitudes. + public static bool operator <=(Quantity left, Quantity right) => left.ValueInSiBaseUnit <= right.ValueInSiBaseUnit; + /// Compares two quantities of the same dimension by their SI magnitudes. + public static bool operator >=(Quantity left, Quantity right) => left.ValueInSiBaseUnit >= right.ValueInSiBaseUnit; // Each closed quantity type intentionally caches its own decoded dimension. // ReSharper disable once StaticMemberInGenericType @@ -24,6 +32,36 @@ public readonly record struct Quantity /// Creates a quantity from a magnitude expressed in coherent SI base units for its dimension. public static Quantity FromSiBaseUnit(double value) => new(value); + /// Returns the absolute magnitude of a quantity. + public static Quantity Abs(Quantity quantity) => new(Math.Abs(quantity.ValueInSiBaseUnit)); + + /// Returns the smaller of two quantities of the same dimension. + public static Quantity Min(Quantity left, Quantity right) => new(Math.Min(left.ValueInSiBaseUnit, right.ValueInSiBaseUnit)); + + /// Returns the larger of two quantities of the same dimension. + public static Quantity Max(Quantity left, Quantity right) => new(Math.Max(left.ValueInSiBaseUnit, right.ValueInSiBaseUnit)); + + /// Restricts a quantity to the inclusive range between two quantities of the same dimension. + public static Quantity Clamp(Quantity value, Quantity min, Quantity max) + { + if (min > max) + { + throw new ArgumentException("The minimum must be less than or equal to the maximum.", nameof(min)); + } + + if (value < min) + { + return min; + } + + if (value > max) + { + return max; + } + + return value; + } + /// Creates a quantity from SI base components with the selected decimal prefixes. public static Quantity FromArbitrarySiComponents( double value, From 75ac1821f19ad0cd169231e19f10ed9188d5e204 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sat, 24 Oct 2026 19:20:39 +0100 Subject: [PATCH 2/2] chore: Reorganised Quantity.cs --- .../TypeSystem/Quantity.cs | 104 +++++++++--------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index e5aef34..fde1c2e 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -9,6 +9,56 @@ namespace DimensionalQuantities.TypeSystem; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public readonly record struct Quantity { + // Each closed quantity type intentionally caches its own decoded dimension. + // ReSharper disable once StaticMemberInGenericType + private static readonly Lazy _dimension = new(ParseDimension); + + internal Quantity(double valueInSiBaseUnit) + { + ValueInSiBaseUnit = valueInSiBaseUnit; + } + + internal double ValueInSiBaseUnit { get; } + + /// Creates a quantity from a magnitude expressed in coherent SI base units for its dimension. + public static Quantity FromSiBaseUnit(double value) => new(value); + + /// Creates a quantity from SI base components with the selected decimal prefixes. + public static Quantity FromArbitrarySiComponents( + double value, + int lengthPrefixExponent = SiBasePrefixExponents.Length, + int massPrefixExponent = SiBasePrefixExponents.Mass, + int timePrefixExponent = SiBasePrefixExponents.Time, + int electricCurrentPrefixExponent = SiBasePrefixExponents.ElectricCurrent, + int temperaturePrefixExponent = SiBasePrefixExponents.Temperature, + int amountOfSubstancePrefixExponent = SiBasePrefixExponents.AmountOfSubstance, + int luminousIntensityPrefixExponent = SiBasePrefixExponents.LuminousIntensity, + double overallScaleExponent = 0) + { + double exponent = GetScaleExponent(lengthPrefixExponent, massPrefixExponent, timePrefixExponent, electricCurrentPrefixExponent, + temperaturePrefixExponent, amountOfSubstancePrefixExponent, luminousIntensityPrefixExponent, overallScaleExponent); + return new(value * Math.Pow(10, exponent)); + } + + /// Returns the magnitude in coherent SI base units for its dimension. + public double AsSiBaseUnit() => ValueInSiBaseUnit; + + /// Returns the magnitude in SI base components with the selected decimal prefixes. + public double AsArbitrarySiComponents( + int lengthPrefixExponent = SiBasePrefixExponents.Length, + int massPrefixExponent = SiBasePrefixExponents.Mass, + int timePrefixExponent = SiBasePrefixExponents.Time, + int electricCurrentPrefixExponent = SiBasePrefixExponents.ElectricCurrent, + int temperaturePrefixExponent = SiBasePrefixExponents.Temperature, + int amountOfSubstancePrefixExponent = SiBasePrefixExponents.AmountOfSubstance, + int luminousIntensityPrefixExponent = SiBasePrefixExponents.LuminousIntensity, + double overallScaleExponent = 0) + { + double exponent = GetScaleExponent(lengthPrefixExponent, massPrefixExponent, timePrefixExponent, electricCurrentPrefixExponent, + temperaturePrefixExponent, amountOfSubstancePrefixExponent, luminousIntensityPrefixExponent, overallScaleExponent); + return ValueInSiBaseUnit / Math.Pow(10, exponent); + } + public static Quantity operator +(Quantity left, Quantity right) => new(left.ValueInSiBaseUnit + right.ValueInSiBaseUnit); public static Quantity operator -(Quantity left, Quantity right) => new(left.ValueInSiBaseUnit - right.ValueInSiBaseUnit); public static Quantity operator -(Quantity quantity) => new(-quantity.ValueInSiBaseUnit); @@ -16,22 +66,19 @@ public readonly record struct Quantity public static Quantity operator *(double scalar, Quantity quantity) => new(scalar * quantity.ValueInSiBaseUnit); public static Quantity operator /(Quantity quantity, double scalar) => new(quantity.ValueInSiBaseUnit / scalar); public static double operator /(Quantity left, Quantity right) => left.ValueInSiBaseUnit / right.ValueInSiBaseUnit; + /// Compares two quantities of the same dimension by their SI magnitudes. public static bool operator <(Quantity left, Quantity right) => left.ValueInSiBaseUnit < right.ValueInSiBaseUnit; + /// Compares two quantities of the same dimension by their SI magnitudes. public static bool operator >(Quantity left, Quantity right) => left.ValueInSiBaseUnit > right.ValueInSiBaseUnit; + /// Compares two quantities of the same dimension by their SI magnitudes. public static bool operator <=(Quantity left, Quantity right) => left.ValueInSiBaseUnit <= right.ValueInSiBaseUnit; + /// Compares two quantities of the same dimension by their SI magnitudes. public static bool operator >=(Quantity left, Quantity right) => left.ValueInSiBaseUnit >= right.ValueInSiBaseUnit; - // Each closed quantity type intentionally caches its own decoded dimension. - // ReSharper disable once StaticMemberInGenericType - private static readonly Lazy _dimension = new(ParseDimension); - - /// Creates a quantity from a magnitude expressed in coherent SI base units for its dimension. - public static Quantity FromSiBaseUnit(double value) => new(value); - /// Returns the absolute magnitude of a quantity. public static Quantity Abs(Quantity quantity) => new(Math.Abs(quantity.ValueInSiBaseUnit)); @@ -62,49 +109,6 @@ public static Quantity Clamp(Quantity value, QuantityCreates a quantity from SI base components with the selected decimal prefixes. - public static Quantity FromArbitrarySiComponents( - double value, - int lengthPrefixExponent = SiBasePrefixExponents.Length, - int massPrefixExponent = SiBasePrefixExponents.Mass, - int timePrefixExponent = SiBasePrefixExponents.Time, - int electricCurrentPrefixExponent = SiBasePrefixExponents.ElectricCurrent, - int temperaturePrefixExponent = SiBasePrefixExponents.Temperature, - int amountOfSubstancePrefixExponent = SiBasePrefixExponents.AmountOfSubstance, - int luminousIntensityPrefixExponent = SiBasePrefixExponents.LuminousIntensity, - double overallScaleExponent = 0) - { - double exponent = GetScaleExponent(lengthPrefixExponent, massPrefixExponent, timePrefixExponent, electricCurrentPrefixExponent, - temperaturePrefixExponent, amountOfSubstancePrefixExponent, luminousIntensityPrefixExponent, overallScaleExponent); - return new(value * Math.Pow(10, exponent)); - } - - internal Quantity(double valueInSiBaseUnit) - { - ValueInSiBaseUnit = valueInSiBaseUnit; - } - - internal double ValueInSiBaseUnit { get; } - - /// Returns the magnitude in coherent SI base units for its dimension. - public double AsSiBaseUnit() => ValueInSiBaseUnit; - - /// Returns the magnitude in SI base components with the selected decimal prefixes. - public double AsArbitrarySiComponents( - int lengthPrefixExponent = SiBasePrefixExponents.Length, - int massPrefixExponent = SiBasePrefixExponents.Mass, - int timePrefixExponent = SiBasePrefixExponents.Time, - int electricCurrentPrefixExponent = SiBasePrefixExponents.ElectricCurrent, - int temperaturePrefixExponent = SiBasePrefixExponents.Temperature, - int amountOfSubstancePrefixExponent = SiBasePrefixExponents.AmountOfSubstance, - int luminousIntensityPrefixExponent = SiBasePrefixExponents.LuminousIntensity, - double overallScaleExponent = 0) - { - double exponent = GetScaleExponent(lengthPrefixExponent, massPrefixExponent, timePrefixExponent, electricCurrentPrefixExponent, - temperaturePrefixExponent, amountOfSubstancePrefixExponent, luminousIntensityPrefixExponent, overallScaleExponent); - return ValueInSiBaseUnit / Math.Pow(10, exponent); - } - private static double GetScaleExponent(int length, int mass, int time, int electricCurrent, int temperature, int amountOfSubstance, int luminousIntensity, double overall) { MathematicalDimension dimension = _dimension.Value;