From 7e99f8fdf1d3206fcd166cf3909f4a6d4805e0a4 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 22:57:33 +0100 Subject: [PATCH] feat: support .NET Standard 2.0 --- .github/scripts/check-package.py | 2 +- README.md | 2 +- ...ensionalQuantities.IntegrationTests.csproj | 2 +- .../PackagedGeneratorTests.cs | 22 +- .../DimensionalQuantities.Roslyn.csproj | 3 +- .../Generators/QuantitySourceEmitter.cs | 7 +- .../Numerics/RationalTests.Conversions.cs | 191 ++++++---------- .../Numerics/RationalTests.Text.cs | 36 +-- .../Numerics/RationalTests.cs | 12 - .../DimensionalQuantities.csproj | 7 +- .../Dimensions/Dimension.cs | 5 +- .../Naming/DimensionNameGenerator.cs | 9 +- .../Numerics/Rational.Conversions.cs | 207 +++++++----------- .../Numerics/Rational.Text.cs | 59 ++--- .../Numerics/Rational.cs | 4 +- .../TypeSystem/Quantity.cs | 2 +- .../TypeSystem/TypeSystemNumberCodec.cs | 5 +- .../Units/UnitExpression.cs | 6 +- 18 files changed, 211 insertions(+), 370 deletions(-) diff --git a/.github/scripts/check-package.py b/.github/scripts/check-package.py index 7c2d837..8098e27 100644 --- a/.github/scripts/check-package.py +++ b/.github/scripts/check-package.py @@ -8,7 +8,7 @@ assert len(packages) == 1, packages with zipfile.ZipFile(packages[0]) as package: names = package.namelist() - assert "lib/net10.0/DimensionalQuantities.dll" in names + assert "lib/netstandard2.0/DimensionalQuantities.dll" in names assert "analyzers/dotnet/cs/DimensionalQuantities.Roslyn.dll" in names assert not any("Microsoft.CodeAnalysis" in name for name in names) specification = ET.fromstring(package.read("DimensionalQuantities.nuspec")) diff --git a/README.md b/README.md index 115dc0f..7418e4f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A C# library for dimensional quantities, with generated type aliases, SI unit co ## Usage -Requires .NET 10 and C# 14. The `DimensionalQuantities` NuGet package includes both the runtime library and its source generator. +The runtime library targets .NET Standard 2.0. Consumers need a C# 14 compiler for the generated extension blocks. The `DimensionalQuantities` NuGet package includes both the runtime library and its source generator. ```csharp Voltage voltage = Voltage.FromVolts(12); diff --git a/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj b/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj index b6ddf5d..7e67257 100644 --- a/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj +++ b/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs index defcdd0..284b559 100644 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs @@ -23,7 +23,7 @@ public void PackagedGeneratorConvertsVoltageInConsumer() { string sourceProject = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "DimensionalQuantities", "DimensionalQuantities.csproj")); FileInfo nupkg = PackLibrary(sourceProject, _scratch); - using PackageRepository repository = PackageRepository.Create(_scratch); + using PackageRepository repository = PackageRepository.Create(_scratch, new Uri("https://api.nuget.org/v3/index.json")); repository.Package(nupkg, out Package package); string consumerDirectory = Path.Combine(_scratch, "consumer"); @@ -39,9 +39,18 @@ public static double ConvertVoltage(double volts) Voltage voltage = Voltage.FromVolts(volts); return voltage.AsVolts(); } + + public static double MultiplyVoltageCurrent(double volts, double amps) + { + Voltage voltage = Voltage.FromVolts(volts); + Current current = Current.FromAmps(amps); + Power power = voltage * current; + return power.AsWatts(); + } } """); - ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: consumerProject, targetFramework: "net10.0") + ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: consumerProject, targetFramework: "netstandard2.0") + .Property("LangVersion", "14.0") .ItemPackageReference(package) .Save(); @@ -51,13 +60,16 @@ public static double ConvertVoltage(double volts) Assert.True(built, $"Consumer build failed.{Environment.NewLine}{output.GetConsoleLog()}"); } - string assemblyFile = Path.Combine(consumerDirectory, "bin", "Debug", "net10.0", "Consumer.dll"); + string assemblyFile = Path.Combine(consumerDirectory, "bin", "Debug", "netstandard2.0", "Consumer.dll"); Assembly assembly = Assembly.LoadFrom(assemblyFile); Type consumerType = assembly.GetType("ConsumerProject.Consumer") ?? throw new InvalidOperationException("Consumer type was not built."); MethodInfo convertVoltage = consumerType.GetMethod("ConvertVoltage") ?? throw new InvalidOperationException("Consumer Voltage conversion was not built."); + MethodInfo multiplyVoltageCurrent = consumerType.GetMethod("MultiplyVoltageCurrent") ?? throw new InvalidOperationException("Consumer arithmetic was not built."); double result = Assert.IsType(convertVoltage.Invoke(null, [12.5])); - double expected = 12.5; - Assert.Equal(expected, result); + double product = Assert.IsType(multiplyVoltageCurrent.Invoke(null, [12.5, 2.0])); + Assert.Multiple( + () => Assert.Equal(12.5, result), + () => Assert.Equal(25.0, product)); } public void Dispose() diff --git a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj index 8a8490d..cfdb6db 100644 --- a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj +++ b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj @@ -1,6 +1,6 @@ - net10.0 + netstandard2.0 true true false @@ -8,6 +8,7 @@ + diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs index c26b4a0..6fe0de1 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs @@ -55,7 +55,10 @@ private static IReadOnlyDictionary CollectConversions(IReadOnl throw new InvalidOperationException($"Unit {name} has conflicting SI scales."); } - conversions.TryAdd(name, scaleExponent); + if (!conversions.ContainsKey(name)) + { + conversions.Add(name, scaleExponent); + } } return conversions; @@ -142,7 +145,7 @@ private static string RenderAccessor(string variableName, ConversionDefinition c return $"public double {conversion.AccessorName}({parameters}) => {variableName}.AsArbitrarySiComponents(overallScaleExponent: {conversion.ScaleExponentExpression});"; } - private static string FormatExponent(Rational exponent) => double.CreateChecked(exponent).ToString("R", CultureInfo.InvariantCulture) + "d"; + private static string FormatExponent(Rational exponent) => ((double)exponent).ToString("R", CultureInfo.InvariantCulture) + "d"; private static Type[] GetExponentTypes(Dimension dimension) => [ diff --git a/src/DimensionalQuantities.Tests/Numerics/RationalTests.Conversions.cs b/src/DimensionalQuantities.Tests/Numerics/RationalTests.Conversions.cs index 4107871..a4cf567 100644 --- a/src/DimensionalQuantities.Tests/Numerics/RationalTests.Conversions.cs +++ b/src/DimensionalQuantities.Tests/Numerics/RationalTests.Conversions.cs @@ -7,87 +7,71 @@ namespace DimensionalQuantities.Tests.Numerics; public sealed partial class RationalTests { [Fact] - public void DecimalConversionPreservesExactBaseTenValue() + public void DecimalCastPreservesExactBaseTenValue() { decimal original = 0.1m; - Rational result = Rational.CreateChecked(original); - Rational expected = new(1, 10); + Rational result = (Rational)original; Assert.Multiple( - () => Assert.Equal(expected, result), - () => Assert.Equal(original, decimal.CreateChecked(result))); + () => Assert.Equal(new Rational(1, 10), result), + () => Assert.Equal(original, (decimal)result)); } [Fact] - public void DoubleConversionPreservesExactBinaryValue() + public void DoubleCastPreservesExactBinaryValue() { double original = 0.1d; - Rational result = Rational.CreateChecked(original); - Rational expected = new(3602879701896397, 36028797018963968); + Rational result = (Rational)original; Assert.Multiple( - () => Assert.Equal(expected, result), - () => Assert.Equal(original, double.CreateChecked(result))); - } - - [Fact] - public void GenericInputModesPreserveFiniteFractions() - { - Rational checkedResult = Rational.CreateChecked(0.125m); - Rational saturatingResult = Rational.CreateSaturating(0.125m); - Rational truncatingResult = Rational.CreateTruncating(0.125m); - Rational expected = new(1, 8); - - Assert.Multiple( - () => Assert.Equal(expected, checkedResult), - () => Assert.Equal(expected, saturatingResult), - () => Assert.Equal(expected, truncatingResult)); + () => Assert.Equal(new Rational(3602879701896397, 36028797018963968), result), + () => Assert.Equal(original, (double)result)); } [Theory] [InlineData(double.NaN)] [InlineData(double.PositiveInfinity)] [InlineData(double.NegativeInfinity)] - public void NonFiniteNumbersHaveNoRationalRepresentation(double value) - { - Assert.Multiple( - () => Assert.Throws(() => Rational.CreateChecked(value)), - () => Assert.Throws(() => Rational.CreateSaturating(value)), - () => Assert.Throws(() => Rational.CreateTruncating(value))); - } + public void NonFiniteNumbersHaveNoRationalRepresentation(double value) => + Assert.Throws(() => (Rational)value); [Fact] - public void IntegralConversionDiscardsFractionTowardsZero() + public void IntegerCastsTruncateTowardsZeroAndCheckRange() { - Rational original = new(-7, 3); - int result = int.CreateChecked(original); - int expected = -2; + Rational fraction = new(-7, 3); + Rational tooLarge = new(601, 2); - Assert.Equal(expected, result); + Assert.Multiple( + () => Assert.Equal(-2, (int)fraction), + () => Assert.Equal((byte)42, (byte)new Rational(85, 2)), + () => Assert.Throws(() => (byte)tooLarge)); } [Fact] - public void IntegralOutputModesUseTargetConversionRules() + public void IntegerCastsCoverSignedAndUnsignedRanges() { - Rational original = new(601, 2); - byte saturatingResult = byte.CreateSaturating(original); - byte truncatingResult = byte.CreateTruncating(original); + Rational value = new(42); + Rational negative = new(-1); Assert.Multiple( - () => Assert.Equal(byte.MaxValue, saturatingResult), - () => Assert.Equal((byte)44, truncatingResult), - () => Assert.Throws(() => byte.CreateChecked(original))); + () => Assert.Equal((sbyte)42, (sbyte)value), + () => Assert.Equal((short)42, (short)value), + () => Assert.Equal((ushort)42, (ushort)value), + () => Assert.Equal(42u, (uint)value), + () => Assert.Equal(42L, (long)value), + () => Assert.Equal(42UL, (ulong)value), + () => Assert.Throws(() => (uint)negative), + () => Assert.Throws(() => (sbyte)new Rational(128))); } [Fact] public void BigIntegerRoundTripPreservesWholeValue() { BigInteger original = BigInteger.One << 128; - Rational converted = Rational.CreateChecked(original); - BigInteger result = BigInteger.CreateChecked(converted); - BigInteger expected = original; + Rational converted = original; + BigInteger result = (BigInteger)converted; - Assert.Equal(expected, result); + Assert.Equal(original, result); } [Fact] @@ -96,18 +80,14 @@ public void PrimitiveIntegerInputsPreserveValues() Rational expected = new(42); Assert.Multiple( - () => Assert.Equal(expected, Rational.CreateChecked((byte)42)), - () => Assert.Equal(expected, Rational.CreateChecked((sbyte)42)), - () => Assert.Equal(expected, Rational.CreateChecked((short)42)), - () => Assert.Equal(expected, Rational.CreateChecked((ushort)42)), - () => Assert.Equal(expected, Rational.CreateChecked(42)), - () => Assert.Equal(expected, Rational.CreateChecked(42u)), - () => Assert.Equal(expected, Rational.CreateChecked(42L)), - () => Assert.Equal(expected, Rational.CreateChecked(42UL)), - () => Assert.Equal(expected, Rational.CreateChecked((nint)42)), - () => Assert.Equal(expected, Rational.CreateChecked((nuint)42)), - () => Assert.Equal(expected, Rational.CreateChecked((Int128)42)), - () => Assert.Equal(expected, Rational.CreateChecked((UInt128)42))); + () => Assert.Equal(expected, (Rational)(byte)42), + () => Assert.Equal(expected, (Rational)(sbyte)42), + () => Assert.Equal(expected, (Rational)(short)42), + () => Assert.Equal(expected, (Rational)(ushort)42), + () => Assert.Equal(expected, (Rational)42), + () => Assert.Equal(expected, (Rational)42u), + () => Assert.Equal(expected, (Rational)42L), + () => Assert.Equal(expected, (Rational)42UL)); } [Theory] @@ -118,11 +98,8 @@ public void PrimitiveIntegerInputsPreserveValues() [InlineData(double.MinValue)] public void DoubleRoundTripPreservesRepresentableValues(double original) { - Rational converted = Rational.CreateChecked(original); - double result = double.CreateChecked(converted); - double expected = original; - - Assert.Equal(expected, result); + Rational converted = (Rational)original; + Assert.Equal(original, (double)converted); } [Theory] @@ -131,32 +108,15 @@ public void DoubleRoundTripPreservesRepresentableValues(double original) [InlineData(-0.1f)] public void SingleRoundTripPreservesRepresentableValues(float original) { - Rational converted = Rational.CreateChecked(original); - float result = float.CreateChecked(converted); - float expected = original; - - Assert.Equal(expected, result); - } - - [Fact] - public void HalfRoundTripPreservesRepresentableValues() - { - Half original = Half.Epsilon; - Rational converted = Rational.CreateChecked(original); - Half result = Half.CreateChecked(converted); - Half expected = original; - - Assert.Equal(expected, result); + Rational converted = (Rational)original; + Assert.Equal(original, (float)converted); } [Fact] public void BinaryConversionRoundsMidpointToEven() { Rational original = new((BigInteger.One << 53) + 1, BigInteger.One << 53); - double result = double.CreateChecked(original); - double expected = 1.0; - - Assert.Equal(expected, result); + Assert.Equal(1.0, (double)original); } [Fact] @@ -164,35 +124,34 @@ public void BinaryConversionDividesBeforeLosingLargeComponents() { BigInteger scale = BigInteger.One << 2000; Rational original = new(scale + 1, 2 * scale + 1); - double result = double.CreateChecked(original); - double expected = 0.5; + Assert.Equal(0.5, (double)original); + } - Assert.Equal(expected, result); + [Fact] + public void BinaryConversionRoundsAtSubnormalBoundary() + { + Rational halfwayToMinimum = new(1, BigInteger.One << 1075); + Rational halfwayToSecond = new(3, BigInteger.One << 1075); + + Assert.Multiple( + () => Assert.Equal(0.0, (double)halfwayToMinimum), + () => Assert.Equal(2 * double.Epsilon, (double)halfwayToSecond)); } [Fact] public void DecimalConversionRoundsRepeatingFractions() { Rational original = new(1, 3); - decimal result = decimal.CreateChecked(original); - decimal expected = 1m / 3m; - - Assert.Equal(expected, result); + Assert.Equal(1m / 3m, (decimal)original); } [Fact] - public void DecimalOutputModesRespectTargetRange() + public void DecimalCastRejectsOutOfRangeValue() { Rational original = new(new BigInteger(decimal.MaxValue) + 1); - decimal saturatingResult = decimal.CreateSaturating(original); - decimal truncatingResult = decimal.CreateTruncating(original); - decimal expected = decimal.MaxValue; - - Assert.Multiple( - () => Assert.Equal(expected, saturatingResult), - () => Assert.Equal(expected, truncatingResult), - () => Assert.Throws(() => decimal.CreateChecked(original))); + Assert.Throws(() => (decimal)original); } + [Theory] [InlineData(1.5)] [InlineData(-0.125)] @@ -200,30 +159,10 @@ public void DecimalOutputModesRespectTargetRange() public void RealComplexInputsPreserveFractions(double real) { Complex original = new(real, 0); - Rational checkedResult = Rational.CreateChecked(original); - Rational saturatingResult = Rational.CreateSaturating(original); - Rational truncatingResult = Rational.CreateTruncating(original); - Rational expected = Rational.CreateChecked(real); - + Rational converted = (Rational)original; Assert.Multiple( - () => Assert.Equal(expected, checkedResult), - () => Assert.Equal(expected, saturatingResult), - () => Assert.Equal(expected, truncatingResult)); - } - - [Fact] - public void ComplexOutputsPreserveFractionalRealPart() - { - Rational original = new(3, 2); - Complex checkedResult = Complex.CreateChecked(original); - Complex saturatingResult = Complex.CreateSaturating(original); - Complex truncatingResult = Complex.CreateTruncating(original); - Complex expected = new(1.5, 0); - - Assert.Multiple( - () => Assert.Equal(expected, checkedResult), - () => Assert.Equal(expected, saturatingResult), - () => Assert.Equal(expected, truncatingResult)); + () => Assert.Equal((Rational)real, converted), + () => Assert.Equal(original, (Complex)converted)); } [Theory] @@ -232,10 +171,6 @@ public void ComplexOutputsPreserveFractionalRealPart() public void NonRealComplexInputsAreRejected(double imaginary) { Complex original = new(1.5, imaginary); - - Assert.Multiple( - () => Assert.Throws(() => Rational.CreateChecked(original)), - () => Assert.Throws(() => Rational.CreateSaturating(original)), - () => Assert.Throws(() => Rational.CreateTruncating(original))); + Assert.Throws(() => (Rational)original); } } diff --git a/src/DimensionalQuantities.Tests/Numerics/RationalTests.Text.cs b/src/DimensionalQuantities.Tests/Numerics/RationalTests.Text.cs index 73bc42a..c830d75 100644 --- a/src/DimensionalQuantities.Tests/Numerics/RationalTests.Text.cs +++ b/src/DimensionalQuantities.Tests/Numerics/RationalTests.Text.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text; using DimensionalQuantities.Numerics; using Xunit; @@ -73,54 +72,31 @@ public void FormattingWholeNumbersOmitsDenominator() } [Fact] - public void CharacterSpanFormattingAndParsingRoundTrip() + public void StringFormattingAndParsingRoundTrip() { Rational original = new(-3, 4); - Span buffer = stackalloc char[20]; - bool formatted = original.TryFormat(buffer, out int written, default, CultureInfo.InvariantCulture); - bool parsed = Rational.TryParse(buffer[..written], CultureInfo.InvariantCulture, out Rational result); - string formattedText = new(buffer[..written]); + string formattedText = original.ToString(null, CultureInfo.InvariantCulture); + bool parsed = Rational.TryParse(formattedText, CultureInfo.InvariantCulture, out Rational result); Rational expected = new(-3, 4); Assert.Multiple( - () => Assert.True(formatted), () => Assert.True(parsed), () => Assert.Equal("-3/4", formattedText), () => Assert.Equal(expected, result)); } [Fact] - public void Utf8FormattingAndParsingHonourCulture() + public void FormattingAndParsingHonourCulture() { Rational original = new(-3, 4); NumberFormatInfo provider = new() { NegativeSign = "−" }; - Span buffer = stackalloc byte[20]; - bool formatted = original.TryFormat(buffer, out int written, default, provider); - bool parsed = Rational.TryParse(buffer[..written], provider, out Rational result); - string formattedText = Encoding.UTF8.GetString(buffer[..written]); + string formattedText = original.ToString(null, provider); + bool parsed = Rational.TryParse(formattedText, provider, out Rational result); Rational expected = new(-3, 4); Assert.Multiple( - () => Assert.True(formatted), () => Assert.True(parsed), () => Assert.Equal("−3/4", formattedText), () => Assert.Equal(expected, result)); } - - [Fact] - public void FormattingReportsInsufficientDestination() - { - Rational original = new(3, 4); - Span characters = stackalloc char[2]; - Span bytes = stackalloc byte[2]; - - bool charactersFormatted = original.TryFormat(characters, out int charsWritten, default, CultureInfo.InvariantCulture); - bool bytesFormatted = original.TryFormat(bytes, out int bytesWritten, default, CultureInfo.InvariantCulture); - - Assert.Multiple( - () => Assert.False(charactersFormatted), - () => Assert.False(bytesFormatted), - () => Assert.Equal(0, charsWritten), - () => Assert.Equal(0, bytesWritten)); - } } diff --git a/src/DimensionalQuantities.Tests/Numerics/RationalTests.cs b/src/DimensionalQuantities.Tests/Numerics/RationalTests.cs index 02dbfab..2ca5a74 100644 --- a/src/DimensionalQuantities.Tests/Numerics/RationalTests.cs +++ b/src/DimensionalQuantities.Tests/Numerics/RationalTests.cs @@ -149,17 +149,6 @@ public void DivisionByZeroIsRejected() Assert.Throws(() => original / zero); } - [Fact] - public void GenericMathAcceptsRational() - { - Rational original = new(1, 3); - Rational operand = new(1, 6); - Rational result = Add(original, operand); - Rational expected = new(1, 2); - - Assert.Equal(expected, result); - } - [Fact] public void ComparisonUsesFractionValues() { @@ -285,5 +274,4 @@ public void AbsoluteValueMakesNegativeFractionPositive() Assert.Equal(expected, result); } - private static T Add(T left, T right) where T : INumber => left + right; } diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index b80e131..00d9058 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -1,6 +1,6 @@ - net10.0 + netstandard2.0 GoSim MIT README.md @@ -19,13 +19,14 @@ - - + diff --git a/src/DimensionalQuantities/Dimensions/Dimension.cs b/src/DimensionalQuantities/Dimensions/Dimension.cs index 8ca3f86..f2ef2dd 100644 --- a/src/DimensionalQuantities/Dimensions/Dimension.cs +++ b/src/DimensionalQuantities/Dimensions/Dimension.cs @@ -92,7 +92,10 @@ public IEnumerable GetComponents() /// public Dimension Root(int degree) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(degree); + if (degree <= 0) + { + throw new ArgumentOutOfRangeException(nameof(degree)); + } return Pow(new(1, degree)); } diff --git a/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs b/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs index 906b53e..bbc7aa6 100644 --- a/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs +++ b/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.Globalization; using System.Numerics; using System.Text; @@ -15,8 +14,8 @@ internal static class DimensionNameGenerator public static string GetUnitExpression(Dimension dimension) { - ImmutableArray components = dimension.GetComponents().ToImmutableArray(); - if (components.IsEmpty) + DimensionComponent[] components = dimension.GetComponents().ToArray(); + if (components.Length == 0) { throw new NotSupportedException("Dimensionless names are not defined."); } @@ -36,8 +35,8 @@ public static string GetUnitExpression(Dimension dimension) private static string GetName(Dimension dimension, Func getFactorName) { - ImmutableArray components = dimension.GetComponents().ToImmutableArray(); - if (components.IsEmpty) + DimensionComponent[] components = dimension.GetComponents().ToArray(); + if (components.Length == 0) { throw new NotSupportedException("Dimensionless names are not defined."); } diff --git a/src/DimensionalQuantities/Numerics/Rational.Conversions.cs b/src/DimensionalQuantities/Numerics/Rational.Conversions.cs index e873f35..a758299 100644 --- a/src/DimensionalQuantities/Numerics/Rational.Conversions.cs +++ b/src/DimensionalQuantities/Numerics/Rational.Conversions.cs @@ -4,68 +4,43 @@ namespace DimensionalQuantities.Numerics; internal readonly partial record struct Rational { - public static Rational CreateChecked(TOther value) where TOther : INumberBase => - TryConvertFrom(value, out Rational result) || TOther.TryConvertToChecked(value, out result) ? result : throw new NotSupportedException($"Cannot convert {typeof(TOther)} to Rational."); - - public static Rational CreateSaturating(TOther value) where TOther : INumberBase => - TryConvertFrom(value, out Rational result) || TOther.TryConvertToSaturating(value, out result) ? result : throw new NotSupportedException($"Cannot convert {typeof(TOther)} to Rational."); - - public static Rational CreateTruncating(TOther value) where TOther : INumberBase => - TryConvertFrom(value, out Rational result) || TOther.TryConvertToTruncating(value, out result) ? result : throw new NotSupportedException($"Cannot convert {typeof(TOther)} to Rational."); - - // All finite built-in inputs are represented exactly, so incoming conversion - // modes coincide. Infinity and NaN have no rational representation. - static bool INumberBase.TryConvertFromChecked(TOther value, out Rational result) => TryConvertFrom(value, out result); - static bool INumberBase.TryConvertFromSaturating(TOther value, out Rational result) => TryConvertFrom(value, out result); - static bool INumberBase.TryConvertFromTruncating(TOther value, out Rational result) => TryConvertFrom(value, out result); - - private static bool TryConvertFrom(TOther value, out Rational result) where TOther : INumberBase + public static implicit operator Rational(byte value) => new(value); + public static implicit operator Rational(sbyte value) => new(value); + public static implicit operator Rational(short value) => new(value); + public static implicit operator Rational(ushort value) => new(value); + public static implicit operator Rational(uint value) => new(value); + public static implicit operator Rational(long value) => new(value); + public static implicit operator Rational(ulong value) => new(value); + public static implicit operator Rational(BigInteger value) => new(value); + + public static explicit operator Rational(float value) => FromDouble(value); + public static explicit operator Rational(double value) => FromDouble(value); + public static explicit operator Rational(decimal value) => FromDecimal(value); + public static explicit operator Rational(Complex value) { - switch (value) - { - case Rational rational: - result = rational; - return true; - case Complex number: - if (number.Imaginary != 0) - { - throw new RationalNumberCantHaveComplexComponentsException(); - } - - result = FromDouble(number.Real); - return true; - case decimal number: - result = FromDecimal(number); - return true; - case double number: - result = FromDouble(number); - return true; - case float number: - result = FromDouble(number); - return true; - case Half number: - result = FromDouble((double)number); - return true; - } - - if (IsBuiltInInteger()) - { - result = new(BigInteger.CreateChecked(value)); - return true; - } - - result = Zero; - return false; + if (value.Imaginary != 0) + { + throw new RationalNumberCantHaveComplexComponentsException(); + } + + return FromDouble(value.Real); } - private static bool IsBuiltInInteger() => - typeof(T) == typeof(byte) || typeof(T) == typeof(sbyte) || - typeof(T) == typeof(short) || typeof(T) == typeof(ushort) || - typeof(T) == typeof(int) || typeof(T) == typeof(uint) || - typeof(T) == typeof(long) || typeof(T) == typeof(ulong) || - typeof(T) == typeof(nint) || typeof(T) == typeof(nuint) || - typeof(T) == typeof(Int128) || typeof(T) == typeof(UInt128) || - typeof(T) == typeof(char) || typeof(T) == typeof(BigInteger); + public static explicit operator byte(Rational value) => checked((byte)value.WholePart); + public static explicit operator sbyte(Rational value) => checked((sbyte)value.WholePart); + public static explicit operator short(Rational value) => checked((short)value.WholePart); + public static explicit operator ushort(Rational value) => checked((ushort)value.WholePart); + public static explicit operator int(Rational value) => checked((int)value.WholePart); + public static explicit operator uint(Rational value) => checked((uint)value.WholePart); + public static explicit operator long(Rational value) => checked((long)value.WholePart); + public static explicit operator ulong(Rational value) => checked((ulong)value.WholePart); + public static explicit operator BigInteger(Rational value) => value.WholePart; + public static explicit operator double(Rational value) => value.ToBinaryFloat(53, -1022, 1023); + public static explicit operator float(Rational value) => (float)value.ToBinaryFloat(24, -126, 127); + public static explicit operator decimal(Rational value) => value.ToDecimal(); + public static explicit operator Complex(Rational value) => new((double)value, 0); + + private BigInteger WholePart => Numerator / Denominator; private static Rational FromDecimal(decimal value) { @@ -82,12 +57,12 @@ private static Rational FromDecimal(decimal value) private static Rational FromDouble(double value) { - if (!double.IsFinite(value)) + if (double.IsNaN(value) || double.IsInfinity(value)) { throw new OverflowException("NaN and infinity cannot be represented as a Rational."); } - ulong bits = BitConverter.DoubleToUInt64Bits(value); + ulong bits = unchecked((ulong)BitConverter.DoubleToInt64Bits(value)); int exponentBits = (int)((bits >> 52) & 0x7ff); BigInteger significand = bits & 0x000f_ffff_ffff_ffff; int exponent = -1074; @@ -104,63 +79,6 @@ private static Rational FromDouble(double value) return exponent >= 0 ? new(significand << exponent) : new(significand, BigInteger.One << -exponent); } - static bool INumberBase.TryConvertToChecked(Rational value, out TOther result) => TryConvertTo(value, ConversionMode.Checked, out result); - static bool INumberBase.TryConvertToSaturating(Rational value, out TOther result) => TryConvertTo(value, ConversionMode.Saturating, out result); - static bool INumberBase.TryConvertToTruncating(Rational value, out TOther result) => TryConvertTo(value, ConversionMode.Truncating, out result); - - private enum ConversionMode { Checked, Saturating, Truncating } - - private static bool TryConvertTo(Rational value, ConversionMode mode, out TOther result) where TOther : INumberBase - { - if (typeof(TOther) == typeof(Rational)) - { - result = (TOther)(object)value; - return true; - } - if (typeof(TOther) == typeof(Complex)) - { - result = (TOther)(object)new Complex(value.ToBinaryFloat(53, -1022, 1023), 0); - return true; - } - if (typeof(TOther) == typeof(double)) - { - result = (TOther)(object)value.ToBinaryFloat(53, -1022, 1023); - return true; - } - if (typeof(TOther) == typeof(float)) - { - result = (TOther)(object)(float)value.ToBinaryFloat(24, -126, 127); - return true; - } - if (typeof(TOther) == typeof(Half)) - { - result = (TOther)(object)(Half)value.ToBinaryFloat(11, -14, 15); - return true; - } - if (typeof(TOther) == typeof(decimal)) - { - result = (TOther)(object)value.ToDecimal(mode); - return true; - } - - if (!IsBuiltInInteger()) - { - result = TOther.Zero; - return false; - } - - // Integral targets discard the fractional part towards zero; the target - // type owns overflow, saturation and truncation of the resulting integer. - BigInteger whole = value.Numerator / value.Denominator; - result = mode switch - { - ConversionMode.Checked => TOther.CreateChecked(whole), - ConversionMode.Saturating => TOther.CreateSaturating(whole), - _ => TOther.CreateTruncating(whole) - }; - return true; - } - // Round the fraction directly to the target precision. Converting numerator // and denominator separately would lose accuracy or produce infinity/infinity. private double ToBinaryFloat(int precision, int minimumExponent, int maximumExponent) @@ -172,7 +90,7 @@ private double ToBinaryFloat(int precision, int minimumExponent, int maximumExpo BigInteger numerator = BigInteger.Abs(Numerator); BigInteger denominator = Denominator; - long exponent = numerator.GetBitLength() - denominator.GetBitLength(); + long exponent = GetBitLength(numerator) - GetBitLength(denominator); if (exponent > maximumExponent + 1L) { return Numerator.Sign * double.PositiveInfinity; @@ -192,26 +110,59 @@ private double ToBinaryFloat(int precision, int minimumExponent, int maximumExpo int shift = (int)Math.Max(exponent - precision + 1, minimumExponent - precision + 1); BigInteger rounded = shift >= 0 ? RoundQuotient(numerator, denominator << shift) : RoundQuotient(numerator << -shift, denominator); - if (rounded.GetBitLength() + shift - 1 > maximumExponent) + if (GetBitLength(rounded) + shift - 1 > maximumExponent) { return Numerator.Sign * double.PositiveInfinity; } - return Numerator.Sign * Math.ScaleB((double)rounded, shift); + return Numerator.Sign * ScaleRoundedBinary(rounded, shift); + } + + private static long GetBitLength(BigInteger value) + { + byte[] bytes = value.ToByteArray(); + int top = bytes.Length - 1; + while (top > 0 && bytes[top] == 0) + { + top--; + } + + byte mostSignificant = bytes[top]; + int bits = 0; + while (mostSignificant != 0) + { + bits++; + mostSignificant >>= 1; + } + + return 8L * top + bits; + } + + private static double ScaleRoundedBinary(BigInteger rounded, int shift) + { + long bitLength = GetBitLength(rounded); + long exponent = bitLength - 1 + shift; + ulong bits; + if (exponent >= -1022) + { + BigInteger significand = bitLength <= 53 ? rounded << (int)(53 - bitLength) : rounded >> (int)(bitLength - 53); + bits = ((ulong)(exponent + 1023) << 52) | ((ulong)significand & 0x000f_ffff_ffff_ffff); + } + else + { + bits = (ulong)(rounded << (shift + 1074)); + } + + return BitConverter.Int64BitsToDouble(unchecked((long)bits)); } - private decimal ToDecimal(ConversionMode mode) + private decimal ToDecimal() { BigInteger numerator = BigInteger.Abs(Numerator); BigInteger maximum = new(decimal.MaxValue); if (numerator > maximum * Denominator) { - if (mode == ConversionMode.Checked) - { - throw new OverflowException("Rational exceeds the decimal range."); - } - - return Numerator.Sign < 0 ? decimal.MinValue : decimal.MaxValue; + throw new OverflowException("Rational exceeds the decimal range."); } for (int scale = 28; scale >= 0; scale--) diff --git a/src/DimensionalQuantities/Numerics/Rational.Text.cs b/src/DimensionalQuantities/Numerics/Rational.Text.cs index d1bf823..a1bbe3b 100644 --- a/src/DimensionalQuantities/Numerics/Rational.Text.cs +++ b/src/DimensionalQuantities/Numerics/Rational.Text.cs @@ -1,6 +1,5 @@ using System.Globalization; using System.Numerics; -using System.Text; namespace DimensionalQuantities.Numerics; @@ -16,68 +15,38 @@ public string ToString(string? format, IFormatProvider? formatProvider) return Denominator.IsOne ? numerator : numerator + "/" + Denominator.ToString(format, formatProvider); } - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) - { - string text = ToString(format.IsEmpty ? null : new(format), provider); - if (!text.AsSpan().TryCopyTo(destination)) - { - charsWritten = 0; - return false; - } - - charsWritten = text.Length; - return true; - } - - public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) - { - string text = ToString(format.IsEmpty ? null : new(format), provider); - if (Encoding.UTF8.GetByteCount(text) > utf8Destination.Length) - { - bytesWritten = 0; - return false; - } - - bytesWritten = Encoding.UTF8.GetBytes(text, utf8Destination); - return true; - } - public static Rational Parse(string s, IFormatProvider? provider) => Parse(s, NumberStyles.Integer, provider); - public static Rational Parse(ReadOnlySpan s, IFormatProvider? provider) => Parse(s, NumberStyles.Integer, provider); - public static Rational Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => Parse(Encoding.UTF8.GetString(utf8Text), provider); public static Rational Parse(string s, NumberStyles style, IFormatProvider? provider) { - ArgumentNullException.ThrowIfNull(s); - return Parse(s.AsSpan(), style, provider); - } + if (s is null) + { + throw new ArgumentNullException(nameof(s)); + } - public static Rational Parse(ReadOnlySpan s, NumberStyles style, IFormatProvider? provider) - { int separator = s.IndexOf('/'); - return separator < 0 ? new(BigInteger.Parse(s, style, provider)) : new(BigInteger.Parse(s[..separator], style, provider), BigInteger.Parse(s[(separator + 1)..], style, provider)); + return separator < 0 ? new(BigInteger.Parse(s, style, provider)) : new(BigInteger.Parse(s.Substring(0, separator), style, provider), BigInteger.Parse(s.Substring(separator + 1), style, provider)); } - public static bool TryParse(string? s, IFormatProvider? provider, out Rational result) => TryParse(s.AsSpan(), NumberStyles.Integer, provider, out result); - - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out Rational result) => TryParse(s, NumberStyles.Integer, provider, out result); + public static bool TryParse(string? s, IFormatProvider? provider, out Rational result) => TryParse(s, NumberStyles.Integer, provider, out result); - public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, out Rational result) => TryParse(Encoding.UTF8.GetString(utf8Text), provider, out result); - - public static bool TryParse(string? s, NumberStyles style, IFormatProvider? provider, out Rational result) => TryParse(s.AsSpan(), style, provider, out result); - - public static bool TryParse(ReadOnlySpan s, NumberStyles style, IFormatProvider? provider, out Rational result) + public static bool TryParse(string? s, NumberStyles style, IFormatProvider? provider, out Rational result) { result = Zero; + if (s is null) + { + return false; + } + int separator = s.IndexOf('/'); - ReadOnlySpan numeratorText = separator < 0 ? s : s[..separator]; + string numeratorText = separator < 0 ? s : s.Substring(0, separator); if (!BigInteger.TryParse(numeratorText, style, provider, out BigInteger numerator)) { return false; } BigInteger denominator = BigInteger.One; - if (separator >= 0 && (!BigInteger.TryParse(s[(separator + 1)..], style, provider, out denominator) || denominator.IsZero)) + if (separator >= 0 && (!BigInteger.TryParse(s.Substring(separator + 1), style, provider, out denominator) || denominator.IsZero)) { return false; } diff --git a/src/DimensionalQuantities/Numerics/Rational.cs b/src/DimensionalQuantities/Numerics/Rational.cs index c9f5e1e..9fde60d 100644 --- a/src/DimensionalQuantities/Numerics/Rational.cs +++ b/src/DimensionalQuantities/Numerics/Rational.cs @@ -3,7 +3,7 @@ namespace DimensionalQuantities.Numerics; /// An exact, reduced fraction with a positive denominator. Default is zero. -internal readonly partial record struct Rational : INumber +internal readonly partial record struct Rational { // Offset storage makes the zero-initialised struct represent 0/1. private readonly BigInteger _denominatorMinusOne; @@ -107,4 +107,4 @@ public static Rational MinMagnitude(Rational x, Rational y) public static Rational MaxMagnitudeNumber(Rational x, Rational y) => MaxMagnitude(x, y); public static Rational MinMagnitudeNumber(Rational x, Rational y) => MinMagnitude(x, y); -} \ No newline at end of file +} diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index 8625408..ef490ea 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -73,7 +73,7 @@ private static double GetScaleExponent(int length, int mass, int time, int elect + (time - SiBasePrefixExponents.Time) * dimension.Time + (electricCurrent - SiBasePrefixExponents.ElectricCurrent) * dimension.ElectricCurrent + (temperature - SiBasePrefixExponents.Temperature) * dimension.Temperature + (amountOfSubstance - SiBasePrefixExponents.AmountOfSubstance) * dimension.AmountOfSubstance + (luminousIntensity - SiBasePrefixExponents.LuminousIntensity) * dimension.LuminousIntensity; - return double.CreateChecked(exponent) + overall; + return (double)exponent + overall; } private static MathematicalDimension ParseDimension() diff --git a/src/DimensionalQuantities/TypeSystem/TypeSystemNumberCodec.cs b/src/DimensionalQuantities/TypeSystem/TypeSystemNumberCodec.cs index 2993188..ed26dde 100644 --- a/src/DimensionalQuantities/TypeSystem/TypeSystemNumberCodec.cs +++ b/src/DimensionalQuantities/TypeSystem/TypeSystemNumberCodec.cs @@ -28,7 +28,10 @@ public static Type CreateType(Rational value) public static Rational Parse(Type type) { - ArgumentNullException.ThrowIfNull(type); + if (type is null) + { + throw new ArgumentNullException(nameof(type)); + } int digit = Array.IndexOf(_terminalDigits, type); if (digit >= 0) diff --git a/src/DimensionalQuantities/Units/UnitExpression.cs b/src/DimensionalQuantities/Units/UnitExpression.cs index 7852a20..a7710cd 100644 --- a/src/DimensionalQuantities/Units/UnitExpression.cs +++ b/src/DimensionalQuantities/Units/UnitExpression.cs @@ -93,13 +93,13 @@ public string GetSymbol() public override int GetHashCode() { - HashCode hash = new(); + int hash = 17; foreach (UnitTerm term in Terms ?? []) { - hash.Add(term); + hash = unchecked(hash * 31 + term.GetHashCode()); } - return hash.ToHashCode(); + return hash; } private static void AppendRootName(StringBuilder name, Rational power)