From 8042ec481b28b0a6825a11db229efdb4db792ffd Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 17:09:43 +0100 Subject: [PATCH 01/25] feat: add exact rational arithmetic and generic math support --- .../DimensionalQuantities.Tests.csproj | 8 + .../Numerics/RationalTests.Conversions.cs | 241 +++++++++++++++ .../Numerics/RationalTests.Text.cs | 126 ++++++++ .../Numerics/RationalTests.cs | 289 ++++++++++++++++++ .../DimensionalQuantities.csproj | 11 + .../Numerics/Rational.Conversions.cs | 240 +++++++++++++++ .../Numerics/Rational.Text.cs | 88 ++++++ .../Numerics/Rational.cs | 110 +++++++ ...umberCantHaveComplexComponentsException.cs | 8 + 9 files changed, 1121 insertions(+) create mode 100644 src/DimensionalQuantities.Tests/Numerics/RationalTests.Conversions.cs create mode 100644 src/DimensionalQuantities.Tests/Numerics/RationalTests.Text.cs create mode 100644 src/DimensionalQuantities.Tests/Numerics/RationalTests.cs create mode 100644 src/DimensionalQuantities/Numerics/Rational.Conversions.cs create mode 100644 src/DimensionalQuantities/Numerics/Rational.Text.cs create mode 100644 src/DimensionalQuantities/Numerics/Rational.cs create mode 100644 src/DimensionalQuantities/Numerics/RationalNumberCantHaveComplexComponentsException.cs diff --git a/src/DimensionalQuantities.Tests/DimensionalQuantities.Tests.csproj b/src/DimensionalQuantities.Tests/DimensionalQuantities.Tests.csproj index f55b3fa..3f1f0f2 100644 --- a/src/DimensionalQuantities.Tests/DimensionalQuantities.Tests.csproj +++ b/src/DimensionalQuantities.Tests/DimensionalQuantities.Tests.csproj @@ -13,4 +13,12 @@ + + + RationalTests.cs + + + RationalTests.cs + + diff --git a/src/DimensionalQuantities.Tests/Numerics/RationalTests.Conversions.cs b/src/DimensionalQuantities.Tests/Numerics/RationalTests.Conversions.cs new file mode 100644 index 0000000..4107871 --- /dev/null +++ b/src/DimensionalQuantities.Tests/Numerics/RationalTests.Conversions.cs @@ -0,0 +1,241 @@ +using System.Numerics; +using DimensionalQuantities.Numerics; +using Xunit; + +namespace DimensionalQuantities.Tests.Numerics; + +public sealed partial class RationalTests +{ + [Fact] + public void DecimalConversionPreservesExactBaseTenValue() + { + decimal original = 0.1m; + Rational result = Rational.CreateChecked(original); + Rational expected = new(1, 10); + + Assert.Multiple( + () => Assert.Equal(expected, result), + () => Assert.Equal(original, decimal.CreateChecked(result))); + } + + [Fact] + public void DoubleConversionPreservesExactBinaryValue() + { + double original = 0.1d; + Rational result = Rational.CreateChecked(original); + Rational expected = new(3602879701896397, 36028797018963968); + + 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)); + } + + [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))); + } + + [Fact] + public void IntegralConversionDiscardsFractionTowardsZero() + { + Rational original = new(-7, 3); + int result = int.CreateChecked(original); + int expected = -2; + + Assert.Equal(expected, result); + } + + [Fact] + public void IntegralOutputModesUseTargetConversionRules() + { + Rational original = new(601, 2); + byte saturatingResult = byte.CreateSaturating(original); + byte truncatingResult = byte.CreateTruncating(original); + + Assert.Multiple( + () => Assert.Equal(byte.MaxValue, saturatingResult), + () => Assert.Equal((byte)44, truncatingResult), + () => Assert.Throws(() => byte.CreateChecked(original))); + } + + [Fact] + public void BigIntegerRoundTripPreservesWholeValue() + { + BigInteger original = BigInteger.One << 128; + Rational converted = Rational.CreateChecked(original); + BigInteger result = BigInteger.CreateChecked(converted); + BigInteger expected = original; + + Assert.Equal(expected, result); + } + + [Fact] + 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))); + } + + [Theory] + [InlineData(0.0)] + [InlineData(-0.125)] + [InlineData(double.Epsilon)] + [InlineData(double.MaxValue)] + [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); + } + + [Theory] + [InlineData(float.Epsilon)] + [InlineData(float.MaxValue)] + [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); + } + + [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); + } + + [Fact] + 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(expected, result); + } + + [Fact] + public void DecimalConversionRoundsRepeatingFractions() + { + Rational original = new(1, 3); + decimal result = decimal.CreateChecked(original); + decimal expected = 1m / 3m; + + Assert.Equal(expected, result); + } + + [Fact] + public void DecimalOutputModesRespectTargetRange() + { + 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))); + } + [Theory] + [InlineData(1.5)] + [InlineData(-0.125)] + [InlineData(0.0)] + 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); + + 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)); + } + + [Theory] + [InlineData(1.0)] + [InlineData(-1.0)] + 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))); + } +} diff --git a/src/DimensionalQuantities.Tests/Numerics/RationalTests.Text.cs b/src/DimensionalQuantities.Tests/Numerics/RationalTests.Text.cs new file mode 100644 index 0000000..73bc42a --- /dev/null +++ b/src/DimensionalQuantities.Tests/Numerics/RationalTests.Text.cs @@ -0,0 +1,126 @@ +using System.Globalization; +using System.Text; +using DimensionalQuantities.Numerics; +using Xunit; + +namespace DimensionalQuantities.Tests.Numerics; + +public sealed partial class RationalTests +{ + [Theory] + [InlineData("6/8", 3, 4)] + [InlineData(" 6 / -8 ", -3, 4)] + [InlineData("7", 7, 1)] + [InlineData("0/15", 0, 1)] + public void ParsingNormalisesFractions(string text, int numerator, int denominator) + { + Rational result = Rational.Parse(text, CultureInfo.InvariantCulture); + Rational expected = new(numerator, denominator); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("1/0")] + [InlineData("1/2/3")] + [InlineData("x/2")] + [InlineData("1/")] + public void TryParseRejectsInvalidFractions(string? text) + { + bool success = Rational.TryParse(text, CultureInfo.InvariantCulture, out Rational result); + Rational expected = default; + + Assert.Multiple( + () => Assert.False(success), + () => Assert.Equal(expected, result)); + } + + [Fact] + public void ParseRejectsZeroDenominator() + { + Assert.Throws(() => Rational.Parse("1/0", CultureInfo.InvariantCulture)); + } + + [Fact] + public void ParseHonoursComponentNumberStyles() + { + Rational result = Rational.Parse("1,000/2,000", NumberStyles.AllowThousands, CultureInfo.InvariantCulture); + Rational expected = new(1, 2); + + Assert.Equal(expected, result); + } + + [Fact] + public void FormattingProducesReducedFraction() + { + Rational original = new(-6, 8); + string result = original.ToString(null, CultureInfo.InvariantCulture); + string expected = "-3/4"; + + Assert.Equal(expected, result); + } + + [Fact] + public void FormattingWholeNumbersOmitsDenominator() + { + Rational original = new(6, 2); + string result = original.ToString(null, CultureInfo.InvariantCulture); + string expected = "3"; + + Assert.Equal(expected, result); + } + + [Fact] + public void CharacterSpanFormattingAndParsingRoundTrip() + { + 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]); + 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() + { + 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]); + 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 new file mode 100644 index 0000000..02dbfab --- /dev/null +++ b/src/DimensionalQuantities.Tests/Numerics/RationalTests.cs @@ -0,0 +1,289 @@ +using System.Numerics; +using DimensionalQuantities.Numerics; +using Xunit; + +namespace DimensionalQuantities.Tests.Numerics; + +public sealed partial class RationalTests +{ + [Fact] + public void ConstructionReducesFractionToLowestTerms() + { + Rational result = new(6, 8); + + Assert.Multiple( + () => Assert.Equal(3, result.Numerator), + () => Assert.Equal(4, result.Denominator)); + } + + [Fact] + public void NegativeDenominatorMovesSignToNumerator() + { + Rational result = new(2, -4); + Rational expected = new(-1, 2); + + Assert.Multiple( + () => Assert.Equal(-1, result.Numerator), + () => Assert.Equal(2, result.Denominator), + () => Assert.Equal(expected, result)); + } + + [Fact] + public void TwoNegativeSignsCancel() + { + Rational result = new(-2, -4); + Rational expected = new(1, 2); + + Assert.Equal(expected, result); + } + + [Fact] + public void ZeroHasCanonicalDenominator() + { + Rational result = new(0, 7); + Rational expected = default; + + Assert.Multiple( + () => Assert.Equal(0, result.Numerator), + () => Assert.Equal(1, result.Denominator), + () => Assert.Equal(expected, result)); + } + + [Fact] + public void DefaultRepresentsZeroOverOne() + { + Rational result = default; + + Assert.Multiple( + () => Assert.Equal(0, result.Numerator), + () => Assert.Equal(1, result.Denominator)); + } + + [Fact] + public void EquivalentFractionsHaveEqualValuesAndHashes() + { + Rational result = new(2, 4); + Rational expected = new(1, 2); + + Assert.Multiple( + () => Assert.Equal(expected, result), + () => Assert.Equal(expected.GetHashCode(), result.GetHashCode())); + } + + [Fact] + public void IntegerConversionProducesWholeFraction() + { + Rational result = 3; + Rational expected = new(3, 1); + + Assert.Equal(expected, result); + } + + [Fact] + public void AdditionCombinesDifferentDenominators() + { + Rational original = new(1, 2); + Rational operand = new(1, 3); + Rational result = original + operand; + Rational expected = new(5, 6); + + Assert.Equal(expected, result); + } + + [Fact] + public void SubtractionCombinesDifferentDenominators() + { + Rational original = new(1, 3); + Rational operand = new(1, 2); + Rational result = original - operand; + Rational expected = new(-1, 6); + + Assert.Equal(expected, result); + } + + [Fact] + public void MultiplicationReducesResult() + { + Rational original = new(2, 3); + Rational operand = new(3, 4); + Rational result = original * operand; + Rational expected = new(1, 2); + + Assert.Equal(expected, result); + } + + [Fact] + public void DivisionUsesReciprocal() + { + Rational original = new(2, 3); + Rational operand = new(4, 5); + Rational result = original / operand; + Rational expected = new(5, 6); + + Assert.Equal(expected, result); + } + + [Fact] + public void DefaultZeroIsAdditiveIdentity() + { + Rational original = new(2, 3); + Rational zero = default; + Rational result = original + zero; + Rational expected = new(2, 3); + + Assert.Equal(expected, result); + } + + [Fact] + public void ZeroDenominatorIsRejected() + { + Assert.Throws(() => new Rational(1, 0)); + } + + [Fact] + public void DivisionByZeroIsRejected() + { + Rational original = new(1, 2); + Rational zero = default; + + 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() + { + Rational original = new(-1, 2); + Rational other = new(-1, 3); + + Assert.Multiple( + () => Assert.True(original < other), + () => Assert.True(original <= other), + () => Assert.True(other > original), + () => Assert.True(other >= original), + () => Assert.Equal(-1, original.CompareTo(other)), + () => Assert.Equal(-1, original.CompareTo((object)other))); + } + + [Fact] + public void RemainderUsesQuotientTruncatedTowardsZero() + { + Rational original = new(-7, 3); + Rational divisor = new(1, 2); + Rational result = original % divisor; + Rational expected = new(-1, 3); + + Assert.Equal(expected, result); + } + + [Fact] + public void UnaryPlusPreservesValue() + { + Rational original = new(1, 3); + Rational result = +original; + Rational expected = original; + + Assert.Equal(expected, result); + } + + [Fact] + public void UnaryMinusNegatesValue() + { + Rational original = new(1, 3); + Rational result = -original; + Rational expected = new(-1, 3); + + Assert.Equal(expected, result); + } + + [Fact] + public void IncrementAddsOneAndPreservesOriginalValue() + { + Rational original = new(1, 3); + Rational result = original; + result++; + Rational expected = new(4, 3); + + Assert.Multiple( + () => Assert.Equal(expected, result), + () => Assert.Equal(new Rational(1, 3), original)); + } + + [Fact] + public void DecrementSubtractsOneAndPreservesOriginalValue() + { + Rational original = new(4, 3); + Rational result = original; + result--; + Rational expected = new(1, 3); + + Assert.Multiple( + () => Assert.Equal(expected, result), + () => Assert.Equal(new Rational(4, 3), original)); + } + + [Fact] + public void ClassificationDistinguishesFractionsAndIntegers() + { + Rational fraction = new(-3, 2); + Rational even = new(8, 2); + Rational odd = new(-6, 2); + + Assert.Multiple( + () => Assert.True(Rational.IsFinite(fraction)), + () => Assert.True(Rational.IsRealNumber(fraction)), + () => Assert.True(Rational.IsNormal(fraction)), + () => Assert.True(Rational.IsNegative(fraction)), + () => Assert.False(Rational.IsInteger(fraction)), + () => Assert.False(Rational.IsEvenInteger(fraction)), + () => Assert.False(Rational.IsOddInteger(fraction)), + () => Assert.True(Rational.IsEvenInteger(even)), + () => Assert.True(Rational.IsOddInteger(odd)), + () => Assert.True(Rational.IsZero(default)), + () => Assert.False(Rational.IsNormal(default))); + } + + [Fact] + public void EqualMagnitudesPreferPositiveForMaximum() + { + Rational original = new(-2, 3); + Rational positive = new(2, 3); + Rational result = Rational.MaxMagnitude(original, positive); + Rational expected = positive; + + Assert.Equal(expected, result); + } + + [Fact] + public void EqualMagnitudesPreferNegativeForMinimum() + { + Rational original = new(-2, 3); + Rational positive = new(2, 3); + Rational result = Rational.MinMagnitude(positive, original); + Rational expected = original; + + Assert.Equal(expected, result); + } + + [Fact] + public void AbsoluteValueMakesNegativeFractionPositive() + { + Rational original = new(-2, 3); + Rational result = Rational.Abs(original); + Rational expected = new(2, 3); + + 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 944a90d..5458cc3 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -10,4 +10,15 @@ + + + + + + Rational.cs + + + Rational.cs + + diff --git a/src/DimensionalQuantities/Numerics/Rational.Conversions.cs b/src/DimensionalQuantities/Numerics/Rational.Conversions.cs new file mode 100644 index 0000000..e873f35 --- /dev/null +++ b/src/DimensionalQuantities/Numerics/Rational.Conversions.cs @@ -0,0 +1,240 @@ +using System.Numerics; + +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 + { + 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; + } + + 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); + + private static Rational FromDecimal(decimal value) + { + int[] bits = decimal.GetBits(value); + BigInteger numerator = (uint)bits[0] | ((BigInteger)(uint)bits[1] << 32) | ((BigInteger)(uint)bits[2] << 64); + if (bits[3] < 0) + { + numerator = -numerator; + } + + int scale = (bits[3] >> 16) & 0xff; + return new(numerator, BigInteger.Pow(10, scale)); + } + + private static Rational FromDouble(double value) + { + if (!double.IsFinite(value)) + { + throw new OverflowException("NaN and infinity cannot be represented as a Rational."); + } + + ulong bits = BitConverter.DoubleToUInt64Bits(value); + int exponentBits = (int)((bits >> 52) & 0x7ff); + BigInteger significand = bits & 0x000f_ffff_ffff_ffff; + int exponent = -1074; + if (exponentBits != 0) + { + significand += BigInteger.One << 52; + exponent = exponentBits - 1023 - 52; + } + if ((bits >> 63) != 0) + { + significand = -significand; + } + + 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) + { + if (IsZero(this)) + { + return 0; + } + + BigInteger numerator = BigInteger.Abs(Numerator); + BigInteger denominator = Denominator; + long exponent = numerator.GetBitLength() - denominator.GetBitLength(); + if (exponent > maximumExponent + 1L) + { + return Numerator.Sign * double.PositiveInfinity; + } + + if (exponent < minimumExponent - precision) + { + return Numerator.Sign < 0 ? -0.0 : 0.0; + } + + bool belowPowerOfTwo = exponent >= 0 ? numerator < (denominator << (int)exponent) : (numerator << (int)-exponent) < denominator; + if (belowPowerOfTwo) + { + exponent--; + } + + 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) + { + return Numerator.Sign * double.PositiveInfinity; + } + + return Numerator.Sign * Math.ScaleB((double)rounded, shift); + } + + private decimal ToDecimal(ConversionMode mode) + { + 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; + } + + for (int scale = 28; scale >= 0; scale--) + { + BigInteger rounded = RoundQuotient(numerator * BigInteger.Pow(10, scale), Denominator); + if (rounded > maximum) + { + continue; + } + + uint low = (uint)(rounded & uint.MaxValue); + uint middle = (uint)((rounded >> 32) & uint.MaxValue); + uint high = (uint)(rounded >> 64); + return new(unchecked((int)low), unchecked((int)middle), unchecked((int)high), Numerator.Sign < 0, (byte)scale); + } + + throw new OverflowException("Rational exceeds the decimal range."); + } + + private static BigInteger RoundQuotient(BigInteger numerator, BigInteger denominator) + { + BigInteger quotient = BigInteger.DivRem(numerator, denominator, out BigInteger remainder); + int comparison = (remainder * 2).CompareTo(denominator); + return comparison > 0 || (comparison == 0 && !quotient.IsEven) ? quotient + 1 : quotient; + } +} diff --git a/src/DimensionalQuantities/Numerics/Rational.Text.cs b/src/DimensionalQuantities/Numerics/Rational.Text.cs new file mode 100644 index 0000000..d1bf823 --- /dev/null +++ b/src/DimensionalQuantities/Numerics/Rational.Text.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using System.Numerics; +using System.Text; + +namespace DimensionalQuantities.Numerics; + +internal readonly partial record struct Rational +{ + // Formatting applies the requested integer format to each component. + // Parsing accepts either one integer or two integers separated by '/'. + public override string ToString() => ToString(null, CultureInfo.CurrentCulture); + + public string ToString(string? format, IFormatProvider? formatProvider) + { + string numerator = Numerator.ToString(format, 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); + } + + 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)); + } + + 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(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) + { + result = Zero; + int separator = s.IndexOf('/'); + ReadOnlySpan numeratorText = separator < 0 ? s : s[..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)) + { + return false; + } + + result = new(numerator, denominator); + return true; + } +} diff --git a/src/DimensionalQuantities/Numerics/Rational.cs b/src/DimensionalQuantities/Numerics/Rational.cs new file mode 100644 index 0000000..c9f5e1e --- /dev/null +++ b/src/DimensionalQuantities/Numerics/Rational.cs @@ -0,0 +1,110 @@ +using System.Numerics; + +namespace DimensionalQuantities.Numerics; + +/// An exact, reduced fraction with a positive denominator. Default is zero. +internal readonly partial record struct Rational : INumber +{ + // Offset storage makes the zero-initialised struct represent 0/1. + private readonly BigInteger _denominatorMinusOne; + + public Rational(BigInteger numerator) : this(numerator, BigInteger.One) + { + } + + public Rational(BigInteger numerator, BigInteger denominator) + { + if (denominator.IsZero) + { + throw new DivideByZeroException(); + } + + if (denominator.Sign < 0) + { + numerator = -numerator; + denominator = -denominator; + } + + BigInteger divisor = BigInteger.GreatestCommonDivisor(numerator, denominator); + Numerator = numerator / divisor; + _denominatorMinusOne = denominator / divisor - BigInteger.One; + } + + public BigInteger Numerator { get; } + public BigInteger Denominator => _denominatorMinusOne + BigInteger.One; + + public static Rational Zero => default; + public static Rational One => new(1); + public static Rational AdditiveIdentity => Zero; + public static Rational MultiplicativeIdentity => One; + public static int Radix => 2; + + public static implicit operator Rational(int value) => new(value); + + public static Rational operator +(Rational left, Rational right) => new(left.Numerator * right.Denominator + right.Numerator * left.Denominator, left.Denominator * right.Denominator); + + public static Rational operator -(Rational left, Rational right) => new(left.Numerator * right.Denominator - right.Numerator * left.Denominator, left.Denominator * right.Denominator); + + public static Rational operator *(Rational left, Rational right) => new(left.Numerator * right.Numerator, left.Denominator * right.Denominator); + + public static Rational operator /(Rational left, Rational right) => new(left.Numerator * right.Denominator, left.Denominator * right.Numerator); + + public static Rational operator %(Rational left, Rational right) + { + Rational quotient = left / right; + BigInteger whole = quotient.Numerator / quotient.Denominator; + return left - new Rational(whole) * right; + } + + public static Rational operator +(Rational value) => value; + public static Rational operator -(Rational value) => new(-value.Numerator, value.Denominator); + public static Rational operator ++(Rational value) => value + One; + public static Rational operator --(Rational value) => value - One; + public static bool operator <(Rational left, Rational right) => left.CompareTo(right) < 0; + public static bool operator >(Rational left, Rational right) => left.CompareTo(right) > 0; + public static bool operator <=(Rational left, Rational right) => left.CompareTo(right) <= 0; + public static bool operator >=(Rational left, Rational right) => left.CompareTo(right) >= 0; + + public int CompareTo(Rational other) => (Numerator * other.Denominator).CompareTo(other.Numerator * Denominator); + + public int CompareTo(object? obj) => obj switch + { + null => 1, + Rational other => CompareTo(other), + _ => throw new ArgumentException("Value must be a Rational.", nameof(obj)) + }; + + public static Rational Abs(Rational value) => new(BigInteger.Abs(value.Numerator), value.Denominator); + public static bool IsCanonical(Rational value) => true; + public static bool IsComplexNumber(Rational value) => false; + public static bool IsEvenInteger(Rational value) => IsInteger(value) && value.Numerator.IsEven; + public static bool IsFinite(Rational value) => true; + public static bool IsImaginaryNumber(Rational value) => false; + public static bool IsInfinity(Rational value) => false; + public static bool IsInteger(Rational value) => value.Denominator.IsOne; + public static bool IsNaN(Rational value) => false; + public static bool IsNegative(Rational value) => value.Numerator.Sign < 0; + public static bool IsNegativeInfinity(Rational value) => false; + public static bool IsNormal(Rational value) => !IsZero(value); + public static bool IsOddInteger(Rational value) => IsInteger(value) && !value.Numerator.IsEven; + public static bool IsPositive(Rational value) => value.Numerator.Sign >= 0; + public static bool IsPositiveInfinity(Rational value) => false; + public static bool IsRealNumber(Rational value) => true; + public static bool IsSubnormal(Rational value) => false; + public static bool IsZero(Rational value) => value.Numerator.IsZero; + + public static Rational MaxMagnitude(Rational x, Rational y) + { + int comparison = Abs(x).CompareTo(Abs(y)); + return comparison > 0 || (comparison == 0 && x >= y) ? x : y; + } + + public static Rational MinMagnitude(Rational x, Rational y) + { + int comparison = Abs(x).CompareTo(Abs(y)); + return comparison < 0 || (comparison == 0 && x <= y) ? x : 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/Numerics/RationalNumberCantHaveComplexComponentsException.cs b/src/DimensionalQuantities/Numerics/RationalNumberCantHaveComplexComponentsException.cs new file mode 100644 index 0000000..de683a4 --- /dev/null +++ b/src/DimensionalQuantities/Numerics/RationalNumberCantHaveComplexComponentsException.cs @@ -0,0 +1,8 @@ +namespace DimensionalQuantities.Numerics; + +internal sealed class RationalNumberCantHaveComplexComponentsException : Exception +{ + public RationalNumberCantHaveComplexComponentsException() : base("A rational number must have integer numerator and denominator components. A value with a nonzero imaginary part cannot be represented as a rational number.") + { + } +} From a9afd4f210bddb0c236767255b89c8ca0c6e79cd Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 17:21:17 +0100 Subject: [PATCH 02/25] feat: add rational dimension algebra and operators --- .../DimensionAnalysis/DimensionTests.cs | 202 ++++++++++++++++++ .../DimensionAnalysis/Dimension.cs | 54 +++++ 2 files changed, 256 insertions(+) create mode 100644 src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs create mode 100644 src/DimensionalQuantities/DimensionAnalysis/Dimension.cs diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs new file mode 100644 index 0000000..14ddc01 --- /dev/null +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs @@ -0,0 +1,202 @@ +using DimensionalQuantities.Numerics; +using DimensionalQuantities.DimensionAnalysis; +using Xunit; + +namespace DimensionalQuantities.Tests.DimensionAnalysis; + +public sealed class DimensionTests +{ + [Fact] + public void MultiplicationOperatorCombinesDimensions() + { + Dimension original = new(ElectricCurrent: new(1, 2)); + Dimension operand = new(ElectricCurrent: new(3, 2), Time: -1); + Dimension result = original * operand; + Dimension expected = new(ElectricCurrent: 2, Time: -1); + + Assert.Equal(expected, result); + } + + [Fact] + public void DivisionOperatorSubtractsDivisorDimensions() + { + Dimension original = new(Length: 1, Time: new(1, 2)); + Dimension operand = new(Time: new(3, 2)); + Dimension result = original / operand; + Dimension expected = new(Length: 1, Time: -1); + + Assert.Equal(expected, result); + } + + [Fact] + public void MultiplicationAddsEveryExponent() + { + Dimension original = new(1, -2, 3, -4, 5, -6, 7); + Dimension operand = new(-7, 6, -5, 4, -3, 2, -1); + Dimension result = original.Multiply(operand); + Dimension expected = new(-6, 4, -2, 0, 2, -4, 6); + + Assert.Equal(expected, result); + } + + [Fact] + public void DivisionSubtractsEveryExponent() + { + Dimension original = new(1, -2, 3, -4, 5, -6, 7); + Dimension operand = new(-7, 6, -5, 4, -3, 2, -1); + Dimension result = original.Divide(operand); + Dimension expected = new(8, -8, 8, -8, 8, -8, 8); + + Assert.Equal(expected, result); + } + + [Fact] + public void DimensionlessIsMultiplicativeIdentity() + { + Dimension original = new(Length: 2, Time: -3); + Dimension dimensionless = default; + Dimension multipliedResult = original.Multiply(dimensionless); + Dimension reverseMultipliedResult = dimensionless.Multiply(original); + Dimension expected = original; + + Assert.Multiple( + () => Assert.Equal(expected, multipliedResult), + () => Assert.Equal(expected, reverseMultipliedResult)); + } + + [Fact] + public void DivisionByDimensionlessPreservesDimension() + { + Dimension original = new(Length: 2, Time: -3); + Dimension dimensionless = default; + Dimension result = original.Divide(dimensionless); + Dimension expected = original; + + Assert.Equal(expected, result); + } + + [Fact] + public void SelfDivisionCancelsEveryExponent() + { + Dimension original = new(Length: 2, Time: -3); + Dimension result = original.Divide(original); + Dimension expected = default; + + Assert.Equal(expected, result); + } + + [Fact] + public void GroupingDoesNotChangeElectricalLossDimension() + { + Dimension current = new(ElectricCurrent: 1); + Dimension resistance = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2); + Dimension result = current.Multiply(current).Multiply(resistance); + Dimension regroupedResult = current.Multiply(current.Multiply(resistance)); + Dimension expected = new(Length: 2, Mass: 1, Time: -3); + + Assert.Multiple( + () => Assert.Equal(expected, result), + () => Assert.Equal(expected, regroupedResult)); + } + + [Theory] + [InlineData(-2)] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + [InlineData(3)] + public void IntegerPowersScaleAllExponents(int power) + { + Dimension original = new(1, -2, 3, -4, 5, -6, 7); + Dimension result = original.Pow(power); + Dimension expected = new(power, -2 * power, 3 * power, -4 * power, 5 * power, -6 * power, 7 * power); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void ExactRootsDivideAllExponents(int degree) + { + Dimension original = new(6, -12, 18, -24, 30, -36, 42); + Dimension result = original.Root(degree); + Dimension expected = new(6 / degree, -12 / degree, 18 / degree, -24 / degree, 30 / degree, -36 / degree, 42 / degree); + + Assert.Equal(expected, result); + } + + [Fact] + public void RootsProduceFractionalExponents() + { + Dimension original = new(1, -2, 3, -4, 5, -6, 7); + Dimension result = original.Root(2); + Dimension expected = new(new(1, 2), -1, new(3, 2), -2, new(5, 2), -3, new(7, 2)); + + Assert.Equal(expected, result); + } + + [Fact] + public void FractionalPowerScalesDimension() + { + Dimension original = new(Length: 3); + Dimension result = original.Pow(new(2, 3)); + Dimension expected = new(Length: 2); + + Assert.Equal(expected, result); + } + + [Fact] + public void MultiplicationCombinesFractionalExponentsExactly() + { + Dimension original = new(ElectricCurrent: new(1, 3)); + Dimension operand = new(ElectricCurrent: new(2, 3)); + Dimension result = original.Multiply(operand); + Dimension expected = new(ElectricCurrent: 1); + + Assert.Equal(expected, result); + } + + [Fact] + public void DivisionSubtractsFractionalExponentsExactly() + { + Dimension original = new(ElectricCurrent: 2); + Dimension operand = new(ElectricCurrent: new(3, 2)); + Dimension result = original.Divide(operand); + Dimension expected = new(ElectricCurrent: new(1, 2)); + + Assert.Equal(expected, result); + } + + [Fact] + public void NoiseDensityTimesRootBandwidthProducesCurrent() + { + Dimension original = new(ElectricCurrent: 1, Time: new(1, 2)); + Dimension bandwidth = new(Time: -1); + Dimension result = original.Multiply(bandwidth.Root(2)); + Dimension expected = new(ElectricCurrent: 1); + + Assert.Equal(expected, result); + } + + [Fact] + public void DimensionlessHasAnExactRoot() + { + Dimension original = default; + Dimension result = original.Root(2); + Dimension expected = default; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void RootRequiresPositiveDegree(int degree) + { + Dimension original = default; + + Assert.Throws(() => original.Root(degree)); + } +} diff --git a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs new file mode 100644 index 0000000..d1e7154 --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs @@ -0,0 +1,54 @@ +using DimensionalQuantities.Numerics; + +namespace DimensionalQuantities.DimensionAnalysis; + +/// An internal value describing the seven rational SI exponents. +internal readonly record struct Dimension( + Rational Length = default, + Rational Mass = default, + Rational Time = default, + Rational ElectricCurrent = default, + Rational Temperature = default, + Rational AmountOfSubstance = default, + Rational LuminousIntensity = default) +{ + public static Dimension operator *(Dimension left, Dimension right) => left.Multiply(right); + public static Dimension operator /(Dimension left, Dimension right) => left.Divide(right); + + /// Adds corresponding exponents. + public Dimension Multiply(Dimension other) => new( + Length + other.Length, + Mass + other.Mass, + Time + other.Time, + ElectricCurrent + other.ElectricCurrent, + Temperature + other.Temperature, + AmountOfSubstance + other.AmountOfSubstance, + LuminousIntensity + other.LuminousIntensity); + + /// Subtracts corresponding exponents. + public Dimension Divide(Dimension other) => new( + Length - other.Length, + Mass - other.Mass, + Time - other.Time, + ElectricCurrent - other.ElectricCurrent, + Temperature - other.Temperature, + AmountOfSubstance - other.AmountOfSubstance, + LuminousIntensity - other.LuminousIntensity); + + /// Scales exponents by an exact rational power. + public Dimension Pow(Rational power) => new(Length * power, Mass * power, Time * power, ElectricCurrent * power, Temperature * power, AmountOfSubstance * power, LuminousIntensity * power); + + /// + /// Divides exponents by a positive root degree. + /// Nonpositive degrees throw ArgumentOutOfRangeException. + /// + public Dimension Root(int degree) + { + if (degree <= 0) + { + throw new ArgumentOutOfRangeException(nameof(degree)); + } + + return Pow(new(1, degree)); + } +} From ead045e9efe3a94b411f33f737de0aabafd8d951 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 17:21:57 +0100 Subject: [PATCH 03/25] chore: adopt C# editor configuration --- .editorconfig | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a966b23 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,104 @@ +root = true + +[*.cs] +indent_style = tab +indent_size = tab +tab_width = 4 +csharp_new_line_before_open_brace = all +csharp_style_namespace_declarations = file_scoped + +# State accessibility explicitly on types and non-interface members. +dotnet_style_require_accessibility_modifiers = for_non_interface_members +dotnet_diagnostic.IDE0040.severity = warning + +# Braces are required even around a single statement. +csharp_prefer_braces = true +dotnet_diagnostic.IDE0011.severity = warning + +# Long lines are acceptable; this is an editor guide, not a compiler limit. +max_line_length = 240 + +# Declare the type explicitly, including when the initializer makes it obvious. +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false +csharp_style_var_elsewhere = false +dotnet_diagnostic.IDE0008.severity = warning + +# Use target-typed new() when the destination type is apparent. +csharp_style_implicit_object_creation_when_type_is_apparent = true +dotnet_diagnostic.IDE0090.severity = warning + +# Do not suggest primary constructors for classes or structs. This does not +# diagnose existing primary constructors; the record-only rule needs review +# guidance or a separate analyzer. +csharp_style_prefer_primary_constructors = false +dotnet_diagnostic.IDE0290.severity = none + +# Requires StyleCop.Analyzers in the consuming project. Its default setting +# only covers some type kinds; strict one-type-per-file also needs stylecop.json. +dotnet_diagnostic.SA1402.severity = warning + +# Standard C# identifier casing. +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = * +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate +dotnet_naming_symbols.types.applicable_accessibilities = * +dotnet_naming_symbols.members.applicable_kinds = property, method, event, local_function +dotnet_naming_symbols.members.applicable_accessibilities = * +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.applicable_accessibilities = * +dotnet_naming_symbols.constants.required_modifiers = const +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = local, parameter +dotnet_naming_symbols.locals_and_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * + +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_naming_style.interface_pascal_case.capitalization = pascal_case +dotnet_naming_style.interface_pascal_case.required_prefix = I +dotnet_naming_style.camel_case.capitalization = camel_case +dotnet_naming_style.private_field_camel_case.capitalization = camel_case +dotnet_naming_style.private_field_camel_case.required_prefix = _ +dotnet_naming_style.type_parameter_pascal_case.capitalization = pascal_case +dotnet_naming_style.type_parameter_pascal_case.required_prefix = T + +dotnet_naming_rule.interfaces.symbols = interfaces +dotnet_naming_rule.interfaces.style = interface_pascal_case +dotnet_naming_rule.interfaces.severity = warning +dotnet_naming_rule.types.symbols = types +dotnet_naming_rule.types.style = pascal_case +dotnet_naming_rule.types.severity = warning +dotnet_naming_rule.members.symbols = members +dotnet_naming_rule.members.style = pascal_case +dotnet_naming_rule.members.severity = warning +dotnet_naming_rule.private_fields.symbols = private_fields +dotnet_naming_rule.private_fields.style = private_field_camel_case +dotnet_naming_rule.private_fields.severity = warning +dotnet_naming_rule.constants.symbols = constants +dotnet_naming_rule.constants.style = pascal_case +dotnet_naming_rule.constants.severity = warning +dotnet_naming_rule.locals_and_parameters.symbols = locals_and_parameters +dotnet_naming_rule.locals_and_parameters.style = camel_case +dotnet_naming_rule.locals_and_parameters.severity = warning +dotnet_naming_rule.type_parameters.symbols = type_parameters +dotnet_naming_rule.type_parameters.style = type_parameter_pascal_case +dotnet_naming_rule.type_parameters.severity = warning +dotnet_diagnostic.IDE1006.severity = warning + +# Keep analyzer warnings out of generated source. +[*.g.cs] +generated_code = true + +[*.g.i.cs] +generated_code = true + +[*.generated.cs] +generated_code = true + +[*.designer.cs] +generated_code = true + +[*.Designer.cs] +generated_code = true From ac735a0b6ce215908f7d1bd2fda4d3bb313f4565 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 17:22:10 +0100 Subject: [PATCH 04/25] chore: enforce LF line endings for text files --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf From 421d0cdbac7e50ceeba4330452752471a0d9a714 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 18:18:13 +0100 Subject: [PATCH 05/25] feat: add quantity type system and exact number codec --- .../QuantityTests.cs | 37 +++++ .../TypeSystem/TypeSystemNumberCodecTests.cs | 121 ++++++++++++++++ .../TypeSystem/TypeSystemNumbersTests.cs | 67 +++++++++ .../DimensionalQuantities.csproj | 3 + src/DimensionalQuantities/Quantity.cs | 15 ++ .../TypeSystem/Dimension.cs | 7 + .../TypeSystem/TypeSystemNumberCodec.cs | 83 +++++++++++ .../TypeSystem/TypeSystemNumbers.cs | 132 ++++++++++++++++++ 8 files changed, 465 insertions(+) create mode 100644 src/DimensionalQuantities.Tests/QuantityTests.cs create mode 100644 src/DimensionalQuantities.Tests/TypeSystem/TypeSystemNumberCodecTests.cs create mode 100644 src/DimensionalQuantities.Tests/TypeSystem/TypeSystemNumbersTests.cs create mode 100644 src/DimensionalQuantities/Quantity.cs create mode 100644 src/DimensionalQuantities/TypeSystem/Dimension.cs create mode 100644 src/DimensionalQuantities/TypeSystem/TypeSystemNumberCodec.cs create mode 100644 src/DimensionalQuantities/TypeSystem/TypeSystemNumbers.cs diff --git a/src/DimensionalQuantities.Tests/QuantityTests.cs b/src/DimensionalQuantities.Tests/QuantityTests.cs new file mode 100644 index 0000000..a7177bb --- /dev/null +++ b/src/DimensionalQuantities.Tests/QuantityTests.cs @@ -0,0 +1,37 @@ +using Xunit; +using Current = DimensionalQuantities.Quantity>; + +namespace DimensionalQuantities.Tests; + +public sealed class QuantityTests +{ + [Theory] + [InlineData(0)] + [InlineData(-0.125)] + [InlineData(1234.5)] + public void ConstructionPreservesSiMagnitude(double expected) + { + Current original = new(expected); + double result = original.Si; + + Assert.Equal(expected, result); + } + + [Fact] + public void RawSiMagnitudeIsNotPartOfPublicApi() + { + Type quantityType = typeof(Current); + + Assert.Multiple( + () => Assert.Empty(quantityType.GetProperties()), + () => Assert.Empty(quantityType.GetFields()), + () => Assert.Null(quantityType.GetConstructor([typeof(double)]))); + } +} diff --git a/src/DimensionalQuantities.Tests/TypeSystem/TypeSystemNumberCodecTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/TypeSystemNumberCodecTests.cs new file mode 100644 index 0000000..92fe01e --- /dev/null +++ b/src/DimensionalQuantities.Tests/TypeSystem/TypeSystemNumberCodecTests.cs @@ -0,0 +1,121 @@ +using System.Numerics; +using DimensionalQuantities.Numerics; +using DimensionalQuantities.TypeSystem; +using Xunit; + +namespace DimensionalQuantities.Tests.TypeSystem; + +public sealed class TypeSystemNumberCodecTests +{ + [Theory] + [InlineData(typeof(TerminalHex0), 0)] + [InlineData(typeof(TerminalHex1), 1)] + [InlineData(typeof(TerminalHex2), 2)] + [InlineData(typeof(TerminalHex3), 3)] + [InlineData(typeof(TerminalHex4), 4)] + [InlineData(typeof(TerminalHex5), 5)] + [InlineData(typeof(TerminalHex6), 6)] + [InlineData(typeof(TerminalHex7), 7)] + [InlineData(typeof(TerminalHex8), 8)] + [InlineData(typeof(TerminalHex9), 9)] + [InlineData(typeof(TerminalHexA), 10)] + [InlineData(typeof(TerminalHexB), 11)] + [InlineData(typeof(TerminalHexC), 12)] + [InlineData(typeof(TerminalHexD), 13)] + [InlineData(typeof(TerminalHexE), 14)] + [InlineData(typeof(TerminalHexF), 15)] + public void ParseDecodesEachTerminalDigit(Type type, int expected) + { + Rational expectedValue = new(expected); + Rational result = TypeSystemNumberCodec.Parse(type); + + Assert.Equal(expectedValue, result); + } + + public static TheoryData NumberCases => new() + { + { new Hex>(), 291, 1 }, + { new Negative>(), -43, 1 }, + { new Fraction(), 1, 2 }, + { new Fraction, TerminalHex2>(), -3, 2 }, + { new Fraction(), 0, 1 } + }; + + [Theory] + [MemberData(nameof(NumberCases))] + public void ParseGenericDecodesNumber(T _, int numerator, int denominator) where T : ITypeSystemNumber + { + Rational result = TypeSystemNumberCodec.Parse(); + Rational expected = new(numerator, denominator); + + Assert.Equal(expected, result); + } + + [Fact] + public void ParseTypeDecodesIntegerBeyondLongRange() + { + Type encoding = typeof(TerminalHex1); + for (int index = 0; index < 16; index++) + { + encoding = typeof(Hex<,>).MakeGenericType(typeof(TerminalHex0), encoding); + } + + Rational expected = new(BigInteger.One << 64); + Rational result = TypeSystemNumberCodec.Parse(encoding); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(0, 1, typeof(TerminalHex0))] + [InlineData(1, 1, typeof(TerminalHex1))] + [InlineData(15, 1, typeof(TerminalHexF))] + [InlineData(16, 1, typeof(Hex))] + [InlineData(291, 1, typeof(Hex>))] + [InlineData(-43, 1, typeof(Negative>))] + [InlineData(-3, 2, typeof(Fraction, TerminalHex2>))] + [InlineData(2, 4, typeof(Fraction))] + [InlineData(0, 2, typeof(TerminalHex0))] + public void CreateTypeUsesCanonicalEncoding(int numerator, int denominator, Type expected) + { + Rational value = new(numerator, denominator); + Type result = TypeSystemNumberCodec.CreateType(value); + + Assert.Equal(expected, result); + } + + [Fact] + public void CreateTypePreservesLargeExactFraction() + { + BigInteger numerator = (BigInteger.One << 80) + 3; + BigInteger denominator = (BigInteger.One << 65) + 2; + Rational expected = new(numerator, denominator); + + Type encoding = TypeSystemNumberCodec.CreateType(expected); + Rational result = TypeSystemNumberCodec.Parse(encoding); + + Assert.Multiple( + () => Assert.Equal(typeof(Fraction<,>), encoding.GetGenericTypeDefinition()), + () => Assert.Equal(expected, result)); + } + + [Theory] + [InlineData(typeof(int))] + [InlineData(typeof(CustomNumber))] + [InlineData(typeof(Hex))] + [InlineData(typeof(Hex<,>))] + public void ParseTypeRejectsUnsupportedTypesAndMarkerImplementations(Type type) + { + Assert.Throws(() => TypeSystemNumberCodec.Parse(type)); + } + + [Fact] + public void ParseGenericRejectsUnsupportedMarkerImplementation() + { + Assert.Throws(() => TypeSystemNumberCodec.Parse()); + } + + private readonly struct CustomNumber : ITypeSystemNumber; + + private readonly struct CustomDigit : IHexDigit; +} diff --git a/src/DimensionalQuantities.Tests/TypeSystem/TypeSystemNumbersTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/TypeSystemNumbersTests.cs new file mode 100644 index 0000000..b1b9749 --- /dev/null +++ b/src/DimensionalQuantities.Tests/TypeSystem/TypeSystemNumbersTests.cs @@ -0,0 +1,67 @@ +using DimensionalQuantities.TypeSystem; +using Xunit; + +namespace DimensionalQuantities.Tests.TypeSystem; + +public sealed class TypeSystemNumbersTests +{ + [Theory] + [InlineData(typeof(TerminalHex0), typeof(TerminalHex1))] + [InlineData(typeof(TerminalHexF), typeof(Hex))] + public void HexAcceptsDigitsAndPositiveRemainders(Type digit, Type remainder) + { + Type result = typeof(Hex<,>).MakeGenericType(digit, remainder); + + Assert.True(typeof(IPositiveTypeSystemInteger).IsAssignableFrom(result)); + } + + [Theory] + [InlineData(typeof(int), typeof(TerminalHex1))] + [InlineData(typeof(Hex), typeof(TerminalHex1))] + [InlineData(typeof(Fraction), typeof(TerminalHex1))] + [InlineData(typeof(TerminalHexC), typeof(TerminalHex0))] + [InlineData(typeof(TerminalHexC), typeof(Negative))] + public void HexRejectsNonDigitsAndNonPositiveRemainders(Type digit, Type remainder) + { + Assert.Throws(() => typeof(Hex<,>).MakeGenericType(digit, remainder)); + } + + [Theory] + [InlineData(typeof(TerminalHexC))] + [InlineData(typeof(Hex))] + public void NegativeAcceptsPositiveIntegerMagnitudes(Type magnitude) + { + Type result = typeof(Negative<>).MakeGenericType(magnitude); + + Assert.True(typeof(ITypeSystemInteger).IsAssignableFrom(result)); + } + + [Theory] + [InlineData(typeof(TerminalHex0))] + [InlineData(typeof(Negative))] + [InlineData(typeof(Fraction))] + public void NegativeRejectsZeroNegativeAndFractionalMagnitudes(Type magnitude) + { + Assert.Throws(() => typeof(Negative<>).MakeGenericType(magnitude)); + } + + [Theory] + [InlineData(typeof(TerminalHex1), typeof(TerminalHex2))] + [InlineData(typeof(Negative), typeof(Hex))] + public void FractionAcceptsSignedIntegerNumeratorsAndPositiveDenominators(Type numerator, Type denominator) + { + Type result = typeof(Fraction<,>).MakeGenericType(numerator, denominator); + + Assert.True(typeof(ITypeSystemNumber).IsAssignableFrom(result)); + } + + [Theory] + [InlineData(typeof(Fraction), typeof(TerminalHex2))] + [InlineData(typeof(TerminalHex1), typeof(TerminalHex0))] + [InlineData(typeof(TerminalHex1), typeof(Negative))] + [InlineData(typeof(TerminalHex1), typeof(Fraction))] + public void FractionRejectsFractionalComponentsAndNonPositiveDenominators(Type numerator, Type denominator) + { + Assert.Throws(() => typeof(Fraction<,>).MakeGenericType(numerator, denominator)); + } +} diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index 5458cc3..138a4c7 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -10,6 +10,9 @@ + + + diff --git a/src/DimensionalQuantities/Quantity.cs b/src/DimensionalQuantities/Quantity.cs new file mode 100644 index 0000000..8914a5c --- /dev/null +++ b/src/DimensionalQuantities/Quantity.cs @@ -0,0 +1,15 @@ +using JetBrains.Annotations; + +namespace DimensionalQuantities; + +/// A quantity whose dimension is encoded in its type and whose magnitude is stored in SI units. +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public readonly record struct Quantity +{ + internal Quantity(double si) + { + Si = si; + } + + internal double Si { get; } +} diff --git a/src/DimensionalQuantities/TypeSystem/Dimension.cs b/src/DimensionalQuantities/TypeSystem/Dimension.cs new file mode 100644 index 0000000..6c4e535 --- /dev/null +++ b/src/DimensionalQuantities/TypeSystem/Dimension.cs @@ -0,0 +1,7 @@ +using JetBrains.Annotations; + +namespace DimensionalQuantities.TypeSystem; + +/// Encodes the seven SI exponents using shared canonical exponent types. +[UsedImplicitly] +public readonly struct Dimension; diff --git a/src/DimensionalQuantities/TypeSystem/TypeSystemNumberCodec.cs b/src/DimensionalQuantities/TypeSystem/TypeSystemNumberCodec.cs new file mode 100644 index 0000000..2993188 --- /dev/null +++ b/src/DimensionalQuantities/TypeSystem/TypeSystemNumberCodec.cs @@ -0,0 +1,83 @@ +using System.Numerics; +using DimensionalQuantities.Numerics; + +namespace DimensionalQuantities.TypeSystem; + +/// Converts between exact rational values and type-encoded numbers for diagnostics and tests. +internal static class TypeSystemNumberCodec +{ + private static readonly Type[] _terminalDigits = + [ + typeof(TerminalHex0), typeof(TerminalHex1), typeof(TerminalHex2), typeof(TerminalHex3), + typeof(TerminalHex4), typeof(TerminalHex5), typeof(TerminalHex6), typeof(TerminalHex7), + typeof(TerminalHex8), typeof(TerminalHex9), typeof(TerminalHexA), typeof(TerminalHexB), + typeof(TerminalHexC), typeof(TerminalHexD), typeof(TerminalHexE), typeof(TerminalHexF) + ]; + + public static Rational Parse() where T : ITypeSystemNumber => Parse(typeof(T)); + + public static Type CreateType(Rational value) + { + if (value.Denominator.IsOne) + { + return CreateIntegerType(value.Numerator); + } + + return typeof(Fraction<,>).MakeGenericType(CreateIntegerType(value.Numerator), CreateMagnitudeType(value.Denominator)); + } + + public static Rational Parse(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + int digit = Array.IndexOf(_terminalDigits, type); + if (digit >= 0) + { + return new(digit); + } + + if (type.IsConstructedGenericType) + { + Type definition = type.GetGenericTypeDefinition(); + Type[] arguments = type.GetGenericArguments(); + + if (definition == typeof(Hex<,>)) + { + return 16 * Parse(arguments[1]) + Parse(arguments[0]); + } + + if (definition == typeof(Negative<>)) + { + return -Parse(arguments[0]); + } + + if (definition == typeof(Fraction<,>)) + { + return Parse(arguments[0]) / Parse(arguments[1]); + } + } + + throw new ArgumentException($"Type '{type}' is not a supported type-system number encoding.", nameof(type)); + } + + private static Type CreateIntegerType(BigInteger value) + { + if (value.Sign < 0) + { + return typeof(Negative<>).MakeGenericType(CreateMagnitudeType(-value)); + } + + return CreateMagnitudeType(value); + } + + private static Type CreateMagnitudeType(BigInteger value) + { + if (value < _terminalDigits.Length) + { + return _terminalDigits[(int)value]; + } + + BigInteger rest = BigInteger.DivRem(value, 16, out BigInteger digit); + return typeof(Hex<,>).MakeGenericType(_terminalDigits[(int)digit], CreateMagnitudeType(rest)); + } +} diff --git a/src/DimensionalQuantities/TypeSystem/TypeSystemNumbers.cs b/src/DimensionalQuantities/TypeSystem/TypeSystemNumbers.cs new file mode 100644 index 0000000..1053df2 --- /dev/null +++ b/src/DimensionalQuantities/TypeSystem/TypeSystemNumbers.cs @@ -0,0 +1,132 @@ +using JetBrains.Annotations; + +namespace DimensionalQuantities.TypeSystem; + +/* + * This file deliberately violates our standard for single files per type because it's just a bunch of empty type definitions. + * + * The point of these types is to allow us to use generic type signatures to let us represent numbers. + * They are markers, not calculators: the generator will do the maths and choose the types representing the result. + * + * TerminalHex0 through TerminalHexF represent the hexadecimal digits 0 through 15. + * Hex means (16 * TRest) + TDigit, so the outermost digit is the LEAST significant: + * + * TerminalHex0 = 0 + * TerminalHexC = 12 (0xC) + * Hex = 16 (0x10) + * Hex = 43 (0x2B): 16 * 2 + 11 + * Hex> = 291 (0x123): 16 * (16 * 1 + 2) + 3 + * Negative = -12 + * Fraction = 1/2 + * Fraction, TerminalHex2> = -3/2 + * + * Base 16 keeps nesting small: each Hex adds a digit rather than a single increment to the number. + * The constraints reject invalid shapes such as leading zeros, Negative, and zero denominators. + * The generator must also emit one representation per value: reduce 2/4 to 1/2, encode 2/1 as TerminalHex2, + * and encode 0/2 as TerminalHex0. C# considers alternative spellings to be different types even if they mean + * the same number, which would otherwise make equivalent dimensions incompatible across assemblies. + */ + + +/// A number encoded in a type signature. +[UsedImplicitly] +public interface ITypeSystemNumber; + +/// An integer encoded in a type signature. +[UsedImplicitly] +public interface ITypeSystemInteger : ITypeSystemNumber; + +/// A strictly positive integer encoded in a type signature. +[UsedImplicitly] +public interface IPositiveTypeSystemInteger : ITypeSystemInteger; + +/// A single hexadecimal digit. +[UsedImplicitly] +public interface IHexDigit : ITypeSystemInteger; + +/// A terminal hexadecimal digit with value 0. +[UsedImplicitly] +public readonly struct TerminalHex0 : IHexDigit; + +/// A terminal hexadecimal digit with value 1. +[UsedImplicitly] +public readonly struct TerminalHex1 : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 2. +[UsedImplicitly] +public readonly struct TerminalHex2 : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 3. +[UsedImplicitly] +public readonly struct TerminalHex3 : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 4. +[UsedImplicitly] +public readonly struct TerminalHex4 : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 5. +[UsedImplicitly] +public readonly struct TerminalHex5 : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 6. +[UsedImplicitly] +public readonly struct TerminalHex6 : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 7. +[UsedImplicitly] +public readonly struct TerminalHex7 : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 8. +[UsedImplicitly] +public readonly struct TerminalHex8 : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 9. +[UsedImplicitly] +public readonly struct TerminalHex9 : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 10. +[UsedImplicitly] +public readonly struct TerminalHexA : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 11. +[UsedImplicitly] +public readonly struct TerminalHexB : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 12. +[UsedImplicitly] +public readonly struct TerminalHexC : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 13. +[UsedImplicitly] +public readonly struct TerminalHexD : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 14. +[UsedImplicitly] +public readonly struct TerminalHexE : IHexDigit, IPositiveTypeSystemInteger; + +/// A terminal hexadecimal digit with value 15. +[UsedImplicitly] +public readonly struct TerminalHexF : IHexDigit, IPositiveTypeSystemInteger; + +/// +/// Encodes 16 times the remaining magnitude plus the least significant digit. +/// Canonical encodings use a TerminalHex digit for TDigit and have no leading zeros. +/// +[UsedImplicitly] +public readonly struct Hex : IPositiveTypeSystemInteger + where TDigit : struct, IHexDigit + where TRest : struct, IPositiveTypeSystemInteger; + +/// Encodes the negation of a nonzero unsigned magnitude. +[UsedImplicitly] +public readonly struct Negative : ITypeSystemInteger + where TMagnitude : struct, IPositiveTypeSystemInteger; + +/// +/// Encodes a reduced rational exponent with a signed numerator and a positive denominator. +/// Canonical encodings represent integers directly, without a Fraction wrapper. +/// +[UsedImplicitly] +public readonly struct Fraction : ITypeSystemNumber + where TNumerator : struct, ITypeSystemInteger + where TDenominator : struct, IPositiveTypeSystemInteger; From f3239241ed8e4ed369412fbe2825e66d91c2305f Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 18:18:22 +0100 Subject: [PATCH 06/25] chore: ignore local handoff files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e50800c..0c24bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ TestResults/ *.snupkg artifacts/ node_modules/ +HANDOFF*.md From fdf7bffffbf06e0acdc8ee10640cc6d01bd26c91 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 18:20:23 +0100 Subject: [PATCH 07/25] refactor: rename quantity SI value property --- src/DimensionalQuantities.Tests/QuantityTests.cs | 2 +- src/DimensionalQuantities/Quantity.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/DimensionalQuantities.Tests/QuantityTests.cs b/src/DimensionalQuantities.Tests/QuantityTests.cs index a7177bb..291c193 100644 --- a/src/DimensionalQuantities.Tests/QuantityTests.cs +++ b/src/DimensionalQuantities.Tests/QuantityTests.cs @@ -19,7 +19,7 @@ public sealed class QuantityTests public void ConstructionPreservesSiMagnitude(double expected) { Current original = new(expected); - double result = original.Si; + double result = original.ValueInSiBaseUnit; Assert.Equal(expected, result); } diff --git a/src/DimensionalQuantities/Quantity.cs b/src/DimensionalQuantities/Quantity.cs index 8914a5c..1801aad 100644 --- a/src/DimensionalQuantities/Quantity.cs +++ b/src/DimensionalQuantities/Quantity.cs @@ -6,10 +6,10 @@ namespace DimensionalQuantities; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public readonly record struct Quantity { - internal Quantity(double si) + internal Quantity(double valueInSiBaseUnit) { - Si = si; + ValueInSiBaseUnit = valueInSiBaseUnit; } - internal double Si { get; } + internal double ValueInSiBaseUnit { get; } } From aebb1315184a327479d3ab16992c922a400620ab Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 18:48:18 +0100 Subject: [PATCH 08/25] refactor: pair dimension exponents with component names --- .../DimensionAnalysis/DimensionTests.cs | 19 +++++++++++++++++++ .../DimensionAnalysis/Dimension.cs | 12 ++++++++++++ .../DimensionAnalysis/DimensionComponent.cs | 5 +++++ 3 files changed, 36 insertions(+) create mode 100644 src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs index 14ddc01..9d8b130 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs @@ -6,6 +6,25 @@ namespace DimensionalQuantities.Tests.DimensionAnalysis; public sealed class DimensionTests { + [Fact] + public void ComponentsPairEveryExponentWithItsNamesInSiOrder() + { + Dimension original = new(1, -2, new(3, 2), 4, 0, -6, 7); + IEnumerable result = original.GetComponents(); + DimensionComponent[] expected = + [ + new(1, "Length", "Metre", "Metres"), + new(-2, "Mass", "Kilogram", "Kilograms"), + new(new(3, 2), "Time", "Second", "Seconds"), + new(4, "Current", "Amp", "Amps"), + new(0, "Temperature", "Kelvin", "Kelvin"), + new(-6, "AmountOfSubstance", "Mole", "Moles"), + new(7, "LuminousIntensity", "Candela", "Candelas") + ]; + + Assert.Equal(expected, result); + } + [Fact] public void MultiplicationOperatorCombinesDimensions() { diff --git a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs index d1e7154..6f11da0 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs @@ -15,6 +15,18 @@ internal readonly record struct Dimension( public static Dimension operator *(Dimension left, Dimension right) => left.Multiply(right); public static Dimension operator /(Dimension left, Dimension right) => left.Divide(right); + /// Pairs every SI exponent with its quantity and unit names, in canonical SI axis order. + public IEnumerable GetComponents() + { + yield return new(Length, "Length", "Metre", "Metres"); + yield return new(Mass, "Mass", "Kilogram", "Kilograms"); + yield return new(Time, "Time", "Second", "Seconds"); + yield return new(ElectricCurrent, "Current", "Amp", "Amps"); + yield return new(Temperature, "Temperature", "Kelvin", "Kelvin"); + yield return new(AmountOfSubstance, "AmountOfSubstance", "Mole", "Moles"); + yield return new(LuminousIntensity, "LuminousIntensity", "Candela", "Candelas"); + } + /// Adds corresponding exponents. public Dimension Multiply(Dimension other) => new( Length + other.Length, diff --git a/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs b/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs new file mode 100644 index 0000000..60b649a --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs @@ -0,0 +1,5 @@ +using DimensionalQuantities.Numerics; + +namespace DimensionalQuantities.DimensionAnalysis; + +internal readonly record struct DimensionComponent(Rational Exponent, string QuantityName, string UnitNameSingular, string UnitNamePlural); From 8da6c024e32a204888279375c99fb4e732962e20 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 19:58:09 +0100 Subject: [PATCH 09/25] feat: generate dimension names and SI unit expressions --- .../DimensionNameGeneratorTests.cs | 231 ++++++++++++++++++ .../DimensionAnalysis/DimensionTests.cs | 14 +- .../DimensionAnalysis/Dimension.cs | 14 +- .../DimensionAnalysis/DimensionComponent.cs | 2 +- .../DimensionNameGenerator.cs | 101 ++++++++ 5 files changed, 347 insertions(+), 15 deletions(-) create mode 100644 src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionNameGeneratorTests.cs create mode 100644 src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionNameGeneratorTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionNameGeneratorTests.cs new file mode 100644 index 0000000..9d6c9da --- /dev/null +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionNameGeneratorTests.cs @@ -0,0 +1,231 @@ +using DimensionalQuantities.DimensionAnalysis; +using Xunit; + +namespace DimensionalQuantities.Tests.DimensionAnalysis; + +public sealed class DimensionNameGeneratorTests +{ + [Theory] + [InlineData(1, 0, 0, 0, 0, 0, 0, "Length")] + [InlineData(0, 1, 0, 0, 0, 0, 0, "Mass")] + [InlineData(0, 0, 1, 0, 0, 0, 0, "Time")] + [InlineData(0, 0, 0, 1, 0, 0, 0, "Current")] + [InlineData(0, 0, 0, 0, 1, 0, 0, "Temperature")] + [InlineData(0, 0, 0, 0, 0, 1, 0, "AmountOfSubstance")] + [InlineData(0, 0, 0, 0, 0, 0, 1, "LuminousIntensity")] + public void GetQuantityNameUsesBaseVocabulary(int length, int mass, int time, int electricCurrent, int temperature, int amountOfSubstance, int luminousIntensity, string expected) + { + Dimension original = new(length, mass, time, electricCurrent, temperature, amountOfSubstance, luminousIntensity); + string result = DimensionNameGenerator.GetQuantityName(original); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, 0, 0, 0, 0, 0, 0, "Metres")] + [InlineData(0, 1, 0, 0, 0, 0, 0, "Kilograms")] + [InlineData(0, 0, 1, 0, 0, 0, 0, "Seconds")] + [InlineData(0, 0, 0, 1, 0, 0, 0, "Amps")] + [InlineData(0, 0, 0, 0, 1, 0, 0, "Kelvin")] + [InlineData(0, 0, 0, 0, 0, 1, 0, "Moles")] + [InlineData(0, 0, 0, 0, 0, 0, 1, "Candelas")] + public void GetUnitNameUsesBaseVocabulary(int length, int mass, int time, int electricCurrent, int temperature, int amountOfSubstance, int luminousIntensity, string expected) + { + Dimension original = new(length, mass, time, electricCurrent, temperature, amountOfSubstance, luminousIntensity); + string result = DimensionNameGenerator.GetUnitName(original); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, "Current")] + [InlineData(2, "CurrentSquared")] + [InlineData(3, "CurrentCubed")] + [InlineData(4, "Current4")] + [InlineData(12, "Current12")] + [InlineData(-1, "InverseCurrent")] + [InlineData(-2, "InverseCurrentSquared")] + [InlineData(-3, "InverseCurrentCubed")] + [InlineData(-4, "InverseCurrent4")] + [InlineData(-12, "InverseCurrent12")] + public void GetQuantityNameFormatsIntegerPowers(int exponent, string expected) + { + Dimension original = new(ElectricCurrent: exponent); + string result = DimensionNameGenerator.GetQuantityName(original); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, "Amps")] + [InlineData(2, "AmpsSquared")] + [InlineData(3, "AmpsCubed")] + [InlineData(4, "Amps4")] + [InlineData(12, "Amps12")] + [InlineData(-1, "InverseAmps")] + [InlineData(-2, "InverseAmpsSquared")] + [InlineData(-3, "InverseAmpsCubed")] + [InlineData(-4, "InverseAmps4")] + [InlineData(-12, "InverseAmps12")] + public void GetUnitNameFormatsIntegerPowers(int exponent, string expected) + { + Dimension original = new(ElectricCurrent: exponent); + string result = DimensionNameGenerator.GetUnitName(original); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, 1, 1, 1, 1, 1, 1, "LengthMassTimeCurrentTemperatureAmountOfSubstanceLuminousIntensity")] + [InlineData(3, 0, -12, 0, 0, 0, 0, "LengthCubedPerTime12")] + [InlineData(1, 1, -2, 0, 0, 0, 0, "LengthMassPerTimeSquared")] + [InlineData(2, 1, -3, 0, 0, 0, 0, "LengthSquaredMassPerTimeCubed")] + [InlineData(0, 0, 0, 0, -1, 0, 1, "LuminousIntensityPerTemperature")] + [InlineData(-2, -1, 3, -4, 1, -2, 1, "TimeCubedTemperatureLuminousIntensityPerLengthSquaredMassCurrent4AmountOfSubstanceSquared")] + [InlineData(-1, -1, -1, -1, -1, -1, -1, "InverseLengthMassTimeCurrentTemperatureAmountOfSubstanceLuminousIntensity")] + public void GetQuantityNameComposesFactorsInSiOrder(int length, int mass, int time, int electricCurrent, int temperature, int amountOfSubstance, int luminousIntensity, string expected) + { + Dimension original = new(length, mass, time, electricCurrent, temperature, amountOfSubstance, luminousIntensity); + string result = DimensionNameGenerator.GetQuantityName(original); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, 1, 1, 1, 1, 1, 1, "MetresKilogramsSecondsAmpsKelvinMolesCandelas")] + [InlineData(3, 0, -12, 0, 0, 0, 0, "MetresCubedPerSeconds12")] + [InlineData(1, 1, -2, 0, 0, 0, 0, "MetresKilogramsPerSecondsSquared")] + [InlineData(2, 1, -3, 0, 0, 0, 0, "MetresSquaredKilogramsPerSecondsCubed")] + [InlineData(0, 0, 0, 0, -1, 0, 1, "CandelasPerKelvin")] + [InlineData(-2, -1, 3, -4, 1, -2, 1, "SecondsCubedKelvinCandelasPerMetresSquaredKilogramsAmps4MolesSquared")] + [InlineData(-1, -1, -1, -1, -1, -1, -1, "InverseMetresKilogramsSecondsAmpsKelvinMolesCandelas")] + public void GetUnitNameComposesFactorsInSiOrder(int length, int mass, int time, int electricCurrent, int temperature, int amountOfSubstance, int luminousIntensity, string expected) + { + Dimension original = new(length, mass, time, electricCurrent, temperature, amountOfSubstance, luminousIntensity); + string result = DimensionNameGenerator.GetUnitName(original); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, 2, "SquareRootCurrent")] + [InlineData(1, 3, "CubeRootCurrent")] + [InlineData(3, 2, "SquareRootCurrentCubed")] + [InlineData(2, 3, "CubeRootCurrentSquared")] + [InlineData(2, 4, "SquareRootCurrent")] + [InlineData(4, 2, "CurrentSquared")] + [InlineData(-3, 2, "InverseSquareRootCurrentCubed")] + [InlineData(1, -2, "InverseSquareRootCurrent")] + [InlineData(1, 12, "Root12Current")] + [InlineData(1, 4, "Root4Current")] + [InlineData(1, 5, "Root5Current")] + [InlineData(4, 5, "Root5Current4")] + [InlineData(1, 45, "Root45Current")] + public void GetQuantityNameFormatsReducedRationalPowers(int numerator, int denominator, string expected) + { + Dimension original = new(ElectricCurrent: new(numerator, denominator)); + string result = DimensionNameGenerator.GetQuantityName(original); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, 2, "SquareRootAmps")] + [InlineData(1, 3, "CubeRootAmps")] + [InlineData(3, 2, "SquareRootAmpsCubed")] + [InlineData(2, 3, "CubeRootAmpsSquared")] + [InlineData(2, 4, "SquareRootAmps")] + [InlineData(4, 2, "AmpsSquared")] + [InlineData(-3, 2, "InverseSquareRootAmpsCubed")] + [InlineData(1, -2, "InverseSquareRootAmps")] + [InlineData(1, 12, "Root12Amps")] + [InlineData(1, 4, "Root4Amps")] + [InlineData(1, 5, "Root5Amps")] + [InlineData(4, 5, "Root5Amps4")] + [InlineData(1, 45, "Root45Amps")] + public void GetUnitNameFormatsReducedRationalPowers(int numerator, int denominator, string expected) + { + Dimension original = new(ElectricCurrent: new(numerator, denominator)); + string result = DimensionNameGenerator.GetUnitName(original); + + Assert.Equal(expected, result); + } + + [Fact] + public void GetQuantityNameKeepsFractionalPowersAttachedToTheirFactors() + { + Dimension original = new(Length: new(1, 2), Time: new(-3, 2), ElectricCurrent: 1); + string result = DimensionNameGenerator.GetQuantityName(original); + string expected = "SquareRootLengthCurrentPerSquareRootTimeCubed"; + + Assert.Equal(expected, result); + } + + [Fact] + public void GetUnitNameKeepsFractionalPowersAttachedToTheirFactors() + { + Dimension original = new(Length: new(1, 2), Time: new(-3, 2), ElectricCurrent: 1); + string result = DimensionNameGenerator.GetUnitName(original); + string expected = "SquareRootMetresAmpsPerSquareRootSecondsCubed"; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, 0, 0, 0, 0, 0, 0, "m")] + [InlineData(0, 1, 0, 0, 0, 0, 0, "kg")] + [InlineData(0, 0, 1, 0, 0, 0, 0, "s")] + [InlineData(0, 0, 0, 1, 0, 0, 0, "A")] + [InlineData(0, 0, 0, 0, 1, 0, 0, "K")] + [InlineData(0, 0, 0, 0, 0, 1, 0, "mol")] + [InlineData(0, 0, 0, 0, 0, 0, 1, "cd")] + [InlineData(1, 1, 1, 1, 1, 1, 1, "mkgsAKmolcd")] + [InlineData(3, 0, -12, 0, 0, 0, 0, "m3s-12")] + [InlineData(0, 0, -1, 0, 0, 0, 0, "s-1")] + [InlineData(-2, 1, 3, 0, 0, 0, 0, "m-2kgs3")] + public void GetUnitExpressionUsesAdjacentSiSymbolsInAxisOrder(int length, int mass, int time, int electricCurrent, int temperature, int amountOfSubstance, int luminousIntensity, string expected) + { + Dimension original = new(length, mass, time, electricCurrent, temperature, amountOfSubstance, luminousIntensity); + string result = DimensionNameGenerator.GetUnitExpression(original); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, 2, "A1/2")] + [InlineData(3, 2, "A3/2")] + [InlineData(-3, 2, "A-3/2")] + [InlineData(1, -2, "A-1/2")] + [InlineData(2, 4, "A1/2")] + [InlineData(4, 2, "A2")] + [InlineData(2, 2, "A")] + [InlineData(4, 5, "A4/5")] + public void GetUnitExpressionFormatsReducedRationalPowers(int numerator, int denominator, string expected) + { + Dimension original = new(ElectricCurrent: new(numerator, denominator)); + string result = DimensionNameGenerator.GetUnitExpression(original); + + Assert.Equal(expected, result); + } + + [Fact] + public void GetUnitExpressionKeepsFractionalPowersAttachedToTheirSymbols() + { + Dimension original = new(Length: new(1, 2), Time: new(-3, 2), ElectricCurrent: 1); + string result = DimensionNameGenerator.GetUnitExpression(original); + string expected = "m1/2s-3/2A"; + + Assert.Equal(expected, result); + } + + [Fact] + public void NamingMethodsRejectDimensionlessQuantities() + { + Dimension original = default; + + Assert.Multiple( + () => Assert.Throws(() => DimensionNameGenerator.GetQuantityName(original)), + () => Assert.Throws(() => DimensionNameGenerator.GetUnitName(original)), + () => Assert.Throws(() => DimensionNameGenerator.GetUnitExpression(original))); + } +} diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs index 9d8b130..2aaec85 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs @@ -13,13 +13,13 @@ public void ComponentsPairEveryExponentWithItsNamesInSiOrder() IEnumerable result = original.GetComponents(); DimensionComponent[] expected = [ - new(1, "Length", "Metre", "Metres"), - new(-2, "Mass", "Kilogram", "Kilograms"), - new(new(3, 2), "Time", "Second", "Seconds"), - new(4, "Current", "Amp", "Amps"), - new(0, "Temperature", "Kelvin", "Kelvin"), - new(-6, "AmountOfSubstance", "Mole", "Moles"), - new(7, "LuminousIntensity", "Candela", "Candelas") + new(1, "Length", "Metre", "Metres", "m"), + new(-2, "Mass", "Kilogram", "Kilograms", "kg"), + new(new(3, 2), "Time", "Second", "Seconds", "s"), + new(4, "Current", "Amp", "Amps", "A"), + new(0, "Temperature", "Kelvin", "Kelvin", "K"), + new(-6, "AmountOfSubstance", "Mole", "Moles", "mol"), + new(7, "LuminousIntensity", "Candela", "Candelas", "cd") ]; Assert.Equal(expected, result); diff --git a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs index 6f11da0..212aa3d 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs @@ -18,13 +18,13 @@ internal readonly record struct Dimension( /// Pairs every SI exponent with its quantity and unit names, in canonical SI axis order. public IEnumerable GetComponents() { - yield return new(Length, "Length", "Metre", "Metres"); - yield return new(Mass, "Mass", "Kilogram", "Kilograms"); - yield return new(Time, "Time", "Second", "Seconds"); - yield return new(ElectricCurrent, "Current", "Amp", "Amps"); - yield return new(Temperature, "Temperature", "Kelvin", "Kelvin"); - yield return new(AmountOfSubstance, "AmountOfSubstance", "Mole", "Moles"); - yield return new(LuminousIntensity, "LuminousIntensity", "Candela", "Candelas"); + yield return new(Length, "Length", "Metre", "Metres", "m"); + yield return new(Mass, "Mass", "Kilogram", "Kilograms", "kg"); + yield return new(Time, "Time", "Second", "Seconds", "s"); + yield return new(ElectricCurrent, "Current", "Amp", "Amps", "A"); + yield return new(Temperature, "Temperature", "Kelvin", "Kelvin", "K"); + yield return new(AmountOfSubstance, "AmountOfSubstance", "Mole", "Moles", "mol"); + yield return new(LuminousIntensity, "LuminousIntensity", "Candela", "Candelas", "cd"); } /// Adds corresponding exponents. diff --git a/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs b/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs index 60b649a..ee878d5 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs @@ -2,4 +2,4 @@ namespace DimensionalQuantities.DimensionAnalysis; -internal readonly record struct DimensionComponent(Rational Exponent, string QuantityName, string UnitNameSingular, string UnitNamePlural); +internal readonly record struct DimensionComponent(Rational Exponent, string QuantityName, string UnitNameSingular, string UnitNamePlural, string UnitSymbol); diff --git a/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs b/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs new file mode 100644 index 0000000..3843c12 --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using System.Numerics; +using System.Text; +using DimensionalQuantities.Numerics; + +namespace DimensionalQuantities.DimensionAnalysis; + +internal static class DimensionNameGenerator +{ + public static string GetQuantityName(Dimension dimension) => GetName(dimension, static component => component.QuantityName); + + public static string GetUnitName(Dimension dimension) => GetName(dimension, static component => component.UnitNamePlural); + + public static string GetUnitExpression(Dimension dimension) + { + StringBuilder expression = new(); + foreach (DimensionComponent component in dimension.GetComponents()) + { + if (component.Exponent == Rational.Zero) + { + continue; + } + + expression.Append(component.UnitSymbol); + if (component.Exponent != Rational.One) + { + expression.Append("").Append(component.Exponent.ToString(null, CultureInfo.InvariantCulture)).Append(""); + } + } + + if (expression.Length == 0) + { + throw new NotSupportedException("Dimensionless names are not defined."); + } + + return expression.ToString(); + } + + private static string GetName(Dimension dimension, Func getFactorName) + { + StringBuilder numerator = new(); + StringBuilder denominator = new(); + + foreach (DimensionComponent component in dimension.GetComponents()) + { + Rational exponent = component.Exponent; + if (exponent.Numerator.Sign > 0) + { + AppendFactor(numerator, getFactorName(component), exponent); + } + else if (exponent.Numerator.Sign < 0) + { + AppendFactor(denominator, getFactorName(component), -exponent); + } + } + + if (numerator.Length == 0) + { + if (denominator.Length == 0) + { + throw new NotSupportedException("Dimensionless names are not defined."); + } + + return "Inverse" + denominator; + } + + return denominator.Length == 0 ? numerator.ToString() : numerator + "Per" + denominator; + } + + private static void AppendFactor(StringBuilder name, string factorName, Rational exponent) + { + BigInteger degree = exponent.Denominator; + if (degree == 2) + { + name.Append("SquareRoot"); + } + else if (degree == 3) + { + name.Append("CubeRoot"); + } + else if (degree > 3) + { + name.Append("Root").Append(degree.ToString(CultureInfo.InvariantCulture)); + } + + name.Append(factorName); + BigInteger power = exponent.Numerator; + if (power == 2) + { + name.Append("Squared"); + } + else if (power == 3) + { + name.Append("Cubed"); + } + else if (power > 3) + { + name.Append(power.ToString(CultureInfo.InvariantCulture)); + } + } +} From 56cd6e1776e48f1d3d6e88efc477f572e36604fc Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 20:02:40 +0100 Subject: [PATCH 10/25] refactor: omit zero dimension components --- .../DimensionAnalysis/DimensionTests.cs | 12 +++++- .../DimensionAnalysis/Dimension.cs | 43 +++++++++++++++---- .../DimensionNameGenerator.cs | 7 +-- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs index 2aaec85..310e9ce 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs @@ -7,7 +7,7 @@ namespace DimensionalQuantities.Tests.DimensionAnalysis; public sealed class DimensionTests { [Fact] - public void ComponentsPairEveryExponentWithItsNamesInSiOrder() + public void ComponentsPairNonzeroExponentsWithTheirNamesInSiOrder() { Dimension original = new(1, -2, new(3, 2), 4, 0, -6, 7); IEnumerable result = original.GetComponents(); @@ -17,7 +17,6 @@ public void ComponentsPairEveryExponentWithItsNamesInSiOrder() new(-2, "Mass", "Kilogram", "Kilograms", "kg"), new(new(3, 2), "Time", "Second", "Seconds", "s"), new(4, "Current", "Amp", "Amps", "A"), - new(0, "Temperature", "Kelvin", "Kelvin", "K"), new(-6, "AmountOfSubstance", "Mole", "Moles", "mol"), new(7, "LuminousIntensity", "Candela", "Candelas", "cd") ]; @@ -25,6 +24,15 @@ public void ComponentsPairEveryExponentWithItsNamesInSiOrder() Assert.Equal(expected, result); } + [Fact] + public void DimensionlessHasNoComponents() + { + Dimension original = default; + IEnumerable result = original.GetComponents(); + + Assert.Empty(result); + } + [Fact] public void MultiplicationOperatorCombinesDimensions() { diff --git a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs index 212aa3d..c489ac6 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs @@ -15,16 +15,43 @@ internal readonly record struct Dimension( public static Dimension operator *(Dimension left, Dimension right) => left.Multiply(right); public static Dimension operator /(Dimension left, Dimension right) => left.Divide(right); - /// Pairs every SI exponent with its quantity and unit names, in canonical SI axis order. + /// Pairs nonzero SI exponents with their quantity and unit names, in canonical SI axis order. public IEnumerable GetComponents() { - yield return new(Length, "Length", "Metre", "Metres", "m"); - yield return new(Mass, "Mass", "Kilogram", "Kilograms", "kg"); - yield return new(Time, "Time", "Second", "Seconds", "s"); - yield return new(ElectricCurrent, "Current", "Amp", "Amps", "A"); - yield return new(Temperature, "Temperature", "Kelvin", "Kelvin", "K"); - yield return new(AmountOfSubstance, "AmountOfSubstance", "Mole", "Moles", "mol"); - yield return new(LuminousIntensity, "LuminousIntensity", "Candela", "Candelas", "cd"); + if (Length != Rational.Zero) + { + yield return new(Length, "Length", "Metre", "Metres", "m"); + } + + if (Mass != Rational.Zero) + { + yield return new(Mass, "Mass", "Kilogram", "Kilograms", "kg"); + } + + if (Time != Rational.Zero) + { + yield return new(Time, "Time", "Second", "Seconds", "s"); + } + + if (ElectricCurrent != Rational.Zero) + { + yield return new(ElectricCurrent, "Current", "Amp", "Amps", "A"); + } + + if (Temperature != Rational.Zero) + { + yield return new(Temperature, "Temperature", "Kelvin", "Kelvin", "K"); + } + + if (AmountOfSubstance != Rational.Zero) + { + yield return new(AmountOfSubstance, "AmountOfSubstance", "Mole", "Moles", "mol"); + } + + if (LuminousIntensity != Rational.Zero) + { + yield return new(LuminousIntensity, "LuminousIntensity", "Candela", "Candelas", "cd"); + } } /// Adds corresponding exponents. diff --git a/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs b/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs index 3843c12..b5e44b3 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs @@ -16,11 +16,6 @@ public static string GetUnitExpression(Dimension dimension) StringBuilder expression = new(); foreach (DimensionComponent component in dimension.GetComponents()) { - if (component.Exponent == Rational.Zero) - { - continue; - } - expression.Append(component.UnitSymbol); if (component.Exponent != Rational.One) { @@ -48,7 +43,7 @@ private static string GetName(Dimension dimension, Func Date: Wed, 21 Oct 2026 20:03:40 +0100 Subject: [PATCH 11/25] refactor: move quantity into type system --- .../{ => TypeSystem}/QuantityTests.cs | 4 ++-- src/DimensionalQuantities/{ => TypeSystem}/Quantity.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/DimensionalQuantities.Tests/{ => TypeSystem}/QuantityTests.cs (86%) rename src/DimensionalQuantities/{ => TypeSystem}/Quantity.cs (89%) diff --git a/src/DimensionalQuantities.Tests/QuantityTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs similarity index 86% rename from src/DimensionalQuantities.Tests/QuantityTests.cs rename to src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs index 291c193..7a9b6c9 100644 --- a/src/DimensionalQuantities.Tests/QuantityTests.cs +++ b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs @@ -1,5 +1,5 @@ using Xunit; -using Current = DimensionalQuantities.Quantity>; -namespace DimensionalQuantities.Tests; +namespace DimensionalQuantities.Tests.TypeSystem; public sealed class QuantityTests { diff --git a/src/DimensionalQuantities/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs similarity index 89% rename from src/DimensionalQuantities/Quantity.cs rename to src/DimensionalQuantities/TypeSystem/Quantity.cs index 1801aad..dea8a1c 100644 --- a/src/DimensionalQuantities/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; -namespace DimensionalQuantities; +namespace DimensionalQuantities.TypeSystem; /// A quantity whose dimension is encoded in its type and whose magnitude is stored in SI units. [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] From 593ffb54df51c078bd675ef2cf7cd7f6d9166e85 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 20:04:08 +0100 Subject: [PATCH 12/25] chore: Deleted placeholders --- src/DimensionalQuantities.Roslyn/Analysers/.gitkeep | 0 src/DimensionalQuantities.Roslyn/DimensionAnalysis/.gitkeep | 0 src/DimensionalQuantities.Roslyn/Generators/.gitkeep | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/DimensionalQuantities.Roslyn/Analysers/.gitkeep delete mode 100644 src/DimensionalQuantities.Roslyn/DimensionAnalysis/.gitkeep delete mode 100644 src/DimensionalQuantities.Roslyn/Generators/.gitkeep diff --git a/src/DimensionalQuantities.Roslyn/Analysers/.gitkeep b/src/DimensionalQuantities.Roslyn/Analysers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/DimensionalQuantities.Roslyn/DimensionAnalysis/.gitkeep b/src/DimensionalQuantities.Roslyn/DimensionAnalysis/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/DimensionalQuantities.Roslyn/Generators/.gitkeep b/src/DimensionalQuantities.Roslyn/Generators/.gitkeep deleted file mode 100644 index e69de29..0000000 From 10aa7c07c812b60de7c7408dbfcf8453cfa76fa8 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 22:11:00 +0100 Subject: [PATCH 13/25] test: verify packaged generator loading on .NET 10 --- .../GeneratorLoadingTests.cs | 15 +++ .../MsbuildCollection.cs | 6 ++ .../PackagedGeneratorTests.cs | 92 +++++++++++++++++++ ...ensionalQuantities.IntegrationTests.csproj | 6 +- .../DimensionalQuantities.Roslyn.csproj | 2 +- .../Generators/SmokeTestGenerator.cs | 12 +++ .../DimensionalQuantities.csproj | 2 +- 7 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/GeneratorLoadingTests.cs create mode 100644 src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/MsbuildCollection.cs create mode 100644 src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/PackagedGeneratorTests.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/SmokeTestGenerator.cs diff --git a/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/GeneratorLoadingTests.cs b/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/GeneratorLoadingTests.cs new file mode 100644 index 0000000..629ddd8 --- /dev/null +++ b/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/GeneratorLoadingTests.cs @@ -0,0 +1,15 @@ +using DimensionalQuantities.Generated; +using Xunit; + +namespace DimensionalQuantities.IntegrationTests.AnalyserPackageSmokeTests; + +public sealed class GeneratorLoadingTests +{ + [Fact] + public void GeneratedTypeCanBeConstructed() + { + GeneratedSmokeTest result = new(); + + Assert.NotNull(result); + } +} diff --git a/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/MsbuildCollection.cs b/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/MsbuildCollection.cs new file mode 100644 index 0000000..28d4ed0 --- /dev/null +++ b/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/MsbuildCollection.cs @@ -0,0 +1,6 @@ +using Xunit; + +namespace DimensionalQuantities.IntegrationTests.AnalyserPackageSmokeTests; + +[CollectionDefinition("MSBuild", DisableParallelization = true)] +public sealed class MsbuildCollection; diff --git a/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/PackagedGeneratorTests.cs new file mode 100644 index 0000000..f034aef --- /dev/null +++ b/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/PackagedGeneratorTests.cs @@ -0,0 +1,92 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Locator; +using Microsoft.Build.Utilities.ProjectCreation; +using Xunit; + +namespace DimensionalQuantities.IntegrationTests.AnalyserPackageSmokeTests; + +[Collection("MSBuild")] +public sealed class PackagedGeneratorTests : IDisposable +{ + private readonly string _scratch = Directory.CreateTempSubdirectory("dimensional-quantities-").FullName; + + static PackagedGeneratorTests() + { + // MSBuild must be located before any Microsoft.Build type is loaded. + MSBuildLocator.RegisterDefaults(); + } + + [Fact] + public void PackagedGeneratorEmitsUsableTypeInConsumer() + { + string sourceProject = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "DimensionalQuantities", "DimensionalQuantities.csproj")); + FileInfo nupkg = PackLibrary(sourceProject, _scratch); + using PackageRepository repository = PackageRepository.Create(_scratch); + repository.Package(nupkg, out Package package); + + string consumerDirectory = Path.Combine(_scratch, "consumer"); + Directory.CreateDirectory(consumerDirectory); + string consumerProject = Path.Combine(consumerDirectory, "Consumer.csproj"); + File.WriteAllText(Path.Combine(consumerDirectory, "Consumer.cs"), "using DimensionalQuantities.Generated; public static class Consumer { public static GeneratedSmokeTest Create() => new(); }"); + ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: consumerProject, targetFramework: "net10.0") + .ItemPackageReference(package) + .Save(); + + creator.TryBuild(restore: true, out bool built, out BuildOutput output); + using (output) + { + Assert.Multiple( + () => Assert.True(built, $"Consumer build failed.{Environment.NewLine}{output.GetConsoleLog()}"), + () => + { + string assemblyFile = Path.Combine(consumerDirectory, "bin", "Debug", "net10.0", "Consumer.dll"); + using FileStream assemblyStream = File.OpenRead(assemblyFile); + using PEReader assembly = new(assemblyStream); + MetadataReader metadata = assembly.GetMetadataReader(); + IEnumerable result = metadata.TypeDefinitions.Select(handle => metadata.GetTypeDefinition(handle)) + .Select(type => $"{metadata.GetString(type.Namespace)}.{metadata.GetString(type.Name)}"); + string expected = "DimensionalQuantities.Generated.GeneratedSmokeTest"; + + Assert.Contains(expected, result); + }); + } + } + + public void Dispose() + { + Directory.Delete(_scratch, recursive: true); + } + + private static FileInfo PackLibrary(string sourceProject, string scratch) + { + string packageOutput = Path.Combine(scratch, "package"); + Directory.CreateDirectory(packageOutput); + Dictionary properties = new() + { + ["Configuration"] = "Debug", + ["PackageOutputPath"] = packageOutput, + ["NuGetAudit"] = "false", + }; + using ProjectCollection collection = new(properties); + Project project = collection.LoadProject(sourceProject); + string packageId = project.GetPropertyValue("PackageId"); + string packageVersion = project.GetPropertyValue("PackageVersion"); + using BuildOutput output = BuildOutput.Create(); + BuildParameters parameters = new(collection) { Loggers = [output] }; + BuildRequestData request = new(project.CreateProjectInstance(), ["Pack"]); + BuildResult result = BuildManager.DefaultBuildManager.Build(parameters, request); + if (result.OverallResult != BuildResultCode.Success) + { + throw new InvalidOperationException($"Pack failed.{Environment.NewLine}{output.GetConsoleLog()}"); + } + FileInfo nupkg = new(Path.Combine(packageOutput, $"{packageId}.{packageVersion}.nupkg")); + if (!nupkg.Exists) + { + throw new FileNotFoundException("Pack did not create the expected package", nupkg.FullName); + } + return nupkg; + } +} diff --git a/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj b/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj index 36e211d..76a0e95 100644 --- a/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj +++ b/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj @@ -12,9 +12,11 @@ - + - + + + diff --git a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj index f43c504..1527196 100644 --- a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj +++ b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj @@ -1,6 +1,6 @@ - netstandard2.0 + net10.0 true true false diff --git a/src/DimensionalQuantities.Roslyn/Generators/SmokeTestGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/SmokeTestGenerator.cs new file mode 100644 index 0000000..f02d1c4 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/SmokeTestGenerator.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis; + +namespace DimensionalQuantities.Roslyn.Generators; + +[Generator] +public sealed class SmokeTestGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static output => output.AddSource("GeneratedSmokeTest.g.cs", "namespace DimensionalQuantities.Generated; public sealed class GeneratedSmokeTest { }")); + } +} diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index 138a4c7..900e4f1 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -7,7 +7,7 @@ - From e5136ad13fe9bf5ee0ce516eb59c7a6a92b5219c Mon Sep 17 00:00:00 2001 From: James McMahon Date: Wed, 21 Oct 2026 23:23:45 +0100 Subject: [PATCH 14/25] feat: add v1 engineering quantity catalogue --- .../DimensionAnalysis/AlternateUnit.cs | 3 ++ .../DimensionAnalysis/EngineeringCatalogue.cs | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/DimensionalQuantities/DimensionAnalysis/AlternateUnit.cs create mode 100644 src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs diff --git a/src/DimensionalQuantities/DimensionAnalysis/AlternateUnit.cs b/src/DimensionalQuantities/DimensionAnalysis/AlternateUnit.cs new file mode 100644 index 0000000..a7a1bea --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/AlternateUnit.cs @@ -0,0 +1,3 @@ +namespace DimensionalQuantities.DimensionAnalysis; + +internal readonly record struct AlternateUnit(string Name, string Symbol); diff --git a/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs b/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs new file mode 100644 index 0000000..acefceb --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs @@ -0,0 +1,31 @@ +namespace DimensionalQuantities.DimensionAnalysis; + +/// Recognised names and symbols for engineering dimensions. +internal static class EngineeringCatalogue +{ + public static readonly Dictionary Entries = new() + { + { new(Length: 2), [new("Area", "m²")] }, + { new(Length: 3), [new("Volume", "m³")] }, + { new(Time: -1), [new("Frequency", "Hz"), new("AngularVelocity", "rad/s")] }, + { new(Length: -3, Mass: 1), [new("Density", "kg/m³")] }, + { new(Length: -1, Mass: 1, Time: -2), [new("Pressure", "Pa"), new("Stress", "Pa"), new("YoungsModulus", "Pa")] }, + { new(Length: 2, Mass: 1, Time: -2), [new("Energy", "J"), new("Torque", "Nm")] }, + { new(Length: 2, Mass: 1, Time: -3), [new("Power", "W")] }, + { new(Length: 2, Time: -3), [new("SpecificPower", "W/kg"), new("SpecificPowerLoss", "W/kg")] }, + { new(Temperature: -1), [new("TemperatureCoefficient", "K⁻¹")] }, + { new(Length: -1, Mass: 1, Time: -3), [new("PowerDensity", "W/m³"), new("VolumetricPowerLoss", "W/m³")] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1), [new("Voltage", "V")] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistance", "Ω")] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Inductance", "H")] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFlux", "Wb"), new("FluxLinkage", "Wb")] }, + { new(Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFluxDensity", "T"), new("MagneticPolarisation", "T"), new("RemanentFluxDensity", "T")] }, + { new(Length: -1, ElectricCurrent: 1), [new("MagneticFieldStrength", "A/m"), new("CoerciveFieldStrength", "A/m")] }, + { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", "A/m²")] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", "H/m")] }, + { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", "Ωm")] }, + { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", "S/m")] }, + { new(Length: -1, Mass: -1, Time: 2, ElectricCurrent: 2), [new("MagneticReluctivity", "m/H")] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticVectorPotential", "Wb/m")] }, + }; +} From ed2d591632662f4e4c3ffb5284e3b7319970abb5 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 11:21:20 +0100 Subject: [PATCH 15/25] feat: generate Voltage aliases and unit helpers --- .../GeneratorLoadingTests.cs | 15 ----- ...ensionalQuantities.IntegrationTests.csproj | 1 + .../GeneratedVoltageTests.cs | 25 ++++++++ .../MsbuildCollection.cs | 2 +- .../PackagedGeneratorTests.cs | 45 ++++++++------ .../DimensionalQuantities.Roslyn.csproj | 1 + .../Generators/SmokeTestGenerator.cs | 12 ---- .../Generators/VoltageGenerator.cs | 61 +++++++++++++++++++ .../TypeSystem/QuantityTests.cs | 4 +- .../DimensionalQuantities.csproj | 13 +++- .../TypeSystem/Quantity.cs | 6 ++ 11 files changed, 134 insertions(+), 51 deletions(-) delete mode 100644 src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/GeneratorLoadingTests.cs create mode 100644 src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs rename src/DimensionalQuantities.IntegrationTests/{AnalyserPackageSmokeTests => GeneratedQuantityTests}/MsbuildCollection.cs (61%) rename src/DimensionalQuantities.IntegrationTests/{AnalyserPackageSmokeTests => GeneratedQuantityTests}/PackagedGeneratorTests.cs (70%) delete mode 100644 src/DimensionalQuantities.Roslyn/Generators/SmokeTestGenerator.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs diff --git a/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/GeneratorLoadingTests.cs b/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/GeneratorLoadingTests.cs deleted file mode 100644 index 629ddd8..0000000 --- a/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/GeneratorLoadingTests.cs +++ /dev/null @@ -1,15 +0,0 @@ -using DimensionalQuantities.Generated; -using Xunit; - -namespace DimensionalQuantities.IntegrationTests.AnalyserPackageSmokeTests; - -public sealed class GeneratorLoadingTests -{ - [Fact] - public void GeneratedTypeCanBeConstructed() - { - GeneratedSmokeTest result = new(); - - Assert.NotNull(result); - } -} diff --git a/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj b/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj index 76a0e95..e942021 100644 --- a/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj +++ b/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj @@ -13,6 +13,7 @@ + diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs new file mode 100644 index 0000000..d04ea9b --- /dev/null +++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs @@ -0,0 +1,25 @@ +using Xunit; + +namespace DimensionalQuantities.IntegrationTests.GeneratedQuantityTests; + +public sealed class GeneratedVoltageTests +{ + [Fact] + public void VoltageAndMechanicalAliasesResolveToSameQuantityType() + { + Type result = typeof(Voltage); + Type expected = typeof(LengthSquaredMassPerTimeCubedCurrent); + + Assert.Equal(expected, result); + } + + [Fact] + public void GeneratedVoltageFactoryAndAccessorRoundtrip() + { + Voltage original = Voltage.FromVolts(12.5); + double result = original.AsVolts(); + double expected = 12.5; + + Assert.Equal(expected, result); + } +} diff --git a/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/MsbuildCollection.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/MsbuildCollection.cs similarity index 61% rename from src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/MsbuildCollection.cs rename to src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/MsbuildCollection.cs index 28d4ed0..b362707 100644 --- a/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/MsbuildCollection.cs +++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/MsbuildCollection.cs @@ -1,6 +1,6 @@ using Xunit; -namespace DimensionalQuantities.IntegrationTests.AnalyserPackageSmokeTests; +namespace DimensionalQuantities.IntegrationTests.GeneratedQuantityTests; [CollectionDefinition("MSBuild", DisableParallelization = true)] public sealed class MsbuildCollection; diff --git a/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs similarity index 70% rename from src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/PackagedGeneratorTests.cs rename to src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs index f034aef..3ac5cd2 100644 --- a/src/DimensionalQuantities.IntegrationTests/AnalyserPackageSmokeTests/PackagedGeneratorTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs @@ -1,12 +1,11 @@ -using System.Reflection.Metadata; -using System.Reflection.PortableExecutable; +using System.Reflection; using Microsoft.Build.Evaluation; using Microsoft.Build.Execution; using Microsoft.Build.Locator; using Microsoft.Build.Utilities.ProjectCreation; using Xunit; -namespace DimensionalQuantities.IntegrationTests.AnalyserPackageSmokeTests; +namespace DimensionalQuantities.IntegrationTests.GeneratedQuantityTests; [Collection("MSBuild")] public sealed class PackagedGeneratorTests : IDisposable @@ -20,7 +19,7 @@ static PackagedGeneratorTests() } [Fact] - public void PackagedGeneratorEmitsUsableTypeInConsumer() + public void PackagedGeneratorCreatesVoltageThatRoundtripsInConsumer() { string sourceProject = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "DimensionalQuantities", "DimensionalQuantities.csproj")); FileInfo nupkg = PackLibrary(sourceProject, _scratch); @@ -30,7 +29,19 @@ public void PackagedGeneratorEmitsUsableTypeInConsumer() string consumerDirectory = Path.Combine(_scratch, "consumer"); Directory.CreateDirectory(consumerDirectory); string consumerProject = Path.Combine(consumerDirectory, "Consumer.csproj"); - File.WriteAllText(Path.Combine(consumerDirectory, "Consumer.cs"), "using DimensionalQuantities.Generated; public static class Consumer { public static GeneratedSmokeTest Create() => new(); }"); + File.WriteAllText(Path.Combine(consumerDirectory, "Consumer.cs"), """ + namespace ConsumerProject; + + public static class Consumer + { + public static double Roundtrip(double value) + { + Voltage original = Voltage.FromVolts(value); + LengthSquaredMassPerTimeCubedCurrent equivalent = original; + return equivalent.AsVolts(); + } + } + """); ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: consumerProject, targetFramework: "net10.0") .ItemPackageReference(package) .Save(); @@ -38,21 +49,17 @@ public void PackagedGeneratorEmitsUsableTypeInConsumer() creator.TryBuild(restore: true, out bool built, out BuildOutput output); using (output) { - Assert.Multiple( - () => Assert.True(built, $"Consumer build failed.{Environment.NewLine}{output.GetConsoleLog()}"), - () => - { - string assemblyFile = Path.Combine(consumerDirectory, "bin", "Debug", "net10.0", "Consumer.dll"); - using FileStream assemblyStream = File.OpenRead(assemblyFile); - using PEReader assembly = new(assemblyStream); - MetadataReader metadata = assembly.GetMetadataReader(); - IEnumerable result = metadata.TypeDefinitions.Select(handle => metadata.GetTypeDefinition(handle)) - .Select(type => $"{metadata.GetString(type.Namespace)}.{metadata.GetString(type.Name)}"); - string expected = "DimensionalQuantities.Generated.GeneratedSmokeTest"; - - Assert.Contains(expected, result); - }); + Assert.True(built, $"Consumer build failed.{Environment.NewLine}{output.GetConsoleLog()}"); } + + string assemblyFile = Path.Combine(consumerDirectory, "bin", "Debug", "net10.0", "Consumer.dll"); + Assembly assembly = Assembly.LoadFrom(assemblyFile); + Type consumerType = assembly.GetType("ConsumerProject.Consumer") ?? throw new InvalidOperationException("Consumer type was not built."); + MethodInfo roundtrip = consumerType.GetMethod("Roundtrip") ?? throw new InvalidOperationException("Consumer roundtrip was not built."); + double result = Assert.IsType(roundtrip.Invoke(null, [12.5])); + double expected = 12.5; + + Assert.Equal(expected, result); } public void Dispose() diff --git a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj index 1527196..ba8d56a 100644 --- a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj +++ b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj @@ -6,6 +6,7 @@ false + diff --git a/src/DimensionalQuantities.Roslyn/Generators/SmokeTestGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/SmokeTestGenerator.cs deleted file mode 100644 index f02d1c4..0000000 --- a/src/DimensionalQuantities.Roslyn/Generators/SmokeTestGenerator.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace DimensionalQuantities.Roslyn.Generators; - -[Generator] -public sealed class SmokeTestGenerator : IIncrementalGenerator -{ - public void Initialize(IncrementalGeneratorInitializationContext context) - { - context.RegisterPostInitializationOutput(static output => output.AddSource("GeneratedSmokeTest.g.cs", "namespace DimensionalQuantities.Generated; public sealed class GeneratedSmokeTest { }")); - } -} diff --git a/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs new file mode 100644 index 0000000..64dc6be --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs @@ -0,0 +1,61 @@ +using System.Text; +using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace DimensionalQuantities.Roslyn.Generators; + +[Generator] +public sealed class VoltageGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static output => output.AddSource("Voltage.g.cs", SourceText.From(GenerateSource(), Encoding.UTF8))); + } + + private static string GenerateSource() + { + Dimension dimension = EngineeringCatalogue.Entries.Single(entry => entry.Value.Any(unit => unit.Name == "Voltage")).Key; + string mechanicalName = DimensionNameGenerator.GetQuantityName(dimension); + string quantityType = $"global::DimensionalQuantities.TypeSystem.Quantity>"; + return $$""" + // + global using Voltage = {{quantityType}}; + global using {{mechanicalName}} = {{quantityType}}; + global using DimensionalQuantities.Generated; + + namespace DimensionalQuantities.Generated; + + internal static class VoltageExtensions + { + extension(Voltage) + { + public static Voltage FromVolts(double value) => Voltage.FromSiBaseUnit(value); + } + + extension(Voltage voltage) + { + public double AsVolts() => voltage.AsSiBaseUnit(); + } + } + """; + } + + private static Type[] GetExponentTypes(Dimension dimension) => + [ + TypeSystemNumberCodec.CreateType(dimension.Length), + TypeSystemNumberCodec.CreateType(dimension.Mass), + TypeSystemNumberCodec.CreateType(dimension.Time), + TypeSystemNumberCodec.CreateType(dimension.ElectricCurrent), + TypeSystemNumberCodec.CreateType(dimension.Temperature), + TypeSystemNumberCodec.CreateType(dimension.AmountOfSubstance), + TypeSystemNumberCodec.CreateType(dimension.LuminousIntensity) + ]; + + private static string FormatType(Type type) + { + string name = $"global::{type.Namespace}.{type.Name.Split('`')[0]}"; + return type.IsConstructedGenericType ? $"{name}<{string.Join(", ", type.GetGenericArguments().Select(FormatType))}>" : name; + } +} diff --git a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs index 7a9b6c9..a59059d 100644 --- a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs +++ b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs @@ -18,8 +18,8 @@ public sealed class QuantityTests [InlineData(1234.5)] public void ConstructionPreservesSiMagnitude(double expected) { - Current original = new(expected); - double result = original.ValueInSiBaseUnit; + Current original = Current.FromSiBaseUnit(expected); + double result = original.AsSiBaseUnit(); Assert.Equal(expected, result); } diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index 900e4f1..0ea5ff8 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -4,17 +4,26 @@ Compile-time dimensional quantities with generated aliases and arithmetic. git + + + + - + + diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index dea8a1c..a070cf4 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -6,10 +6,16 @@ namespace DimensionalQuantities.TypeSystem; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] 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); + 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; } From ef75026118c13faa60d72a19ad04516ead377068 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 11:41:58 +0100 Subject: [PATCH 16/25] feat: generate SI-prefixed Voltage conversions --- .../GeneratedVoltageTests.cs | 43 +++++++++++++++++++ .../PackagedGeneratorTests.cs | 14 +++--- .../Generators/VoltageGenerator.cs | 9 ++++ src/DimensionalQuantities/Units/SiPrefix.cs | 3 ++ src/DimensionalQuantities/Units/SiPrefixes.cs | 32 ++++++++++++++ 5 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 src/DimensionalQuantities/Units/SiPrefix.cs create mode 100644 src/DimensionalQuantities/Units/SiPrefixes.cs diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs index d04ea9b..41a53d4 100644 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs @@ -22,4 +22,47 @@ public void GeneratedVoltageFactoryAndAccessorRoundtrip() Assert.Equal(expected, result); } + + [Fact] + public void PrefixedFactoriesStoreSiMagnitude() + { + Voltage milliVolts = Voltage.FromMilliVolts(2500); + Voltage kiloVolts = Voltage.FromKiloVolts(2.5); + Voltage rontoVolts = Voltage.FromRontoVolts(1); + Voltage quettaVolts = Voltage.FromQuettaVolts(1); + + Assert.Multiple( + () => Assert.Equal(2.5, milliVolts.AsSiBaseUnit()), + () => Assert.Equal(2500, kiloVolts.AsSiBaseUnit()), + () => Assert.Equal(1e-27, rontoVolts.AsSiBaseUnit()), + () => Assert.Equal(1e30, quettaVolts.AsSiBaseUnit())); + } + + [Fact] + public void PrefixedAccessorsConvertFromSiMagnitude() + { + Voltage original = Voltage.FromVolts(2.5); + double milliVolts = original.AsMilliVolts(); + double kiloVolts = original.AsKiloVolts(); + double deciVolts = original.AsDeciVolts(); + double hectoVolts = original.AsHectoVolts(); + + Assert.Multiple( + () => Assert.Equal(2500, milliVolts), + () => Assert.Equal(0.0025, kiloVolts), + () => Assert.Equal(25, deciVolts), + () => Assert.Equal(0.025, hectoVolts)); + } + + [Fact] + public void NewRangePrefixesAreAvailableInBothDirections() + { + Voltage original = Voltage.FromQuectoVolts(1); + double result = original.AsQuectoVolts(); + double expected = 1; + + Assert.Multiple( + () => Assert.Equal(1e-30, original.AsVolts()), + () => Assert.Equal(expected, result)); + } } diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs index 3ac5cd2..8f9096c 100644 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs @@ -19,7 +19,7 @@ static PackagedGeneratorTests() } [Fact] - public void PackagedGeneratorCreatesVoltageThatRoundtripsInConsumer() + public void PackagedGeneratorConvertsPrefixedVoltageInConsumer() { string sourceProject = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "DimensionalQuantities", "DimensionalQuantities.csproj")); FileInfo nupkg = PackLibrary(sourceProject, _scratch); @@ -34,11 +34,11 @@ namespace ConsumerProject; public static class Consumer { - public static double Roundtrip(double value) + public static double Convert(double milliVolts) { - Voltage original = Voltage.FromVolts(value); + Voltage original = Voltage.FromMilliVolts(milliVolts); LengthSquaredMassPerTimeCubedCurrent equivalent = original; - return equivalent.AsVolts(); + return equivalent.AsKiloVolts(); } } """); @@ -55,9 +55,9 @@ public static double Roundtrip(double value) string assemblyFile = Path.Combine(consumerDirectory, "bin", "Debug", "net10.0", "Consumer.dll"); Assembly assembly = Assembly.LoadFrom(assemblyFile); Type consumerType = assembly.GetType("ConsumerProject.Consumer") ?? throw new InvalidOperationException("Consumer type was not built."); - MethodInfo roundtrip = consumerType.GetMethod("Roundtrip") ?? throw new InvalidOperationException("Consumer roundtrip was not built."); - double result = Assert.IsType(roundtrip.Invoke(null, [12.5])); - double expected = 12.5; + MethodInfo convert = consumerType.GetMethod("Convert") ?? throw new InvalidOperationException("Consumer conversion was not built."); + double result = Assert.IsType(convert.Invoke(null, [12500])); + double expected = 0.0125; Assert.Equal(expected, result); } diff --git a/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs index 64dc6be..6e8dafe 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs @@ -1,6 +1,8 @@ +using System.Globalization; using System.Text; using DimensionalQuantities.DimensionAnalysis; using DimensionalQuantities.TypeSystem; +using DimensionalQuantities.Units; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -19,6 +21,11 @@ private static string GenerateSource() Dimension dimension = EngineeringCatalogue.Entries.Single(entry => entry.Value.Any(unit => unit.Name == "Voltage")).Key; string mechanicalName = DimensionNameGenerator.GetQuantityName(dimension); string quantityType = $"global::DimensionalQuantities.TypeSystem.Quantity>"; + KeyValuePair[] prefixes = SiPrefixes.Entries.OrderByDescending(entry => entry.Key).ToArray(); + string prefixedFactories = string.Join("\n\t\t", prefixes + .Select(entry => $"public static Voltage From{entry.Value.Name}Volts(double value) => Voltage.FromSiBaseUnit(value * 1e{entry.Key.ToString(CultureInfo.InvariantCulture)});")); + string prefixedAccessors = string.Join("\n\t\t", prefixes + .Select(entry => $"public double As{entry.Value.Name}Volts() => voltage.AsSiBaseUnit() / 1e{entry.Key.ToString(CultureInfo.InvariantCulture)};")); return $$""" // global using Voltage = {{quantityType}}; @@ -32,11 +39,13 @@ internal static class VoltageExtensions extension(Voltage) { public static Voltage FromVolts(double value) => Voltage.FromSiBaseUnit(value); + {{prefixedFactories}} } extension(Voltage voltage) { public double AsVolts() => voltage.AsSiBaseUnit(); + {{prefixedAccessors}} } } """; diff --git a/src/DimensionalQuantities/Units/SiPrefix.cs b/src/DimensionalQuantities/Units/SiPrefix.cs new file mode 100644 index 0000000..6b29717 --- /dev/null +++ b/src/DimensionalQuantities/Units/SiPrefix.cs @@ -0,0 +1,3 @@ +namespace DimensionalQuantities.Units; + +internal readonly record struct SiPrefix(string Name, string Symbol); diff --git a/src/DimensionalQuantities/Units/SiPrefixes.cs b/src/DimensionalQuantities/Units/SiPrefixes.cs new file mode 100644 index 0000000..68b1925 --- /dev/null +++ b/src/DimensionalQuantities/Units/SiPrefixes.cs @@ -0,0 +1,32 @@ +namespace DimensionalQuantities.Units; + +internal static class SiPrefixes +{ + public static readonly Dictionary Entries = new() + { + [30] = new("Quetta", "Q"), + [27] = new("Ronna", "R"), + [24] = new("Yotta", "Y"), + [21] = new("Zetta", "Z"), + [18] = new("Exa", "E"), + [15] = new("Peta", "P"), + [12] = new("Tera", "T"), + [9] = new("Giga", "G"), + [6] = new("Mega", "M"), + [3] = new("Kilo", "k"), + [2] = new("Hecto", "h"), + [1] = new("Deca", "da"), + [-1] = new("Deci", "d"), + [-2] = new("Centi", "c"), + [-3] = new("Milli", "m"), + [-6] = new("Micro", "µ"), + [-9] = new("Nano", "n"), + [-12] = new("Pico", "p"), + [-15] = new("Femto", "f"), + [-18] = new("Atto", "a"), + [-21] = new("Zepto", "z"), + [-24] = new("Yocto", "y"), + [-27] = new("Ronto", "r"), + [-30] = new("Quecto", "q") + }; +} From 5e322ccb6510fadfe6d776cbd92e545da8a68dfc Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 12:59:39 +0100 Subject: [PATCH 17/25] feat: generate SI base quantities with shared unit metadata --- .../GeneratedVoltageTests.cs | 68 -------- .../PackagedGeneratorTests.cs | 16 +- .../DimensionalQuantities.Roslyn.Tests.csproj | 1 + .../Generators/QuantityGeneratorTests.cs | 150 ++++++++++++++++++ .../Generators/QuantityGenerator.cs | 130 +++++++++++++++ .../Generators/VoltageGenerator.cs | 70 -------- .../DimensionAnalysis/DimensionTests.cs | 12 +- src/DimensionalQuantities.slnx | 12 +- .../DimensionAnalysis/AlternateUnit.cs | 3 - .../DimensionAnalysis/Dimension.cs | 27 ++-- .../DimensionAnalysis/DimensionComponent.cs | 2 +- .../DimensionNameGenerator.cs | 39 +++-- .../DimensionAnalysis/EngineeringCatalogue.cs | 70 +++++--- .../DimensionAnalysis/UnitDefinition.cs | 33 ++++ 14 files changed, 420 insertions(+), 213 deletions(-) delete mode 100644 src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs delete mode 100644 src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs delete mode 100644 src/DimensionalQuantities/DimensionAnalysis/AlternateUnit.cs create mode 100644 src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs deleted file mode 100644 index 41a53d4..0000000 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/GeneratedVoltageTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Xunit; - -namespace DimensionalQuantities.IntegrationTests.GeneratedQuantityTests; - -public sealed class GeneratedVoltageTests -{ - [Fact] - public void VoltageAndMechanicalAliasesResolveToSameQuantityType() - { - Type result = typeof(Voltage); - Type expected = typeof(LengthSquaredMassPerTimeCubedCurrent); - - Assert.Equal(expected, result); - } - - [Fact] - public void GeneratedVoltageFactoryAndAccessorRoundtrip() - { - Voltage original = Voltage.FromVolts(12.5); - double result = original.AsVolts(); - double expected = 12.5; - - Assert.Equal(expected, result); - } - - [Fact] - public void PrefixedFactoriesStoreSiMagnitude() - { - Voltage milliVolts = Voltage.FromMilliVolts(2500); - Voltage kiloVolts = Voltage.FromKiloVolts(2.5); - Voltage rontoVolts = Voltage.FromRontoVolts(1); - Voltage quettaVolts = Voltage.FromQuettaVolts(1); - - Assert.Multiple( - () => Assert.Equal(2.5, milliVolts.AsSiBaseUnit()), - () => Assert.Equal(2500, kiloVolts.AsSiBaseUnit()), - () => Assert.Equal(1e-27, rontoVolts.AsSiBaseUnit()), - () => Assert.Equal(1e30, quettaVolts.AsSiBaseUnit())); - } - - [Fact] - public void PrefixedAccessorsConvertFromSiMagnitude() - { - Voltage original = Voltage.FromVolts(2.5); - double milliVolts = original.AsMilliVolts(); - double kiloVolts = original.AsKiloVolts(); - double deciVolts = original.AsDeciVolts(); - double hectoVolts = original.AsHectoVolts(); - - Assert.Multiple( - () => Assert.Equal(2500, milliVolts), - () => Assert.Equal(0.0025, kiloVolts), - () => Assert.Equal(25, deciVolts), - () => Assert.Equal(0.025, hectoVolts)); - } - - [Fact] - public void NewRangePrefixesAreAvailableInBothDirections() - { - Voltage original = Voltage.FromQuectoVolts(1); - double result = original.AsQuectoVolts(); - double expected = 1; - - Assert.Multiple( - () => Assert.Equal(1e-30, original.AsVolts()), - () => Assert.Equal(expected, result)); - } -} diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs index 8f9096c..defcdd0 100644 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs @@ -19,7 +19,7 @@ static PackagedGeneratorTests() } [Fact] - public void PackagedGeneratorConvertsPrefixedVoltageInConsumer() + public void PackagedGeneratorConvertsVoltageInConsumer() { string sourceProject = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "DimensionalQuantities", "DimensionalQuantities.csproj")); FileInfo nupkg = PackLibrary(sourceProject, _scratch); @@ -34,11 +34,10 @@ namespace ConsumerProject; public static class Consumer { - public static double Convert(double milliVolts) + public static double ConvertVoltage(double volts) { - Voltage original = Voltage.FromMilliVolts(milliVolts); - LengthSquaredMassPerTimeCubedCurrent equivalent = original; - return equivalent.AsKiloVolts(); + Voltage voltage = Voltage.FromVolts(volts); + return voltage.AsVolts(); } } """); @@ -55,10 +54,9 @@ public static double Convert(double milliVolts) string assemblyFile = Path.Combine(consumerDirectory, "bin", "Debug", "net10.0", "Consumer.dll"); Assembly assembly = Assembly.LoadFrom(assemblyFile); Type consumerType = assembly.GetType("ConsumerProject.Consumer") ?? throw new InvalidOperationException("Consumer type was not built."); - MethodInfo convert = consumerType.GetMethod("Convert") ?? throw new InvalidOperationException("Consumer conversion was not built."); - double result = Assert.IsType(convert.Invoke(null, [12500])); - double expected = 0.0125; - + MethodInfo convertVoltage = consumerType.GetMethod("ConvertVoltage") ?? throw new InvalidOperationException("Consumer Voltage conversion was not built."); + double result = Assert.IsType(convertVoltage.Invoke(null, [12.5])); + double expected = 12.5; Assert.Equal(expected, result); } diff --git a/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj b/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj index b7b9602..36e211d 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj +++ b/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs new file mode 100644 index 0000000..2aa4bb1 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs @@ -0,0 +1,150 @@ +using System.Collections.Immutable; +using System.Reflection; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed class QuantityGeneratorTests +{ + private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.CSharp14); + private static readonly string[] _prefixes = + [ + "Quetta", "Ronna", "Yotta", "Zetta", "Exa", "Peta", "Tera", "Giga", "Mega", "Kilo", "Hecto", "Deca", + "Deci", "Centi", "Milli", "Micro", "Nano", "Pico", "Femto", "Atto", "Zepto", "Yocto", "Ronto", "Quecto" + ]; + + [Fact] + public void BaseAliasesHaveUnprefixedFactoriesAndAccessors() + { + const string source = """ + public static class Consumer + { + public static double UseBaseQuantities() + { + Length length = Length.FromMetres(1); + Mass mass = Mass.FromGrams(1000); + Time time = Time.FromSeconds(1); + Current current = Current.FromAmps(1); + Temperature temperature = Temperature.FromKelvin(1); + AmountOfSubstance amount = AmountOfSubstance.FromMoles(1); + LuminousIntensity intensity = LuminousIntensity.FromCandelas(1); + return length.AsMetres() + mass.AsGrams() + time.AsSeconds() + current.AsAmps() + + temperature.AsKelvin() + amount.AsMoles() + intensity.AsCandelas(); + } + } + """; + + Compile(source); + } + + [Fact] + public void VoltageAndMechanicalAliasAreTheSameType() + { + const string expression = "typeof(Voltage) == typeof(LengthSquaredMassPerTimeCubedCurrent)"; + bool result = Evaluate(expression); + Assert.True(result); + } + + [Theory] + [InlineData("Length", "Metres")] + [InlineData("Mass", "Grams")] + [InlineData("Time", "Seconds")] + [InlineData("Current", "Amps")] + [InlineData("Temperature", "Kelvin")] + [InlineData("AmountOfSubstance", "Moles")] + [InlineData("LuminousIntensity", "Candelas")] + [InlineData("Voltage", "Volts")] + public void EverySiPrefixHasUsableFactoryAndAccessor(string quantity, string unit) + { + string calls = string.Join("\n", _prefixes.Select(prefix => + $"{quantity} value{prefix} = {quantity}.From{prefix}{unit}(1); double output{prefix} = value{prefix}.As{prefix}{unit}();")); + string source = $$""" + public static class Consumer + { + public static void UsePrefixes() + { + {{calls}} + } + } + """; + + Compile(source); + } + + [Theory] + [InlineData("Length.FromMilliMetres(2500)", 2.5)] + [InlineData("Mass.FromGrams(2500)", 2.5)] + [InlineData("Mass.FromKiloGrams(2.5)", 2.5)] + [InlineData("Mass.FromMilliGrams(2500000)", 2.5)] + [InlineData("Time.FromKiloSeconds(2.5)", 2500)] + [InlineData("Current.FromMilliAmps(2500)", 2.5)] + [InlineData("Temperature.FromMilliKelvin(2500)", 2.5)] + [InlineData("AmountOfSubstance.FromKiloMoles(2.5)", 2500)] + [InlineData("LuminousIntensity.FromMilliCandelas(2500)", 2.5)] + [InlineData("Voltage.FromMilliVolts(2500)", 2.5)] + [InlineData("Voltage.FromKiloVolts(2.5)", 2500)] + [InlineData("Voltage.FromRontoVolts(1)", 1e-27)] + [InlineData("Voltage.FromQuettaVolts(1)", 1e30)] + [InlineData("Voltage.FromQuectoVolts(1)", 1e-30)] + public void FactoryStoresCoherentSiMagnitude(string factory, double expected) + { + double result = Evaluate($"{factory}.AsSiBaseUnit()"); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("Mass.FromSiBaseUnit(2.5).AsGrams()", 2500)] + [InlineData("Mass.FromSiBaseUnit(2.5).AsKiloGrams()", 2.5)] + [InlineData("Mass.FromSiBaseUnit(2.5).AsMilliGrams()", 2500000)] + [InlineData("Voltage.FromSiBaseUnit(2.5).AsMilliVolts()", 2500)] + [InlineData("Voltage.FromSiBaseUnit(2.5).AsKiloVolts()", 0.0025)] + [InlineData("Voltage.FromSiBaseUnit(2.5).AsDeciVolts()", 25)] + [InlineData("Voltage.FromSiBaseUnit(2.5).AsHectoVolts()", 0.025)] + public void AccessorConvertsFromCoherentSiMagnitude(string expression, double expected) + { + double result = Evaluate(expression); + Assert.Equal(expected, result); + } + + private static T Evaluate(string expression) + { + string source = $$""" + public static class Consumer + { + public static global::{{typeof(T).FullName}} Value() => {{expression}}; + } + """; + CSharpCompilation compilation = Compile(source); + using MemoryStream assemblyBytes = new(); + EmitResult emitResult = compilation.Emit(assemblyBytes, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(emitResult.Success, string.Join(Environment.NewLine, emitResult.Diagnostics)); + Assembly assembly = Assembly.Load(assemblyBytes.ToArray()); + MethodInfo method = assembly.GetType("Consumer")!.GetMethod("Value")!; + return Assert.IsType(method.Invoke(null, null)); + } + + private static CSharpCompilation Compile(string source) + { + string[] platformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + CSharpCompilation input = CSharpCompilation.Create( + "GeneratedQuantityConsumer", + [CSharpSyntaxTree.ParseText(source, _parseOptions)], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], parseOptions: _parseOptions); + driver.RunGeneratorsAndUpdateCompilation(input, out Compilation output, out ImmutableArray generatorDiagnostics); + Diagnostic[] compilationErrors = output.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + Assert.Multiple( + () => Assert.True(generatorDiagnostics.IsEmpty, string.Join(Environment.NewLine, generatorDiagnostics)), + () => Assert.True(compilationErrors.Length == 0, string.Join(Environment.NewLine, compilationErrors))); + return (CSharpCompilation)output; + } +} diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs new file mode 100644 index 0000000..4dbe035 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using System.Text; +using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.TypeSystem; +using DimensionalQuantities.Units; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace DimensionalQuantities.Roslyn.Generators; + +[Generator] +public sealed class QuantityGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static output => output.AddSource("Quantities.g.cs", SourceText.From(GenerateSource(), Encoding.UTF8))); + } + + private static string GenerateSource() + { + StringBuilder aliases = new(); + StringBuilder extensions = new(); + KeyValuePair[] prefixes = SiPrefixes.Entries.OrderByDescending(entry => entry.Key).ToArray(); + Dimension[] baseDimensions = + [ + new(Length: 1), + new(Mass: 1), + new(Time: 1), + new(ElectricCurrent: 1), + new(Temperature: 1), + new(AmountOfSubstance: 1), + new(LuminousIntensity: 1) + ]; + + foreach (Dimension dimension in baseDimensions) + { + DimensionComponent component = dimension.GetComponents().Single(); + AppendQuantity(aliases, extensions, prefixes, dimension, component.Unit); + } + + KeyValuePair voltage = EngineeringCatalogue.Entries.Single(entry => entry.Value.Any(unit => unit.QuantityName == "Voltage")); + UnitDefinition voltageUnit = voltage.Value.Single(unit => unit.QuantityName == "Voltage"); + AppendQuantity(aliases, extensions, prefixes, voltage.Key, voltageUnit); + + return $$""" + // + {{aliases}}global using DimensionalQuantities.Generated; + + namespace DimensionalQuantities.Generated; + + internal static class QuantityExtensions + { + {{extensions}}} + """; + } + + private static void AppendQuantity(StringBuilder aliases, StringBuilder extensions, KeyValuePair[] prefixes, Dimension dimension, UnitDefinition unit) + { + string quantityName = unit.QuantityName; + string unitName = unit.PrefixableUnitNamePlural; + int siBasePrefixExponent = unit.SiBasePrefixExponent; + string mechanicalName = DimensionNameGenerator.GetQuantityName(dimension); + string quantityType = $"global::DimensionalQuantities.TypeSystem.Quantity>"; + aliases.Append("global using ").Append(quantityName).Append(" = ").Append(quantityType).AppendLine(";"); + if (mechanicalName != quantityName) + { + aliases.Append("global using ").Append(mechanicalName).Append(" = ").Append(quantityType).AppendLine(";"); + } + + extensions.Append("\textension(").Append(quantityName).AppendLine(")") + .AppendLine("\t{"); + AppendFactory(extensions, quantityName, unitName, -siBasePrefixExponent); + foreach (KeyValuePair prefix in prefixes) + { + AppendFactory(extensions, quantityName, prefix.Value.Name + unitName, prefix.Key - siBasePrefixExponent); + } + + string variableName = char.ToLowerInvariant(quantityName[0]) + quantityName[1..]; + extensions.AppendLine("\t}").AppendLine() + .Append("\textension(").Append(quantityName).Append(' ').Append(variableName).AppendLine(")") + .AppendLine("\t{"); + AppendAccessor(extensions, variableName, unitName, -siBasePrefixExponent); + foreach (KeyValuePair prefix in prefixes) + { + AppendAccessor(extensions, variableName, prefix.Value.Name + unitName, prefix.Key - siBasePrefixExponent); + } + + extensions.AppendLine("\t}").AppendLine(); + } + + private static void AppendFactory(StringBuilder source, string quantityName, string unitName, int exponent) + { + source.Append("\t\tpublic static ").Append(quantityName).Append(" From").Append(unitName) + .Append("(double value) => ").Append(quantityName).Append(".FromSiBaseUnit(value"); + if (exponent != 0) + { + source.Append(" * 1e").Append(exponent.ToString(CultureInfo.InvariantCulture)); + } + + source.AppendLine(");"); + } + + private static void AppendAccessor(StringBuilder source, string variableName, string unitName, int exponent) + { + source.Append("\t\tpublic double As").Append(unitName).Append("() => ").Append(variableName).Append(".AsSiBaseUnit()"); + if (exponent != 0) + { + source.Append(" / 1e").Append(exponent.ToString(CultureInfo.InvariantCulture)); + } + + source.AppendLine(";"); + } + + private static Type[] GetExponentTypes(Dimension dimension) => + [ + TypeSystemNumberCodec.CreateType(dimension.Length), + TypeSystemNumberCodec.CreateType(dimension.Mass), + TypeSystemNumberCodec.CreateType(dimension.Time), + TypeSystemNumberCodec.CreateType(dimension.ElectricCurrent), + TypeSystemNumberCodec.CreateType(dimension.Temperature), + TypeSystemNumberCodec.CreateType(dimension.AmountOfSubstance), + TypeSystemNumberCodec.CreateType(dimension.LuminousIntensity) + ]; + + private static string FormatType(Type type) + { + string name = $"global::{type.Namespace}.{type.Name.Split('`')[0]}"; + return type.IsConstructedGenericType ? $"{name}<{string.Join(", ", type.GetGenericArguments().Select(FormatType))}>" : name; + } +} diff --git a/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs deleted file mode 100644 index 6e8dafe..0000000 --- a/src/DimensionalQuantities.Roslyn/Generators/VoltageGenerator.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Globalization; -using System.Text; -using DimensionalQuantities.DimensionAnalysis; -using DimensionalQuantities.TypeSystem; -using DimensionalQuantities.Units; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace DimensionalQuantities.Roslyn.Generators; - -[Generator] -public sealed class VoltageGenerator : IIncrementalGenerator -{ - public void Initialize(IncrementalGeneratorInitializationContext context) - { - context.RegisterPostInitializationOutput(static output => output.AddSource("Voltage.g.cs", SourceText.From(GenerateSource(), Encoding.UTF8))); - } - - private static string GenerateSource() - { - Dimension dimension = EngineeringCatalogue.Entries.Single(entry => entry.Value.Any(unit => unit.Name == "Voltage")).Key; - string mechanicalName = DimensionNameGenerator.GetQuantityName(dimension); - string quantityType = $"global::DimensionalQuantities.TypeSystem.Quantity>"; - KeyValuePair[] prefixes = SiPrefixes.Entries.OrderByDescending(entry => entry.Key).ToArray(); - string prefixedFactories = string.Join("\n\t\t", prefixes - .Select(entry => $"public static Voltage From{entry.Value.Name}Volts(double value) => Voltage.FromSiBaseUnit(value * 1e{entry.Key.ToString(CultureInfo.InvariantCulture)});")); - string prefixedAccessors = string.Join("\n\t\t", prefixes - .Select(entry => $"public double As{entry.Value.Name}Volts() => voltage.AsSiBaseUnit() / 1e{entry.Key.ToString(CultureInfo.InvariantCulture)};")); - return $$""" - // - global using Voltage = {{quantityType}}; - global using {{mechanicalName}} = {{quantityType}}; - global using DimensionalQuantities.Generated; - - namespace DimensionalQuantities.Generated; - - internal static class VoltageExtensions - { - extension(Voltage) - { - public static Voltage FromVolts(double value) => Voltage.FromSiBaseUnit(value); - {{prefixedFactories}} - } - - extension(Voltage voltage) - { - public double AsVolts() => voltage.AsSiBaseUnit(); - {{prefixedAccessors}} - } - } - """; - } - - private static Type[] GetExponentTypes(Dimension dimension) => - [ - TypeSystemNumberCodec.CreateType(dimension.Length), - TypeSystemNumberCodec.CreateType(dimension.Mass), - TypeSystemNumberCodec.CreateType(dimension.Time), - TypeSystemNumberCodec.CreateType(dimension.ElectricCurrent), - TypeSystemNumberCodec.CreateType(dimension.Temperature), - TypeSystemNumberCodec.CreateType(dimension.AmountOfSubstance), - TypeSystemNumberCodec.CreateType(dimension.LuminousIntensity) - ]; - - private static string FormatType(Type type) - { - string name = $"global::{type.Namespace}.{type.Name.Split('`')[0]}"; - return type.IsConstructedGenericType ? $"{name}<{string.Join(", ", type.GetGenericArguments().Select(FormatType))}>" : name; - } -} diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs index 310e9ce..9f6616f 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs @@ -13,12 +13,12 @@ public void ComponentsPairNonzeroExponentsWithTheirNamesInSiOrder() IEnumerable result = original.GetComponents(); DimensionComponent[] expected = [ - new(1, "Length", "Metre", "Metres", "m"), - new(-2, "Mass", "Kilogram", "Kilograms", "kg"), - new(new(3, 2), "Time", "Second", "Seconds", "s"), - new(4, "Current", "Amp", "Amps", "A"), - new(-6, "AmountOfSubstance", "Mole", "Moles", "mol"), - new(7, "LuminousIntensity", "Candela", "Candelas", "cd") + new(1, new("Length", "Metre", "Metres", "m", "Metre", "Metres", "m", 0)), + new(-2, new("Mass", "Kilogram", "Kilograms", "kg", "Gram", "Grams", "g", 3)), + new(new(3, 2), new("Time", "Second", "Seconds", "s", "Second", "Seconds", "s", 0)), + new(4, new("Current", "Amp", "Amps", "A", "Amp", "Amps", "A", 0)), + new(-6, new("AmountOfSubstance", "Mole", "Moles", "mol", "Mole", "Moles", "mol", 0)), + new(7, new("LuminousIntensity", "Candela", "Candelas", "cd", "Candela", "Candelas", "cd", 0)) ]; Assert.Equal(expected, result); diff --git a/src/DimensionalQuantities.slnx b/src/DimensionalQuantities.slnx index 3e0cbb4..420d2ce 100644 --- a/src/DimensionalQuantities.slnx +++ b/src/DimensionalQuantities.slnx @@ -1,7 +1,11 @@ - - - - + + + + + + + + diff --git a/src/DimensionalQuantities/DimensionAnalysis/AlternateUnit.cs b/src/DimensionalQuantities/DimensionAnalysis/AlternateUnit.cs deleted file mode 100644 index a7a1bea..0000000 --- a/src/DimensionalQuantities/DimensionAnalysis/AlternateUnit.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace DimensionalQuantities.DimensionAnalysis; - -internal readonly record struct AlternateUnit(string Name, string Symbol); diff --git a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs index c489ac6..8db5023 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs @@ -12,6 +12,14 @@ internal readonly record struct Dimension( Rational AmountOfSubstance = default, Rational LuminousIntensity = default) { + private static readonly UnitDefinition _lengthUnit = new("Length", "Metre", "Metres", "m"); + private static readonly UnitDefinition _massUnit = new("Mass", "Kilogram", "Kilograms", "kg", "Gram", "Grams", "g", 3); + private static readonly UnitDefinition _timeUnit = new("Time", "Second", "Seconds", "s"); + private static readonly UnitDefinition _electricCurrentUnit = new("Current", "Amp", "Amps", "A"); + private static readonly UnitDefinition _temperatureUnit = new("Temperature", "Kelvin", "Kelvin", "K"); + private static readonly UnitDefinition _amountOfSubstanceUnit = new("AmountOfSubstance", "Mole", "Moles", "mol"); + private static readonly UnitDefinition _luminousIntensityUnit = new("LuminousIntensity", "Candela", "Candelas", "cd"); + public static Dimension operator *(Dimension left, Dimension right) => left.Multiply(right); public static Dimension operator /(Dimension left, Dimension right) => left.Divide(right); @@ -20,37 +28,37 @@ public IEnumerable GetComponents() { if (Length != Rational.Zero) { - yield return new(Length, "Length", "Metre", "Metres", "m"); + yield return new(Length, _lengthUnit); } if (Mass != Rational.Zero) { - yield return new(Mass, "Mass", "Kilogram", "Kilograms", "kg"); + yield return new(Mass, _massUnit); } if (Time != Rational.Zero) { - yield return new(Time, "Time", "Second", "Seconds", "s"); + yield return new(Time, _timeUnit); } if (ElectricCurrent != Rational.Zero) { - yield return new(ElectricCurrent, "Current", "Amp", "Amps", "A"); + yield return new(ElectricCurrent, _electricCurrentUnit); } if (Temperature != Rational.Zero) { - yield return new(Temperature, "Temperature", "Kelvin", "Kelvin", "K"); + yield return new(Temperature, _temperatureUnit); } if (AmountOfSubstance != Rational.Zero) { - yield return new(AmountOfSubstance, "AmountOfSubstance", "Mole", "Moles", "mol"); + yield return new(AmountOfSubstance, _amountOfSubstanceUnit); } if (LuminousIntensity != Rational.Zero) { - yield return new(LuminousIntensity, "LuminousIntensity", "Candela", "Candelas", "cd"); + yield return new(LuminousIntensity, _luminousIntensityUnit); } } @@ -83,10 +91,7 @@ public IEnumerable GetComponents() /// public Dimension Root(int degree) { - if (degree <= 0) - { - throw new ArgumentOutOfRangeException(nameof(degree)); - } + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(degree); return Pow(new(1, degree)); } diff --git a/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs b/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs index ee878d5..4252335 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs @@ -2,4 +2,4 @@ namespace DimensionalQuantities.DimensionAnalysis; -internal readonly record struct DimensionComponent(Rational Exponent, string QuantityName, string UnitNameSingular, string UnitNamePlural, string UnitSymbol); +internal readonly record struct DimensionComponent(Rational Exponent, UnitDefinition Unit); diff --git a/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs b/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs index b5e44b3..879e67a 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Globalization; using System.Numerics; using System.Text; @@ -7,36 +8,45 @@ namespace DimensionalQuantities.DimensionAnalysis; internal static class DimensionNameGenerator { - public static string GetQuantityName(Dimension dimension) => GetName(dimension, static component => component.QuantityName); + public static string GetQuantityName(Dimension dimension) => GetName(dimension, static component => component.Unit.QuantityName); - public static string GetUnitName(Dimension dimension) => GetName(dimension, static component => component.UnitNamePlural); + public static string GetUnitName(Dimension dimension) => GetName(dimension, static component => component.Unit.SiUnitNamePlural); public static string GetUnitExpression(Dimension dimension) { + ImmutableArray components = dimension.GetComponents().ToImmutableArray(); + if (components.IsEmpty) + { + throw new NotSupportedException("Dimensionless names are not defined."); + } + StringBuilder expression = new(); - foreach (DimensionComponent component in dimension.GetComponents()) + foreach (DimensionComponent component in components) { - expression.Append(component.UnitSymbol); + expression.Append(component.Unit.SiUnitSymbol); if (component.Exponent != Rational.One) { expression.Append("").Append(component.Exponent.ToString(null, CultureInfo.InvariantCulture)).Append(""); } } - if (expression.Length == 0) - { - throw new NotSupportedException("Dimensionless names are not defined."); - } - return expression.ToString(); } private static string GetName(Dimension dimension, Func getFactorName) { + ImmutableArray components = dimension.GetComponents().ToImmutableArray(); + if (components.IsEmpty) + { + throw new NotSupportedException("Dimensionless names are not defined."); + } + + bool hasNumerator = components.Any(component => component.Exponent.Numerator.Sign > 0); + bool hasDenominator = components.Any(component => component.Exponent.Numerator.Sign < 0); StringBuilder numerator = new(); StringBuilder denominator = new(); - foreach (DimensionComponent component in dimension.GetComponents()) + foreach (DimensionComponent component in components) { Rational exponent = component.Exponent; if (exponent.Numerator.Sign > 0) @@ -49,17 +59,12 @@ private static string GetName(Dimension dimension, FuncRecognised names and symbols for engineering dimensions. +/// Recognised names and units for engineering dimensions. internal static class EngineeringCatalogue { - public static readonly Dictionary Entries = new() + public static readonly Dictionary Entries = new() { - { new(Length: 2), [new("Area", "m²")] }, - { new(Length: 3), [new("Volume", "m³")] }, - { new(Time: -1), [new("Frequency", "Hz"), new("AngularVelocity", "rad/s")] }, - { new(Length: -3, Mass: 1), [new("Density", "kg/m³")] }, - { new(Length: -1, Mass: 1, Time: -2), [new("Pressure", "Pa"), new("Stress", "Pa"), new("YoungsModulus", "Pa")] }, - { new(Length: 2, Mass: 1, Time: -2), [new("Energy", "J"), new("Torque", "Nm")] }, - { new(Length: 2, Mass: 1, Time: -3), [new("Power", "W")] }, - { new(Length: 2, Time: -3), [new("SpecificPower", "W/kg"), new("SpecificPowerLoss", "W/kg")] }, - { new(Temperature: -1), [new("TemperatureCoefficient", "K⁻¹")] }, - { new(Length: -1, Mass: 1, Time: -3), [new("PowerDensity", "W/m³"), new("VolumetricPowerLoss", "W/m³")] }, - { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1), [new("Voltage", "V")] }, - { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistance", "Ω")] }, - { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Inductance", "H")] }, - { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFlux", "Wb"), new("FluxLinkage", "Wb")] }, - { new(Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFluxDensity", "T"), new("MagneticPolarisation", "T"), new("RemanentFluxDensity", "T")] }, - { new(Length: -1, ElectricCurrent: 1), [new("MagneticFieldStrength", "A/m"), new("CoerciveFieldStrength", "A/m")] }, - { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", "A/m²")] }, - { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", "H/m")] }, - { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", "Ωm")] }, - { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", "S/m")] }, - { new(Length: -1, Mass: -1, Time: 2, ElectricCurrent: 2), [new("MagneticReluctivity", "m/H")] }, - { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticVectorPotential", "Wb/m")] }, + { new(Length: 2), [new("Area", "SquareMetre", "SquareMetres", "m²")] }, + { new(Length: 3), [new("Volume", "CubicMetre", "CubicMetres", "m³")] }, + { new(Time: -1), [new("Frequency", "Hertz", "Hertz", "Hz"), new("AngularVelocity", "RadianPerSecond", "RadiansPerSecond", "rad/s")] }, + { new(Length: -3, Mass: 1), [new("Density", "KilogramPerCubicMetre", "KilogramsPerCubicMetre", "kg/m³", "GramPerCubicMetre", "GramsPerCubicMetre", "g/m³", 3)] }, + { new(Length: -1, Mass: 1, Time: -2), + [ + new("Pressure", "Pascal", "Pascals", "Pa"), + new("Stress", "Pascal", "Pascals", "Pa"), + new("YoungsModulus", "Pascal", "Pascals", "Pa") + ] }, + { new(Length: 2, Mass: 1, Time: -2), [new("Energy", "Joule", "Joules", "J"), new("Torque", "NewtonMetre", "NewtonMetres", "Nm")] }, + { new(Length: 2, Mass: 1, Time: -3), [new("Power", "Watt", "Watts", "W")] }, + { new(Length: 2, Time: -3), + [ + new("SpecificPower", "WattPerKilogram", "WattsPerKilogram", "W/kg"), + new("SpecificPowerLoss", "WattPerKilogram", "WattsPerKilogram", "W/kg") + ] }, + { new(Temperature: -1), [new("TemperatureCoefficient", "InverseKelvin", "InverseKelvin", "K⁻¹")] }, + { new(Length: -1, Mass: 1, Time: -3), + [ + new("PowerDensity", "WattPerCubicMetre", "WattsPerCubicMetre", "W/m³"), + new("VolumetricPowerLoss", "WattPerCubicMetre", "WattsPerCubicMetre", "W/m³") + ] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1), [new("Voltage", "Volt", "Volts", "V")] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistance", "Ohm", "Ohms", "Ω")] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Inductance", "Henry", "Henries", "H")] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFlux", "Weber", "Webers", "Wb"), new("FluxLinkage", "Weber", "Webers", "Wb")] }, + { new(Mass: 1, Time: -2, ElectricCurrent: -1), + [ + new("MagneticFluxDensity", "Tesla", "Teslas", "T"), + new("MagneticPolarisation", "Tesla", "Teslas", "T"), + new("RemanentFluxDensity", "Tesla", "Teslas", "T") + ] }, + { new(Length: -1, ElectricCurrent: 1), + [ + new("MagneticFieldStrength", "AmpPerMetre", "AmpsPerMetre", "A/m"), + new("CoerciveFieldStrength", "AmpPerMetre", "AmpsPerMetre", "A/m") + ] }, + { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", "AmpPerSquareMetre", "AmpsPerSquareMetre", "A/m²")] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", "HenryPerMetre", "HenriesPerMetre", "H/m")] }, + { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", "OhmMetre", "OhmMetres", "Ωm")] }, + { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", "SiemensPerMetre", "SiemensPerMetre", "S/m")] }, + { new(Length: -1, Mass: -1, Time: 2, ElectricCurrent: 2), [new("MagneticReluctivity", "MetrePerHenry", "MetresPerHenry", "m/H")] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticVectorPotential", "WeberPerMetre", "WebersPerMetre", "Wb/m")] }, }; } diff --git a/src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs b/src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs new file mode 100644 index 0000000..c6b3709 --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs @@ -0,0 +1,33 @@ +namespace DimensionalQuantities.DimensionAnalysis; + +internal readonly record struct UnitDefinition +{ + public UnitDefinition( + string quantityName, + string siUnitNameSingular, + string siUnitNamePlural, + string siUnitSymbol, + string? prefixableUnitNameSingular = null, + string? prefixableUnitNamePlural = null, + string? prefixableUnitSymbol = null, + int siBasePrefixExponent = 0) + { + QuantityName = quantityName; + SiUnitNameSingular = siUnitNameSingular; + SiUnitNamePlural = siUnitNamePlural; + SiUnitSymbol = siUnitSymbol; + PrefixableUnitNameSingular = prefixableUnitNameSingular ?? siUnitNameSingular; + PrefixableUnitNamePlural = prefixableUnitNamePlural ?? siUnitNamePlural; + PrefixableUnitSymbol = prefixableUnitSymbol ?? siUnitSymbol; + SiBasePrefixExponent = siBasePrefixExponent; + } + + public string QuantityName { get; } + public string SiUnitNameSingular { get; } + public string SiUnitNamePlural { get; } + public string SiUnitSymbol { get; } + public string PrefixableUnitNameSingular { get; } + public string PrefixableUnitNamePlural { get; } + public string PrefixableUnitSymbol { get; } + public int SiBasePrefixExponent { get; } +} From d3e16b8b761ce1fa01e8622db394281a42439d65 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 15:58:36 +0100 Subject: [PATCH 18/25] feat: generate engineering quantities with compound unit conversions --- .../Generators/QuantityGeneratorTests.cs | 150 ++++++++---- .../Generators/QuantityGenerator.cs | 91 ++++++-- .../DimensionAnalysis/DimensionTests.cs | 12 +- .../DimensionAnalysis/UnitExpressionTests.cs | 215 ++++++++++++++++++ .../DimensionAnalysis/AtomicUnit.cs | 3 + .../DimensionAnalysis/AtomicUnits.cs | 24 ++ .../DimensionAnalysis/Dimension.cs | 14 +- .../DimensionNameGenerator.cs | 4 +- .../DimensionAnalysis/EngineeringCatalogue.cs | 82 ++++--- .../DimensionAnalysis/UnitDefinition.cs | 30 +-- .../DimensionAnalysis/UnitExpression.cs | 135 +++++++++++ .../DimensionAnalysis/UnitTerm.cs | 3 + 12 files changed, 623 insertions(+), 140 deletions(-) create mode 100644 src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs create mode 100644 src/DimensionalQuantities/DimensionAnalysis/AtomicUnit.cs create mode 100644 src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs create mode 100644 src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs create mode 100644 src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs index 2aa4bb1..18e2903 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs @@ -50,19 +50,117 @@ public void VoltageAndMechanicalAliasAreTheSameType() Assert.True(result); } + [Fact] + public void CatalogueAliasesAreAvailableToConsumers() + { + const string source = """ + public static class Consumer + { + public static global::System.Type[] Types() => + [ + typeof(Area), typeof(Volume), typeof(Frequency), typeof(AngularVelocity), typeof(Density), + typeof(Pressure), typeof(Stress), typeof(YoungsModulus), typeof(Energy), typeof(Torque), + typeof(Power), typeof(SpecificPower), typeof(SpecificPowerLoss), typeof(TemperatureCoefficient), + typeof(PowerDensity), typeof(VolumetricPowerLoss), typeof(Voltage), typeof(Resistance), + typeof(Inductance), typeof(MagneticFlux), typeof(FluxLinkage), typeof(MagneticFluxDensity), + typeof(MagneticPolarisation), typeof(RemanentFluxDensity), typeof(MagneticFieldStrength), + typeof(CoerciveFieldStrength), typeof(CurrentDensity), typeof(Permeability), typeof(Resistivity), + typeof(Conductivity), typeof(MagneticReluctivity), typeof(MagneticVectorPotential) + ]; + } + """; + + Compile(source); + } + + [Fact] + public void CatalogueUnitsHaveUnprefixedFactoriesAndAccessors() + { + const string source = """ + public static class Consumer + { + public static double UseUnits() => + Area.FromSquareMetres(1).AsSquareMetres() + + Volume.FromCubicMetres(1).AsCubicMetres() + + Frequency.FromHertz(1).AsHertz() + + AngularVelocity.FromRadiansPerSecond(1).AsRadiansPerSecond() + + Density.FromGramsPerCubicMetre(1).AsGramsPerCubicMetre() + + Pressure.FromPascals(1).AsPascals() + + Energy.FromJoules(1).AsJoules() + + Torque.FromNewtonMetres(1).AsNewtonMetres() + + Power.FromWatts(1).AsWatts() + + SpecificPower.FromWattsPerKilogram(1).AsWattsPerKilogram() + + TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin() + + PowerDensity.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre() + + Voltage.FromVolts(1).AsVolts() + + Resistance.FromOhms(1).AsOhms() + + Inductance.FromHenries(1).AsHenries() + + MagneticFlux.FromWebers(1).AsWebers() + + MagneticFluxDensity.FromTeslas(1).AsTeslas() + + MagneticFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre() + + CurrentDensity.FromAmpsPerSquareMetre(1).AsAmpsPerSquareMetre() + + Permeability.FromHenriesPerMetre(1).AsHenriesPerMetre() + + Resistivity.FromOhmMetres(1).AsOhmMetres() + + Conductivity.FromSiemensPerMetre(1).AsSiemensPerMetre() + + MagneticReluctivity.FromMetresPerHenry(1).AsMetresPerHenry() + + MagneticVectorPotential.FromWebersPerMetre(1).AsWebersPerMetre(); + } + """; + + Compile(source); + } + [Theory] - [InlineData("Length", "Metres")] - [InlineData("Mass", "Grams")] - [InlineData("Time", "Seconds")] - [InlineData("Current", "Amps")] - [InlineData("Temperature", "Kelvin")] - [InlineData("AmountOfSubstance", "Moles")] - [InlineData("LuminousIntensity", "Candelas")] - [InlineData("Voltage", "Volts")] - public void EverySiPrefixHasUsableFactoryAndAccessor(string quantity, string unit) + [InlineData("Area", "LengthSquared")] + [InlineData("CurrentDensity", "CurrentPerLengthSquared")] + [InlineData("Pressure", "Stress")] + [InlineData("Pressure", "YoungsModulus")] + [InlineData("Energy", "Torque")] + public void CatalogueAliasesShareTheCanonicalDimensionType(string alias, string equivalent) + { + bool result = Evaluate($"typeof({alias}) == typeof({equivalent})"); + Assert.True(result); + } + + [Fact] + public void SharedUnitsAreAvailableThroughEveryAliasWithoutDuplicateHelpers() + { + const string source = """ + public static class Consumer + { + public static double UsePressureAliases() + { + Pressure pressure = Pressure.FromPascals(1); + Stress stress = Stress.FromPascals(1); + YoungsModulus modulus = YoungsModulus.FromKiloPascals(1); + return pressure.AsPascals() + stress.AsPascals() + modulus.AsKiloPascals(); + } + } + """; + + Compile(source); + } + + [Fact] + public void PrefixesAreOnlyGeneratedForSupportedFactorPlacements() + { + const string source = "public static class Consumer { public static double Value() => TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin(); }"; + CSharpCompilation result = Compile(source); + INamedTypeSymbol extensions = result.GetTypeByMetadataName("DimensionalQuantities.Generated.QuantityExtensions")!; + string[] names = extensions.GetMembers().OfType().Select(method => method.Name).ToArray(); + + Assert.Multiple( + () => Assert.DoesNotContain("FromInverseMilliKelvin", names), + () => Assert.DoesNotContain("AsInverseMilliKelvin", names), + () => Assert.DoesNotContain("FromMilliAmpsPerSquareMilliMetre", names), + () => Assert.DoesNotContain("FromKiloGramsPerCubicCentiMetre", names)); + } + + [Fact] + public void EverySiPrefixHasUsableFactoryAndAccessor() { string calls = string.Join("\n", _prefixes.Select(prefix => - $"{quantity} value{prefix} = {quantity}.From{prefix}{unit}(1); double output{prefix} = value{prefix}.As{prefix}{unit}();")); + $"Length value{prefix} = Length.From{prefix}Metres(1); double output{prefix} = value{prefix}.As{prefix}Metres();")); string source = $$""" public static class Consumer { @@ -77,35 +175,11 @@ public static void UsePrefixes() } [Theory] - [InlineData("Length.FromMilliMetres(2500)", 2.5)] - [InlineData("Mass.FromGrams(2500)", 2.5)] - [InlineData("Mass.FromKiloGrams(2.5)", 2.5)] - [InlineData("Mass.FromMilliGrams(2500000)", 2.5)] - [InlineData("Time.FromKiloSeconds(2.5)", 2500)] - [InlineData("Current.FromMilliAmps(2500)", 2.5)] - [InlineData("Temperature.FromMilliKelvin(2500)", 2.5)] - [InlineData("AmountOfSubstance.FromKiloMoles(2.5)", 2500)] - [InlineData("LuminousIntensity.FromMilliCandelas(2500)", 2.5)] - [InlineData("Voltage.FromMilliVolts(2500)", 2.5)] - [InlineData("Voltage.FromKiloVolts(2.5)", 2500)] - [InlineData("Voltage.FromRontoVolts(1)", 1e-27)] - [InlineData("Voltage.FromQuettaVolts(1)", 1e30)] - [InlineData("Voltage.FromQuectoVolts(1)", 1e-30)] - public void FactoryStoresCoherentSiMagnitude(string factory, double expected) - { - double result = Evaluate($"{factory}.AsSiBaseUnit()"); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("Mass.FromSiBaseUnit(2.5).AsGrams()", 2500)] - [InlineData("Mass.FromSiBaseUnit(2.5).AsKiloGrams()", 2.5)] - [InlineData("Mass.FromSiBaseUnit(2.5).AsMilliGrams()", 2500000)] - [InlineData("Voltage.FromSiBaseUnit(2.5).AsMilliVolts()", 2500)] + [InlineData("Length.FromMilliMetres(2500).AsSiBaseUnit()", 2.5)] [InlineData("Voltage.FromSiBaseUnit(2.5).AsKiloVolts()", 0.0025)] - [InlineData("Voltage.FromSiBaseUnit(2.5).AsDeciVolts()", 25)] - [InlineData("Voltage.FromSiBaseUnit(2.5).AsHectoVolts()", 0.025)] - public void AccessorConvertsFromCoherentSiMagnitude(string expression, double expected) + [InlineData("Mass.FromKiloGrams(2.5).AsSiBaseUnit()", 2.5)] + [InlineData("Density.FromGramsPerCubicCentiMetre(1).AsSiBaseUnit()", 1000)] + public void GeneratedConversionsApplyRepresentativeUnitScales(string expression, double expected) { double result = Evaluate(expression); Assert.Equal(expected, result); diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs index 4dbe035..aba73ae 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs @@ -20,7 +20,7 @@ private static string GenerateSource() { StringBuilder aliases = new(); StringBuilder extensions = new(); - KeyValuePair[] prefixes = SiPrefixes.Entries.OrderByDescending(entry => entry.Key).ToArray(); + int[] prefixes = SiPrefixes.Entries.Keys.OrderByDescending(exponent => exponent).ToArray(); Dimension[] baseDimensions = [ new(Length: 1), @@ -34,13 +34,13 @@ private static string GenerateSource() foreach (Dimension dimension in baseDimensions) { - DimensionComponent component = dimension.GetComponents().Single(); - AppendQuantity(aliases, extensions, prefixes, dimension, component.Unit); + AppendDimension(aliases, extensions, prefixes, dimension, [dimension.GetComponents().Single().Unit]); } - KeyValuePair voltage = EngineeringCatalogue.Entries.Single(entry => entry.Value.Any(unit => unit.QuantityName == "Voltage")); - UnitDefinition voltageUnit = voltage.Value.Single(unit => unit.QuantityName == "Voltage"); - AppendQuantity(aliases, extensions, prefixes, voltage.Key, voltageUnit); + foreach (KeyValuePair entry in EngineeringCatalogue.Entries) + { + AppendDimension(aliases, extensions, prefixes, entry.Key, entry.Value); + } return $$""" // @@ -54,40 +54,87 @@ internal static class QuantityExtensions """; } - private static void AppendQuantity(StringBuilder aliases, StringBuilder extensions, KeyValuePair[] prefixes, Dimension dimension, UnitDefinition unit) + private static void AppendDimension(StringBuilder aliases, StringBuilder extensions, int[] prefixes, Dimension dimension, UnitDefinition[] units) + { + AppendAliases(aliases, dimension, units); + IReadOnlyDictionary conversions = CollectConversions(units, prefixes); + string quantityName = units[0].QuantityName; + AppendFactories(extensions, quantityName, conversions); + AppendAccessors(extensions, quantityName, conversions); + } + + private static void AppendAliases(StringBuilder aliases, Dimension dimension, UnitDefinition[] units) { - string quantityName = unit.QuantityName; - string unitName = unit.PrefixableUnitNamePlural; - int siBasePrefixExponent = unit.SiBasePrefixExponent; - string mechanicalName = DimensionNameGenerator.GetQuantityName(dimension); string quantityType = $"global::DimensionalQuantities.TypeSystem.Quantity>"; - aliases.Append("global using ").Append(quantityName).Append(" = ").Append(quantityType).AppendLine(";"); - if (mechanicalName != quantityName) + string mechanicalName = DimensionNameGenerator.GetQuantityName(dimension); + IEnumerable names = units.Select(unit => unit.QuantityName).Append(mechanicalName).Distinct(StringComparer.Ordinal); + foreach (string name in names) { - aliases.Append("global using ").Append(mechanicalName).Append(" = ").Append(quantityType).AppendLine(";"); + aliases.Append("global using ").Append(name).Append(" = ").Append(quantityType).AppendLine(";"); } + } + + private static IReadOnlyDictionary CollectConversions(UnitDefinition[] units, int[] prefixes) + { + Dictionary conversions = new(StringComparer.Ordinal); + foreach ((string name, int scaleExponent) in units.SelectMany(unit => GetConversions(unit, prefixes))) + { + if (conversions.TryGetValue(name, out int existingExponent) && existingExponent != scaleExponent) + { + throw new InvalidOperationException($"Unit {name} has conflicting SI scales."); + } + + conversions.TryAdd(name, scaleExponent); + } + + return conversions; + } + private static void AppendFactories(StringBuilder extensions, string quantityName, IReadOnlyDictionary conversions) + { extensions.Append("\textension(").Append(quantityName).AppendLine(")") .AppendLine("\t{"); - AppendFactory(extensions, quantityName, unitName, -siBasePrefixExponent); - foreach (KeyValuePair prefix in prefixes) + foreach ((string name, int scaleExponent) in conversions) { - AppendFactory(extensions, quantityName, prefix.Value.Name + unitName, prefix.Key - siBasePrefixExponent); + AppendFactory(extensions, quantityName, name, scaleExponent); } + extensions.AppendLine("\t}").AppendLine(); + } + + private static void AppendAccessors(StringBuilder extensions, string quantityName, IReadOnlyDictionary conversions) + { string variableName = char.ToLowerInvariant(quantityName[0]) + quantityName[1..]; - extensions.AppendLine("\t}").AppendLine() - .Append("\textension(").Append(quantityName).Append(' ').Append(variableName).AppendLine(")") + extensions.Append("\textension(").Append(quantityName).Append(' ').Append(variableName).AppendLine(")") .AppendLine("\t{"); - AppendAccessor(extensions, variableName, unitName, -siBasePrefixExponent); - foreach (KeyValuePair prefix in prefixes) + foreach ((string name, int scaleExponent) in conversions) { - AppendAccessor(extensions, variableName, prefix.Value.Name + unitName, prefix.Key - siBasePrefixExponent); + AppendAccessor(extensions, variableName, name, scaleExponent); } extensions.AppendLine("\t}").AppendLine(); } + private static IEnumerable<(string Name, int ScaleExponent)> GetConversions(UnitDefinition unit, int[] prefixes) + => GetStandardConversions(unit.Expression, prefixes).Concat(unit.AdditionalConversions + .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent()))); + + private static IEnumerable<(string Name, int ScaleExponent)> GetStandardConversions(UnitExpression expression, int[] prefixes) + { + int firstNumerator = Array.FindIndex(expression.Terms, term => term.Power > 0); + if (firstNumerator < 0) + { + yield return (expression.GetName(), expression.GetSiScaleExponent()); + yield break; + } + + foreach (int prefix in prefixes.Prepend(0)) + { + UnitExpression prefixed = expression.WithPrefix(firstNumerator, prefix); + yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent()); + } + } + private static void AppendFactory(StringBuilder source, string quantityName, string unitName, int exponent) { source.Append("\t\tpublic static ").Append(quantityName).Append(" From").Append(unitName) diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs index 9f6616f..eadd11a 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs @@ -13,12 +13,12 @@ public void ComponentsPairNonzeroExponentsWithTheirNamesInSiOrder() IEnumerable result = original.GetComponents(); DimensionComponent[] expected = [ - new(1, new("Length", "Metre", "Metres", "m", "Metre", "Metres", "m", 0)), - new(-2, new("Mass", "Kilogram", "Kilograms", "kg", "Gram", "Grams", "g", 3)), - new(new(3, 2), new("Time", "Second", "Seconds", "s", "Second", "Seconds", "s", 0)), - new(4, new("Current", "Amp", "Amps", "A", "Amp", "Amps", "A", 0)), - new(-6, new("AmountOfSubstance", "Mole", "Moles", "mol", "Mole", "Moles", "mol", 0)), - new(7, new("LuminousIntensity", "Candela", "Candelas", "cd", "Candela", "Candelas", "cd", 0)) + new(1, new("Length", [new(AtomicUnits.Metre)])), + new(-2, new("Mass", [new(AtomicUnits.Gram, PrefixExponent: 3)])), + new(new(3, 2), new("Time", [new(AtomicUnits.Second)])), + new(4, new("Current", [new(AtomicUnits.Amp)])), + new(-6, new("AmountOfSubstance", [new(AtomicUnits.Mole)])), + new(7, new("LuminousIntensity", [new(AtomicUnits.Candela)])) ]; Assert.Equal(expected, result); diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs new file mode 100644 index 0000000..d4432d9 --- /dev/null +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs @@ -0,0 +1,215 @@ +using DimensionalQuantities.DimensionAnalysis; +using Xunit; + +namespace DimensionalQuantities.Tests.DimensionAnalysis; + +public sealed class UnitExpressionTests +{ + [Theory] + [InlineData(1, "Metres")] + [InlineData(2, "SquareMetres")] + [InlineData(3, "CubicMetres")] + [InlineData(5, "Power5Metres")] + public void GetNameFormatsAtomicPowers(int power, string expected) + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Metre, power)); + string result = original.GetName(); + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameFormatsCompoundNumeratorAndDenominator() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Amp), new UnitTerm(AtomicUnits.Second, -1)); + string result = original.GetName(); + string expected = "AmpsPerSecond"; + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNamePluralisesOnlyTheLastNumeratorFactor() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Newton), new UnitTerm(AtomicUnits.Metre)); + string result = original.GetName(); + string expected = "NewtonMetres"; + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameFormatsInverseWithSingularUnitName() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Second, -1)); + string result = original.GetName(); + string expected = "InverseSecond"; + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNamePreservesKelvinSpelling() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Kelvin)); + string result = original.GetName(); + string expected = "Kelvin"; + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameFormatsKilogramsWithFixedPrefixCasing() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, PrefixExponent: 3)); + string result = original.GetName(); + string expected = "Kilograms"; + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameCapitalisesThePrefixOfAVariableFactor() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, PrefixExponent: 3)); + string result = original.GetName(variableFactorIndex: 0); + string expected = "KiloGrams"; + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameFormatsReferenceAwareCurrentDensityDenominator() + { + UnitExpression currentDensityReference = new(new UnitTerm(AtomicUnits.Amp), new UnitTerm(AtomicUnits.Metre, -2)); + UnitExpression currentDensity = currentDensityReference.WithPrefix(1, -3); + string currentDensityResult = currentDensity.GetName(currentDensityReference); + string expected = "AmpsPerSquareMilliMetre"; + + Assert.Equal(expected, currentDensityResult); + } + + [Fact] + public void GetNameFormatsReferenceAwareMassDensityDenominator() + { + UnitExpression reference = new(new UnitTerm(AtomicUnits.Gram, PrefixExponent: 3), new UnitTerm(AtomicUnits.Metre, -3)); + UnitExpression original = new(new UnitTerm(AtomicUnits.Gram), new UnitTerm(AtomicUnits.Metre, -3, -2)); + string result = original.GetName(reference); + string expected = "GramsPerCubicCentiMetre"; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, 0, "m")] + [InlineData(2, 0, "m²")] + [InlineData(3, 0, "m³")] + [InlineData(1, -3, "mm")] + public void GetSymbolFormatsUnitPowerAndPrefix(int power, int prefixExponent, string expected) + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Metre, power, prefixExponent)); + string result = original.GetSymbol(); + + Assert.Equal(expected, result); + } + + [Fact] + public void GetSymbolFormatsInverseAndCompoundUnits() + { + UnitExpression inverse = new(new UnitTerm(AtomicUnits.Second, -1)); + UnitExpression compound = new(new UnitTerm(AtomicUnits.Amp), new UnitTerm(AtomicUnits.Metre, -2, -3)); + string inverseResult = inverse.GetSymbol(); + string compoundResult = compound.GetSymbol(); + + Assert.Multiple( + () => Assert.Equal("s⁻¹", inverseResult), + () => Assert.Equal("A/mm²", compoundResult)); + } + + [Theory] + [InlineData(1, -3, -3)] + [InlineData(1, 3, 3)] + [InlineData(2, -3, -6)] + [InlineData(-2, -3, 6)] + [InlineData(3, -3, -9)] + [InlineData(-3, -3, 9)] + public void GetSiScaleExponentAppliesMetrePrefixPowerAndSign(int power, int prefixExponent, int expected) + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Metre, power, prefixExponent)); + int result = original.GetSiScaleExponent(); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(3, 3)] + [InlineData(-3, -3)] + [InlineData(-27, -27)] + [InlineData(30, 30)] + [InlineData(-30, -30)] + public void GetSiScaleExponentAppliesVoltagePrefix(int prefixExponent, int expected) + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Volt, PrefixExponent: prefixExponent)); + int result = original.GetSiScaleExponent(); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(0, -3)] + [InlineData(3, 0)] + [InlineData(-3, -6)] + public void GetSiScaleExponentAccountsForGramBasePrefixOffset(int prefixExponent, int expected) + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, PrefixExponent: prefixExponent)); + int result = original.GetSiScaleExponent(); + + Assert.Equal(expected, result); + } + + [Fact] + public void GetSiScaleExponentCombinesReciprocalFactors() + { + UnitExpression ampPerSquareMillimetre = new(new UnitTerm(AtomicUnits.Amp), new UnitTerm(AtomicUnits.Metre, -2, -3)); + UnitExpression gramPerCubicCentimetre = new(new UnitTerm(AtomicUnits.Gram), new UnitTerm(AtomicUnits.Metre, -3, -2)); + int currentDensityResult = ampPerSquareMillimetre.GetSiScaleExponent(); + int massDensityResult = gramPerCubicCentimetre.GetSiScaleExponent(); + + Assert.Multiple( + () => Assert.Equal(6, currentDensityResult), + () => Assert.Equal(3, massDensityResult)); + } + + [Fact] + public void GetSiScaleExponentCancelsKilogramInWattsPerKilogram() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Watt), new UnitTerm(AtomicUnits.Gram, -1, 3)); + int result = original.GetSiScaleExponent(); + int expected = 0; + + Assert.Equal(expected, result); + } + + [Fact] + public void GetSiScaleExponentIncludesKilowattPrefix() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Watt, PrefixExponent: 3), new UnitTerm(AtomicUnits.Gram, -1, 3)); + int result = original.GetSiScaleExponent(); + int expected = 3; + + Assert.Equal(expected, result); + } + + [Fact] + public void WithPrefixChangesOnlyTheSelectedTermAndPreservesOriginal() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Amp), new UnitTerm(AtomicUnits.Metre, -2)); + UnitExpression result = original.WithPrefix(1, -3); + UnitExpression expected = new(new UnitTerm(AtomicUnits.Amp), new UnitTerm(AtomicUnits.Metre, -2, -3)); + UnitExpression expectedOriginal = new(new UnitTerm(AtomicUnits.Amp), new UnitTerm(AtomicUnits.Metre, -2)); + + Assert.Multiple( + () => Assert.Equal(expected, result), + () => Assert.Equal(expectedOriginal, original)); + } +} diff --git a/src/DimensionalQuantities/DimensionAnalysis/AtomicUnit.cs b/src/DimensionalQuantities/DimensionAnalysis/AtomicUnit.cs new file mode 100644 index 0000000..304ead3 --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/AtomicUnit.cs @@ -0,0 +1,3 @@ +namespace DimensionalQuantities.DimensionAnalysis; + +internal readonly record struct AtomicUnit(string NameSingular, string NamePlural, string Symbol, int SiBasePrefixExponent = 0); diff --git a/src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs b/src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs new file mode 100644 index 0000000..88c2612 --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs @@ -0,0 +1,24 @@ +namespace DimensionalQuantities.DimensionAnalysis; + +internal static class AtomicUnits +{ + public static readonly AtomicUnit Metre = new("Metre", "Metres", "m"); + public static readonly AtomicUnit Gram = new("Gram", "Grams", "g", 3); + public static readonly AtomicUnit Second = new("Second", "Seconds", "s"); + public static readonly AtomicUnit Amp = new("Amp", "Amps", "A"); + public static readonly AtomicUnit Kelvin = new("Kelvin", "Kelvin", "K"); + public static readonly AtomicUnit Mole = new("Mole", "Moles", "mol"); + public static readonly AtomicUnit Candela = new("Candela", "Candelas", "cd"); + public static readonly AtomicUnit Radian = new("Radian", "Radians", "rad"); + public static readonly AtomicUnit Hertz = new("Hertz", "Hertz", "Hz"); + public static readonly AtomicUnit Pascal = new("Pascal", "Pascals", "Pa"); + public static readonly AtomicUnit Joule = new("Joule", "Joules", "J"); + public static readonly AtomicUnit Newton = new("Newton", "Newtons", "N"); + public static readonly AtomicUnit Watt = new("Watt", "Watts", "W"); + public static readonly AtomicUnit Volt = new("Volt", "Volts", "V"); + public static readonly AtomicUnit Ohm = new("Ohm", "Ohms", "Ω"); + public static readonly AtomicUnit Henry = new("Henry", "Henries", "H"); + public static readonly AtomicUnit Weber = new("Weber", "Webers", "Wb"); + public static readonly AtomicUnit Tesla = new("Tesla", "Teslas", "T"); + public static readonly AtomicUnit Siemens = new("Siemens", "Siemens", "S"); +} diff --git a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs index 8db5023..6cf952f 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs @@ -12,13 +12,13 @@ internal readonly record struct Dimension( Rational AmountOfSubstance = default, Rational LuminousIntensity = default) { - private static readonly UnitDefinition _lengthUnit = new("Length", "Metre", "Metres", "m"); - private static readonly UnitDefinition _massUnit = new("Mass", "Kilogram", "Kilograms", "kg", "Gram", "Grams", "g", 3); - private static readonly UnitDefinition _timeUnit = new("Time", "Second", "Seconds", "s"); - private static readonly UnitDefinition _electricCurrentUnit = new("Current", "Amp", "Amps", "A"); - private static readonly UnitDefinition _temperatureUnit = new("Temperature", "Kelvin", "Kelvin", "K"); - private static readonly UnitDefinition _amountOfSubstanceUnit = new("AmountOfSubstance", "Mole", "Moles", "mol"); - private static readonly UnitDefinition _luminousIntensityUnit = new("LuminousIntensity", "Candela", "Candelas", "cd"); + private static readonly UnitDefinition _lengthUnit = new("Length", [new(AtomicUnits.Metre)]); + private static readonly UnitDefinition _massUnit = new("Mass", [new(AtomicUnits.Gram, PrefixExponent: 3)]); + private static readonly UnitDefinition _timeUnit = new("Time", [new(AtomicUnits.Second)]); + private static readonly UnitDefinition _electricCurrentUnit = new("Current", [new(AtomicUnits.Amp)]); + private static readonly UnitDefinition _temperatureUnit = new("Temperature", [new(AtomicUnits.Kelvin)]); + private static readonly UnitDefinition _amountOfSubstanceUnit = new("AmountOfSubstance", [new(AtomicUnits.Mole)]); + private static readonly UnitDefinition _luminousIntensityUnit = new("LuminousIntensity", [new(AtomicUnits.Candela)]); public static Dimension operator *(Dimension left, Dimension right) => left.Multiply(right); public static Dimension operator /(Dimension left, Dimension right) => left.Divide(right); diff --git a/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs b/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs index 879e67a..480bfcc 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs @@ -10,7 +10,7 @@ internal static class DimensionNameGenerator { public static string GetQuantityName(Dimension dimension) => GetName(dimension, static component => component.Unit.QuantityName); - public static string GetUnitName(Dimension dimension) => GetName(dimension, static component => component.Unit.SiUnitNamePlural); + public static string GetUnitName(Dimension dimension) => GetName(dimension, static component => component.Unit.Expression.GetName()); public static string GetUnitExpression(Dimension dimension) { @@ -23,7 +23,7 @@ public static string GetUnitExpression(Dimension dimension) StringBuilder expression = new(); foreach (DimensionComponent component in components) { - expression.Append(component.Unit.SiUnitSymbol); + expression.Append(component.Unit.Expression.GetSymbol()); if (component.Exponent != Rational.One) { expression.Append("").Append(component.Exponent.ToString(null, CultureInfo.InvariantCulture)).Append(""); diff --git a/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs b/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs index ff12e61..eb0a2a5 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs @@ -3,51 +3,49 @@ namespace DimensionalQuantities.DimensionAnalysis; /// Recognised names and units for engineering dimensions. internal static class EngineeringCatalogue { + private static readonly UnitExpression _density = new(new(AtomicUnits.Gram, PrefixExponent: 3), new(AtomicUnits.Metre, -3)); + private static readonly UnitExpression _specificPower = new(new(AtomicUnits.Watt), new(AtomicUnits.Gram, -1, 3)); + private static readonly UnitExpression _powerDensity = new(new(AtomicUnits.Watt), new(AtomicUnits.Metre, -3)); + private static readonly UnitExpression _magneticFieldStrength = new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, -1)); + private static readonly UnitExpression _currentDensity = new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, -2)); + public static readonly Dictionary Entries = new() { - { new(Length: 2), [new("Area", "SquareMetre", "SquareMetres", "m²")] }, - { new(Length: 3), [new("Volume", "CubicMetre", "CubicMetres", "m³")] }, - { new(Time: -1), [new("Frequency", "Hertz", "Hertz", "Hz"), new("AngularVelocity", "RadianPerSecond", "RadiansPerSecond", "rad/s")] }, - { new(Length: -3, Mass: 1), [new("Density", "KilogramPerCubicMetre", "KilogramsPerCubicMetre", "kg/m³", "GramPerCubicMetre", "GramsPerCubicMetre", "g/m³", 3)] }, - { new(Length: -1, Mass: 1, Time: -2), - [ - new("Pressure", "Pascal", "Pascals", "Pa"), - new("Stress", "Pascal", "Pascals", "Pa"), - new("YoungsModulus", "Pascal", "Pascals", "Pa") - ] }, - { new(Length: 2, Mass: 1, Time: -2), [new("Energy", "Joule", "Joules", "J"), new("Torque", "NewtonMetre", "NewtonMetres", "Nm")] }, - { new(Length: 2, Mass: 1, Time: -3), [new("Power", "Watt", "Watts", "W")] }, - { new(Length: 2, Time: -3), - [ - new("SpecificPower", "WattPerKilogram", "WattsPerKilogram", "W/kg"), - new("SpecificPowerLoss", "WattPerKilogram", "WattsPerKilogram", "W/kg") - ] }, - { new(Temperature: -1), [new("TemperatureCoefficient", "InverseKelvin", "InverseKelvin", "K⁻¹")] }, - { new(Length: -1, Mass: 1, Time: -3), - [ - new("PowerDensity", "WattPerCubicMetre", "WattsPerCubicMetre", "W/m³"), - new("VolumetricPowerLoss", "WattPerCubicMetre", "WattsPerCubicMetre", "W/m³") - ] }, - { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1), [new("Voltage", "Volt", "Volts", "V")] }, - { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistance", "Ohm", "Ohms", "Ω")] }, - { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Inductance", "Henry", "Henries", "H")] }, - { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFlux", "Weber", "Webers", "Wb"), new("FluxLinkage", "Weber", "Webers", "Wb")] }, - { new(Mass: 1, Time: -2, ElectricCurrent: -1), + { new(Length: 2), [new("Area", [new(AtomicUnits.Metre, 2)])] }, + { new(Length: 3), [new("Volume", [new(AtomicUnits.Metre, 3)])] }, + { new(Time: -1), [new("Frequency", [new(AtomicUnits.Hertz)]), new("AngularVelocity", [new(AtomicUnits.Radian), new(AtomicUnits.Second, -1)])] }, + { new(Length: -3, Mass: 1), [new("Density", _density, [_density.WithPrefix(0, 0).WithPrefix(1, -2)])] }, + { + new(Length: -1, Mass: 1, Time: -2), [ - new("MagneticFluxDensity", "Tesla", "Teslas", "T"), - new("MagneticPolarisation", "Tesla", "Teslas", "T"), - new("RemanentFluxDensity", "Tesla", "Teslas", "T") - ] }, - { new(Length: -1, ElectricCurrent: 1), + new("Pressure", [new(AtomicUnits.Pascal)]), + new("Stress", [new(AtomicUnits.Pascal)]), + new("YoungsModulus", [new(AtomicUnits.Pascal)]) + ] + }, + { new(Length: 2, Mass: 1, Time: -2), [new("Energy", [new(AtomicUnits.Joule)]), new("Torque", [new(AtomicUnits.Newton), new(AtomicUnits.Metre)])] }, + { new(Length: 2, Mass: 1, Time: -3), [new("Power", [new(AtomicUnits.Watt)])] }, + { new(Length: 2, Time: -3), [new("SpecificPower", _specificPower), new("SpecificPowerLoss", _specificPower)] }, + { new(Temperature: -1), [new("TemperatureCoefficient", [new(AtomicUnits.Kelvin, -1)])] }, + { new(Length: -1, Mass: 1, Time: -3), [new("PowerDensity", _powerDensity), new("VolumetricPowerLoss", _powerDensity)] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1), [new("Voltage", [new(AtomicUnits.Volt)])] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistance", [new(AtomicUnits.Ohm)])] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Inductance", [new(AtomicUnits.Henry)])] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFlux", [new(AtomicUnits.Weber)]), new("FluxLinkage", [new(AtomicUnits.Weber)])] }, + { + new(Mass: 1, Time: -2, ElectricCurrent: -1), [ - new("MagneticFieldStrength", "AmpPerMetre", "AmpsPerMetre", "A/m"), - new("CoerciveFieldStrength", "AmpPerMetre", "AmpsPerMetre", "A/m") - ] }, - { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", "AmpPerSquareMetre", "AmpsPerSquareMetre", "A/m²")] }, - { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", "HenryPerMetre", "HenriesPerMetre", "H/m")] }, - { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", "OhmMetre", "OhmMetres", "Ωm")] }, - { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", "SiemensPerMetre", "SiemensPerMetre", "S/m")] }, - { new(Length: -1, Mass: -1, Time: 2, ElectricCurrent: 2), [new("MagneticReluctivity", "MetrePerHenry", "MetresPerHenry", "m/H")] }, - { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticVectorPotential", "WeberPerMetre", "WebersPerMetre", "Wb/m")] }, + new("MagneticFluxDensity", [new(AtomicUnits.Tesla)]), + new("MagneticPolarisation", [new(AtomicUnits.Tesla)]), + new("RemanentFluxDensity", [new(AtomicUnits.Tesla)]) + ] + }, + { new(Length: -1, ElectricCurrent: 1), [new("MagneticFieldStrength", _magneticFieldStrength), new("CoerciveFieldStrength", _magneticFieldStrength)] }, + { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", _currentDensity, [_currentDensity.WithPrefix(1, -3)])] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", [new(AtomicUnits.Henry), new(AtomicUnits.Metre, -1)])] }, + { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", [new(AtomicUnits.Ohm), new(AtomicUnits.Metre)])] }, + { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", [new(AtomicUnits.Siemens), new(AtomicUnits.Metre, -1)])] }, + { new(Length: -1, Mass: -1, Time: 2, ElectricCurrent: 2), [new("MagneticReluctivity", [new(AtomicUnits.Metre), new(AtomicUnits.Henry, -1)])] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticVectorPotential", [new(AtomicUnits.Weber), new(AtomicUnits.Metre, -1)])] }, }; } diff --git a/src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs b/src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs index c6b3709..a7b4744 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs @@ -2,32 +2,16 @@ namespace DimensionalQuantities.DimensionAnalysis; internal readonly record struct UnitDefinition { - public UnitDefinition( - string quantityName, - string siUnitNameSingular, - string siUnitNamePlural, - string siUnitSymbol, - string? prefixableUnitNameSingular = null, - string? prefixableUnitNamePlural = null, - string? prefixableUnitSymbol = null, - int siBasePrefixExponent = 0) + public UnitDefinition(string quantityName, UnitExpression expression, UnitExpression[]? additionalConversions = null) { QuantityName = quantityName; - SiUnitNameSingular = siUnitNameSingular; - SiUnitNamePlural = siUnitNamePlural; - SiUnitSymbol = siUnitSymbol; - PrefixableUnitNameSingular = prefixableUnitNameSingular ?? siUnitNameSingular; - PrefixableUnitNamePlural = prefixableUnitNamePlural ?? siUnitNamePlural; - PrefixableUnitSymbol = prefixableUnitSymbol ?? siUnitSymbol; - SiBasePrefixExponent = siBasePrefixExponent; + Expression = expression; + AdditionalConversions = additionalConversions ?? []; } + public UnitDefinition(string quantityName, UnitTerm[] terms) : this(quantityName, new UnitExpression(terms)) { } + public string QuantityName { get; } - public string SiUnitNameSingular { get; } - public string SiUnitNamePlural { get; } - public string SiUnitSymbol { get; } - public string PrefixableUnitNameSingular { get; } - public string PrefixableUnitNamePlural { get; } - public string PrefixableUnitSymbol { get; } - public int SiBasePrefixExponent { get; } + public UnitExpression Expression { get; } + public UnitExpression[] AdditionalConversions { get; } } diff --git a/src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs b/src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs new file mode 100644 index 0000000..f232a71 --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs @@ -0,0 +1,135 @@ +using System.Globalization; +using System.Text; +using DimensionalQuantities.Units; + +namespace DimensionalQuantities.DimensionAnalysis; + +internal readonly record struct UnitExpression +{ + public UnitExpression(params UnitTerm[] terms) + { + Terms = terms; + } + + public UnitTerm[] Terms { get; } + + public UnitExpression WithPrefix(int factorIndex, int prefixExponent) + { + UnitTerm[] terms = Terms.ToArray(); + terms[factorIndex] = terms[factorIndex] with { PrefixExponent = prefixExponent }; + return new(terms); + } + + public int GetSiScaleExponent() => Terms.Sum(term => (term.PrefixExponent - term.Unit.SiBasePrefixExponent) * term.Power); + + public string GetName(UnitExpression? reference = null, int variableFactorIndex = -1) + { + StringBuilder numerator = new(); + StringBuilder denominator = new(); + bool hasNumerator = Terms.Any(term => term.Power > 0); + bool hasDenominator = Terms.Any(term => term.Power < 0); + int lastNumerator = Array.FindLastIndex(Terms, term => term.Power > 0); + + for (int index = 0; index < Terms.Length; index++) + { + UnitTerm term = Terms[index]; + StringBuilder destination = term.Power > 0 ? numerator : denominator; + AppendPowerName(destination, Math.Abs(term.Power)); + bool generatedPrefix = index == variableFactorIndex || + (reference is { } original && term.PrefixExponent != original.Terms[index].PrefixExponent); + AppendUnitName(destination, term, index == lastNumerator, generatedPrefix); + } + + if (!hasNumerator) + { + return "Inverse" + denominator; + } + + return hasDenominator ? numerator + "Per" + denominator : numerator.ToString(); + } + + public string GetSymbol() + { + StringBuilder numerator = new(); + StringBuilder denominator = new(); + bool hasNumerator = Terms.Any(term => term.Power > 0); + bool hasDenominator = Terms.Any(term => term.Power < 0); + foreach (UnitTerm term in Terms) + { + StringBuilder destination = term.Power > 0 ? numerator : denominator; + if (term.PrefixExponent != 0) + { + destination.Append(SiPrefixes.Entries[term.PrefixExponent].Symbol); + } + + destination.Append(term.Unit.Symbol); + if (Math.Abs(term.Power) != 1 || !hasNumerator) + { + AppendSuperscript(destination, hasNumerator ? Math.Abs(term.Power) : term.Power); + } + } + + if (!hasNumerator) + { + return denominator.ToString(); + } + + return hasDenominator ? numerator + "/" + denominator : numerator.ToString(); + } + + public bool Equals(UnitExpression other) => (Terms ?? []).SequenceEqual(other.Terms ?? []); + + public override int GetHashCode() + { + HashCode hash = new(); + foreach (UnitTerm term in Terms ?? []) + { + hash.Add(term); + } + + return hash.ToHashCode(); + } + + private static void AppendPowerName(StringBuilder name, int power) + { + if (power == 2) + { + name.Append("Square"); + } + else if (power == 3) + { + name.Append("Cubic"); + } + else if (power > 3) + { + name.Append("Power").Append(power.ToString(CultureInfo.InvariantCulture)); + } + } + + private static void AppendUnitName(StringBuilder name, UnitTerm term, bool plural, bool generatedPrefix) + { + string unitName = plural ? term.Unit.NamePlural : term.Unit.NameSingular; + if (term.PrefixExponent == 0) + { + name.Append(unitName); + return; + } + + name.Append(SiPrefixes.Entries[term.PrefixExponent].Name); + name.Append(generatedPrefix ? unitName : char.ToLowerInvariant(unitName[0]) + unitName[1..]); + } + + private static void AppendSuperscript(StringBuilder symbol, int exponent) + { + const string digits = "⁰¹²³⁴⁵⁶⁷⁸⁹"; + if (exponent < 0) + { + symbol.Append('⁻'); + } + + foreach (char digit in Math.Abs(exponent).ToString(CultureInfo.InvariantCulture)) + { + symbol.Append(digits[digit - '0']); + } + } +} diff --git a/src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs b/src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs new file mode 100644 index 0000000..398b56e --- /dev/null +++ b/src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs @@ -0,0 +1,3 @@ +namespace DimensionalQuantities.DimensionAnalysis; + +internal readonly record struct UnitTerm(AtomicUnit Unit, int Power = 1, int PrefixExponent = 0); From 403a20d78be8b6efbcb425f6f48e958e9332e735 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 16:16:08 +0100 Subject: [PATCH 19/25] refactor: clarify catalogue unit expressions with named SI prefixes --- .../DimensionAnalysis/EngineeringCatalogue.cs | 10 ++- src/DimensionalQuantities/Units/SiPrefixes.cs | 73 +++++++++++++------ 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs b/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs index eb0a2a5..e51e8a5 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs @@ -1,10 +1,12 @@ +using DimensionalQuantities.Units; + namespace DimensionalQuantities.DimensionAnalysis; /// Recognised names and units for engineering dimensions. internal static class EngineeringCatalogue { - private static readonly UnitExpression _density = new(new(AtomicUnits.Gram, PrefixExponent: 3), new(AtomicUnits.Metre, -3)); - private static readonly UnitExpression _specificPower = new(new(AtomicUnits.Watt), new(AtomicUnits.Gram, -1, 3)); + private static readonly UnitExpression _density = new(new(AtomicUnits.Gram, PrefixExponent: SiPrefixes.Kilo), new(AtomicUnits.Metre, -3)); + private static readonly UnitExpression _specificPower = new(new(AtomicUnits.Watt), new(AtomicUnits.Gram, -1, SiPrefixes.Kilo)); private static readonly UnitExpression _powerDensity = new(new(AtomicUnits.Watt), new(AtomicUnits.Metre, -3)); private static readonly UnitExpression _magneticFieldStrength = new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, -1)); private static readonly UnitExpression _currentDensity = new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, -2)); @@ -14,7 +16,7 @@ internal static class EngineeringCatalogue { new(Length: 2), [new("Area", [new(AtomicUnits.Metre, 2)])] }, { new(Length: 3), [new("Volume", [new(AtomicUnits.Metre, 3)])] }, { new(Time: -1), [new("Frequency", [new(AtomicUnits.Hertz)]), new("AngularVelocity", [new(AtomicUnits.Radian), new(AtomicUnits.Second, -1)])] }, - { new(Length: -3, Mass: 1), [new("Density", _density, [_density.WithPrefix(0, 0).WithPrefix(1, -2)])] }, + { new(Length: -3, Mass: 1), [new("Density", _density, [new(new(AtomicUnits.Gram), new(AtomicUnits.Metre, Power: -3, PrefixExponent: SiPrefixes.Centi))])] }, { new(Length: -1, Mass: 1, Time: -2), [ @@ -41,7 +43,7 @@ internal static class EngineeringCatalogue ] }, { new(Length: -1, ElectricCurrent: 1), [new("MagneticFieldStrength", _magneticFieldStrength), new("CoerciveFieldStrength", _magneticFieldStrength)] }, - { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", _currentDensity, [_currentDensity.WithPrefix(1, -3)])] }, + { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", _currentDensity, [new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, Power: -2, PrefixExponent: SiPrefixes.Milli))])] }, { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", [new(AtomicUnits.Henry), new(AtomicUnits.Metre, -1)])] }, { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", [new(AtomicUnits.Ohm), new(AtomicUnits.Metre)])] }, { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", [new(AtomicUnits.Siemens), new(AtomicUnits.Metre, -1)])] }, diff --git a/src/DimensionalQuantities/Units/SiPrefixes.cs b/src/DimensionalQuantities/Units/SiPrefixes.cs index 68b1925..ee7e7ed 100644 --- a/src/DimensionalQuantities/Units/SiPrefixes.cs +++ b/src/DimensionalQuantities/Units/SiPrefixes.cs @@ -2,31 +2,56 @@ namespace DimensionalQuantities.Units; internal static class SiPrefixes { + public const int Quetta = 30; + public const int Ronna = 27; + public const int Yotta = 24; + public const int Zetta = 21; + public const int Exa = 18; + public const int Peta = 15; + public const int Tera = 12; + public const int Giga = 9; + public const int Mega = 6; + public const int Kilo = 3; + public const int Hecto = 2; + public const int Deca = 1; + public const int Deci = -1; + public const int Centi = -2; + public const int Milli = -3; + public const int Micro = -6; + public const int Nano = -9; + public const int Pico = -12; + public const int Femto = -15; + public const int Atto = -18; + public const int Zepto = -21; + public const int Yocto = -24; + public const int Ronto = -27; + public const int Quecto = -30; + public static readonly Dictionary Entries = new() { - [30] = new("Quetta", "Q"), - [27] = new("Ronna", "R"), - [24] = new("Yotta", "Y"), - [21] = new("Zetta", "Z"), - [18] = new("Exa", "E"), - [15] = new("Peta", "P"), - [12] = new("Tera", "T"), - [9] = new("Giga", "G"), - [6] = new("Mega", "M"), - [3] = new("Kilo", "k"), - [2] = new("Hecto", "h"), - [1] = new("Deca", "da"), - [-1] = new("Deci", "d"), - [-2] = new("Centi", "c"), - [-3] = new("Milli", "m"), - [-6] = new("Micro", "µ"), - [-9] = new("Nano", "n"), - [-12] = new("Pico", "p"), - [-15] = new("Femto", "f"), - [-18] = new("Atto", "a"), - [-21] = new("Zepto", "z"), - [-24] = new("Yocto", "y"), - [-27] = new("Ronto", "r"), - [-30] = new("Quecto", "q") + [Quetta] = new("Quetta", "Q"), + [Ronna] = new("Ronna", "R"), + [Yotta] = new("Yotta", "Y"), + [Zetta] = new("Zetta", "Z"), + [Exa] = new("Exa", "E"), + [Peta] = new("Peta", "P"), + [Tera] = new("Tera", "T"), + [Giga] = new("Giga", "G"), + [Mega] = new("Mega", "M"), + [Kilo] = new("Kilo", "k"), + [Hecto] = new("Hecto", "h"), + [Deca] = new("Deca", "da"), + [Deci] = new("Deci", "d"), + [Centi] = new("Centi", "c"), + [Milli] = new("Milli", "m"), + [Micro] = new("Micro", "µ"), + [Nano] = new("Nano", "n"), + [Pico] = new("Pico", "p"), + [Femto] = new("Femto", "f"), + [Atto] = new("Atto", "a"), + [Zepto] = new("Zepto", "z"), + [Yocto] = new("Yocto", "y"), + [Ronto] = new("Ronto", "r"), + [Quecto] = new("Quecto", "q") }; } From 4f1686b128748aeb22fec689f39bf5887ae02e88 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 16:56:21 +0100 Subject: [PATCH 20/25] feat: centralise quantity conversions and generate prefixed unit helpers --- .../Generators/QuantityGeneratorTests.cs | 28 +++- .../Generators/QuantityGenerator.cs | 121 +++++++++++++----- .../DimensionAnalysis/DimensionTests.cs | 2 +- .../DimensionAnalysis/UnitExpressionTests.cs | 80 +++++++++--- .../TypeSystem/QuantityTests.cs | 93 ++++++++++++++ .../DimensionAnalysis/AtomicUnits.cs | 16 ++- .../DimensionAnalysis/Dimension.cs | 15 ++- .../DimensionAnalysis/EngineeringCatalogue.cs | 6 +- .../DimensionAnalysis/UnitExpression.cs | 68 +++++++--- .../DimensionAnalysis/UnitTerm.cs | 16 ++- .../TypeSystem/Quantity.cs | 65 ++++++++++ .../Units/SiBasePrefixExponents.cs | 12 ++ 12 files changed, 441 insertions(+), 81 deletions(-) create mode 100644 src/DimensionalQuantities/Units/SiBasePrefixExponents.cs diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs index 18e2903..28ecd6d 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs @@ -146,8 +146,8 @@ public void PrefixesAreOnlyGeneratedForSupportedFactorPlacements() { const string source = "public static class Consumer { public static double Value() => TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin(); }"; CSharpCompilation result = Compile(source); - INamedTypeSymbol extensions = result.GetTypeByMetadataName("DimensionalQuantities.Generated.QuantityExtensions")!; - string[] names = extensions.GetMembers().OfType().Select(method => method.Name).ToArray(); + INamespaceSymbol generated = result.GetTypeByMetadataName("DimensionalQuantities.Generated.LengthExtensions")!.ContainingNamespace; + string[] names = generated.GetTypeMembers().SelectMany(type => type.GetMembers().OfType()).Select(method => method.Name).ToArray(); Assert.Multiple( () => Assert.DoesNotContain("FromInverseMilliKelvin", names), @@ -185,6 +185,30 @@ public void GeneratedConversionsApplyRepresentativeUnitScales(string expression, Assert.Equal(expected, result); } + [Theory] + [InlineData("Voltage.FromPrefixedUnits(2, -3).AsSiBaseUnit()", 0.002)] + [InlineData("CurrentDensity.FromPrefixedUnits(1, ampPrefixExponent: 0, metrePrefixExponent: -3).AsSiBaseUnit()", 1000000)] + [InlineData("SpecificPower.FromPrefixedUnits(1, wattPrefixExponent: 3, gramPrefixExponent: 3).AsSiBaseUnit()", 1000)] + [InlineData("TemperatureCoefficient.FromPrefixedUnits(1, kelvinPrefixExponent: -3).AsSiBaseUnit()", 1000)] + [InlineData("Mass.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] + [InlineData("Torque.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] + public void PrefixedUnitFactoriesUseCanonicalUnitFactors(string expression, double expected) + { + double result = Evaluate(expression); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("Voltage.FromSiBaseUnit(0.002).AsPrefixedUnits(-3)", 2)] + [InlineData("CurrentDensity.FromSiBaseUnit(1000000).AsPrefixedUnits(ampPrefixExponent: 0, metrePrefixExponent: -3)", 1)] + [InlineData("Mass.FromSiBaseUnit(1).AsPrefixedUnits(gramPrefixExponent: 0)", 1000)] + [InlineData("Torque.FromSiBaseUnit(2).AsPrefixedUnits()", 2)] + public void PrefixedUnitAccessorsUseCanonicalUnitFactors(string expression, double expected) + { + double result = Evaluate(expression); + Assert.Equal(expected, result); + } + private static T Evaluate(string expression) { string source = $$""" diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs index aba73ae..4315ddf 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text; using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.Numerics; using DimensionalQuantities.TypeSystem; using DimensionalQuantities.Units; using Microsoft.CodeAnalysis; @@ -48,19 +49,20 @@ private static string GenerateSource() namespace DimensionalQuantities.Generated; - internal static class QuantityExtensions - { - {{extensions}}} + {{extensions}} """; } private static void AppendDimension(StringBuilder aliases, StringBuilder extensions, int[] prefixes, Dimension dimension, UnitDefinition[] units) { AppendAliases(aliases, dimension, units); - IReadOnlyDictionary conversions = CollectConversions(units, prefixes); + IReadOnlyDictionary conversions = CollectConversions(units, prefixes); string quantityName = units[0].QuantityName; - AppendFactories(extensions, quantityName, conversions); - AppendAccessors(extensions, quantityName, conversions); + UnitExpression canonicalExpression = units[0].Expression; + extensions.Append("internal static class ").Append(quantityName).AppendLine("Extensions").AppendLine("{"); + AppendFactories(extensions, quantityName, canonicalExpression, conversions); + AppendAccessors(extensions, quantityName, canonicalExpression, conversions); + extensions.AppendLine("}").AppendLine(); } private static void AppendAliases(StringBuilder aliases, Dimension dimension, UnitDefinition[] units) @@ -74,12 +76,12 @@ private static void AppendAliases(StringBuilder aliases, Dimension dimension, Un } } - private static IReadOnlyDictionary CollectConversions(UnitDefinition[] units, int[] prefixes) + private static IReadOnlyDictionary CollectConversions(UnitDefinition[] units, int[] prefixes) { - Dictionary conversions = new(StringComparer.Ordinal); - foreach ((string name, int scaleExponent) in units.SelectMany(unit => GetConversions(unit, prefixes))) + Dictionary conversions = new(StringComparer.Ordinal); + foreach ((string name, Rational scaleExponent) in units.SelectMany(unit => GetConversions(unit, prefixes))) { - if (conversions.TryGetValue(name, out int existingExponent) && existingExponent != scaleExponent) + if (conversions.TryGetValue(name, out Rational existingExponent) && existingExponent != scaleExponent) { throw new InvalidOperationException($"Unit {name} has conflicting SI scales."); } @@ -90,11 +92,11 @@ private static IReadOnlyDictionary CollectConversions(UnitDefinitio return conversions; } - private static void AppendFactories(StringBuilder extensions, string quantityName, IReadOnlyDictionary conversions) + private static void AppendFactories(StringBuilder extensions, string quantityName, UnitExpression canonicalExpression, IReadOnlyDictionary conversions) { - extensions.Append("\textension(").Append(quantityName).AppendLine(")") - .AppendLine("\t{"); - foreach ((string name, int scaleExponent) in conversions) + extensions.Append("\textension(").Append(quantityName).AppendLine(")").AppendLine("\t{"); + AppendGenericFactory(extensions, quantityName, canonicalExpression); + foreach ((string name, Rational scaleExponent) in conversions) { AppendFactory(extensions, quantityName, name, scaleExponent); } @@ -102,12 +104,12 @@ private static void AppendFactories(StringBuilder extensions, string quantityNam extensions.AppendLine("\t}").AppendLine(); } - private static void AppendAccessors(StringBuilder extensions, string quantityName, IReadOnlyDictionary conversions) + private static void AppendAccessors(StringBuilder extensions, string quantityName, UnitExpression canonicalExpression, IReadOnlyDictionary conversions) { string variableName = char.ToLowerInvariant(quantityName[0]) + quantityName[1..]; - extensions.Append("\textension(").Append(quantityName).Append(' ').Append(variableName).AppendLine(")") - .AppendLine("\t{"); - foreach ((string name, int scaleExponent) in conversions) + extensions.Append("\textension(").Append(quantityName).Append(' ').Append(variableName).AppendLine(")").AppendLine("\t{"); + AppendGenericAccessor(extensions, variableName, canonicalExpression); + foreach ((string name, Rational scaleExponent) in conversions) { AppendAccessor(extensions, variableName, name, scaleExponent); } @@ -115,13 +117,13 @@ private static void AppendAccessors(StringBuilder extensions, string quantityNam extensions.AppendLine("\t}").AppendLine(); } - private static IEnumerable<(string Name, int ScaleExponent)> GetConversions(UnitDefinition unit, int[] prefixes) + private static IEnumerable<(string Name, Rational ScaleExponent)> GetConversions(UnitDefinition unit, int[] prefixes) => GetStandardConversions(unit.Expression, prefixes).Concat(unit.AdditionalConversions .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent()))); - private static IEnumerable<(string Name, int ScaleExponent)> GetStandardConversions(UnitExpression expression, int[] prefixes) + private static IEnumerable<(string Name, Rational ScaleExponent)> GetStandardConversions(UnitExpression expression, int[] prefixes) { - int firstNumerator = Array.FindIndex(expression.Terms, term => term.Power > 0); + int firstNumerator = Array.FindIndex(expression.Terms, term => term.Power > Rational.Zero); if (firstNumerator < 0) { yield return (expression.GetName(), expression.GetSiScaleExponent()); @@ -135,29 +137,88 @@ private static void AppendAccessors(StringBuilder extensions, string quantityNam } } - private static void AppendFactory(StringBuilder source, string quantityName, string unitName, int exponent) + private static void AppendGenericFactory(StringBuilder source, string quantityName, UnitExpression expression) + { + source.Append("\t\tpublic static ").Append(quantityName).Append(" FromPrefixedUnits(double value"); + AppendTermParameters(source, expression); + source.Append(") => ").Append(quantityName).Append(".FromArbitrarySiComponents(value, overallScaleExponent: "); + AppendTermScaleExponent(source, expression); + source.AppendLine(");"); + } + + private static void AppendGenericAccessor(StringBuilder source, string variableName, UnitExpression expression) + { + source.Append("\t\tpublic double AsPrefixedUnits("); + AppendTermParameters(source, expression, false); + source.Append(") => ").Append(variableName).Append(".AsArbitrarySiComponents(overallScaleExponent: "); + AppendTermScaleExponent(source, expression); + source.AppendLine(");"); + } + + private static void AppendTermParameters(StringBuilder source, UnitExpression expression, bool leadingComma = true) + { + for (int index = 0; index < expression.Terms.Length; index++) + { + UnitTerm term = expression.Terms[index]; + source.Append(index > 0 || leadingComma ? ", " : "").Append("int ").Append(GetPrefixParameterName(term)) + .Append(" = ").Append(term.PrefixExponent.ToString(CultureInfo.InvariantCulture)); + } + } + + private static void AppendTermScaleExponent(StringBuilder source, UnitExpression expression) + { + for (int index = 0; index < expression.Terms.Length; index++) + { + if (index > 0) + { + source.Append(" + "); + } + + UnitTerm term = expression.Terms[index]; + string parameterName = GetPrefixParameterName(term); + source.Append("((double)").Append(parameterName); + if (term.Unit.SiBasePrefixExponent != 0) + { + source.Append(" - ").Append(term.Unit.SiBasePrefixExponent.ToString(CultureInfo.InvariantCulture)); + } + + source.Append(')'); + if (term.Power != Rational.One) + { + source.Append(" * ").Append(term.Power.Denominator.IsOne + ? term.Power.Numerator.ToString(CultureInfo.InvariantCulture) + : "(" + term.Power.Numerator.ToString(CultureInfo.InvariantCulture) + "d / " + term.Power.Denominator.ToString(CultureInfo.InvariantCulture) + "d)"); + } + } + } + + private static string GetPrefixParameterName(UnitTerm term) => char.ToLowerInvariant(term.Unit.NameSingular[0]) + term.Unit.NameSingular[1..] + "PrefixExponent"; + + private static void AppendFactory(StringBuilder source, string quantityName, string unitName, Rational exponent) { source.Append("\t\tpublic static ").Append(quantityName).Append(" From").Append(unitName) - .Append("(double value) => ").Append(quantityName).Append(".FromSiBaseUnit(value"); - if (exponent != 0) + .Append("(double value) => ").Append(quantityName).Append(".FromArbitrarySiComponents(value"); + if (exponent != Rational.Zero) { - source.Append(" * 1e").Append(exponent.ToString(CultureInfo.InvariantCulture)); + source.Append(", overallScaleExponent: ").Append(FormatExponent(exponent)); } source.AppendLine(");"); } - private static void AppendAccessor(StringBuilder source, string variableName, string unitName, int exponent) + private static void AppendAccessor(StringBuilder source, string variableName, string unitName, Rational exponent) { - source.Append("\t\tpublic double As").Append(unitName).Append("() => ").Append(variableName).Append(".AsSiBaseUnit()"); - if (exponent != 0) + source.Append("\t\tpublic double As").Append(unitName).Append("() => ").Append(variableName).Append(".AsArbitrarySiComponents("); + if (exponent != Rational.Zero) { - source.Append(" / 1e").Append(exponent.ToString(CultureInfo.InvariantCulture)); + source.Append("overallScaleExponent: ").Append(FormatExponent(exponent)); } - source.AppendLine(";"); + source.AppendLine(");"); } + private static string FormatExponent(Rational exponent) => double.CreateChecked(exponent).ToString("R", CultureInfo.InvariantCulture) + "d"; + private static Type[] GetExponentTypes(Dimension dimension) => [ TypeSystemNumberCodec.CreateType(dimension.Length), diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs index eadd11a..18941ce 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs @@ -14,7 +14,7 @@ public void ComponentsPairNonzeroExponentsWithTheirNamesInSiOrder() DimensionComponent[] expected = [ new(1, new("Length", [new(AtomicUnits.Metre)])), - new(-2, new("Mass", [new(AtomicUnits.Gram, PrefixExponent: 3)])), + new(-2, new("Mass", [new(AtomicUnits.Gram, prefixExponent: 3)])), new(new(3, 2), new("Time", [new(AtomicUnits.Second)])), new(4, new("Current", [new(AtomicUnits.Amp)])), new(-6, new("AmountOfSubstance", [new(AtomicUnits.Mole)])), diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs b/src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs index d4432d9..cbd7fc8 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs +++ b/src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs @@ -1,4 +1,5 @@ using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.Numerics; using Xunit; namespace DimensionalQuantities.Tests.DimensionAnalysis; @@ -18,6 +19,20 @@ public void GetNameFormatsAtomicPowers(int power, string expected) Assert.Equal(expected, result); } + [Theory] + [InlineData(1, 2, "SquareRootMetres")] + [InlineData(3, 2, "SquareRootCubicMetres")] + [InlineData(2, 3, "CubeRootSquareMetres")] + [InlineData(1, 4, "Root4Metres")] + [InlineData(-1, 2, "InverseSquareRootMetre")] + public void GetNameFormatsFractionalPowers(int numerator, int denominator, string expected) + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Metre, new Rational(numerator, denominator))); + string result = original.GetName(); + + Assert.Equal(expected, result); + } + [Fact] public void GetNameFormatsCompoundNumeratorAndDenominator() { @@ -61,7 +76,7 @@ public void GetNamePreservesKelvinSpelling() [Fact] public void GetNameFormatsKilogramsWithFixedPrefixCasing() { - UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, PrefixExponent: 3)); + UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, prefixExponent: 3)); string result = original.GetName(); string expected = "Kilograms"; @@ -71,7 +86,7 @@ public void GetNameFormatsKilogramsWithFixedPrefixCasing() [Fact] public void GetNameCapitalisesThePrefixOfAVariableFactor() { - UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, PrefixExponent: 3)); + UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, prefixExponent: 3)); string result = original.GetName(variableFactorIndex: 0); string expected = "KiloGrams"; @@ -92,7 +107,7 @@ public void GetNameFormatsReferenceAwareCurrentDensityDenominator() [Fact] public void GetNameFormatsReferenceAwareMassDensityDenominator() { - UnitExpression reference = new(new UnitTerm(AtomicUnits.Gram, PrefixExponent: 3), new UnitTerm(AtomicUnits.Metre, -3)); + UnitExpression reference = new(new UnitTerm(AtomicUnits.Gram, prefixExponent: 3), new UnitTerm(AtomicUnits.Metre, -3)); UnitExpression original = new(new UnitTerm(AtomicUnits.Gram), new UnitTerm(AtomicUnits.Metre, -3, -2)); string result = original.GetName(reference); string expected = "GramsPerCubicCentiMetre"; @@ -113,6 +128,18 @@ public void GetSymbolFormatsUnitPowerAndPrefix(int power, int prefixExponent, st Assert.Equal(expected, result); } + [Theory] + [InlineData(1, 2, "m¹⁄²")] + [InlineData(3, 2, "m³⁄²")] + [InlineData(-1, 2, "m⁻¹⁄²")] + public void GetSymbolFormatsFractionalPowers(int numerator, int denominator, string expected) + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Metre, new Rational(numerator, denominator))); + string result = original.GetSymbol(); + + Assert.Equal(expected, result); + } + [Fact] public void GetSymbolFormatsInverseAndCompoundUnits() { @@ -136,7 +163,30 @@ public void GetSymbolFormatsInverseAndCompoundUnits() public void GetSiScaleExponentAppliesMetrePrefixPowerAndSign(int power, int prefixExponent, int expected) { UnitExpression original = new(new UnitTerm(AtomicUnits.Metre, power, prefixExponent)); - int result = original.GetSiScaleExponent(); + Rational result = original.GetSiScaleExponent(); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(1, 2, -3, -3, 2)] + [InlineData(-1, 2, -3, 3, 2)] + [InlineData(3, 2, -3, -9, 2)] + public void GetSiScaleExponentAppliesFractionalPower(int numerator, int denominator, int prefixExponent, int expectedNumerator, int expectedDenominator) + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Metre, new Rational(numerator, denominator), prefixExponent)); + Rational result = original.GetSiScaleExponent(); + Rational expected = new(expectedNumerator, expectedDenominator); + + Assert.Equal(expected, result); + } + + [Fact] + public void GetSiScaleExponentCombinesFractionalGramOffsetAndPrefixedDenominator() + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, new Rational(1, 2)), new UnitTerm(AtomicUnits.Second, -1, -3)); + Rational result = original.GetSiScaleExponent(); + Rational expected = new(3, 2); Assert.Equal(expected, result); } @@ -149,8 +199,8 @@ public void GetSiScaleExponentAppliesMetrePrefixPowerAndSign(int power, int pref [InlineData(-30, -30)] public void GetSiScaleExponentAppliesVoltagePrefix(int prefixExponent, int expected) { - UnitExpression original = new(new UnitTerm(AtomicUnits.Volt, PrefixExponent: prefixExponent)); - int result = original.GetSiScaleExponent(); + UnitExpression original = new(new UnitTerm(AtomicUnits.Volt, prefixExponent: prefixExponent)); + Rational result = original.GetSiScaleExponent(); Assert.Equal(expected, result); } @@ -161,8 +211,8 @@ public void GetSiScaleExponentAppliesVoltagePrefix(int prefixExponent, int expec [InlineData(-3, -6)] public void GetSiScaleExponentAccountsForGramBasePrefixOffset(int prefixExponent, int expected) { - UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, PrefixExponent: prefixExponent)); - int result = original.GetSiScaleExponent(); + UnitExpression original = new(new UnitTerm(AtomicUnits.Gram, prefixExponent: prefixExponent)); + Rational result = original.GetSiScaleExponent(); Assert.Equal(expected, result); } @@ -172,8 +222,8 @@ public void GetSiScaleExponentCombinesReciprocalFactors() { UnitExpression ampPerSquareMillimetre = new(new UnitTerm(AtomicUnits.Amp), new UnitTerm(AtomicUnits.Metre, -2, -3)); UnitExpression gramPerCubicCentimetre = new(new UnitTerm(AtomicUnits.Gram), new UnitTerm(AtomicUnits.Metre, -3, -2)); - int currentDensityResult = ampPerSquareMillimetre.GetSiScaleExponent(); - int massDensityResult = gramPerCubicCentimetre.GetSiScaleExponent(); + Rational currentDensityResult = ampPerSquareMillimetre.GetSiScaleExponent(); + Rational massDensityResult = gramPerCubicCentimetre.GetSiScaleExponent(); Assert.Multiple( () => Assert.Equal(6, currentDensityResult), @@ -184,8 +234,8 @@ public void GetSiScaleExponentCombinesReciprocalFactors() public void GetSiScaleExponentCancelsKilogramInWattsPerKilogram() { UnitExpression original = new(new UnitTerm(AtomicUnits.Watt), new UnitTerm(AtomicUnits.Gram, -1, 3)); - int result = original.GetSiScaleExponent(); - int expected = 0; + Rational result = original.GetSiScaleExponent(); + Rational expected = Rational.Zero; Assert.Equal(expected, result); } @@ -193,9 +243,9 @@ public void GetSiScaleExponentCancelsKilogramInWattsPerKilogram() [Fact] public void GetSiScaleExponentIncludesKilowattPrefix() { - UnitExpression original = new(new UnitTerm(AtomicUnits.Watt, PrefixExponent: 3), new UnitTerm(AtomicUnits.Gram, -1, 3)); - int result = original.GetSiScaleExponent(); - int expected = 3; + UnitExpression original = new(new UnitTerm(AtomicUnits.Watt, prefixExponent: 3), new UnitTerm(AtomicUnits.Gram, -1, 3)); + Rational result = original.GetSiScaleExponent(); + Rational expected = 3; Assert.Equal(expected, result); } diff --git a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs index a59059d..3c81253 100644 --- a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs +++ b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs @@ -1,4 +1,12 @@ using Xunit; +using AllAxes = DimensionalQuantities.TypeSystem.Quantity, + DimensionalQuantities.TypeSystem.TerminalHex3, + DimensionalQuantities.TypeSystem.Negative, + DimensionalQuantities.TypeSystem.TerminalHex1, + DimensionalQuantities.TypeSystem.TerminalHex4>>; using Current = DimensionalQuantities.TypeSystem.Quantity>; +using Length = DimensionalQuantities.TypeSystem.Quantity>; +using Mass = DimensionalQuantities.TypeSystem.Quantity>; +using SquareRootLength = DimensionalQuantities.TypeSystem.Quantity, + DimensionalQuantities.TypeSystem.TerminalHex0, + DimensionalQuantities.TypeSystem.TerminalHex0, + DimensionalQuantities.TypeSystem.TerminalHex0, + DimensionalQuantities.TypeSystem.TerminalHex0, + DimensionalQuantities.TypeSystem.TerminalHex0, + DimensionalQuantities.TypeSystem.TerminalHex0>>; namespace DimensionalQuantities.Tests.TypeSystem; @@ -24,6 +56,67 @@ public void ConstructionPreservesSiMagnitude(double expected) Assert.Equal(expected, result); } + [Fact] + public void ArbitraryComponentsUseKilogramsByDefaultAndAllowGrams() + { + Mass original = Mass.FromArbitrarySiComponents(2, massPrefixExponent: 0); + double siResult = original.AsSiBaseUnit(); + double gramResult = original.AsArbitrarySiComponents(massPrefixExponent: 0); + + Assert.Multiple( + () => Assert.Equal(0.002, siResult), + () => Assert.Equal(2, gramResult), + () => Assert.Equal(2, Mass.FromArbitrarySiComponents(2).AsSiBaseUnit())); + } + + [Fact] + public void ArbitraryComponentsApplyAllSevenAxisPrefixesAndOverallScale() + { + AllAxes original = AllAxes.FromArbitrarySiComponents(2, 1, 0, 2, 3, 4, 5, 6, 2); + double result = original.AsSiBaseUnit(); + double expected = 2e25; + + Assert.Equal(expected, result); + } + + [Fact] + public void ArbitraryComponentsApplyFractionalDimensionPower() + { + SquareRootLength original = SquareRootLength.FromArbitrarySiComponents(4, lengthPrefixExponent: -3); + double result = original.AsSiBaseUnit(); + double expected = 4 * Math.Sqrt(0.001); + + Assert.Equal(expected, result, 12); + } + + [Fact] + public void ArbitraryComponentsUseEachClosedQuantityDimensionAcrossInterleavedConversions() + { + Length firstLength = Length.FromArbitrarySiComponents(2, lengthPrefixExponent: -2); + SquareRootLength squareRootLength = SquareRootLength.FromArbitrarySiComponents(2, lengthPrefixExponent: -2); + Length secondLength = Length.FromArbitrarySiComponents(3, lengthPrefixExponent: -2); + double firstLengthResult = firstLength.AsSiBaseUnit(); + double squareRootLengthResult = squareRootLength.AsSiBaseUnit(); + double secondLengthResult = secondLength.AsSiBaseUnit(); + double squareRootLengthComponentsResult = squareRootLength.AsArbitrarySiComponents(lengthPrefixExponent: -2); + + Assert.Multiple( + () => Assert.Equal(0.02, firstLengthResult, 12), + () => Assert.Equal(0.2, squareRootLengthResult, 12), + () => Assert.Equal(0.03, secondLengthResult, 12), + () => Assert.Equal(2, squareRootLengthComponentsResult, 12)); + } + + [Fact] + public void ArbitraryComponentsAccessorAppliesPrefixAndOverallScale() + { + Current original = Current.FromSiBaseUnit(0.3); + double result = original.AsArbitrarySiComponents(electricCurrentPrefixExponent: -3, overallScaleExponent: 2); + double expected = 3; + + Assert.Equal(expected, result, 12); + } + [Fact] public void RawSiMagnitudeIsNotPartOfPublicApi() { diff --git a/src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs b/src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs index 88c2612..359607d 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs @@ -1,14 +1,16 @@ +using DimensionalQuantities.Units; + namespace DimensionalQuantities.DimensionAnalysis; internal static class AtomicUnits { - public static readonly AtomicUnit Metre = new("Metre", "Metres", "m"); - public static readonly AtomicUnit Gram = new("Gram", "Grams", "g", 3); - public static readonly AtomicUnit Second = new("Second", "Seconds", "s"); - public static readonly AtomicUnit Amp = new("Amp", "Amps", "A"); - public static readonly AtomicUnit Kelvin = new("Kelvin", "Kelvin", "K"); - public static readonly AtomicUnit Mole = new("Mole", "Moles", "mol"); - public static readonly AtomicUnit Candela = new("Candela", "Candelas", "cd"); + public static readonly AtomicUnit Metre = new("Metre", "Metres", "m", SiBasePrefixExponents.Length); + public static readonly AtomicUnit Gram = new("Gram", "Grams", "g", SiBasePrefixExponents.Mass); + public static readonly AtomicUnit Second = new("Second", "Seconds", "s", SiBasePrefixExponents.Time); + public static readonly AtomicUnit Amp = new("Amp", "Amps", "A", SiBasePrefixExponents.ElectricCurrent); + public static readonly AtomicUnit Kelvin = new("Kelvin", "Kelvin", "K", SiBasePrefixExponents.Temperature); + public static readonly AtomicUnit Mole = new("Mole", "Moles", "mol", SiBasePrefixExponents.AmountOfSubstance); + public static readonly AtomicUnit Candela = new("Candela", "Candelas", "cd", SiBasePrefixExponents.LuminousIntensity); public static readonly AtomicUnit Radian = new("Radian", "Radians", "rad"); public static readonly AtomicUnit Hertz = new("Hertz", "Hertz", "Hz"); public static readonly AtomicUnit Pascal = new("Pascal", "Pascals", "Pa"); diff --git a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs index 6cf952f..ab17235 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs @@ -1,4 +1,5 @@ using DimensionalQuantities.Numerics; +using DimensionalQuantities.Units; namespace DimensionalQuantities.DimensionAnalysis; @@ -12,13 +13,13 @@ internal readonly record struct Dimension( Rational AmountOfSubstance = default, Rational LuminousIntensity = default) { - private static readonly UnitDefinition _lengthUnit = new("Length", [new(AtomicUnits.Metre)]); - private static readonly UnitDefinition _massUnit = new("Mass", [new(AtomicUnits.Gram, PrefixExponent: 3)]); - private static readonly UnitDefinition _timeUnit = new("Time", [new(AtomicUnits.Second)]); - private static readonly UnitDefinition _electricCurrentUnit = new("Current", [new(AtomicUnits.Amp)]); - private static readonly UnitDefinition _temperatureUnit = new("Temperature", [new(AtomicUnits.Kelvin)]); - private static readonly UnitDefinition _amountOfSubstanceUnit = new("AmountOfSubstance", [new(AtomicUnits.Mole)]); - private static readonly UnitDefinition _luminousIntensityUnit = new("LuminousIntensity", [new(AtomicUnits.Candela)]); + private static readonly UnitDefinition _lengthUnit = new("Length", [new(AtomicUnits.Metre, prefixExponent: SiBasePrefixExponents.Length)]); + private static readonly UnitDefinition _massUnit = new("Mass", [new(AtomicUnits.Gram, prefixExponent: SiBasePrefixExponents.Mass)]); + private static readonly UnitDefinition _timeUnit = new("Time", [new(AtomicUnits.Second, prefixExponent: SiBasePrefixExponents.Time)]); + private static readonly UnitDefinition _electricCurrentUnit = new("Current", [new(AtomicUnits.Amp, prefixExponent: SiBasePrefixExponents.ElectricCurrent)]); + private static readonly UnitDefinition _temperatureUnit = new("Temperature", [new(AtomicUnits.Kelvin, prefixExponent: SiBasePrefixExponents.Temperature)]); + private static readonly UnitDefinition _amountOfSubstanceUnit = new("AmountOfSubstance", [new(AtomicUnits.Mole, prefixExponent: SiBasePrefixExponents.AmountOfSubstance)]); + private static readonly UnitDefinition _luminousIntensityUnit = new("LuminousIntensity", [new(AtomicUnits.Candela, prefixExponent: SiBasePrefixExponents.LuminousIntensity)]); public static Dimension operator *(Dimension left, Dimension right) => left.Multiply(right); public static Dimension operator /(Dimension left, Dimension right) => left.Divide(right); diff --git a/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs b/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs index e51e8a5..181375b 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs @@ -5,7 +5,7 @@ namespace DimensionalQuantities.DimensionAnalysis; /// Recognised names and units for engineering dimensions. internal static class EngineeringCatalogue { - private static readonly UnitExpression _density = new(new(AtomicUnits.Gram, PrefixExponent: SiPrefixes.Kilo), new(AtomicUnits.Metre, -3)); + private static readonly UnitExpression _density = new(new(AtomicUnits.Gram, prefixExponent: SiPrefixes.Kilo), new(AtomicUnits.Metre, -3)); private static readonly UnitExpression _specificPower = new(new(AtomicUnits.Watt), new(AtomicUnits.Gram, -1, SiPrefixes.Kilo)); private static readonly UnitExpression _powerDensity = new(new(AtomicUnits.Watt), new(AtomicUnits.Metre, -3)); private static readonly UnitExpression _magneticFieldStrength = new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, -1)); @@ -16,7 +16,7 @@ internal static class EngineeringCatalogue { new(Length: 2), [new("Area", [new(AtomicUnits.Metre, 2)])] }, { new(Length: 3), [new("Volume", [new(AtomicUnits.Metre, 3)])] }, { new(Time: -1), [new("Frequency", [new(AtomicUnits.Hertz)]), new("AngularVelocity", [new(AtomicUnits.Radian), new(AtomicUnits.Second, -1)])] }, - { new(Length: -3, Mass: 1), [new("Density", _density, [new(new(AtomicUnits.Gram), new(AtomicUnits.Metre, Power: -3, PrefixExponent: SiPrefixes.Centi))])] }, + { new(Length: -3, Mass: 1), [new("Density", _density, [new(new(AtomicUnits.Gram), new(AtomicUnits.Metre, power: -3, prefixExponent: SiPrefixes.Centi))])] }, { new(Length: -1, Mass: 1, Time: -2), [ @@ -43,7 +43,7 @@ internal static class EngineeringCatalogue ] }, { new(Length: -1, ElectricCurrent: 1), [new("MagneticFieldStrength", _magneticFieldStrength), new("CoerciveFieldStrength", _magneticFieldStrength)] }, - { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", _currentDensity, [new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, Power: -2, PrefixExponent: SiPrefixes.Milli))])] }, + { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", _currentDensity, [new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, power: -2, prefixExponent: SiPrefixes.Milli))])] }, { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", [new(AtomicUnits.Henry), new(AtomicUnits.Metre, -1)])] }, { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", [new(AtomicUnits.Ohm), new(AtomicUnits.Metre)])] }, { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", [new(AtomicUnits.Siemens), new(AtomicUnits.Metre, -1)])] }, diff --git a/src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs b/src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs index f232a71..09c4af0 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs @@ -1,5 +1,7 @@ using System.Globalization; +using System.Numerics; using System.Text; +using DimensionalQuantities.Numerics; using DimensionalQuantities.Units; namespace DimensionalQuantities.DimensionAnalysis; @@ -20,21 +22,32 @@ public UnitExpression WithPrefix(int factorIndex, int prefixExponent) return new(terms); } - public int GetSiScaleExponent() => Terms.Sum(term => (term.PrefixExponent - term.Unit.SiBasePrefixExponent) * term.Power); + public Rational GetSiScaleExponent() + { + Rational exponent = Rational.Zero; + foreach (UnitTerm term in Terms) + { + exponent += (term.PrefixExponent - term.Unit.SiBasePrefixExponent) * term.Power; + } + + return exponent; + } public string GetName(UnitExpression? reference = null, int variableFactorIndex = -1) { StringBuilder numerator = new(); StringBuilder denominator = new(); - bool hasNumerator = Terms.Any(term => term.Power > 0); - bool hasDenominator = Terms.Any(term => term.Power < 0); - int lastNumerator = Array.FindLastIndex(Terms, term => term.Power > 0); + bool hasNumerator = Terms.Any(term => term.Power > Rational.Zero); + bool hasDenominator = Terms.Any(term => term.Power < Rational.Zero); + int lastNumerator = Array.FindLastIndex(Terms, term => term.Power > Rational.Zero); for (int index = 0; index < Terms.Length; index++) { UnitTerm term = Terms[index]; - StringBuilder destination = term.Power > 0 ? numerator : denominator; - AppendPowerName(destination, Math.Abs(term.Power)); + StringBuilder destination = term.Power > Rational.Zero ? numerator : denominator; + Rational power = Rational.Abs(term.Power); + AppendRootName(destination, power); + AppendPowerName(destination, power.Numerator); bool generatedPrefix = index == variableFactorIndex || (reference is { } original && term.PrefixExponent != original.Terms[index].PrefixExponent); AppendUnitName(destination, term, index == lastNumerator, generatedPrefix); @@ -52,20 +65,20 @@ public string GetSymbol() { StringBuilder numerator = new(); StringBuilder denominator = new(); - bool hasNumerator = Terms.Any(term => term.Power > 0); - bool hasDenominator = Terms.Any(term => term.Power < 0); + bool hasNumerator = Terms.Any(term => term.Power > Rational.Zero); + bool hasDenominator = Terms.Any(term => term.Power < Rational.Zero); foreach (UnitTerm term in Terms) { - StringBuilder destination = term.Power > 0 ? numerator : denominator; + StringBuilder destination = term.Power > Rational.Zero ? numerator : denominator; if (term.PrefixExponent != 0) { destination.Append(SiPrefixes.Entries[term.PrefixExponent].Symbol); } destination.Append(term.Unit.Symbol); - if (Math.Abs(term.Power) != 1 || !hasNumerator) + if (Rational.Abs(term.Power) != Rational.One || !hasNumerator) { - AppendSuperscript(destination, hasNumerator ? Math.Abs(term.Power) : term.Power); + AppendSuperscript(destination, hasNumerator ? Rational.Abs(term.Power) : term.Power); } } @@ -90,7 +103,23 @@ public override int GetHashCode() return hash.ToHashCode(); } - private static void AppendPowerName(StringBuilder name, int power) + private static void AppendRootName(StringBuilder name, Rational power) + { + if (power.Denominator == 2) + { + name.Append("SquareRoot"); + } + else if (power.Denominator == 3) + { + name.Append("CubeRoot"); + } + else if (power.Denominator > 3) + { + name.Append("Root").Append(power.Denominator.ToString(CultureInfo.InvariantCulture)); + } + } + + private static void AppendPowerName(StringBuilder name, BigInteger power) { if (power == 2) { @@ -119,17 +148,26 @@ private static void AppendUnitName(StringBuilder name, UnitTerm term, bool plura name.Append(generatedPrefix ? unitName : char.ToLowerInvariant(unitName[0]) + unitName[1..]); } - private static void AppendSuperscript(StringBuilder symbol, int exponent) + private static void AppendSuperscript(StringBuilder symbol, Rational exponent) { const string digits = "⁰¹²³⁴⁵⁶⁷⁸⁹"; - if (exponent < 0) + if (exponent < Rational.Zero) { symbol.Append('⁻'); } - foreach (char digit in Math.Abs(exponent).ToString(CultureInfo.InvariantCulture)) + foreach (char digit in BigInteger.Abs(exponent.Numerator).ToString(CultureInfo.InvariantCulture)) { symbol.Append(digits[digit - '0']); } + + if (exponent.Denominator != BigInteger.One) + { + symbol.Append('⁄'); + foreach (char digit in exponent.Denominator.ToString(CultureInfo.InvariantCulture)) + { + symbol.Append(digits[digit - '0']); + } + } } } diff --git a/src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs b/src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs index 398b56e..5543237 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs +++ b/src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs @@ -1,3 +1,17 @@ +using DimensionalQuantities.Numerics; + namespace DimensionalQuantities.DimensionAnalysis; -internal readonly record struct UnitTerm(AtomicUnit Unit, int Power = 1, int PrefixExponent = 0); +internal readonly record struct UnitTerm +{ + public UnitTerm(AtomicUnit unit, Rational? power = null, int prefixExponent = 0) + { + Unit = unit; + Power = power ?? Rational.One; + PrefixExponent = prefixExponent; + } + + public AtomicUnit Unit { get; init; } + public Rational Power { get; init; } + public int PrefixExponent { get; init; } +} diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index a070cf4..ff99798 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -1,4 +1,7 @@ +using DimensionalQuantities.Numerics; +using DimensionalQuantities.Units; using JetBrains.Annotations; +using MathematicalDimension = DimensionalQuantities.DimensionAnalysis.Dimension; namespace DimensionalQuantities.TypeSystem; @@ -6,9 +9,30 @@ 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); + /// 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)); + } + internal Quantity(double valueInSiBaseUnit) { ValueInSiBaseUnit = valueInSiBaseUnit; @@ -18,4 +42,45 @@ internal Quantity(double valueInSiBaseUnit) /// 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; + Rational exponent = (length - SiBasePrefixExponents.Length) * dimension.Length + (mass - SiBasePrefixExponents.Mass) * dimension.Mass + + (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; + } + + private static MathematicalDimension ParseDimension() + { + Type type = typeof(TDimension); + if (!type.IsConstructedGenericType || type.GetGenericTypeDefinition() != typeof(Dimension<,,,,,,>)) + { + throw new ArgumentException($"Type '{type}' is not a dimension encoding."); + } + + Type[] exponents = type.GetGenericArguments(); + return new( + TypeSystemNumberCodec.Parse(exponents[0]), TypeSystemNumberCodec.Parse(exponents[1]), TypeSystemNumberCodec.Parse(exponents[2]), + TypeSystemNumberCodec.Parse(exponents[3]), TypeSystemNumberCodec.Parse(exponents[4]), TypeSystemNumberCodec.Parse(exponents[5]), + TypeSystemNumberCodec.Parse(exponents[6])); + } } diff --git a/src/DimensionalQuantities/Units/SiBasePrefixExponents.cs b/src/DimensionalQuantities/Units/SiBasePrefixExponents.cs new file mode 100644 index 0000000..a12a87e --- /dev/null +++ b/src/DimensionalQuantities/Units/SiBasePrefixExponents.cs @@ -0,0 +1,12 @@ +namespace DimensionalQuantities.Units; + +internal static class SiBasePrefixExponents +{ + public const int Length = 0; + public const int Mass = SiPrefixes.Kilo; + public const int Time = 0; + public const int ElectricCurrent = 0; + public const int Temperature = 0; + public const int AmountOfSubstance = 0; + public const int LuminousIntensity = 0; +} From dcfcf3cf4c2ee064a0fe1f5aab49d47f8d2b5592 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 17:05:47 +0100 Subject: [PATCH 21/25] refactor: Pulled discovery and source emission out --- .../Generators/QuantityDiscovery.cs | 33 +++ .../Generators/QuantityGenerator.cs | 228 +----------------- .../Generators/QuantitySourceEmitter.cs | 214 ++++++++++++++++ 3 files changed, 252 insertions(+), 223 deletions(-) create mode 100644 src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs new file mode 100644 index 0000000..79735d2 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs @@ -0,0 +1,33 @@ +using DimensionalQuantities.DimensionAnalysis; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal static class QuantityDiscovery +{ + public static IReadOnlyDictionary Discover() + { + Dictionary quantities = new(); + Dimension[] baseDimensions = + [ + new(Length: 1), + new(Mass: 1), + new(Time: 1), + new(ElectricCurrent: 1), + new(Temperature: 1), + new(AmountOfSubstance: 1), + new(LuminousIntensity: 1) + ]; + + foreach (Dimension dimension in baseDimensions) + { + quantities.Add(dimension, [dimension.GetComponents().Single().Unit]); + } + + foreach (KeyValuePair entry in EngineeringCatalogue.Entries) + { + quantities.Add(entry.Key, entry.Value); + } + + return quantities; + } +} diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs index 4315ddf..59e6bb7 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs @@ -1,9 +1,5 @@ -using System.Globalization; using System.Text; using DimensionalQuantities.DimensionAnalysis; -using DimensionalQuantities.Numerics; -using DimensionalQuantities.TypeSystem; -using DimensionalQuantities.Units; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -14,225 +10,11 @@ public sealed class QuantityGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - context.RegisterPostInitializationOutput(static output => output.AddSource("Quantities.g.cs", SourceText.From(GenerateSource(), Encoding.UTF8))); - } - - private static string GenerateSource() - { - StringBuilder aliases = new(); - StringBuilder extensions = new(); - int[] prefixes = SiPrefixes.Entries.Keys.OrderByDescending(exponent => exponent).ToArray(); - Dimension[] baseDimensions = - [ - new(Length: 1), - new(Mass: 1), - new(Time: 1), - new(ElectricCurrent: 1), - new(Temperature: 1), - new(AmountOfSubstance: 1), - new(LuminousIntensity: 1) - ]; - - foreach (Dimension dimension in baseDimensions) - { - AppendDimension(aliases, extensions, prefixes, dimension, [dimension.GetComponents().Single().Unit]); - } - - foreach (KeyValuePair entry in EngineeringCatalogue.Entries) - { - AppendDimension(aliases, extensions, prefixes, entry.Key, entry.Value); - } - - return $$""" - // - {{aliases}}global using DimensionalQuantities.Generated; - - namespace DimensionalQuantities.Generated; - - {{extensions}} - """; - } - - private static void AppendDimension(StringBuilder aliases, StringBuilder extensions, int[] prefixes, Dimension dimension, UnitDefinition[] units) - { - AppendAliases(aliases, dimension, units); - IReadOnlyDictionary conversions = CollectConversions(units, prefixes); - string quantityName = units[0].QuantityName; - UnitExpression canonicalExpression = units[0].Expression; - extensions.Append("internal static class ").Append(quantityName).AppendLine("Extensions").AppendLine("{"); - AppendFactories(extensions, quantityName, canonicalExpression, conversions); - AppendAccessors(extensions, quantityName, canonicalExpression, conversions); - extensions.AppendLine("}").AppendLine(); - } - - private static void AppendAliases(StringBuilder aliases, Dimension dimension, UnitDefinition[] units) - { - string quantityType = $"global::DimensionalQuantities.TypeSystem.Quantity>"; - string mechanicalName = DimensionNameGenerator.GetQuantityName(dimension); - IEnumerable names = units.Select(unit => unit.QuantityName).Append(mechanicalName).Distinct(StringComparer.Ordinal); - foreach (string name in names) - { - aliases.Append("global using ").Append(name).Append(" = ").Append(quantityType).AppendLine(";"); - } - } - - private static IReadOnlyDictionary CollectConversions(UnitDefinition[] units, int[] prefixes) - { - Dictionary conversions = new(StringComparer.Ordinal); - foreach ((string name, Rational scaleExponent) in units.SelectMany(unit => GetConversions(unit, prefixes))) - { - if (conversions.TryGetValue(name, out Rational existingExponent) && existingExponent != scaleExponent) - { - throw new InvalidOperationException($"Unit {name} has conflicting SI scales."); - } - - conversions.TryAdd(name, scaleExponent); - } - - return conversions; - } - - private static void AppendFactories(StringBuilder extensions, string quantityName, UnitExpression canonicalExpression, IReadOnlyDictionary conversions) - { - extensions.Append("\textension(").Append(quantityName).AppendLine(")").AppendLine("\t{"); - AppendGenericFactory(extensions, quantityName, canonicalExpression); - foreach ((string name, Rational scaleExponent) in conversions) - { - AppendFactory(extensions, quantityName, name, scaleExponent); - } - - extensions.AppendLine("\t}").AppendLine(); - } - - private static void AppendAccessors(StringBuilder extensions, string quantityName, UnitExpression canonicalExpression, IReadOnlyDictionary conversions) - { - string variableName = char.ToLowerInvariant(quantityName[0]) + quantityName[1..]; - extensions.Append("\textension(").Append(quantityName).Append(' ').Append(variableName).AppendLine(")").AppendLine("\t{"); - AppendGenericAccessor(extensions, variableName, canonicalExpression); - foreach ((string name, Rational scaleExponent) in conversions) + context.RegisterPostInitializationOutput(static output => { - AppendAccessor(extensions, variableName, name, scaleExponent); - } - - extensions.AppendLine("\t}").AppendLine(); - } - - private static IEnumerable<(string Name, Rational ScaleExponent)> GetConversions(UnitDefinition unit, int[] prefixes) - => GetStandardConversions(unit.Expression, prefixes).Concat(unit.AdditionalConversions - .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent()))); - - private static IEnumerable<(string Name, Rational ScaleExponent)> GetStandardConversions(UnitExpression expression, int[] prefixes) - { - int firstNumerator = Array.FindIndex(expression.Terms, term => term.Power > Rational.Zero); - if (firstNumerator < 0) - { - yield return (expression.GetName(), expression.GetSiScaleExponent()); - yield break; - } - - foreach (int prefix in prefixes.Prepend(0)) - { - UnitExpression prefixed = expression.WithPrefix(firstNumerator, prefix); - yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent()); - } - } - - private static void AppendGenericFactory(StringBuilder source, string quantityName, UnitExpression expression) - { - source.Append("\t\tpublic static ").Append(quantityName).Append(" FromPrefixedUnits(double value"); - AppendTermParameters(source, expression); - source.Append(") => ").Append(quantityName).Append(".FromArbitrarySiComponents(value, overallScaleExponent: "); - AppendTermScaleExponent(source, expression); - source.AppendLine(");"); - } - - private static void AppendGenericAccessor(StringBuilder source, string variableName, UnitExpression expression) - { - source.Append("\t\tpublic double AsPrefixedUnits("); - AppendTermParameters(source, expression, false); - source.Append(") => ").Append(variableName).Append(".AsArbitrarySiComponents(overallScaleExponent: "); - AppendTermScaleExponent(source, expression); - source.AppendLine(");"); - } - - private static void AppendTermParameters(StringBuilder source, UnitExpression expression, bool leadingComma = true) - { - for (int index = 0; index < expression.Terms.Length; index++) - { - UnitTerm term = expression.Terms[index]; - source.Append(index > 0 || leadingComma ? ", " : "").Append("int ").Append(GetPrefixParameterName(term)) - .Append(" = ").Append(term.PrefixExponent.ToString(CultureInfo.InvariantCulture)); - } - } - - private static void AppendTermScaleExponent(StringBuilder source, UnitExpression expression) - { - for (int index = 0; index < expression.Terms.Length; index++) - { - if (index > 0) - { - source.Append(" + "); - } - - UnitTerm term = expression.Terms[index]; - string parameterName = GetPrefixParameterName(term); - source.Append("((double)").Append(parameterName); - if (term.Unit.SiBasePrefixExponent != 0) - { - source.Append(" - ").Append(term.Unit.SiBasePrefixExponent.ToString(CultureInfo.InvariantCulture)); - } - - source.Append(')'); - if (term.Power != Rational.One) - { - source.Append(" * ").Append(term.Power.Denominator.IsOne - ? term.Power.Numerator.ToString(CultureInfo.InvariantCulture) - : "(" + term.Power.Numerator.ToString(CultureInfo.InvariantCulture) + "d / " + term.Power.Denominator.ToString(CultureInfo.InvariantCulture) + "d)"); - } - } - } - - private static string GetPrefixParameterName(UnitTerm term) => char.ToLowerInvariant(term.Unit.NameSingular[0]) + term.Unit.NameSingular[1..] + "PrefixExponent"; - - private static void AppendFactory(StringBuilder source, string quantityName, string unitName, Rational exponent) - { - source.Append("\t\tpublic static ").Append(quantityName).Append(" From").Append(unitName) - .Append("(double value) => ").Append(quantityName).Append(".FromArbitrarySiComponents(value"); - if (exponent != Rational.Zero) - { - source.Append(", overallScaleExponent: ").Append(FormatExponent(exponent)); - } - - source.AppendLine(");"); - } - - private static void AppendAccessor(StringBuilder source, string variableName, string unitName, Rational exponent) - { - source.Append("\t\tpublic double As").Append(unitName).Append("() => ").Append(variableName).Append(".AsArbitrarySiComponents("); - if (exponent != Rational.Zero) - { - source.Append("overallScaleExponent: ").Append(FormatExponent(exponent)); - } - - source.AppendLine(");"); - } - - private static string FormatExponent(Rational exponent) => double.CreateChecked(exponent).ToString("R", CultureInfo.InvariantCulture) + "d"; - - private static Type[] GetExponentTypes(Dimension dimension) => - [ - TypeSystemNumberCodec.CreateType(dimension.Length), - TypeSystemNumberCodec.CreateType(dimension.Mass), - TypeSystemNumberCodec.CreateType(dimension.Time), - TypeSystemNumberCodec.CreateType(dimension.ElectricCurrent), - TypeSystemNumberCodec.CreateType(dimension.Temperature), - TypeSystemNumberCodec.CreateType(dimension.AmountOfSubstance), - TypeSystemNumberCodec.CreateType(dimension.LuminousIntensity) - ]; - - private static string FormatType(Type type) - { - string name = $"global::{type.Namespace}.{type.Name.Split('`')[0]}"; - return type.IsConstructedGenericType ? $"{name}<{string.Join(", ", type.GetGenericArguments().Select(FormatType))}>" : name; + IReadOnlyDictionary quantities = QuantityDiscovery.Discover(); + string source = QuantitySourceEmitter.Emit(quantities); + output.AddSource("Quantities.g.cs", SourceText.From(source, Encoding.UTF8)); + }); } } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs new file mode 100644 index 0000000..4342d2b --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs @@ -0,0 +1,214 @@ +using System.Globalization; +using System.Text; +using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.Numerics; +using DimensionalQuantities.TypeSystem; +using DimensionalQuantities.Units; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal static class QuantitySourceEmitter +{ + public static string Emit(IReadOnlyDictionary quantities) + { + StringBuilder aliases = new(); + StringBuilder extensions = new(); + int[] prefixes = SiPrefixes.Entries.Keys.OrderByDescending(exponent => exponent).ToArray(); + foreach (KeyValuePair entry in quantities) + { + AppendDimension(aliases, extensions, prefixes, entry.Key, entry.Value); + } + + return $$""" + // + {{aliases}}global using DimensionalQuantities.Generated; + + namespace DimensionalQuantities.Generated; + + {{extensions}} + """; + } + + private static void AppendDimension(StringBuilder aliases, StringBuilder extensions, int[] prefixes, Dimension dimension, UnitDefinition[] units) + { + AppendAliases(aliases, dimension, units); + IReadOnlyDictionary conversions = CollectConversions(units, prefixes); + string quantityName = units[0].QuantityName; + UnitExpression canonicalExpression = units[0].Expression; + extensions.Append("internal static class ").Append(quantityName).AppendLine("Extensions").AppendLine("{"); + AppendFactories(extensions, quantityName, canonicalExpression, conversions); + AppendAccessors(extensions, quantityName, canonicalExpression, conversions); + extensions.AppendLine("}").AppendLine(); + } + + private static void AppendAliases(StringBuilder aliases, Dimension dimension, UnitDefinition[] units) + { + string quantityType = $"global::DimensionalQuantities.TypeSystem.Quantity>"; + string mechanicalName = DimensionNameGenerator.GetQuantityName(dimension); + IEnumerable names = units.Select(unit => unit.QuantityName).Append(mechanicalName).Distinct(StringComparer.Ordinal); + foreach (string name in names) + { + aliases.Append("global using ").Append(name).Append(" = ").Append(quantityType).AppendLine(";"); + } + } + + private static IReadOnlyDictionary CollectConversions(UnitDefinition[] units, int[] prefixes) + { + Dictionary conversions = new(StringComparer.Ordinal); + foreach ((string name, Rational scaleExponent) in units.SelectMany(unit => GetConversions(unit, prefixes))) + { + if (conversions.TryGetValue(name, out Rational existingExponent) && existingExponent != scaleExponent) + { + throw new InvalidOperationException($"Unit {name} has conflicting SI scales."); + } + + conversions.TryAdd(name, scaleExponent); + } + + return conversions; + } + + private static void AppendFactories(StringBuilder extensions, string quantityName, UnitExpression canonicalExpression, IReadOnlyDictionary conversions) + { + extensions.Append("\textension(").Append(quantityName).AppendLine(")").AppendLine("\t{"); + AppendGenericFactory(extensions, quantityName, canonicalExpression); + foreach ((string name, Rational scaleExponent) in conversions) + { + AppendFactory(extensions, quantityName, name, scaleExponent); + } + + extensions.AppendLine("\t}").AppendLine(); + } + + private static void AppendAccessors(StringBuilder extensions, string quantityName, UnitExpression canonicalExpression, IReadOnlyDictionary conversions) + { + string variableName = char.ToLowerInvariant(quantityName[0]) + quantityName[1..]; + extensions.Append("\textension(").Append(quantityName).Append(' ').Append(variableName).AppendLine(")").AppendLine("\t{"); + AppendGenericAccessor(extensions, variableName, canonicalExpression); + foreach ((string name, Rational scaleExponent) in conversions) + { + AppendAccessor(extensions, variableName, name, scaleExponent); + } + + extensions.AppendLine("\t}").AppendLine(); + } + + private static IEnumerable<(string Name, Rational ScaleExponent)> GetConversions(UnitDefinition unit, int[] prefixes) + => GetStandardConversions(unit.Expression, prefixes).Concat(unit.AdditionalConversions + .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent()))); + + private static IEnumerable<(string Name, Rational ScaleExponent)> GetStandardConversions(UnitExpression expression, int[] prefixes) + { + int firstNumerator = Array.FindIndex(expression.Terms, term => term.Power > Rational.Zero); + if (firstNumerator < 0) + { + yield return (expression.GetName(), expression.GetSiScaleExponent()); + yield break; + } + + foreach (int prefix in prefixes.Prepend(0)) + { + UnitExpression prefixed = expression.WithPrefix(firstNumerator, prefix); + yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent()); + } + } + + private static void AppendGenericFactory(StringBuilder source, string quantityName, UnitExpression expression) + { + source.Append("\t\tpublic static ").Append(quantityName).Append(" FromPrefixedUnits(double value"); + AppendTermParameters(source, expression); + source.Append(") => ").Append(quantityName).Append(".FromArbitrarySiComponents(value, overallScaleExponent: "); + AppendTermScaleExponent(source, expression); + source.AppendLine(");"); + } + + private static void AppendGenericAccessor(StringBuilder source, string variableName, UnitExpression expression) + { + source.Append("\t\tpublic double AsPrefixedUnits("); + AppendTermParameters(source, expression, false); + source.Append(") => ").Append(variableName).Append(".AsArbitrarySiComponents(overallScaleExponent: "); + AppendTermScaleExponent(source, expression); + source.AppendLine(");"); + } + + private static void AppendTermParameters(StringBuilder source, UnitExpression expression, bool leadingComma = true) + { + for (int index = 0; index < expression.Terms.Length; index++) + { + UnitTerm term = expression.Terms[index]; + source.Append(index > 0 || leadingComma ? ", " : "").Append("int ").Append(GetPrefixParameterName(term)) + .Append(" = ").Append(term.PrefixExponent.ToString(CultureInfo.InvariantCulture)); + } + } + + private static void AppendTermScaleExponent(StringBuilder source, UnitExpression expression) + { + for (int index = 0; index < expression.Terms.Length; index++) + { + if (index > 0) + { + source.Append(" + "); + } + + UnitTerm term = expression.Terms[index]; + string parameterName = GetPrefixParameterName(term); + source.Append("((double)").Append(parameterName); + if (term.Unit.SiBasePrefixExponent != 0) + { + source.Append(" - ").Append(term.Unit.SiBasePrefixExponent.ToString(CultureInfo.InvariantCulture)); + } + + source.Append(')'); + if (term.Power != Rational.One) + { + source.Append(" * ").Append(term.Power.Denominator.IsOne + ? term.Power.Numerator.ToString(CultureInfo.InvariantCulture) + : "(" + term.Power.Numerator.ToString(CultureInfo.InvariantCulture) + "d / " + term.Power.Denominator.ToString(CultureInfo.InvariantCulture) + "d)"); + } + } + } + + private static string GetPrefixParameterName(UnitTerm term) => char.ToLowerInvariant(term.Unit.NameSingular[0]) + term.Unit.NameSingular[1..] + "PrefixExponent"; + + private static void AppendFactory(StringBuilder source, string quantityName, string unitName, Rational exponent) + { + source.Append("\t\tpublic static ").Append(quantityName).Append(" From").Append(unitName) + .Append("(double value) => ").Append(quantityName).Append(".FromArbitrarySiComponents(value"); + if (exponent != Rational.Zero) + { + source.Append(", overallScaleExponent: ").Append(FormatExponent(exponent)); + } + + source.AppendLine(");"); + } + + private static void AppendAccessor(StringBuilder source, string variableName, string unitName, Rational exponent) + { + source.Append("\t\tpublic double As").Append(unitName).Append("() => ").Append(variableName).Append(".AsArbitrarySiComponents("); + if (exponent != Rational.Zero) + { + source.Append("overallScaleExponent: ").Append(FormatExponent(exponent)); + } + + source.AppendLine(");"); + } + + private static string FormatExponent(Rational exponent) => double.CreateChecked(exponent).ToString("R", CultureInfo.InvariantCulture) + "d"; + + private static Type[] GetExponentTypes(Dimension dimension) => + [ + TypeSystemNumberCodec.CreateType(dimension.Length), + TypeSystemNumberCodec.CreateType(dimension.Mass), + TypeSystemNumberCodec.CreateType(dimension.Time), + TypeSystemNumberCodec.CreateType(dimension.ElectricCurrent), + TypeSystemNumberCodec.CreateType(dimension.Temperature), + TypeSystemNumberCodec.CreateType(dimension.AmountOfSubstance), + TypeSystemNumberCodec.CreateType(dimension.LuminousIntensity) + ]; + + private static string FormatType(Type type) + { + string name = $"global::{type.Namespace}.{type.Name.Split('`')[0]}"; + return type.IsConstructedGenericType ? $"{name}<{string.Join(", ", type.GetGenericArguments().Select(FormatType))}>" : name; + } +} From 714ff4cc4a7e5bdf85e1cf70991351d6d8a569c5 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 18:41:53 +0100 Subject: [PATCH 22/25] refactor: render quantity source from definitions and templates --- .../Generators/QuantitySourceEmitter.cs | 171 ++++++------------ 1 file changed, 60 insertions(+), 111 deletions(-) diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs index 4342d2b..8f14083 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text; using DimensionalQuantities.DimensionAnalysis; using DimensionalQuantities.Numerics; using DimensionalQuantities.TypeSystem; @@ -11,17 +10,15 @@ internal static class QuantitySourceEmitter { public static string Emit(IReadOnlyDictionary quantities) { - StringBuilder aliases = new(); - StringBuilder extensions = new(); int[] prefixes = SiPrefixes.Entries.Keys.OrderByDescending(exponent => exponent).ToArray(); - foreach (KeyValuePair entry in quantities) - { - AppendDimension(aliases, extensions, prefixes, entry.Key, entry.Value); - } + QuantityDefinition[] definitions = quantities.Select(entry => DefineQuantity(entry.Key, entry.Value, prefixes)).ToArray(); + string aliases = string.Join("\n", definitions.SelectMany(definition => definition.Aliases.Select(alias => $"global using {alias} = {definition.TypeName};"))); + string extensions = string.Join("\n\n", definitions.Select(RenderQuantity)); return $$""" // - {{aliases}}global using DimensionalQuantities.Generated; + {{aliases}} + global using DimensionalQuantities.Generated; namespace DimensionalQuantities.Generated; @@ -29,27 +26,17 @@ namespace DimensionalQuantities.Generated; """; } - private static void AppendDimension(StringBuilder aliases, StringBuilder extensions, int[] prefixes, Dimension dimension, UnitDefinition[] units) + private static QuantityDefinition DefineQuantity(Dimension dimension, UnitDefinition[] units, int[] prefixes) { - AppendAliases(aliases, dimension, units); - IReadOnlyDictionary conversions = CollectConversions(units, prefixes); + string typeName = $"global::DimensionalQuantities.TypeSystem.Quantity>"; + string[] aliases = units.Select(unit => unit.QuantityName).Append(DimensionNameGenerator.GetQuantityName(dimension)).Distinct(StringComparer.Ordinal).ToArray(); string quantityName = units[0].QuantityName; UnitExpression canonicalExpression = units[0].Expression; - extensions.Append("internal static class ").Append(quantityName).AppendLine("Extensions").AppendLine("{"); - AppendFactories(extensions, quantityName, canonicalExpression, conversions); - AppendAccessors(extensions, quantityName, canonicalExpression, conversions); - extensions.AppendLine("}").AppendLine(); - } - - private static void AppendAliases(StringBuilder aliases, Dimension dimension, UnitDefinition[] units) - { - string quantityType = $"global::DimensionalQuantities.TypeSystem.Quantity>"; - string mechanicalName = DimensionNameGenerator.GetQuantityName(dimension); - IEnumerable names = units.Select(unit => unit.QuantityName).Append(mechanicalName).Distinct(StringComparer.Ordinal); - foreach (string name in names) - { - aliases.Append("global using ").Append(name).Append(" = ").Append(quantityType).AppendLine(";"); - } + ConversionDefinition genericConversion = new("FromPrefixedUnits", "AsPrefixedUnits", GetTermParameters(canonicalExpression), GetTermScaleExponent(canonicalExpression)); + ConversionDefinition[] namedConversions = CollectConversions(units, prefixes) + .Select(conversion => new ConversionDefinition($"From{conversion.Key}", $"As{conversion.Key}", [], FormatExponent(conversion.Value))) + .ToArray(); + return new(quantityName, typeName, aliases, [genericConversion, .. namedConversions]); } private static IReadOnlyDictionary CollectConversions(UnitDefinition[] units, int[] prefixes) @@ -68,31 +55,6 @@ private static IReadOnlyDictionary CollectConversions(UnitDefi return conversions; } - private static void AppendFactories(StringBuilder extensions, string quantityName, UnitExpression canonicalExpression, IReadOnlyDictionary conversions) - { - extensions.Append("\textension(").Append(quantityName).AppendLine(")").AppendLine("\t{"); - AppendGenericFactory(extensions, quantityName, canonicalExpression); - foreach ((string name, Rational scaleExponent) in conversions) - { - AppendFactory(extensions, quantityName, name, scaleExponent); - } - - extensions.AppendLine("\t}").AppendLine(); - } - - private static void AppendAccessors(StringBuilder extensions, string quantityName, UnitExpression canonicalExpression, IReadOnlyDictionary conversions) - { - string variableName = char.ToLowerInvariant(quantityName[0]) + quantityName[1..]; - extensions.Append("\textension(").Append(quantityName).Append(' ').Append(variableName).AppendLine(")").AppendLine("\t{"); - AppendGenericAccessor(extensions, variableName, canonicalExpression); - foreach ((string name, Rational scaleExponent) in conversions) - { - AppendAccessor(extensions, variableName, name, scaleExponent); - } - - extensions.AppendLine("\t}").AppendLine(); - } - private static IEnumerable<(string Name, Rational ScaleExponent)> GetConversions(UnitDefinition unit, int[] prefixes) => GetStandardConversions(unit.Expression, prefixes).Concat(unit.AdditionalConversions .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent()))); @@ -113,84 +75,65 @@ private static void AppendAccessors(StringBuilder extensions, string quantityNam } } - private static void AppendGenericFactory(StringBuilder source, string quantityName, UnitExpression expression) - { - source.Append("\t\tpublic static ").Append(quantityName).Append(" FromPrefixedUnits(double value"); - AppendTermParameters(source, expression); - source.Append(") => ").Append(quantityName).Append(".FromArbitrarySiComponents(value, overallScaleExponent: "); - AppendTermScaleExponent(source, expression); - source.AppendLine(");"); - } + private static PrefixParameterDefinition[] GetTermParameters(UnitExpression expression) => expression.Terms + .Select(term => new PrefixParameterDefinition(GetPrefixParameterName(term), term.PrefixExponent)).ToArray(); - private static void AppendGenericAccessor(StringBuilder source, string variableName, UnitExpression expression) - { - source.Append("\t\tpublic double AsPrefixedUnits("); - AppendTermParameters(source, expression, false); - source.Append(") => ").Append(variableName).Append(".AsArbitrarySiComponents(overallScaleExponent: "); - AppendTermScaleExponent(source, expression); - source.AppendLine(");"); - } + private static string GetTermScaleExponent(UnitExpression expression) => string.Join(" + ", expression.Terms.Select(FormatTermScaleExponent)); - private static void AppendTermParameters(StringBuilder source, UnitExpression expression, bool leadingComma = true) + private static string FormatTermScaleExponent(UnitTerm term) { - for (int index = 0; index < expression.Terms.Length; index++) + string prefix = $"((double){GetPrefixParameterName(term)}"; + if (term.Unit.SiBasePrefixExponent != 0) { - UnitTerm term = expression.Terms[index]; - source.Append(index > 0 || leadingComma ? ", " : "").Append("int ").Append(GetPrefixParameterName(term)) - .Append(" = ").Append(term.PrefixExponent.ToString(CultureInfo.InvariantCulture)); + prefix += " - " + term.Unit.SiBasePrefixExponent.ToString(CultureInfo.InvariantCulture); } - } - private static void AppendTermScaleExponent(StringBuilder source, UnitExpression expression) - { - for (int index = 0; index < expression.Terms.Length; index++) + prefix += ")"; + if (term.Power == Rational.One) { - if (index > 0) - { - source.Append(" + "); - } - - UnitTerm term = expression.Terms[index]; - string parameterName = GetPrefixParameterName(term); - source.Append("((double)").Append(parameterName); - if (term.Unit.SiBasePrefixExponent != 0) - { - source.Append(" - ").Append(term.Unit.SiBasePrefixExponent.ToString(CultureInfo.InvariantCulture)); - } - - source.Append(')'); - if (term.Power != Rational.One) - { - source.Append(" * ").Append(term.Power.Denominator.IsOne - ? term.Power.Numerator.ToString(CultureInfo.InvariantCulture) - : "(" + term.Power.Numerator.ToString(CultureInfo.InvariantCulture) + "d / " + term.Power.Denominator.ToString(CultureInfo.InvariantCulture) + "d)"); - } + return prefix; } + + string power = term.Power.Denominator.IsOne + ? term.Power.Numerator.ToString(CultureInfo.InvariantCulture) + : $"({term.Power.Numerator.ToString(CultureInfo.InvariantCulture)}d / {term.Power.Denominator.ToString(CultureInfo.InvariantCulture)}d)"; + return $"{prefix} * {power}"; } private static string GetPrefixParameterName(UnitTerm term) => char.ToLowerInvariant(term.Unit.NameSingular[0]) + term.Unit.NameSingular[1..] + "PrefixExponent"; - private static void AppendFactory(StringBuilder source, string quantityName, string unitName, Rational exponent) + private static string RenderQuantity(QuantityDefinition definition) { - source.Append("\t\tpublic static ").Append(quantityName).Append(" From").Append(unitName) - .Append("(double value) => ").Append(quantityName).Append(".FromArbitrarySiComponents(value"); - if (exponent != Rational.Zero) - { - source.Append(", overallScaleExponent: ").Append(FormatExponent(exponent)); - } + string variableName = char.ToLowerInvariant(definition.Name[0]) + definition.Name[1..]; + string factories = string.Join("\n\t\t", definition.Conversions.Select(conversion => RenderFactory(definition.Name, conversion))); + string accessors = string.Join("\n\t\t", definition.Conversions.Select(conversion => RenderAccessor(variableName, conversion))); - source.AppendLine(");"); + return $$""" + internal static class {{definition.Name}}Extensions + { + extension({{definition.Name}}) + { + {{factories}} + } + + extension({{definition.Name}} {{variableName}}) + { + {{accessors}} + } + } + """; } - private static void AppendAccessor(StringBuilder source, string variableName, string unitName, Rational exponent) + private static string RenderFactory(string quantityName, ConversionDefinition conversion) { - source.Append("\t\tpublic double As").Append(unitName).Append("() => ").Append(variableName).Append(".AsArbitrarySiComponents("); - if (exponent != Rational.Zero) - { - source.Append("overallScaleExponent: ").Append(FormatExponent(exponent)); - } + string parameters = string.Concat(conversion.PrefixParameters.Select(parameter => $", int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); + return $"public static {quantityName} {conversion.FactoryName}(double value{parameters}) => {quantityName}.FromArbitrarySiComponents(value, overallScaleExponent: {conversion.ScaleExponentExpression});"; + } - source.AppendLine(");"); + private static string RenderAccessor(string variableName, ConversionDefinition conversion) + { + string parameters = string.Join(", ", conversion.PrefixParameters.Select(parameter => $"int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); + 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"; @@ -211,4 +154,10 @@ private static string FormatType(Type type) string name = $"global::{type.Namespace}.{type.Name.Split('`')[0]}"; return type.IsConstructedGenericType ? $"{name}<{string.Join(", ", type.GetGenericArguments().Select(FormatType))}>" : name; } + + private sealed record QuantityDefinition(string Name, string TypeName, string[] Aliases, ConversionDefinition[] Conversions); + + private sealed record ConversionDefinition(string FactoryName, string AccessorName, PrefixParameterDefinition[] PrefixParameters, string ScaleExponentExpression); + + private sealed record PrefixParameterDefinition(string Name, int DefaultExponent); } From 29a8c984b7c1a771c76b63b063be5e4747b13e10 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 20:00:24 +0100 Subject: [PATCH 23/25] refactor: organise dimensions units and naming namespaces --- .../Generators/QuantityDiscovery.cs | 3 ++- .../Generators/QuantityGenerator.cs | 3 ++- .../Generators/QuantitySourceEmitter.cs | 9 +++++---- .../{DimensionAnalysis => Dimensions}/DimensionTests.cs | 5 +++-- .../DimensionNameGeneratorTests.cs | 5 +++-- .../{DimensionAnalysis => Units}/UnitExpressionTests.cs | 4 ++-- .../{DimensionAnalysis => Dimensions}/Dimension.cs | 2 +- .../DimensionComponent.cs | 3 ++- .../DimensionNameGenerator.cs | 3 ++- src/DimensionalQuantities/TypeSystem/Quantity.cs | 2 +- .../{DimensionAnalysis => Units}/AtomicUnit.cs | 2 +- .../{DimensionAnalysis => Units}/AtomicUnits.cs | 4 +--- .../{DimensionAnalysis => Units}/EngineeringCatalogue.cs | 4 ++-- .../{DimensionAnalysis => Units}/UnitDefinition.cs | 2 +- .../{DimensionAnalysis => Units}/UnitExpression.cs | 3 +-- .../{DimensionAnalysis => Units}/UnitTerm.cs | 2 +- 16 files changed, 30 insertions(+), 26 deletions(-) rename src/DimensionalQuantities.Tests/{DimensionAnalysis => Dimensions}/DimensionTests.cs (98%) rename src/DimensionalQuantities.Tests/{DimensionAnalysis => Naming}/DimensionNameGeneratorTests.cs (98%) rename src/DimensionalQuantities.Tests/{DimensionAnalysis => Units}/UnitExpressionTests.cs (98%) rename src/DimensionalQuantities/{DimensionAnalysis => Dimensions}/Dimension.cs (98%) rename src/DimensionalQuantities/{DimensionAnalysis => Dimensions}/DimensionComponent.cs (62%) rename src/DimensionalQuantities/{DimensionAnalysis => Naming}/DimensionNameGenerator.cs (97%) rename src/DimensionalQuantities/{DimensionAnalysis => Units}/AtomicUnit.cs (71%) rename src/DimensionalQuantities/{DimensionAnalysis => Units}/AtomicUnits.cs (95%) rename src/DimensionalQuantities/{DimensionAnalysis => Units}/EngineeringCatalogue.cs (97%) rename src/DimensionalQuantities/{DimensionAnalysis => Units}/UnitDefinition.cs (91%) rename src/DimensionalQuantities/{DimensionAnalysis => Units}/UnitExpression.cs (98%) rename src/DimensionalQuantities/{DimensionAnalysis => Units}/UnitTerm.cs (88%) diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs index 79735d2..64ebb93 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs @@ -1,4 +1,5 @@ -using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Units; namespace DimensionalQuantities.Roslyn.Generators; diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs index 59e6bb7..b8cad5a 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs @@ -1,5 +1,6 @@ using System.Text; -using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Units; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs index 8f14083..cd98f55 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs @@ -1,5 +1,6 @@ using System.Globalization; -using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Naming; using DimensionalQuantities.Numerics; using DimensionalQuantities.TypeSystem; using DimensionalQuantities.Units; @@ -15,14 +16,14 @@ public static string Emit(IReadOnlyDictionary quant string aliases = string.Join("\n", definitions.SelectMany(definition => definition.Aliases.Select(alias => $"global using {alias} = {definition.TypeName};"))); string extensions = string.Join("\n\n", definitions.Select(RenderQuantity)); - return $$""" + return $""" // - {{aliases}} + {aliases} global using DimensionalQuantities.Generated; namespace DimensionalQuantities.Generated; - {{extensions}} + {extensions} """; } diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs b/src/DimensionalQuantities.Tests/Dimensions/DimensionTests.cs similarity index 98% rename from src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs rename to src/DimensionalQuantities.Tests/Dimensions/DimensionTests.cs index 18941ce..e68faf8 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionTests.cs +++ b/src/DimensionalQuantities.Tests/Dimensions/DimensionTests.cs @@ -1,8 +1,9 @@ using DimensionalQuantities.Numerics; -using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Units; using Xunit; -namespace DimensionalQuantities.Tests.DimensionAnalysis; +namespace DimensionalQuantities.Tests.Dimensions; public sealed class DimensionTests { diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionNameGeneratorTests.cs b/src/DimensionalQuantities.Tests/Naming/DimensionNameGeneratorTests.cs similarity index 98% rename from src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionNameGeneratorTests.cs rename to src/DimensionalQuantities.Tests/Naming/DimensionNameGeneratorTests.cs index 9d6c9da..cac1559 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/DimensionNameGeneratorTests.cs +++ b/src/DimensionalQuantities.Tests/Naming/DimensionNameGeneratorTests.cs @@ -1,7 +1,8 @@ -using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Naming; using Xunit; -namespace DimensionalQuantities.Tests.DimensionAnalysis; +namespace DimensionalQuantities.Tests.Naming; public sealed class DimensionNameGeneratorTests { diff --git a/src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs similarity index 98% rename from src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs rename to src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs index cbd7fc8..74b66e5 100644 --- a/src/DimensionalQuantities.Tests/DimensionAnalysis/UnitExpressionTests.cs +++ b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs @@ -1,8 +1,8 @@ -using DimensionalQuantities.DimensionAnalysis; +using DimensionalQuantities.Units; using DimensionalQuantities.Numerics; using Xunit; -namespace DimensionalQuantities.Tests.DimensionAnalysis; +namespace DimensionalQuantities.Tests.Units; public sealed class UnitExpressionTests { diff --git a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs b/src/DimensionalQuantities/Dimensions/Dimension.cs similarity index 98% rename from src/DimensionalQuantities/DimensionAnalysis/Dimension.cs rename to src/DimensionalQuantities/Dimensions/Dimension.cs index ab17235..8ca3f86 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/Dimension.cs +++ b/src/DimensionalQuantities/Dimensions/Dimension.cs @@ -1,7 +1,7 @@ using DimensionalQuantities.Numerics; using DimensionalQuantities.Units; -namespace DimensionalQuantities.DimensionAnalysis; +namespace DimensionalQuantities.Dimensions; /// An internal value describing the seven rational SI exponents. internal readonly record struct Dimension( diff --git a/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs b/src/DimensionalQuantities/Dimensions/DimensionComponent.cs similarity index 62% rename from src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs rename to src/DimensionalQuantities/Dimensions/DimensionComponent.cs index 4252335..a544957 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/DimensionComponent.cs +++ b/src/DimensionalQuantities/Dimensions/DimensionComponent.cs @@ -1,5 +1,6 @@ using DimensionalQuantities.Numerics; +using DimensionalQuantities.Units; -namespace DimensionalQuantities.DimensionAnalysis; +namespace DimensionalQuantities.Dimensions; internal readonly record struct DimensionComponent(Rational Exponent, UnitDefinition Unit); diff --git a/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs b/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs similarity index 97% rename from src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs rename to src/DimensionalQuantities/Naming/DimensionNameGenerator.cs index 480bfcc..906b53e 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/DimensionNameGenerator.cs +++ b/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs @@ -2,9 +2,10 @@ using System.Globalization; using System.Numerics; using System.Text; +using DimensionalQuantities.Dimensions; using DimensionalQuantities.Numerics; -namespace DimensionalQuantities.DimensionAnalysis; +namespace DimensionalQuantities.Naming; internal static class DimensionNameGenerator { diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index ff99798..a33f2c0 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -1,7 +1,7 @@ using DimensionalQuantities.Numerics; using DimensionalQuantities.Units; using JetBrains.Annotations; -using MathematicalDimension = DimensionalQuantities.DimensionAnalysis.Dimension; +using MathematicalDimension = DimensionalQuantities.Dimensions.Dimension; namespace DimensionalQuantities.TypeSystem; diff --git a/src/DimensionalQuantities/DimensionAnalysis/AtomicUnit.cs b/src/DimensionalQuantities/Units/AtomicUnit.cs similarity index 71% rename from src/DimensionalQuantities/DimensionAnalysis/AtomicUnit.cs rename to src/DimensionalQuantities/Units/AtomicUnit.cs index 304ead3..25ce1d6 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/AtomicUnit.cs +++ b/src/DimensionalQuantities/Units/AtomicUnit.cs @@ -1,3 +1,3 @@ -namespace DimensionalQuantities.DimensionAnalysis; +namespace DimensionalQuantities.Units; internal readonly record struct AtomicUnit(string NameSingular, string NamePlural, string Symbol, int SiBasePrefixExponent = 0); diff --git a/src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs b/src/DimensionalQuantities/Units/AtomicUnits.cs similarity index 95% rename from src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs rename to src/DimensionalQuantities/Units/AtomicUnits.cs index 359607d..f114a19 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/AtomicUnits.cs +++ b/src/DimensionalQuantities/Units/AtomicUnits.cs @@ -1,6 +1,4 @@ -using DimensionalQuantities.Units; - -namespace DimensionalQuantities.DimensionAnalysis; +namespace DimensionalQuantities.Units; internal static class AtomicUnits { diff --git a/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs b/src/DimensionalQuantities/Units/EngineeringCatalogue.cs similarity index 97% rename from src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs rename to src/DimensionalQuantities/Units/EngineeringCatalogue.cs index 181375b..5133e39 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/EngineeringCatalogue.cs +++ b/src/DimensionalQuantities/Units/EngineeringCatalogue.cs @@ -1,6 +1,6 @@ -using DimensionalQuantities.Units; +using DimensionalQuantities.Dimensions; -namespace DimensionalQuantities.DimensionAnalysis; +namespace DimensionalQuantities.Units; /// Recognised names and units for engineering dimensions. internal static class EngineeringCatalogue diff --git a/src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs b/src/DimensionalQuantities/Units/UnitDefinition.cs similarity index 91% rename from src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs rename to src/DimensionalQuantities/Units/UnitDefinition.cs index a7b4744..b8d7454 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/UnitDefinition.cs +++ b/src/DimensionalQuantities/Units/UnitDefinition.cs @@ -1,4 +1,4 @@ -namespace DimensionalQuantities.DimensionAnalysis; +namespace DimensionalQuantities.Units; internal readonly record struct UnitDefinition { diff --git a/src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs b/src/DimensionalQuantities/Units/UnitExpression.cs similarity index 98% rename from src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs rename to src/DimensionalQuantities/Units/UnitExpression.cs index 09c4af0..7852a20 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/UnitExpression.cs +++ b/src/DimensionalQuantities/Units/UnitExpression.cs @@ -2,9 +2,8 @@ using System.Numerics; using System.Text; using DimensionalQuantities.Numerics; -using DimensionalQuantities.Units; -namespace DimensionalQuantities.DimensionAnalysis; +namespace DimensionalQuantities.Units; internal readonly record struct UnitExpression { diff --git a/src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs b/src/DimensionalQuantities/Units/UnitTerm.cs similarity index 88% rename from src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs rename to src/DimensionalQuantities/Units/UnitTerm.cs index 5543237..be48d08 100644 --- a/src/DimensionalQuantities/DimensionAnalysis/UnitTerm.cs +++ b/src/DimensionalQuantities/Units/UnitTerm.cs @@ -1,6 +1,6 @@ using DimensionalQuantities.Numerics; -namespace DimensionalQuantities.DimensionAnalysis; +namespace DimensionalQuantities.Units; internal readonly record struct UnitTerm { From a8e4af6b772d84f5be179cabb5240578c1d18a69 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 21:16:24 +0100 Subject: [PATCH 24/25] feat: discover and generate quantity arithmetic --- .../DimensionalQuantities.Roslyn.Tests.csproj | 4 + .../Discovery/ArithmeticDiscoveryTests.cs | 192 ++++++++++++++ .../Discovery/QuantityTypeReaderTests.cs | 74 ++++++ .../ArithmeticSourceEmitterTests.cs | 78 ++++++ .../Generators/QuantityDiscoveryTests.cs | 51 ++++ .../QuantityGeneratorTests.Arithmetic.cs | 95 +++++++ .../QuantityGeneratorTests.Incremental.cs | 139 ++++++++++ .../Generators/QuantityGeneratorTests.cs | 222 +++++----------- .../Generators/QuantitySourceEmitterTests.cs | 251 ++++++++++++++++++ .../DimensionalQuantities.Roslyn.csproj | 3 + .../Discovery/ArithmeticDiscovery.cs | 124 +++++++++ .../Discovery/ArithmeticOperation.cs | 5 + .../Discovery/ArithmeticOperationKind.cs | 8 + .../Discovery/PlannedQuantity.cs | 10 + .../Discovery/QuantityGenerationPlan.cs | 23 ++ .../Discovery/QuantityTypeReader.cs | 103 +++++++ .../Generators/ArithmeticSourceEmitter.cs | 46 ++++ .../Generators/QuantityDiscovery.cs | 45 +++- .../Generators/QuantityGenerator.cs | 39 ++- .../Generators/QuantitySourceEmitter.cs | 19 +- .../TypeSystem/QuantityTests.cs | 16 ++ .../DimensionalQuantities.csproj | 1 + .../TypeSystem/Quantity.cs | 4 + 23 files changed, 1380 insertions(+), 172 deletions(-) create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Discovery/ArithmeticDiscoveryTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityTypeReaderTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.Arithmetic.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.Incremental.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs create mode 100644 src/DimensionalQuantities.Roslyn/Discovery/ArithmeticDiscovery.cs create mode 100644 src/DimensionalQuantities.Roslyn/Discovery/ArithmeticOperation.cs create mode 100644 src/DimensionalQuantities.Roslyn/Discovery/ArithmeticOperationKind.cs create mode 100644 src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs create mode 100644 src/DimensionalQuantities.Roslyn/Discovery/QuantityGenerationPlan.cs create mode 100644 src/DimensionalQuantities.Roslyn/Discovery/QuantityTypeReader.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/ArithmeticSourceEmitter.cs diff --git a/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj b/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj index 36e211d..663e063 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj +++ b/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj @@ -17,4 +17,8 @@ + + + + diff --git a/src/DimensionalQuantities.Roslyn.Tests/Discovery/ArithmeticDiscoveryTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Discovery/ArithmeticDiscoveryTests.cs new file mode 100644 index 0000000..e5c8de4 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Discovery/ArithmeticDiscoveryTests.cs @@ -0,0 +1,192 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Discovery; + +public sealed class ArithmeticDiscoveryTests +{ + private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.CSharp14); + + [Theory] + [InlineData("a * b", true)] + [InlineData("a / b", false)] + public void DiscoveryReadsQuantityOperandsFromConsumerSymbols(string expression, bool multiply) + { + string source = $$""" + public static class Consumer + { + public static object Test(Length a, Time b) => {{expression}}; + } + """; + CSharpCompilation compilation = Compile(source); + ImmutableArray result = Analyse(compilation); + ArithmeticOperation operation = Assert.Single(result); + ArithmeticOperationKind expectedKind = multiply ? ArithmeticOperationKind.Multiply : ArithmeticOperationKind.Divide; + Dimension expectedLeft = new(Length: 1); + Dimension expectedRight = new(Time: 1); + Dimension expectedResult = new(Length: 1, Time: multiply ? 1 : -1); + ArithmeticOperation expected = new(expectedKind, expectedLeft, expectedRight, expectedResult); + + Assert.Equal(expected, operation); + } + + [Fact] + public void DiscoveryFindsLeftNestedOperationsBottomUp() + { + const string source = """ + public static class Consumer + { + public static object Test(Current a, Current b, Resistance r) => (a * b) * r; + } + """; + CSharpCompilation compilation = Compile(source); + ImmutableArray result = Analyse(compilation); + Dimension current = new(ElectricCurrent: 1); + Dimension resistance = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2); + Dimension currentSquared = new(ElectricCurrent: 2); + Dimension power = new(Length: 2, Mass: 1, Time: -3); + ArithmeticOperation[] expected = + [ + new(ArithmeticOperationKind.Multiply, current, current, currentSquared), + new(ArithmeticOperationKind.Multiply, currentSquared, resistance, power) + ]; + + Assert.Equal(expected, result); + } + + [Fact] + public void DiscoveryFindsRightNestedOperationsBottomUp() + { + const string source = """ + public static class Consumer + { + public static object Test(Current a, Current b, Resistance r) => a * (b * r); + } + """; + CSharpCompilation compilation = Compile(source); + ImmutableArray result = Analyse(compilation); + Dimension current = new(ElectricCurrent: 1); + Dimension resistance = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2); + Dimension currentTimesResistance = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1); + Dimension power = new(Length: 2, Mass: 1, Time: -3); + ArithmeticOperation[] expected = + [ + new(ArithmeticOperationKind.Multiply, current, resistance, currentTimesResistance), + new(ArithmeticOperationKind.Multiply, current, currentTimesResistance, power) + ]; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("a * missing")] + [InlineData("a * 1m")] + [InlineData("a * \"text\"")] + [InlineData("a + b")] + [InlineData("a / a")] + public void DiscoverySkipsUnsupportedOrDimensionlessExpressions(string expression) + { + string source = $$""" + public static class Consumer + { + public static object Test(Length a, Time b) => {{expression}}; + } + """; + CSharpCompilation compilation = Compile(source); + ImmutableArray result = Analyse(compilation); + + Assert.Empty(result); + } + + [Fact] + public void ScalarDivisionCreatesInverseDimensionForNestedQuantityArithmetic() + { + const string source = """ + public static class Consumer + { + public static object Test(Length length, Time time) => (2d / length) * time; + } + """; + CSharpCompilation compilation = Compile(source); + ImmutableArray result = Analyse(compilation); + Dimension length = new(Length: 1); + Dimension inverseLength = new(Length: -1); + Dimension time = new(Time: 1); + Dimension timePerLength = new(Length: -1, Time: 1); + ArithmeticOperation[] expected = + [ + new(ArithmeticOperationKind.ScalarDivide, default, length, inverseLength), + new(ArithmeticOperationKind.Multiply, inverseLength, time, timePerLength) + ]; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("(2 + 3) * a * b")] + [InlineData("(2 * 3) * a * b")] + [InlineData("(a * b) / 2")] + public void ScalarFactorsPreserveQuantityDimensionsInChains(string expression) + { + string source = $$""" + public static class Consumer + { + public static object Test(Length a, Time b) => {{expression}}; + } + """; + CSharpCompilation compilation = Compile(source); + ImmutableArray result = Analyse(compilation); + Dimension length = new(Length: 1); + Dimension time = new(Time: 1); + Dimension lengthTime = new(Length: 1, Time: 1); + ArithmeticOperation expected = new(ArithmeticOperationKind.Multiply, length, time, lengthTime); + + Assert.Equal(expected, Assert.Single(result)); + } + + [Fact] + public void DiscoverySkipsDimensionlessQuantityOperand() + { + const string source = """ + using DimensionalQuantities.TypeSystem; + public static class Consumer + { + public static object Test(Length length, + Quantity> scalar) + => length * scalar; + } + """; + CSharpCompilation compilation = Compile(source); + ImmutableArray result = Analyse(compilation); + + Assert.Empty(result); + } + + private static ImmutableArray Analyse(CSharpCompilation compilation) + { + SyntaxTree tree = compilation.SyntaxTrees.Single(candidate => candidate.FilePath == "Consumer.cs"); + ArrowExpressionClauseSyntax expressionBody = tree.GetRoot().DescendantNodes().OfType().Single(); + SemanticModel semanticModel = compilation.GetSemanticModel(tree); + return ArithmeticDiscovery.Analyse(expressionBody.Expression, semanticModel, TestContext.Current.CancellationToken); + } + + private static CSharpCompilation Compile(string source) + { + string[] assemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = assemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + QuantityGenerationPlan plan = QuantityDiscovery.Discover([]); + SyntaxTree knownQuantities = CSharpSyntaxTree.ParseText(QuantitySourceEmitter.Emit(plan.KnownQuantities), _parseOptions, + path: "KnownQuantities.g.cs"); + return CSharpCompilation.Create("DiscoveryConsumer", + [CSharpSyntaxTree.ParseText(source, _parseOptions, path: "Consumer.cs"), knownQuantities], references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityTypeReaderTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityTypeReaderTests.cs new file mode 100644 index 0000000..e20dc99 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityTypeReaderTests.cs @@ -0,0 +1,74 @@ +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Numerics; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Discovery; + +public sealed class QuantityTypeReaderTests +{ + private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.CSharp14); + + [Fact] + public void ReaderDecodesEveryAxisAndRationalMarker() + { + const string source = """ + using DimensionalQuantities.TypeSystem; + public static class Consumer + { + public static Quantity, Negative, + Fraction, TerminalHex4, TerminalHex5, TerminalHex6, TerminalHex7>> Value; + } + """; + CSharpCompilation compilation = Compile(source); + IFieldSymbol field = GetField(compilation, "Value"); + QuantityTypeReader reader = new(compilation); + bool read = reader.TryRead(field.Type, out Dimension result, TestContext.Current.CancellationToken); + Dimension expected = new(19, -2, new Rational(1, 2), 4, 5, 6, 7); + + Assert.Multiple( + () => Assert.True(read), + () => Assert.Equal(expected, result)); + } + + [Fact] + public void ReaderRejectsUnrelatedAndUnresolvedTypes() + { + const string source = """ + namespace Other { public class Quantity { } } + public static class Consumer + { + public static Other.Quantity Unrelated; + public static string Plain; + public static Missing Unresolved; + } + """; + CSharpCompilation compilation = Compile(source); + QuantityTypeReader reader = new(compilation); + bool unrelated = reader.TryRead(GetField(compilation, "Unrelated").Type, out _, TestContext.Current.CancellationToken); + bool plain = reader.TryRead(GetField(compilation, "Plain").Type, out _, TestContext.Current.CancellationToken); + bool unresolved = reader.TryRead(GetField(compilation, "Unresolved").Type, out _, TestContext.Current.CancellationToken); + + Assert.Multiple( + () => Assert.False(unrelated), + () => Assert.False(plain), + () => Assert.False(unresolved)); + } + + private static IFieldSymbol GetField(CSharpCompilation compilation, string name) => + Assert.IsAssignableFrom(Assert.Single(compilation.GetTypeByMetadataName("Consumer")!.GetMembers(name))); + + private static CSharpCompilation Compile(string source) + { + string[] assemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = assemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + CSharpCompilation compilation = CSharpCompilation.Create("DiscoveryConsumer", + [CSharpSyntaxTree.ParseText(source, _parseOptions, path: "Consumer.cs")], references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + return compilation; + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs new file mode 100644 index 0000000..f9d492c --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed class ArithmeticSourceEmitterTests +{ + private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.CSharp14); + + [Fact] + public void ExplicitPlanEmitsUsableArithmetic() + { + Dimension current = new(ElectricCurrent: 1); + Dimension currentSquared = new(ElectricCurrent: 2); + Dimension resistance = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2); + Dimension currentResistance = current * resistance; + Dimension power = new(Length: 2, Mass: 1, Time: -3); + Dimension voltage = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1); + ArithmeticOperation[] operations = + [ + new(ArithmeticOperationKind.Multiply, current, current, currentSquared), + new(ArithmeticOperationKind.Multiply, currentSquared, resistance, power), + new(ArithmeticOperationKind.Multiply, current, resistance, currentResistance), + new(ArithmeticOperationKind.Multiply, current, currentResistance, power), + new(ArithmeticOperationKind.Divide, voltage, current, resistance) + ]; + QuantityGenerationPlan plan = QuantityDiscovery.Discover(operations); + const string source = """ + public static class Consumer + { + public static double Left() => (Current.FromAmps(2) * Current.FromAmps(3) * Resistance.FromOhms(4)).AsSiBaseUnit(); + public static double Right() => (Current.FromAmps(2) * (Current.FromAmps(3) * Resistance.FromOhms(4))).AsSiBaseUnit(); + public static double Divided() => (Voltage.FromVolts(12) / Current.FromAmps(3)).AsSiBaseUnit(); + } + """; + Assembly assembly = CompileAndEmit(source, plan); + Type consumer = assembly.GetType("Consumer")!; + double left = (double)consumer.GetMethod("Left")!.Invoke(null, null)!; + double right = (double)consumer.GetMethod("Right")!.Invoke(null, null)!; + double divided = (double)consumer.GetMethod("Divided")!.Invoke(null, null)!; + + Assert.Multiple( + () => Assert.Equal(24, left), + () => Assert.Equal(24, right), + () => Assert.Equal(4, divided)); + } + + private static Assembly CompileAndEmit(string source, QuantityGenerationPlan plan) + { + string[] assemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = assemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + CSharpCompilation input = CSharpCompilation.Create( + "ExplicitArithmeticConsumer", + [CSharpSyntaxTree.ParseText(source, _parseOptions, path: "Consumer.cs")], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + SyntaxTree knownQuantities = CSharpSyntaxTree.ParseText(QuantitySourceEmitter.Emit(plan.KnownQuantities), _parseOptions, path: "KnownQuantities.g.cs"); + SyntaxTree additionalQuantities = CSharpSyntaxTree.ParseText(QuantitySourceEmitter.Emit(plan.AdditionalQuantities), _parseOptions, path: "AdditionalQuantities.g.cs"); + SyntaxTree arithmetic = CSharpSyntaxTree.ParseText(ArithmeticSourceEmitter.Emit(plan), _parseOptions, path: "Arithmetic.g.cs"); + CSharpCompilation compilation = input.AddSyntaxTrees(knownQuantities, additionalQuantities, arithmetic); + Diagnostic[] errors = compilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + Assert.Empty(errors); + + using MemoryStream assemblyBytes = new(); + EmitResult emit = compilation.Emit(assemblyBytes, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + return Assembly.Load(assemblyBytes.ToArray()); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs new file mode 100644 index 0000000..d7bdedf --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs @@ -0,0 +1,51 @@ +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.Units; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed class QuantityDiscoveryTests +{ + [Fact] + public void PlanKeepsCatalogueNameAndDeduplicatesOrderedOperations() + { + Dimension current = new(ElectricCurrent: 1); + Dimension currentSquared = new(ElectricCurrent: 2); + Dimension resistance = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2); + Dimension power = new(Length: 2, Mass: 1, Time: -3); + Dimension energy = new(Length: 2, Mass: 1, Time: -2); + ArithmeticOperation square = new(ArithmeticOperationKind.Multiply, current, current, currentSquared); + ArithmeticOperation heat = new(ArithmeticOperationKind.Multiply, currentSquared, resistance, power); + ArithmeticOperation[] operations = [square, square, heat]; + QuantityGenerationPlan plan = QuantityDiscovery.Discover(operations); + ArithmeticOperation[] expected = [square, heat]; + + Assert.Multiple( + () => Assert.Equal(expected, plan.Operations), + () => Assert.Equal("Power", plan.GetQuantityName(power)), + () => Assert.Equal("Energy", plan.GetQuantityName(energy)), + () => Assert.Equal("CurrentSquared", plan.GetQuantityName(currentSquared)), + () => Assert.Single(plan.AdditionalQuantities)); + } + + [Fact] + public void UnknownQuantityUsesSiComponentPowersAndBasePrefixes() + { + Dimension mass = new(Mass: 1); + Dimension massSquared = new(Mass: 2); + ArithmeticOperation operation = new(ArithmeticOperationKind.Multiply, mass, mass, massSquared); + QuantityGenerationPlan plan = QuantityDiscovery.Discover([operation]); + PlannedQuantity quantity = Assert.Single(plan.AdditionalQuantities); + UnitDefinition unit = Assert.Single(quantity.Units); + UnitTerm term = Assert.Single(unit.Expression.Terms); + + Assert.Multiple( + () => Assert.Equal(massSquared, quantity.Dimension), + () => Assert.False(quantity.IncludeNamedConversions), + () => Assert.Equal("MassSquared", unit.QuantityName), + () => Assert.Equal(2, term.Power), + () => Assert.Equal(SiBasePrefixExponents.Mass, term.PrefixExponent)); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.Arithmetic.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.Arithmetic.cs new file mode 100644 index 0000000..bf4f3db --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.Arithmetic.cs @@ -0,0 +1,95 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed partial class QuantityGeneratorTests +{ + [Theory] + [InlineData("Length.FromMetres(2) * Length.FromMetres(3)", "Area", 6)] + [InlineData("Current.FromAmps(2) * Current.FromAmps(3)", "CurrentSquared", 6)] + [InlineData("Current.FromAmps(2) * Current.FromAmps(3) * Resistance.FromOhms(4)", "Power", 24)] + [InlineData("Current.FromAmps(2) * (Current.FromAmps(3) * Resistance.FromOhms(4))", "Power", 24)] + [InlineData("Voltage.FromVolts(12) / Current.FromAmps(3)", "Resistance", 4)] + [InlineData("Current.FromAmps(3) * Time.FromSeconds(2)", "TimeCurrent", 6)] + [InlineData("(Current.FromAmps(2) * Time.FromSeconds(3)) / Time.FromSeconds(3)", "Current", 2)] + [InlineData("(Voltage.FromVolts(4) * Current.FromAmps(3)) / Current.FromAmps(2)", "Voltage", 6)] + [InlineData("Current.FromAmps(3) * 2d", "Current", 6)] + [InlineData("2 * Current.FromAmps(3)", "Current", 6)] + [InlineData("Current.FromAmps(6) / 2f", "Current", 3)] + [InlineData("2d / Time.FromSeconds(4)", "Frequency", 0.5)] + [InlineData("2f / Current.FromAmps(4)", "InverseCurrent", 0.5)] + [InlineData("(2 / Current.FromAmps(4)) * Time.FromSeconds(3)", "TimePerCurrent", 1.5)] + [InlineData("(Current.FromAmps(2) * 2) * Current.FromAmps(3)", "CurrentSquared", 12)] + [InlineData("2 * (Current.FromAmps(2) * Current.FromAmps(3))", "CurrentSquared", 12)] + [InlineData("(Current.FromAmps(2) * Current.FromAmps(3)) / 2", "CurrentSquared", 3)] + [InlineData("(2 + 3) * Current.FromAmps(2) * Current.FromAmps(3)", "CurrentSquared", 30)] + [InlineData("(2 * 3) * Current.FromAmps(2) * Current.FromAmps(3)", "CurrentSquared", 36)] + public void QuantityArithmeticProducesTheExpectedTypeAndSiMagnitude(string arithmetic, string resultType, double expected) + { + string expression = $"(({resultType})({arithmetic})).AsSiBaseUnit()"; + double result = Evaluate(expression); + + Assert.Equal(expected, result); + } + + [Fact] + public void QuantityArithmeticAcceptsTypedMethodParametersAndReturns() + { + const string source = """ + public static class Consumer + { + public static Area Multiply(Length left, Length right) => left * right; + public static Voltage Divide(Power power, Current current) => power / current; + public static InverseCurrent Invert(double numerator, Current current) => numerator / current; + } + """; + + Compile(source); + } + + [Fact] + public void RepeatedConsumerOperationIsEmittedOnce() + { + const string source = """ + public static class Consumer + { + public static Area First(Length left, Length right) => left * right; + public static Area Second(Length left, Length right) => left * right; + } + """; + CSharpCompilation compilation = Compile(source); + INamedTypeSymbol extensions = compilation.GetTypeByMetadataName("DimensionalQuantities.Generated.ArithmeticExtensions")!; + Assert.Single(extensions.GetMembers().OfType(), method => method.MetadataName == "op_Multiply"); + } + + + [Fact] + public void ArithmeticReadsFieldAndMethodReturnTypes() + { + const string source = """ + public static class Consumer + { + public static Current Input = Current.FromAmps(2); + public static Resistance GetResistance() => Resistance.FromOhms(4); + public static Power Calculate() => Input * Input * GetResistance(); + } + """; + Compile(source); + } + + [Fact] + public void DimensionPreservingScalarArithmeticUsesCoreOperatorsOnly() + { + const string source = """ + public static class Consumer + { + public static Current Scale(Current original, double factor) => factor * original / 2; + } + """; + CSharpCompilation compilation = Compile(source); + + Assert.Null(compilation.GetTypeByMetadataName("DimensionalQuantities.Generated.ArithmeticExtensions")); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.Incremental.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.Incremental.cs new file mode 100644 index 0000000..9d2baa8 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.Incremental.cs @@ -0,0 +1,139 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.Roslyn.Generators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed partial class QuantityGeneratorTests +{ + [Fact] + public void GeneratorRefreshesArithmeticAfterAddingEditingAndRemovingConsumerTrees() + { + const string firstSource = """ + public static class Consumer + { + public static CurrentSquared Value() => Current.FromAmps(2) * Current.FromAmps(3); + } + """; + const string addedSource = """ + public static class Added + { + public static LengthTime Value() => Length.FromMetres(2) * Time.FromSeconds(3); + } + """; + const string editedSource = """ + public static class Consumer + { + public static Resistance Value() => Voltage.FromVolts(12) / Current.FromAmps(3); + } + """; + CSharpCompilation initialInput = CreateInputCompilation(firstSource); + SyntaxTree initialTree = Assert.Single(initialInput.SyntaxTrees); + SyntaxTree addedTree = CSharpSyntaxTree.ParseText(addedSource, _parseOptions, path: "Added.cs", cancellationToken: TestContext.Current.CancellationToken); + SyntaxTree editedTree = CSharpSyntaxTree.ParseText(editedSource, _parseOptions, path: "Consumer.cs", cancellationToken: TestContext.Current.CancellationToken); + GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], parseOptions: _parseOptions); + + driver = driver.RunGeneratorsAndUpdateCompilation(initialInput, out Compilation firstOutput, out ImmutableArray firstDiagnostics, + TestContext.Current.CancellationToken); + Assert.Multiple( + () => Assert.Empty(firstDiagnostics), + () => Assert.Empty(firstOutput.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)), + () => Assert.NotNull(firstOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.CurrentSquaredExtensions"))); + + CSharpCompilation addedInput = initialInput.AddSyntaxTrees(addedTree); + driver = driver.RunGeneratorsAndUpdateCompilation(addedInput, out Compilation addedOutput, out ImmutableArray addedDiagnostics, + TestContext.Current.CancellationToken); + Assert.Multiple( + () => Assert.Empty(addedDiagnostics), + () => Assert.Empty(addedOutput.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)), + () => Assert.NotNull(addedOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.CurrentSquaredExtensions")), + () => Assert.NotNull(addedOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.LengthTimeExtensions"))); + + CSharpCompilation editedInput = addedInput.ReplaceSyntaxTree(initialTree, editedTree); + driver = driver.RunGeneratorsAndUpdateCompilation(editedInput, out Compilation editedOutput, out ImmutableArray editedDiagnostics, + TestContext.Current.CancellationToken); + Dimension length = new(Length: 1); + Dimension time = new(Time: 1); + Dimension lengthTime = new(Length: 1, Time: 1); + Dimension voltage = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1); + Dimension current = new(ElectricCurrent: 1); + Dimension resistance = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2); + ArithmeticOperation addedOperation = new(ArithmeticOperationKind.Multiply, length, time, lengthTime); + ArithmeticOperation editedOperation = new(ArithmeticOperationKind.Divide, voltage, current, resistance); + ArithmeticOperation[] editedOperators = ReadOperators(editedOutput); + Assert.Multiple( + () => Assert.Empty(editedDiagnostics), + () => Assert.Empty(editedOutput.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)), + () => Assert.Null(editedOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.CurrentSquaredExtensions")), + () => Assert.NotNull(editedOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.LengthTimeExtensions")), + () => Assert.Equal(2, editedOperators.Length), + () => Assert.Contains(addedOperation, editedOperators), + () => Assert.Contains(editedOperation, editedOperators)); + + CSharpCompilation removedInput = editedInput.RemoveSyntaxTrees(addedTree); + driver = driver.RunGeneratorsAndUpdateCompilation(removedInput, out Compilation removedOutput, out ImmutableArray removedDiagnostics, + TestContext.Current.CancellationToken); + ArithmeticOperation[] removedOperators = ReadOperators(removedOutput); + Assert.Multiple( + () => Assert.Empty(removedDiagnostics), + () => Assert.Empty(removedOutput.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)), + () => Assert.Null(removedOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.CurrentSquaredExtensions")), + () => Assert.Null(removedOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.LengthTimeExtensions")), + () => Assert.Equal(editedOperation, Assert.Single(removedOperators))); + + CSharpCompilation emptyInput = removedInput.RemoveSyntaxTrees(editedTree); + driver.RunGeneratorsAndUpdateCompilation(emptyInput, out Compilation emptyOutput, out ImmutableArray emptyDiagnostics, + TestContext.Current.CancellationToken); + Assert.Multiple( + () => Assert.Empty(emptyDiagnostics), + () => Assert.Empty(emptyOutput.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)), + () => Assert.Null(emptyOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.ArithmeticExtensions")), + () => Assert.Null(emptyOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.CurrentSquaredExtensions")), + () => Assert.Null(emptyOutput.GetTypeByMetadataName("DimensionalQuantities.Generated.LengthTimeExtensions"))); + } + + [Fact] + public void GeneratorIgnoresUnrelatedIncompleteAndDimensionlessArithmetic() + { + const string source = """ + public static class Consumer + { + public static int Number(int left, int right) => left * right; + public static object Incomplete(Length length) => length * Missing; + public static object Dimensionless(Length length) => length / length; + } + """; + CSharpCompilation input = CreateInputCompilation(source); + GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], parseOptions: _parseOptions); + driver.RunGeneratorsAndUpdateCompilation(input, out Compilation output, out ImmutableArray generatorDiagnostics, + TestContext.Current.CancellationToken); + + Assert.Multiple( + () => Assert.Empty(generatorDiagnostics), + () => Assert.Null(output.GetTypeByMetadataName("DimensionalQuantities.Generated.ArithmeticExtensions"))); + } + + private static ArithmeticOperation[] ReadOperators(Compilation compilation) + { + INamedTypeSymbol extensions = compilation.GetTypeByMetadataName("DimensionalQuantities.Generated.ArithmeticExtensions")!; + QuantityTypeReader reader = new(compilation); + return extensions.GetMembers().OfType() + .Where(method => method.MetadataName is "op_Multiply" or "op_Division") + .Select(method => + { + if (!reader.TryRead(method.Parameters[0].Type, out Dimension left, TestContext.Current.CancellationToken) + || !reader.TryRead(method.Parameters[1].Type, out Dimension right, TestContext.Current.CancellationToken) + || !reader.TryRead(method.ReturnType, out Dimension result, TestContext.Current.CancellationToken)) + { + throw new InvalidOperationException("Generated operator has an unreadable quantity signature."); + } + + ArithmeticOperationKind kind = method.MetadataName == "op_Multiply" ? ArithmeticOperationKind.Multiply : ArithmeticOperationKind.Divide; + return new ArithmeticOperation(kind, left, right, result); + }).ToArray(); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs index 28ecd6d..8598a48 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs @@ -1,22 +1,20 @@ using System.Collections.Immutable; using System.Reflection; +using System.Text; using DimensionalQuantities.Roslyn.Generators; using DimensionalQuantities.TypeSystem; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Emit; +using Microsoft.CodeAnalysis.Text; using Xunit; namespace DimensionalQuantities.Roslyn.Tests.Generators; -public sealed class QuantityGeneratorTests +public sealed partial class QuantityGeneratorTests { private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.CSharp14); - private static readonly string[] _prefixes = - [ - "Quetta", "Ronna", "Yotta", "Zetta", "Exa", "Peta", "Tera", "Giga", "Mega", "Kilo", "Hecto", "Deca", - "Deci", "Centi", "Milli", "Micro", "Nano", "Pico", "Femto", "Atto", "Zepto", "Yocto", "Ronto", "Quecto" - ]; [Fact] public void BaseAliasesHaveUnprefixedFactoriesAndAccessors() @@ -43,11 +41,22 @@ public static double UseBaseQuantities() } [Fact] - public void VoltageAndMechanicalAliasAreTheSameType() + public void SemanticPipelineResolvesPostInitialisationQuantityAliasesAndFactories() { - const string expression = "typeof(Voltage) == typeof(LengthSquaredMassPerTimeCubedCurrent)"; - bool result = Evaluate(expression); - Assert.True(result); + const string source = """ + public static class Consumer + { + public static Length Use(Length input) => Length.FromMetres(2); + } + """; + CSharpCompilation result = Compile(source, new SemanticProbeGenerator()); + INamedTypeSymbol probe = result.GetTypeByMetadataName("SemanticProbe")!; + IFieldSymbol aliasResolved = Assert.IsAssignableFrom(Assert.Single(probe.GetMembers("AliasResolved"))); + IFieldSymbol factoryResolved = Assert.IsAssignableFrom(Assert.Single(probe.GetMembers("FactoryResolved"))); + + Assert.Multiple( + () => Assert.Equal(true, aliasResolved.ConstantValue), + () => Assert.Equal(true, factoryResolved.ConstantValue)); } [Fact] @@ -73,142 +82,6 @@ public static class Consumer Compile(source); } - [Fact] - public void CatalogueUnitsHaveUnprefixedFactoriesAndAccessors() - { - const string source = """ - public static class Consumer - { - public static double UseUnits() => - Area.FromSquareMetres(1).AsSquareMetres() + - Volume.FromCubicMetres(1).AsCubicMetres() + - Frequency.FromHertz(1).AsHertz() + - AngularVelocity.FromRadiansPerSecond(1).AsRadiansPerSecond() + - Density.FromGramsPerCubicMetre(1).AsGramsPerCubicMetre() + - Pressure.FromPascals(1).AsPascals() + - Energy.FromJoules(1).AsJoules() + - Torque.FromNewtonMetres(1).AsNewtonMetres() + - Power.FromWatts(1).AsWatts() + - SpecificPower.FromWattsPerKilogram(1).AsWattsPerKilogram() + - TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin() + - PowerDensity.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre() + - Voltage.FromVolts(1).AsVolts() + - Resistance.FromOhms(1).AsOhms() + - Inductance.FromHenries(1).AsHenries() + - MagneticFlux.FromWebers(1).AsWebers() + - MagneticFluxDensity.FromTeslas(1).AsTeslas() + - MagneticFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre() + - CurrentDensity.FromAmpsPerSquareMetre(1).AsAmpsPerSquareMetre() + - Permeability.FromHenriesPerMetre(1).AsHenriesPerMetre() + - Resistivity.FromOhmMetres(1).AsOhmMetres() + - Conductivity.FromSiemensPerMetre(1).AsSiemensPerMetre() + - MagneticReluctivity.FromMetresPerHenry(1).AsMetresPerHenry() + - MagneticVectorPotential.FromWebersPerMetre(1).AsWebersPerMetre(); - } - """; - - Compile(source); - } - - [Theory] - [InlineData("Area", "LengthSquared")] - [InlineData("CurrentDensity", "CurrentPerLengthSquared")] - [InlineData("Pressure", "Stress")] - [InlineData("Pressure", "YoungsModulus")] - [InlineData("Energy", "Torque")] - public void CatalogueAliasesShareTheCanonicalDimensionType(string alias, string equivalent) - { - bool result = Evaluate($"typeof({alias}) == typeof({equivalent})"); - Assert.True(result); - } - - [Fact] - public void SharedUnitsAreAvailableThroughEveryAliasWithoutDuplicateHelpers() - { - const string source = """ - public static class Consumer - { - public static double UsePressureAliases() - { - Pressure pressure = Pressure.FromPascals(1); - Stress stress = Stress.FromPascals(1); - YoungsModulus modulus = YoungsModulus.FromKiloPascals(1); - return pressure.AsPascals() + stress.AsPascals() + modulus.AsKiloPascals(); - } - } - """; - - Compile(source); - } - - [Fact] - public void PrefixesAreOnlyGeneratedForSupportedFactorPlacements() - { - const string source = "public static class Consumer { public static double Value() => TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin(); }"; - CSharpCompilation result = Compile(source); - INamespaceSymbol generated = result.GetTypeByMetadataName("DimensionalQuantities.Generated.LengthExtensions")!.ContainingNamespace; - string[] names = generated.GetTypeMembers().SelectMany(type => type.GetMembers().OfType()).Select(method => method.Name).ToArray(); - - Assert.Multiple( - () => Assert.DoesNotContain("FromInverseMilliKelvin", names), - () => Assert.DoesNotContain("AsInverseMilliKelvin", names), - () => Assert.DoesNotContain("FromMilliAmpsPerSquareMilliMetre", names), - () => Assert.DoesNotContain("FromKiloGramsPerCubicCentiMetre", names)); - } - - [Fact] - public void EverySiPrefixHasUsableFactoryAndAccessor() - { - string calls = string.Join("\n", _prefixes.Select(prefix => - $"Length value{prefix} = Length.From{prefix}Metres(1); double output{prefix} = value{prefix}.As{prefix}Metres();")); - string source = $$""" - public static class Consumer - { - public static void UsePrefixes() - { - {{calls}} - } - } - """; - - Compile(source); - } - - [Theory] - [InlineData("Length.FromMilliMetres(2500).AsSiBaseUnit()", 2.5)] - [InlineData("Voltage.FromSiBaseUnit(2.5).AsKiloVolts()", 0.0025)] - [InlineData("Mass.FromKiloGrams(2.5).AsSiBaseUnit()", 2.5)] - [InlineData("Density.FromGramsPerCubicCentiMetre(1).AsSiBaseUnit()", 1000)] - public void GeneratedConversionsApplyRepresentativeUnitScales(string expression, double expected) - { - double result = Evaluate(expression); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("Voltage.FromPrefixedUnits(2, -3).AsSiBaseUnit()", 0.002)] - [InlineData("CurrentDensity.FromPrefixedUnits(1, ampPrefixExponent: 0, metrePrefixExponent: -3).AsSiBaseUnit()", 1000000)] - [InlineData("SpecificPower.FromPrefixedUnits(1, wattPrefixExponent: 3, gramPrefixExponent: 3).AsSiBaseUnit()", 1000)] - [InlineData("TemperatureCoefficient.FromPrefixedUnits(1, kelvinPrefixExponent: -3).AsSiBaseUnit()", 1000)] - [InlineData("Mass.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] - [InlineData("Torque.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] - public void PrefixedUnitFactoriesUseCanonicalUnitFactors(string expression, double expected) - { - double result = Evaluate(expression); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("Voltage.FromSiBaseUnit(0.002).AsPrefixedUnits(-3)", 2)] - [InlineData("CurrentDensity.FromSiBaseUnit(1000000).AsPrefixedUnits(ampPrefixExponent: 0, metrePrefixExponent: -3)", 1)] - [InlineData("Mass.FromSiBaseUnit(1).AsPrefixedUnits(gramPrefixExponent: 0)", 1000)] - [InlineData("Torque.FromSiBaseUnit(2).AsPrefixedUnits()", 2)] - public void PrefixedUnitAccessorsUseCanonicalUnitFactors(string expression, double expected) - { - double result = Evaluate(expression); - Assert.Equal(expected, result); - } - private static T Evaluate(string expression) { string source = $$""" @@ -226,17 +99,13 @@ public static class Consumer return Assert.IsType(method.Invoke(null, null)); } - private static CSharpCompilation Compile(string source) + private static CSharpCompilation Compile(string source, IIncrementalGenerator? additionalGenerator = null) { - string[] platformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); - IEnumerable references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path)) - .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); - CSharpCompilation input = CSharpCompilation.Create( - "GeneratedQuantityConsumer", - [CSharpSyntaxTree.ParseText(source, _parseOptions)], - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], parseOptions: _parseOptions); + CSharpCompilation input = CreateInputCompilation(source); + ISourceGenerator[] generators = additionalGenerator is null + ? [new QuantityGenerator().AsSourceGenerator()] + : [new QuantityGenerator().AsSourceGenerator(), additionalGenerator.AsSourceGenerator()]; + GeneratorDriver driver = CSharpGeneratorDriver.Create(generators, parseOptions: _parseOptions); driver.RunGeneratorsAndUpdateCompilation(input, out Compilation output, out ImmutableArray generatorDiagnostics); Diagnostic[] compilationErrors = output.GetDiagnostics(TestContext.Current.CancellationToken) .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); @@ -245,4 +114,45 @@ private static CSharpCompilation Compile(string source) () => Assert.True(compilationErrors.Length == 0, string.Join(Environment.NewLine, compilationErrors))); return (CSharpCompilation)output; } + + private static CSharpCompilation CreateInputCompilation(string source) + { + string[] platformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + return CSharpCompilation.Create( + "GeneratedQuantityConsumer", + [CSharpSyntaxTree.ParseText(source, _parseOptions, path: "Consumer.cs")], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + private sealed class SemanticProbeGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider<(bool IsInvocation, bool Resolved)> observed = context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => node.SyntaxTree.FilePath == "Consumer.cs" && node is (ParameterSyntax or InvocationExpressionSyntax), + static (syntaxContext, _) => + { + bool isInvocation = syntaxContext.Node is InvocationExpressionSyntax; + ITypeSymbol? type = syntaxContext.Node switch + { + ParameterSyntax parameter => (syntaxContext.SemanticModel.GetDeclaredSymbol(parameter) as IParameterSymbol)?.Type, + InvocationExpressionSyntax invocation => (syntaxContext.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol)?.ReturnType, + _ => null + }; + INamedTypeSymbol? quantity = syntaxContext.SemanticModel.Compilation.GetTypeByMetadataName(typeof(Quantity<>).FullName!); + bool resolved = type is INamedTypeSymbol named && SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, quantity); + return (isInvocation, resolved); + }); + context.RegisterSourceOutput(observed.Collect(), static (output, symbols) => + { + bool aliasResolved = symbols.Any(symbol => !symbol.IsInvocation && symbol.Resolved); + bool factoryResolved = symbols.Any(symbol => symbol.IsInvocation && symbol.Resolved); + string source = $"internal static class SemanticProbe {{ public const bool AliasResolved = {aliasResolved.ToString().ToLowerInvariant()}; public const bool FactoryResolved = {factoryResolved.ToString().ToLowerInvariant()}; }}"; + output.AddSource("SemanticProbe.g.cs", SourceText.From(source, Encoding.UTF8)); + }); + } + } } diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs new file mode 100644 index 0000000..6931e64 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs @@ -0,0 +1,251 @@ +using System.Reflection; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed class QuantitySourceEmitterTests +{ + private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.CSharp14); + private static readonly string[] _prefixes = + [ + "Quetta", "Ronna", "Yotta", "Zetta", "Exa", "Peta", "Tera", "Giga", "Mega", "Kilo", "Hecto", "Deca", + "Deci", "Centi", "Milli", "Micro", "Nano", "Pico", "Femto", "Atto", "Zepto", "Yocto", "Ronto", "Quecto" + ]; + + [Fact] + public void DiscoveredMechanicalQuantityUsesItsGenericPrefixConversion() + { + Dimension mass = new(Mass: 1); + Dimension massSquared = new(Mass: 2); + ArithmeticOperation operation = new(ArithmeticOperationKind.Multiply, mass, mass, massSquared); + QuantityGenerationPlan plan = QuantityDiscovery.Discover([operation]); + const string source = "public static class Consumer { public static double Value() => MassSquared.FromPrefixedUnits(2, gramPrefixExponent: 0).AsSiBaseUnit(); }"; + Assembly assembly = CompileAndEmit(source, plan.KnownQuantities, plan.AdditionalQuantities); + Type consumer = assembly.GetType("Consumer")!; + double result = (double)consumer.GetMethod("Value")!.Invoke(null, null)!; + + Assert.Equal(0.000002, result, 12); + } + + [Fact] + public void DiscoveredQuantityOnlyHasGenericPrefixConversions() + { + Dimension current = new(ElectricCurrent: 1); + Dimension currentSquared = new(ElectricCurrent: 2); + ArithmeticOperation operation = new(ArithmeticOperationKind.Multiply, current, current, currentSquared); + QuantityGenerationPlan plan = QuantityDiscovery.Discover([operation]); + CSharpCompilation compilation = Compile(QuantitySourceEmitter.Emit(plan.AdditionalQuantities)); + INamedTypeSymbol extensions = compilation.GetTypeByMetadataName("DimensionalQuantities.Generated.CurrentSquaredExtensions")!; + string[] methods = extensions.GetMembers().OfType().Select(method => method.Name).ToArray(); + + Assert.Multiple( + () => Assert.Contains("FromPrefixedUnits", methods), + () => Assert.Contains("AsPrefixedUnits", methods), + () => Assert.DoesNotContain(methods, method => method.StartsWith("FromSquare", StringComparison.Ordinal)), + () => Assert.DoesNotContain(methods, method => method.StartsWith("AsSquare", StringComparison.Ordinal))); + } + + [Fact] + public void VoltageAndMechanicalAliasAreTheSameType() + { + const string expression = "typeof(Voltage) == typeof(LengthSquaredMassPerTimeCubedCurrent)"; + bool result = Evaluate(expression); + Assert.True(result); + } + + [Fact] + public void CatalogueUnitsHaveUnprefixedFactoriesAndAccessors() + { + const string source = """ + public static class Consumer + { + public static double UseUnits() => + Area.FromSquareMetres(1).AsSquareMetres() + + Volume.FromCubicMetres(1).AsCubicMetres() + + Frequency.FromHertz(1).AsHertz() + + AngularVelocity.FromRadiansPerSecond(1).AsRadiansPerSecond() + + Density.FromGramsPerCubicMetre(1).AsGramsPerCubicMetre() + + Pressure.FromPascals(1).AsPascals() + + Energy.FromJoules(1).AsJoules() + + Torque.FromNewtonMetres(1).AsNewtonMetres() + + Power.FromWatts(1).AsWatts() + + SpecificPower.FromWattsPerKilogram(1).AsWattsPerKilogram() + + TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin() + + PowerDensity.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre() + + Voltage.FromVolts(1).AsVolts() + + Resistance.FromOhms(1).AsOhms() + + Inductance.FromHenries(1).AsHenries() + + MagneticFlux.FromWebers(1).AsWebers() + + MagneticFluxDensity.FromTeslas(1).AsTeslas() + + MagneticFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre() + + CurrentDensity.FromAmpsPerSquareMetre(1).AsAmpsPerSquareMetre() + + Permeability.FromHenriesPerMetre(1).AsHenriesPerMetre() + + Resistivity.FromOhmMetres(1).AsOhmMetres() + + Conductivity.FromSiemensPerMetre(1).AsSiemensPerMetre() + + MagneticReluctivity.FromMetresPerHenry(1).AsMetresPerHenry() + + MagneticVectorPotential.FromWebersPerMetre(1).AsWebersPerMetre(); + } + """; + + Compile(source); + } + + [Theory] + [InlineData("Area", "LengthSquared")] + [InlineData("CurrentDensity", "CurrentPerLengthSquared")] + [InlineData("Pressure", "Stress")] + [InlineData("Pressure", "YoungsModulus")] + [InlineData("Energy", "Torque")] + public void CatalogueAliasesShareTheCanonicalDimensionType(string alias, string equivalent) + { + bool result = Evaluate($"typeof({alias}) == typeof({equivalent})"); + Assert.True(result); + } + + [Fact] + public void SharedUnitsAreAvailableThroughEveryAliasWithoutDuplicateHelpers() + { + const string source = """ + public static class Consumer + { + public static double UsePressureAliases() + { + Pressure pressure = Pressure.FromPascals(1); + Stress stress = Stress.FromPascals(1); + YoungsModulus modulus = YoungsModulus.FromKiloPascals(1); + return pressure.AsPascals() + stress.AsPascals() + modulus.AsKiloPascals(); + } + } + """; + + Compile(source); + } + + [Fact] + public void PrefixesAreOnlyGeneratedForSupportedFactorPlacements() + { + const string source = "public static class Consumer { public static double Value() => TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin(); }"; + CSharpCompilation result = Compile(source); + INamespaceSymbol generated = result.GetTypeByMetadataName("DimensionalQuantities.Generated.LengthExtensions")!.ContainingNamespace; + string[] names = generated.GetTypeMembers().SelectMany(type => type.GetMembers().OfType()).Select(method => method.Name).ToArray(); + + Assert.Multiple( + () => Assert.DoesNotContain("FromInverseMilliKelvin", names), + () => Assert.DoesNotContain("AsInverseMilliKelvin", names), + () => Assert.DoesNotContain("FromMilliAmpsPerSquareMilliMetre", names), + () => Assert.DoesNotContain("FromKiloGramsPerCubicCentiMetre", names)); + } + + [Fact] + public void EverySiPrefixHasUsableFactoryAndAccessor() + { + string calls = string.Join("\n", _prefixes.Select(prefix => + $"Length value{prefix} = Length.From{prefix}Metres(1); double output{prefix} = value{prefix}.As{prefix}Metres();")); + string source = $$""" + public static class Consumer + { + public static void UsePrefixes() + { + {{calls}} + } + } + """; + + Compile(source); + } + + [Theory] + [InlineData("Length.FromMilliMetres(2500).AsSiBaseUnit()", 2.5)] + [InlineData("Voltage.FromSiBaseUnit(2.5).AsKiloVolts()", 0.0025)] + [InlineData("Mass.FromKiloGrams(2.5).AsSiBaseUnit()", 2.5)] + [InlineData("Density.FromGramsPerCubicCentiMetre(1).AsSiBaseUnit()", 1000)] + public void GeneratedConversionsApplyRepresentativeUnitScales(string expression, double expected) + { + double result = Evaluate(expression); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("Voltage.FromPrefixedUnits(2, -3).AsSiBaseUnit()", 0.002)] + [InlineData("CurrentDensity.FromPrefixedUnits(1, ampPrefixExponent: 0, metrePrefixExponent: -3).AsSiBaseUnit()", 1000000)] + [InlineData("SpecificPower.FromPrefixedUnits(1, wattPrefixExponent: 3, gramPrefixExponent: 3).AsSiBaseUnit()", 1000)] + [InlineData("TemperatureCoefficient.FromPrefixedUnits(1, kelvinPrefixExponent: -3).AsSiBaseUnit()", 1000)] + [InlineData("Mass.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] + [InlineData("Torque.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] + public void PrefixedUnitFactoriesUseCanonicalUnitFactors(string expression, double expected) + { + double result = Evaluate(expression); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("Voltage.FromSiBaseUnit(0.002).AsPrefixedUnits(-3)", 2)] + [InlineData("CurrentDensity.FromSiBaseUnit(1000000).AsPrefixedUnits(ampPrefixExponent: 0, metrePrefixExponent: -3)", 1)] + [InlineData("Mass.FromSiBaseUnit(1).AsPrefixedUnits(gramPrefixExponent: 0)", 1000)] + [InlineData("Torque.FromSiBaseUnit(2).AsPrefixedUnits()", 2)] + public void PrefixedUnitAccessorsUseCanonicalUnitFactors(string expression, double expected) + { + double result = Evaluate(expression); + Assert.Equal(expected, result); + } + + private static T Evaluate(string expression) + { + string source = $$""" + public static class Consumer + { + public static global::{{typeof(T).FullName}} Value() => {{expression}}; + } + """; + Assembly assembly = CompileAndEmit(source, QuantityDiscovery.Discover([]).KnownQuantities, []); + MethodInfo method = assembly.GetType("Consumer")!.GetMethod("Value")!; + return Assert.IsType(method.Invoke(null, null)); + } + + private static CSharpCompilation Compile(string source) + { + SyntaxTree generated = CSharpSyntaxTree.ParseText( + QuantitySourceEmitter.Emit(QuantityDiscovery.Discover([]).KnownQuantities), + _parseOptions, + path: "KnownQuantities.g.cs"); + CSharpCompilation compilation = CreateInputCompilation(source).AddSyntaxTrees(generated); + Diagnostic[] errors = compilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + Assert.Empty(errors); + return compilation; + } + + private static Assembly CompileAndEmit(string source, IReadOnlyList known, IReadOnlyList additional) + { + SyntaxTree knownQuantities = CSharpSyntaxTree.ParseText(QuantitySourceEmitter.Emit(known), _parseOptions, path: "KnownQuantities.g.cs"); + SyntaxTree additionalQuantities = CSharpSyntaxTree.ParseText(QuantitySourceEmitter.Emit(additional), _parseOptions, path: "AdditionalQuantities.g.cs"); + CSharpCompilation compilation = CreateInputCompilation(source).AddSyntaxTrees(knownQuantities, additionalQuantities); + Diagnostic[] errors = compilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + Assert.Empty(errors); + + using MemoryStream assemblyBytes = new(); + EmitResult emit = compilation.Emit(assemblyBytes, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + return Assembly.Load(assemblyBytes.ToArray()); + } + + private static CSharpCompilation CreateInputCompilation(string source) + { + string[] platformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + return CSharpCompilation.Create( + "QuantitySourceEmitterConsumer", + [CSharpSyntaxTree.ParseText(source, _parseOptions, path: "Consumer.cs")], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} diff --git a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj index ba8d56a..4b95c43 100644 --- a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj +++ b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj @@ -9,4 +9,7 @@ + + + diff --git a/src/DimensionalQuantities.Roslyn/Discovery/ArithmeticDiscovery.cs b/src/DimensionalQuantities.Roslyn/Discovery/ArithmeticDiscovery.cs new file mode 100644 index 0000000..b69f29f --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Discovery/ArithmeticDiscovery.cs @@ -0,0 +1,124 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Dimensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +namespace DimensionalQuantities.Roslyn.Discovery; + +internal static class ArithmeticDiscovery +{ + public static bool IsRoot(BinaryExpressionSyntax binary) + { + if (!IsSupported(binary)) + { + return false; + } + + SyntaxNode node = binary; + while (node.Parent is ParenthesizedExpressionSyntax parenthesized) + { + node = parenthesized; + } + + return node.Parent is not BinaryExpressionSyntax parent || !IsSupported(parent); + } + + public static ImmutableArray Analyse(ExpressionSyntax expression, SemanticModel semanticModel, CancellationToken cancellationToken) + { + QuantityTypeReader typeReader = new(semanticModel.Compilation); + ImmutableArray.Builder operations = ImmutableArray.CreateBuilder(); + AnalyseExpression(expression, semanticModel, typeReader, operations, cancellationToken); + return operations.ToImmutable(); + } + + private static Operand AnalyseExpression(ExpressionSyntax expression, SemanticModel semanticModel, QuantityTypeReader typeReader, + ImmutableArray.Builder operations, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (expression is ParenthesizedExpressionSyntax parenthesized) + { + return AnalyseExpression(parenthesized.Expression, semanticModel, typeReader, operations, cancellationToken); + } + + if (expression is BinaryExpressionSyntax binary && IsSupported(binary)) + { + Operand left = AnalyseExpression(binary.Left, semanticModel, typeReader, operations, cancellationToken); + Operand right = AnalyseExpression(binary.Right, semanticModel, typeReader, operations, cancellationToken); + return Combine(binary, left, right, operations); + } + + return ClassifyLeaf(expression, semanticModel, typeReader, cancellationToken); + } + + private static Operand Combine(BinaryExpressionSyntax binary, Operand left, Operand right, ImmutableArray.Builder operations) + { + if (left.Kind == OperandKind.Scalar && right.Kind == OperandKind.Scalar) + { + return new(OperandKind.Scalar); + } + + if (left.Kind == OperandKind.Quantity && right.Kind == OperandKind.Scalar) + { + return left; + } + + if (left.Kind == OperandKind.Scalar && right.Kind == OperandKind.Quantity) + { + if (binary.IsKind(SyntaxKind.MultiplyExpression)) + { + return right; + } + + Dimension inverse = right.Dimension.Pow(-1); + operations.Add(new(ArithmeticOperationKind.ScalarDivide, default, right.Dimension, inverse)); + return new(OperandKind.Quantity, inverse); + } + + if (left.Kind == OperandKind.Quantity && right.Kind == OperandKind.Quantity) + { + ArithmeticOperationKind kind = binary.IsKind(SyntaxKind.MultiplyExpression) ? ArithmeticOperationKind.Multiply : ArithmeticOperationKind.Divide; + Dimension result = kind == ArithmeticOperationKind.Multiply ? left.Dimension * right.Dimension : left.Dimension / right.Dimension; + if (result != default) + { + operations.Add(new(kind, left.Dimension, right.Dimension, result)); + return new(OperandKind.Quantity, result); + } + } + + return default; + } + + private static Operand ClassifyLeaf(ExpressionSyntax expression, SemanticModel semanticModel, QuantityTypeReader typeReader, CancellationToken cancellationToken) + { + ITypeSymbol? type = semanticModel.GetTypeInfo(expression, cancellationToken).Type; + if (typeReader.TryRead(type, out Dimension dimension, cancellationToken)) + { + return dimension == default ? default : new(OperandKind.Quantity, dimension); + } + + ITypeSymbol doubleType = semanticModel.Compilation.GetSpecialType(SpecialType.System_Double); + if (type is not null) + { + CommonConversion conversion = semanticModel.Compilation.ClassifyCommonConversion(type, doubleType); + if (conversion.IsImplicit && (conversion.IsIdentity || conversion.IsNumeric) && !conversion.IsUserDefined) + { + return new(OperandKind.Scalar); + } + } + + return default; + } + + private static bool IsSupported(BinaryExpressionSyntax binary) => binary.IsKind(SyntaxKind.MultiplyExpression) || binary.IsKind(SyntaxKind.DivideExpression); + + private enum OperandKind + { + Unsupported, + Scalar, + Quantity + } + + private readonly record struct Operand(OperandKind Kind, Dimension Dimension = default); +} diff --git a/src/DimensionalQuantities.Roslyn/Discovery/ArithmeticOperation.cs b/src/DimensionalQuantities.Roslyn/Discovery/ArithmeticOperation.cs new file mode 100644 index 0000000..49048b0 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Discovery/ArithmeticOperation.cs @@ -0,0 +1,5 @@ +using DimensionalQuantities.Dimensions; + +namespace DimensionalQuantities.Roslyn.Discovery; + +internal readonly record struct ArithmeticOperation(ArithmeticOperationKind Kind, Dimension Left, Dimension Right, Dimension Result); diff --git a/src/DimensionalQuantities.Roslyn/Discovery/ArithmeticOperationKind.cs b/src/DimensionalQuantities.Roslyn/Discovery/ArithmeticOperationKind.cs new file mode 100644 index 0000000..2a3a1fe --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Discovery/ArithmeticOperationKind.cs @@ -0,0 +1,8 @@ +namespace DimensionalQuantities.Roslyn.Discovery; + +internal enum ArithmeticOperationKind +{ + Multiply, + Divide, + ScalarDivide +} diff --git a/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs new file mode 100644 index 0000000..3fe2669 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs @@ -0,0 +1,10 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Units; + +namespace DimensionalQuantities.Roslyn.Discovery; + +internal sealed record PlannedQuantity(Dimension Dimension, ImmutableArray Units, bool IncludeNamedConversions) +{ + public string Name => Units[0].QuantityName; +} diff --git a/src/DimensionalQuantities.Roslyn/Discovery/QuantityGenerationPlan.cs b/src/DimensionalQuantities.Roslyn/Discovery/QuantityGenerationPlan.cs new file mode 100644 index 0000000..c73647f --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Discovery/QuantityGenerationPlan.cs @@ -0,0 +1,23 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Dimensions; + +namespace DimensionalQuantities.Roslyn.Discovery; + +internal sealed class QuantityGenerationPlan +{ + private readonly ImmutableDictionary _names; + + public QuantityGenerationPlan(ImmutableArray knownQuantities, ImmutableArray additionalQuantities, ImmutableArray operations) + { + KnownQuantities = knownQuantities; + AdditionalQuantities = additionalQuantities; + Operations = operations; + _names = knownQuantities.Concat(additionalQuantities).ToImmutableDictionary(quantity => quantity.Dimension, quantity => quantity.Name); + } + + public ImmutableArray KnownQuantities { get; } + public ImmutableArray AdditionalQuantities { get; } + public ImmutableArray Operations { get; } + + public string GetQuantityName(Dimension dimension) => _names[dimension]; +} diff --git a/src/DimensionalQuantities.Roslyn/Discovery/QuantityTypeReader.cs b/src/DimensionalQuantities.Roslyn/Discovery/QuantityTypeReader.cs new file mode 100644 index 0000000..5ef6da7 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Discovery/QuantityTypeReader.cs @@ -0,0 +1,103 @@ +using System.Numerics; +using DimensionalQuantities.Numerics; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Dimension = DimensionalQuantities.Dimensions.Dimension; + +namespace DimensionalQuantities.Roslyn.Discovery; + +internal sealed class QuantityTypeReader +{ + private readonly INamedTypeSymbol? _quantity; + private readonly INamedTypeSymbol? _dimension; + private readonly INamedTypeSymbol? _hex; + private readonly INamedTypeSymbol? _negative; + private readonly INamedTypeSymbol? _fraction; + private readonly Dictionary _terminalDigits = new(SymbolEqualityComparer.Default); + + public QuantityTypeReader(Compilation compilation) + { + _quantity = compilation.GetTypeByMetadataName(typeof(Quantity<>).FullName!); + _dimension = compilation.GetTypeByMetadataName(typeof(TypeSystem.Dimension<,,,,,,>).FullName!); + _hex = compilation.GetTypeByMetadataName(typeof(Hex<,>).FullName!); + _negative = compilation.GetTypeByMetadataName(typeof(Negative<>).FullName!); + _fraction = compilation.GetTypeByMetadataName(typeof(Fraction<,>).FullName!); + + for (int digit = 0; digit < 16; digit++) + { + string name = TypeSystemNumberCodec.CreateType(new(digit)).FullName!; + INamedTypeSymbol? symbol = compilation.GetTypeByMetadataName(name); + if (symbol is not null) + { + _terminalDigits.Add(symbol, digit); + } + } + } + + public bool TryRead(ITypeSymbol? type, out Dimension dimension, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + dimension = default; + if (type is not INamedTypeSymbol quantity || !IsDefinition(quantity, _quantity) + || quantity.TypeArguments[0] is not INamedTypeSymbol encodedDimension || !IsDefinition(encodedDimension, _dimension)) + { + return false; + } + + if (!TryReadNumber(encodedDimension.TypeArguments[0], out Rational length, cancellationToken) + || !TryReadNumber(encodedDimension.TypeArguments[1], out Rational mass, cancellationToken) + || !TryReadNumber(encodedDimension.TypeArguments[2], out Rational time, cancellationToken) + || !TryReadNumber(encodedDimension.TypeArguments[3], out Rational electricCurrent, cancellationToken) + || !TryReadNumber(encodedDimension.TypeArguments[4], out Rational temperature, cancellationToken) + || !TryReadNumber(encodedDimension.TypeArguments[5], out Rational amountOfSubstance, cancellationToken) + || !TryReadNumber(encodedDimension.TypeArguments[6], out Rational luminousIntensity, cancellationToken)) + { + return false; + } + + dimension = new(length, mass, time, electricCurrent, temperature, amountOfSubstance, luminousIntensity); + return true; + } + + private bool TryReadNumber(ITypeSymbol type, out Rational value, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + value = default; + if (type is not INamedTypeSymbol named) + { + return false; + } + + if (_terminalDigits.TryGetValue(named, out int digit)) + { + value = new(digit); + return true; + } + + if (IsDefinition(named, _hex) && TryReadNumber(named.TypeArguments[0], out Rational low, cancellationToken) + && TryReadNumber(named.TypeArguments[1], out Rational rest, cancellationToken) && low.Denominator.IsOne && rest.Denominator.IsOne) + { + value = 16 * rest + low; + return true; + } + + if (IsDefinition(named, _negative) && TryReadNumber(named.TypeArguments[0], out Rational magnitude, cancellationToken) && magnitude.Denominator.IsOne) + { + value = -magnitude; + return true; + } + + if (IsDefinition(named, _fraction) && TryReadNumber(named.TypeArguments[0], out Rational numerator, cancellationToken) + && TryReadNumber(named.TypeArguments[1], out Rational denominator, cancellationToken) && numerator.Denominator.IsOne + && denominator.Denominator.IsOne && denominator.Numerator > BigInteger.Zero) + { + value = numerator / denominator; + return true; + } + + return false; + } + + private static bool IsDefinition(INamedTypeSymbol symbol, INamedTypeSymbol? definition) => + definition is not null && SymbolEqualityComparer.Default.Equals(symbol.OriginalDefinition, definition); +} diff --git a/src/DimensionalQuantities.Roslyn/Generators/ArithmeticSourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/ArithmeticSourceEmitter.cs new file mode 100644 index 0000000..27731d5 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/ArithmeticSourceEmitter.cs @@ -0,0 +1,46 @@ +using DimensionalQuantities.Roslyn.Discovery; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal static class ArithmeticSourceEmitter +{ + public static string Emit(QuantityGenerationPlan plan) + { + string operators = string.Join("\n\n", plan.Operations.Select(operation => RenderOperator(operation, plan))).Replace("\n", "\n\t"); + return $$""" + // + namespace DimensionalQuantities.Generated; + + internal static class ArithmeticExtensions + { + {{operators}} + } + """; + } + + private static string RenderOperator(ArithmeticOperation operation, QuantityGenerationPlan plan) + { + string right = plan.GetQuantityName(operation.Right); + string result = plan.GetQuantityName(operation.Result); + if (operation.Kind == ArithmeticOperationKind.ScalarDivide) + { + return $$""" + extension({{right}}) + { + public static {{result}} operator /(double left, {{right}} right) => + {{result}}.FromSiBaseUnit(left / right.AsSiBaseUnit()); + } + """; + } + + string left = plan.GetQuantityName(operation.Left); + string symbol = operation.Kind == ArithmeticOperationKind.Multiply ? "*" : "/"; + return $$""" + extension({{left}}) + { + public static {{result}} operator {{symbol}}({{left}} left, {{right}} right) => + {{result}}.FromSiBaseUnit(left.AsSiBaseUnit() {{symbol}} right.AsSiBaseUnit()); + } + """; + } +} diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs index 64ebb93..bd7a43d 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs @@ -1,13 +1,20 @@ +using System.Collections.Immutable; using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Naming; +using DimensionalQuantities.Roslyn.Discovery; using DimensionalQuantities.Units; namespace DimensionalQuantities.Roslyn.Generators; internal static class QuantityDiscovery { - public static IReadOnlyDictionary Discover() + public static QuantityGenerationPlan Discover(IEnumerable operations) { - Dictionary quantities = new(); + List knownQuantities = []; + List additionalQuantities = []; + HashSet dimensions = []; + HashSet<(ArithmeticOperationKind Kind, Dimension Left, Dimension Right)> seenOperations = []; + List orderedOperations = []; Dimension[] baseDimensions = [ new(Length: 1), @@ -21,14 +28,42 @@ public static IReadOnlyDictionary Discover() foreach (Dimension dimension in baseDimensions) { - quantities.Add(dimension, [dimension.GetComponents().Single().Unit]); + knownQuantities.Add(new(dimension, [dimension.GetComponents().Single().Unit], true)); + dimensions.Add(dimension); } foreach (KeyValuePair entry in EngineeringCatalogue.Entries) { - quantities.Add(entry.Key, entry.Value); + knownQuantities.Add(new(entry.Key, [.. entry.Value], true)); + dimensions.Add(entry.Key); } - return quantities; + foreach (ArithmeticOperation operation in operations) + { + if (!seenOperations.Add((operation.Kind, operation.Left, operation.Right))) + { + continue; + } + + orderedOperations.Add(operation); + AddUnknown(operation.Left); + AddUnknown(operation.Right); + AddUnknown(operation.Result); + } + + return new(knownQuantities.ToImmutableArray(), additionalQuantities.ToImmutableArray(), orderedOperations.ToImmutableArray()); + + void AddUnknown(Dimension dimension) + { + if (dimension == default || !dimensions.Add(dimension)) + { + return; + } + + UnitTerm[] terms = dimension.GetComponents() + .Select(component => component.Unit.Expression.Terms.Single() with { Power = component.Exponent }).ToArray(); + UnitDefinition unit = new(DimensionNameGenerator.GetQuantityName(dimension), terms); + additionalQuantities.Add(new(dimension, [unit], false)); + } } } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs index b8cad5a..b1ad3bd 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs @@ -1,7 +1,8 @@ +using System.Collections.Immutable; using System.Text; -using DimensionalQuantities.Dimensions; -using DimensionalQuantities.Units; +using DimensionalQuantities.Roslyn.Discovery; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; namespace DimensionalQuantities.Roslyn.Generators; @@ -13,9 +14,39 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { context.RegisterPostInitializationOutput(static output => { - IReadOnlyDictionary quantities = QuantityDiscovery.Discover(); - string source = QuantitySourceEmitter.Emit(quantities); + QuantityGenerationPlan plan = QuantityDiscovery.Discover([]); + string source = QuantitySourceEmitter.Emit(plan.KnownQuantities); output.AddSource("Quantities.g.cs", SourceText.From(source, Encoding.UTF8)); }); + + IncrementalValuesProvider> operations = context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => node is BinaryExpressionSyntax binary && ArithmeticDiscovery.IsRoot(binary), + static (syntaxContext, cancellationToken) => ArithmeticDiscovery.Analyse( + (BinaryExpressionSyntax)syntaxContext.Node, syntaxContext.SemanticModel, cancellationToken)); + + context.RegisterSourceOutput(operations.Collect(), static (output, discoveredExpressions) => + { + ImmutableArray.Builder discoveredOperations = ImmutableArray.CreateBuilder(); + foreach (ImmutableArray expressionOperations in discoveredExpressions) + { + output.CancellationToken.ThrowIfCancellationRequested(); + discoveredOperations.AddRange(expressionOperations); + } + + if (discoveredOperations.Count == 0) + { + return; + } + + QuantityGenerationPlan plan = QuantityDiscovery.Discover(discoveredOperations); + if (!plan.AdditionalQuantities.IsEmpty) + { + string quantitySource = QuantitySourceEmitter.Emit(plan.AdditionalQuantities); + output.AddSource("DiscoveredQuantities.g.cs", SourceText.From(quantitySource, Encoding.UTF8)); + } + + string arithmeticSource = ArithmeticSourceEmitter.Emit(plan); + output.AddSource("Arithmetic.g.cs", SourceText.From(arithmeticSource, Encoding.UTF8)); + }); } } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs index cd98f55..c26b4a0 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs @@ -2,6 +2,7 @@ using DimensionalQuantities.Dimensions; using DimensionalQuantities.Naming; using DimensionalQuantities.Numerics; +using DimensionalQuantities.Roslyn.Discovery; using DimensionalQuantities.TypeSystem; using DimensionalQuantities.Units; @@ -9,10 +10,10 @@ namespace DimensionalQuantities.Roslyn.Generators; internal static class QuantitySourceEmitter { - public static string Emit(IReadOnlyDictionary quantities) + public static string Emit(IReadOnlyList quantities) { int[] prefixes = SiPrefixes.Entries.Keys.OrderByDescending(exponent => exponent).ToArray(); - QuantityDefinition[] definitions = quantities.Select(entry => DefineQuantity(entry.Key, entry.Value, prefixes)).ToArray(); + QuantityDefinition[] definitions = quantities.Select(quantity => DefineQuantity(quantity, prefixes)).ToArray(); string aliases = string.Join("\n", definitions.SelectMany(definition => definition.Aliases.Select(alias => $"global using {alias} = {definition.TypeName};"))); string extensions = string.Join("\n\n", definitions.Select(RenderQuantity)); @@ -27,20 +28,24 @@ namespace DimensionalQuantities.Generated; """; } - private static QuantityDefinition DefineQuantity(Dimension dimension, UnitDefinition[] units, int[] prefixes) + private static QuantityDefinition DefineQuantity(PlannedQuantity quantity, int[] prefixes) { + Dimension dimension = quantity.Dimension; + IReadOnlyList units = quantity.Units; string typeName = $"global::DimensionalQuantities.TypeSystem.Quantity>"; string[] aliases = units.Select(unit => unit.QuantityName).Append(DimensionNameGenerator.GetQuantityName(dimension)).Distinct(StringComparer.Ordinal).ToArray(); string quantityName = units[0].QuantityName; UnitExpression canonicalExpression = units[0].Expression; ConversionDefinition genericConversion = new("FromPrefixedUnits", "AsPrefixedUnits", GetTermParameters(canonicalExpression), GetTermScaleExponent(canonicalExpression)); - ConversionDefinition[] namedConversions = CollectConversions(units, prefixes) - .Select(conversion => new ConversionDefinition($"From{conversion.Key}", $"As{conversion.Key}", [], FormatExponent(conversion.Value))) - .ToArray(); + ConversionDefinition[] namedConversions = quantity.IncludeNamedConversions + ? CollectConversions(units, prefixes) + .Select(conversion => new ConversionDefinition($"From{conversion.Key}", $"As{conversion.Key}", [], FormatExponent(conversion.Value))) + .ToArray() + : []; return new(quantityName, typeName, aliases, [genericConversion, .. namedConversions]); } - private static IReadOnlyDictionary CollectConversions(UnitDefinition[] units, int[] prefixes) + private static IReadOnlyDictionary CollectConversions(IReadOnlyList units, int[] prefixes) { Dictionary conversions = new(StringComparer.Ordinal); foreach ((string name, Rational scaleExponent) in units.SelectMany(unit => GetConversions(unit, prefixes))) diff --git a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs index 3c81253..fd27a52 100644 --- a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs +++ b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs @@ -44,6 +44,22 @@ namespace DimensionalQuantities.Tests.TypeSystem; public sealed class QuantityTests { + [Fact] + public void MultiplyingAndDividingByScalarsPreservesQuantityTypeAndSiMagnitude() + { + Current original = Current.FromSiBaseUnit(6); + Current rightProduct = original * 2; + Current leftProduct = 2f * original; + Current quotient = original / 2; + Current expectedProduct = Current.FromSiBaseUnit(12); + Current expectedQuotient = Current.FromSiBaseUnit(3); + + Assert.Multiple( + () => Assert.Equal(expectedProduct, rightProduct), + () => Assert.Equal(expectedProduct, leftProduct), + () => Assert.Equal(expectedQuotient, quotient)); + } + [Theory] [InlineData(0)] [InlineData(-0.125)] diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index 0ea5ff8..f5800fe 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -24,6 +24,7 @@ + diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index a33f2c0..0428dcb 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -9,6 +9,10 @@ namespace DimensionalQuantities.TypeSystem; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public readonly record struct Quantity { + public static Quantity operator *(Quantity quantity, double scalar) => new(quantity.ValueInSiBaseUnit * scalar); + public static Quantity operator *(double scalar, Quantity quantity) => new(scalar * quantity.ValueInSiBaseUnit); + public static Quantity operator /(Quantity quantity, double scalar) => new(quantity.ValueInSiBaseUnit / scalar); + // Each closed quantity type intentionally caches its own decoded dimension. // ReSharper disable once StaticMemberInGenericType private static readonly Lazy _dimension = new(ParseDimension); From 4ae5b34e0cfabfb3c7288118de32a5bb524bec76 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Thu, 22 Oct 2026 21:35:20 +0100 Subject: [PATCH 25/25] ci: configure NuGet trusted publishing and package metadata --- .github/scripts/publish.sh | 4 + .github/testing/run-act.py | 2 +- .github/workflows/{release.yml => cd.yml} | 16 +++- .github/workflows/ci.yml | 8 +- LICENSE | 21 ++++++ README.md | 75 +++++++++++++++++++ .../DimensionalQuantities.csproj | 6 ++ 7 files changed, 123 insertions(+), 9 deletions(-) rename .github/workflows/{release.yml => cd.yml} (77%) diff --git a/.github/scripts/publish.sh b/.github/scripts/publish.sh index cc4c1a5..b6dcb67 100644 --- a/.github/scripts/publish.sh +++ b/.github/scripts/publish.sh @@ -6,6 +6,10 @@ if [[ -n "${ACT:-}" ]]; then fi : "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" : "${GITHUB_REPOSITORY_OWNER:?GITHUB_REPOSITORY_OWNER is required}" +: "${NUGET_API_KEY:?NUGET_API_KEY is required}" +dotnet nuget push 'artifacts/packages/*.nupkg' \ + --source 'https://api.nuget.org/v3/index.json' \ + --api-key "$NUGET_API_KEY" --skip-duplicate dotnet nuget push 'artifacts/packages/*.nupkg' \ --source "https://nuget.pkg.github.com/${GITHUB_REPOSITORY_OWNER}/index.json" \ --api-key "$GITHUB_TOKEN" --skip-duplicate diff --git a/.github/testing/run-act.py b/.github/testing/run-act.py index 3061808..d701d11 100644 --- a/.github/testing/run-act.py +++ b/.github/testing/run-act.py @@ -53,4 +53,4 @@ def git(*arguments): if args.workflow in ("ci", "both"): subprocess.run([*base, "pull_request", "-W", ".github/workflows/ci.yml"], cwd=fixture, check=True) if args.workflow in ("release", "both"): - subprocess.run([*base, "push", "-W", ".github/workflows/release.yml"], cwd=fixture, check=True) + subprocess.run([*base, "push", "-W", ".github/workflows/cd.yml"], cwd=fixture, check=True) diff --git a/.github/workflows/release.yml b/.github/workflows/cd.yml similarity index 77% rename from .github/workflows/release.yml rename to .github/workflows/cd.yml index 9a6762e..521fdf9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/cd.yml @@ -19,22 +19,29 @@ jobs: permissions: contents: write packages: write + id-token: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 - - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + - uses: actions/setup-dotnet@v6.0.0 with: global-json-file: global.json - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + - uses: actions/setup-node@v7.1.0 with: node-version: '24.10.0' - name: Prepare isolated act release destination if: env.ACT run: bash .github/testing/prepare-local-remote.sh + - name: NuGet trusted publishing login + if: ${{ !env.ACT }} + uses: NuGet/login@v1 + id: nuget-login + with: + user: ${{ vars.NUGET_USER }} - name: Version, package and release id: release - uses: cycjimmy/semantic-release-action@d9bf53a078bd2f86aaffaec796dbb3d28f1b6f34 # v8.1.3 + uses: cycjimmy/semantic-release-action@v8.1.3 with: semantic_version: 25.0.9 repository_url: ${{ env.ACT && 'file:///tmp/dimensional-quantities-remote.git' || '' }} @@ -46,6 +53,7 @@ jobs: conventional-changelog-conventionalcommits@9.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} - name: Verify local release if: env.ACT env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32509c9..ded3495 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,13 +10,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 - name: Check conventional PR title if: github.event_name == 'pull_request' env: PR_TITLE: ${{ github.event.pull_request.title }} run: python3 .github/scripts/check-pr-title.py - - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + - uses: actions/setup-dotnet@v6.0.0 with: global-json-file: global.json - name: Restore @@ -24,10 +24,10 @@ jobs: - name: Build run: dotnet build src/DimensionalQuantities.slnx -c Release --no-restore -p:ContinuousIntegrationBuild=true - name: Test - run: dotnet test --solution src/DimensionalQuantities.slnx -c Release --no-build --no-restore --report-trx --ignore-exit-code 8 --results-directory artifacts/test-results + run: dotnet test --solution src/DimensionalQuantities.slnx -c Release --no-build --no-restore --report-trx --results-directory artifacts/test-results - name: Check NuGet package run: bash .github/scripts/pack.sh 0.0.0-ci - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - uses: actions/upload-artifact@v7.0.2 if: ${{ always() && !env.ACT }} with: name: ci-results diff --git a/LICENSE b/LICENSE index e69de29..a766f55 100644 --- a/LICENSE +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GoSim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29..deb8083 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,75 @@ +# DimensionalQuantities + +A C# library for dimensional quantities, with generated type aliases, SI unit conversions, and compile-time dimensional arithmetic. + +> [!WARNING] +> We developed this library for our own internal use and are sharing it because we believe it may be useful to the wider developer community. Issues and pull requests are welcome, but we make no support commitments or warranties, and we cannot guarantee that we will respond to issues or review or merge pull requests. + +## Usage + +Requires .NET 10 and C# 14. The `DimensionalQuantities` NuGet package includes both the runtime library and its source generator. + +```csharp +Voltage voltage = Voltage.FromVolts(12); +Current current = Current.FromAmps(3); + +Resistance resistance = voltage / current; +Power power = voltage * current; + +double ohms = resistance.AsOhms(); // 4 +double watts = power.AsWatts(); // 36 +``` + +### SI prefixes and compound units + +Named helpers handle conversion into and out of SI prefixes, including powers of prefixed units: + +```csharp +Length length = Length.FromMilliMetres(2500); +double metres = length.AsMetres(); // 2.5 + +Density density = Density.FromGramsPerCubicCentiMetre(1); +double kilogramsPerCubicMetre = density.AsSiBaseUnit(); // 1000 +``` + +For more control, `FromPrefixedUnits` and `AsPrefixedUnits` accept decimal prefix exponents for each unit factor. Powers are taken into account automatically: + +```csharp +// 1 A/mm² = 1,000,000 A/m² +CurrentDensity density = CurrentDensity.FromPrefixedUnits( + 1, ampPrefixExponent: 0, metrePrefixExponent: -3); + +double ampsPerSquareMillimetre = density.AsPrefixedUnits( + ampPrefixExponent: 0, metrePrefixExponent: -3); // 1 +``` + +Named compound units remain a single factor where appropriate: `Voltage.FromPrefixedUnits(2, -3)` means 2 millivolts, without requiring you to know the SI dimensions of a volt. + +### Arithmetic and generated intermediate quantities + +The generator follows multiplication and division through nested expressions, derives their dimensions, and generates the required operators and intermediate aliases: + +```csharp +Current current = Current.FromAmps(3); +Resistance resistance = Resistance.FromOhms(4); + +CurrentSquared squared = current * current; +Power heating = current * current * resistance; // 36 W +Current doubled = 2 * current; +Current halved = current / 2; +Frequency frequency = 1 / Time.FromSeconds(0.02); // 50 Hz +``` + +Known dimensions use names from the engineering catalogue, such as `Power`, `Voltage`, `Permeability` and `MagneticFluxDensity`. Other dimensions receive mechanical names such as `CurrentSquared` or `TimePerCurrent`. Discovered quantities have SI base-unit and generic prefix conversion helpers. + +Aliases with the same dimensions share a type. For example, `Energy` and `Torque` are interchangeable: the library checks dimensions, not the physical meaning of a calculation. Magnitudes are stored as `double` in coherent SI base units. + +### Current scope + +Arithmetic discovery currently supports multiplication and division with known quantity operands, scalar factors, and nested expressions. Roots, powers, dimensionless results and inference through unresolved intermediate local variables are not yet supported by discovery. + +The library is under active development; the API and supported expression patterns are still evolving. + +## Licence + +[MIT](LICENSE), copyright GoSim. diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index f5800fe..b80e131 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -1,6 +1,10 @@ net10.0 + GoSim + MIT + README.md + https://github.com/GoSimulate/DimensionalQuantities Compile-time dimensional quantities with generated aliases and arithmetic. git @@ -13,6 +17,8 @@ Properties="Configuration=$(Configuration)" /> + +