From d8b8e4979f2757d5a92b9d8c0ce62f7040c664a6 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sun, 25 Oct 2026 15:32:03 +0000 Subject: [PATCH 1/2] feat: add affine temperature conversions and explicit deltas --- README.md | 23 ++++++- .../ImperialConversionsTests.cs | 12 +++- .../ImperialSymbols.cs | 3 + .../ImperialUnits.cs | 5 +- .../MetricConversionsTests.cs | 8 +++ .../MetricSymbols.cs | 3 + .../MetricTemperatureUnits.cs | 12 ++++ .../Packaging/PackagedGeneratorTests.cs | 62 ++++++++++++++++++- .../Generators/QuantityGeneratorTests.cs | 2 +- .../Generators/QuantitySourceEmitterTests.cs | 9 +++ .../UnitConversionSourceEmitterTests.cs | 51 ++++++++++++++- .../Generators/UnitProviderGenerationTests.cs | 30 +++++++++ .../Generators/UnitProviderReaderTests.cs | 61 +++++++++++++++++- .../Analyzers/DimensionNames.cs | 3 +- .../Discovery/PlannedQuantity.cs | 5 +- .../Generators/QuantityDiscovery.cs | 5 +- .../Generators/UnitConversionSourceEmitter.cs | 60 +++++++++++++----- .../Generators/UnitProviderReader.cs | 47 ++++++++++---- .../TypeSystem/QuantityTests.cs | 26 ++++++++ src/DimensionalQuantities/TypeSystem/Delta.cs | 12 ++++ .../TypeSystem/Quantity.cs | 3 + .../Units/Declarations/AffineUnitAttribute.cs | 10 +++ .../Units/UnitConversion.cs | 3 + .../Units/UnitDefinition.cs | 7 ++- 24 files changed, 418 insertions(+), 44 deletions(-) create mode 100644 src/DimensionalQuantities.Extensions.Metric/MetricTemperatureUnits.cs create mode 100644 src/DimensionalQuantities/TypeSystem/Delta.cs create mode 100644 src/DimensionalQuantities/Units/Declarations/AffineUnitAttribute.cs create mode 100644 src/DimensionalQuantities/Units/UnitConversion.cs diff --git a/README.md b/README.md index a7c86bb..75c4f0d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A C# library for dimensional quantities, with generated type aliases, SI unit co The runtime library targets .NET Standard 2.0. Consumers need a C# 14 compiler for the generated extension blocks. The `DimensionalQuantities` NuGet package includes the runtime library, source generator, analysers and an alias quick fix. -Core includes the seven SI base quantities' conversion helpers. Add `DimensionalQuantities.Extensions.Metric` for catalogue conversions such as volts, watts and pascals, and import its namespace: +Core includes the seven SI base quantities' conversion helpers. Add `DimensionalQuantities.Extensions.Metric` for catalogue conversions such as volts, watts and pascals, plus Celsius conversions for `AbsoluteTemperature`, and import its namespace: ```csharp using DimensionalQuantities.Extensions.Metric; @@ -23,7 +23,7 @@ using DimensionalQuantities.Extensions.Metric; Metric depends on the matching core version, so referencing Metric also supplies core and its compiler tooling. Quantity names and dimensional arithmetic are available with core alone. -Add `DimensionalQuantities.Extensions.Imperial` for British and US customary conversions. It includes feet, miles, stones, pounds-force, US and Imperial liquid measures, Rankine, mechanical horsepower and the International Table British thermal unit. Import `DimensionalQuantities.Extensions.Imperial` to use its helpers: +Add `DimensionalQuantities.Extensions.Imperial` for British and US customary conversions. It includes feet, miles, stones, pounds-force, US and Imperial liquid measures, Rankine and Fahrenheit conversions for `AbsoluteTemperature`, mechanical horsepower and the International Table British thermal unit. Import `DimensionalQuantities.Extensions.Imperial` to use its helpers: ```csharp Length depth = Length.FromFeet(6); @@ -94,6 +94,25 @@ double ampsPerSquareMillimetre = density.AsPrefixedUnits( 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. +### Affine temperature units and deltas + +Temperature magnitudes are stored internally in kelvin. `AbsoluteTemperature.FromCelsius(20)` stores 293.15 K, and `AbsoluteTemperature.FromFahrenheit(68)` stores the same value. Their `AsCelsius()` and `AsFahrenheit()` accessors apply the corresponding offsets when converting an absolute value. + +Subtraction operates on those stored kelvin values. To convert the result as a temperature difference, call `.AsDelta()` and then use a delta accessor. Delta accessors apply scale only, without an offset: + +```csharp +AbsoluteTemperature warm = AbsoluteTemperature.FromCelsius(20); +AbsoluteTemperature cool = AbsoluteTemperature.FromFahrenheit(50); + +var difference = (warm - cool).AsDelta(); +double celsiusDifference = difference.AsCelsius(); // 10 +double fahrenheitDifference = difference.AsFahrenheit(); // 18 +double kelvinDifference = difference.AsKelvin(); // 10 +double rankineDifference = difference.AsRankine(); // 18 +``` + +`.AsDelta()` preserves and reinterprets the stored SI magnitude; it does not detect whether subtraction occurred. Calling it on an absolute reading treats that reading's full kelvin value as a difference. The library does not track reading-versus-difference provenance through arithmetic. Celsius helpers are provided by Metric; Fahrenheit and Rankine helpers are provided by Imperial. Kelvin helpers are available from core. + ### Arithmetic and generated intermediate quantities The generator derives dimensions through arithmetic and generates the required operators and intermediate aliases. Quantities also support addition, subtraction and equality checks with quantities of the same dimension: diff --git a/src/DimensionalQuantities.Extensions.Imperial.Tests/ImperialConversionsTests.cs b/src/DimensionalQuantities.Extensions.Imperial.Tests/ImperialConversionsTests.cs index b8b2989..cadf5f1 100644 --- a/src/DimensionalQuantities.Extensions.Imperial.Tests/ImperialConversionsTests.cs +++ b/src/DimensionalQuantities.Extensions.Imperial.Tests/ImperialConversionsTests.cs @@ -73,8 +73,16 @@ public void EnergyAndPowerUseExplicitBritishThermalUnitDefinition() public void RankineUsesFiveNinthsKelvinPerDegree() { Assert.Multiple( - () => Assert.Equal(273.15, Temperature.FromRankine(491.67).AsKelvin(), 10), - () => Assert.Equal(491.67, Temperature.FromKelvin(273.15).AsRankine(), 10)); + () => Assert.Equal(273.15, AbsoluteTemperature.FromRankine(491.67).AsKelvin(), 10), + () => Assert.Equal(491.67, AbsoluteTemperature.FromKelvin(273.15).AsRankine(), 10)); + } + + [Fact] + public void FahrenheitConvertsToAndFromAbsoluteKelvin() + { + Assert.Multiple( + () => Assert.Equal(273.15, AbsoluteTemperature.FromFahrenheit(32).AsKelvin(), 10), + () => Assert.Equal(32, AbsoluteTemperature.FromKelvin(273.15).AsFahrenheit(), 10)); } [Fact] diff --git a/src/DimensionalQuantities.Extensions.Imperial/ImperialSymbols.cs b/src/DimensionalQuantities.Extensions.Imperial/ImperialSymbols.cs index 7ccb032..f59fb3f 100644 --- a/src/DimensionalQuantities.Extensions.Imperial/ImperialSymbols.cs +++ b/src/DimensionalQuantities.Extensions.Imperial/ImperialSymbols.cs @@ -56,4 +56,7 @@ internal sealed class BritishThermalUnitIT { } internal sealed class MechanicalHorsepower { } [UnitSymbol("Rankine", "Rankine", "°R", Scale = 5d / 9d)] internal sealed class Rankine { } + [UnitSymbol("Fahrenheit", "Fahrenheit", "°F", Scale = 5d / 9d)] + [AffineUnit(459.67)] + internal sealed class Fahrenheit { } } diff --git a/src/DimensionalQuantities.Extensions.Imperial/ImperialUnits.cs b/src/DimensionalQuantities.Extensions.Imperial/ImperialUnits.cs index ae44321..e394eb2 100644 --- a/src/DimensionalQuantities.Extensions.Imperial/ImperialUnits.cs +++ b/src/DimensionalQuantities.Extensions.Imperial/ImperialUnits.cs @@ -117,7 +117,10 @@ private sealed class PowerUnit { } [UnitFactor(typeof(ImperialSymbols.Hour), Power = -1)] private sealed class BritishThermalUnitsITPerHourUnit { } - [QuantityUnit(typeof(Temperature))] + [QuantityUnit(typeof(AbsoluteTemperature))] [UnitFactor(typeof(ImperialSymbols.Rankine))] + [AdditionalUnit(typeof(FahrenheitUnit))] private sealed class TemperatureUnit { } + [UnitFactor(typeof(ImperialSymbols.Fahrenheit))] + private sealed class FahrenheitUnit { } } diff --git a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs index 651797b..e8b3740 100644 --- a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs +++ b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs @@ -41,6 +41,14 @@ public void CatalogueFactoriesConvertToExpectedSiValues() Assert.Equal(expected, result); } + [Fact] + public void CelsiusConvertsToAndFromAbsoluteKelvin() + { + Assert.Multiple( + () => Assert.Equal(273.15, AbsoluteTemperature.FromCelsius(0).AsKelvin(), 10), + () => Assert.Equal(0, AbsoluteTemperature.FromKelvin(273.15).AsCelsius(), 10)); + } + [Fact] public void CatalogueAccessorsConvertFromKnownSiValues() { diff --git a/src/DimensionalQuantities.Extensions.Metric/MetricSymbols.cs b/src/DimensionalQuantities.Extensions.Metric/MetricSymbols.cs index 3a2813d..158dc5b 100644 --- a/src/DimensionalQuantities.Extensions.Metric/MetricSymbols.cs +++ b/src/DimensionalQuantities.Extensions.Metric/MetricSymbols.cs @@ -6,6 +6,9 @@ internal static class MetricSymbols { [UnitSymbol("Radian", "Radians", "rad")] internal sealed class Radian { } + [UnitSymbol("Celsius", "Celsius", "°C")] + [AffineUnit(273.15)] + internal sealed class Celsius { } [UnitSymbol("Hertz", "Hertz", "Hz")] internal sealed class Hertz { } [UnitSymbol("Pascal", "Pascals", "Pa")] diff --git a/src/DimensionalQuantities.Extensions.Metric/MetricTemperatureUnits.cs b/src/DimensionalQuantities.Extensions.Metric/MetricTemperatureUnits.cs new file mode 100644 index 0000000..0dfcc3d --- /dev/null +++ b/src/DimensionalQuantities.Extensions.Metric/MetricTemperatureUnits.cs @@ -0,0 +1,12 @@ +using DimensionalQuantities.Units.Declarations; +using DimensionalQuantities.Units.Symbols; + +namespace DimensionalQuantities.Extensions.Metric; + +[UnitProvider] +internal static class MetricTemperatureUnits +{ + [QuantityUnit(typeof(AbsoluteTemperature))] + [UnitFactor(typeof(MetricSymbols.Celsius))] + private sealed class TemperatureUnit { } +} diff --git a/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs index db69ada..6854a54 100644 --- a/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs @@ -86,7 +86,7 @@ public void ImperialPackageProvidesConversionsThroughCoreDependency() public static class Consumer { public static double ConvertLength() => Length.FromFeet(6).AsSiBaseUnit(); - public static double ConvertTemperature() => Temperature.FromRankine(491.67).AsSiBaseUnit(); + public static double ConvertTemperature() => AbsoluteTemperature.FromRankine(491.67).AsSiBaseUnit(); } """, imperial); Build(consumer); @@ -99,6 +99,66 @@ public static class Consumer () => Assert.Equal(273.15, InvokeDouble(type, "ConvertTemperature"), 12)); } + [Fact] + public void AffineTemperatureConversionsAndDeltasWorkAcrossPackages() + { + using PackageRepository repository = PackageRepository.Create(_scratch, new Uri("https://api.nuget.org/v3/index.json")); + PackCore(repository); + Package metric = PackMetric(repository); + Package imperial = PackSourceProject(repository, "DimensionalQuantities.Extensions.Imperial"); + + ProjectCreator measurements = CreateProject("TemperatureMeasurements", """ + using DimensionalQuantities.Extensions.Metric; + using DimensionalQuantities.TypeSystem; + + namespace TemperatureMeasurements; + public static class Readings + { + public static Delta Difference() => + (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(10)).AsDelta(); + } + """, metric); + Pack(measurements, "TemperatureMeasurements", repository, out Package measurementPackage); + + ProjectCreator consumer = CreateProject("TemperatureConsumer", """ + using DimensionalQuantities.Extensions.Imperial; + using DimensionalQuantities.Extensions.Metric; + using TemperatureMeasurements; + + public static class Consumer + { + public static double[] DifferenceInEachUnit() + { + var delta = Readings.Difference(); + return [delta.AsCelsius(), delta.AsFahrenheit(), delta.AsKelvin(), delta.AsRankine()]; + } + + public static double[] AbsoluteRoundTrips() => + [ + AbsoluteTemperature.FromCelsius(20).AsCelsius(), + AbsoluteTemperature.FromFahrenheit(68).AsFahrenheit(), + AbsoluteTemperature.FromKelvin(293.15).AsCelsius(), + AbsoluteTemperature.FromRankine(527.67).AsFahrenheit() + ]; + } + """, metric, imperial, measurementPackage); + Build(consumer); + + string outputDirectory = OutputDirectory("TemperatureConsumer"); + using PackageAssemblyContext context = new(outputDirectory); + Type type = context.LoadFromAssemblyPath(Path.Combine(outputDirectory, "TemperatureConsumer.dll")) + .GetType("Consumer", throwOnError: true)!; + double[] difference = (double[])type.GetMethod("DifferenceInEachUnit")!.Invoke(null, null)!; + double[] absolute = (double[])type.GetMethod("AbsoluteRoundTrips")!.Invoke(null, null)!; + + Assert.Multiple( + () => Assert.Equal([10d, 18d, 10d, 18d], difference), + () => Assert.Equal(20d, absolute[0], 12), + () => Assert.Equal(68d, absolute[1], 12), + () => Assert.Equal(20d, absolute[2], 12), + () => Assert.Equal(68d, absolute[3], 12)); + } + [Fact] public void NauticalPackageProvidesConversionsThroughCoreDependency() { diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs index 32849f3..f2e7bbb 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs @@ -30,7 +30,7 @@ public static double UseBaseQuantities() Mass mass = Mass.FromGrams(1000); Time time = Time.FromSeconds(1); Current current = Current.FromAmps(1); - Temperature temperature = Temperature.FromKelvin(1); + AbsoluteTemperature temperature = AbsoluteTemperature.FromKelvin(1); AmountOfSubstance amount = AmountOfSubstance.FromMoles(1); LuminousIntensity intensity = LuminousIntensity.FromCandelas(1); return length.AsMetres() + mass.AsGrams() + time.AsSeconds() + current.AsAmps() diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs index f619447..d4f28eb 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs @@ -14,6 +14,15 @@ public sealed class QuantitySourceEmitterTests { private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.Preview); + [Fact] + public void StandaloneTemperatureUsesOnlyTheAbsoluteAlias() + { + string source = QuantitySourceEmitter.Emit(QuantityDiscovery.Discover([]).KnownQuantities); + Assert.Multiple( + () => Assert.Contains("global using AbsoluteTemperature =", source), + () => Assert.DoesNotContain("global using Temperature =", source)); + } + [Fact] public void DiscoveredMechanicalQuantityUsesItsGenericPrefixConversion() { diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs index a57cfb3..361f03c 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs @@ -49,6 +49,7 @@ public static void UsePrefixes() [InlineData("Mass.FromKiloGrams(2.5).AsSiBaseUnit()", 2.5)] [InlineData("Mass.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] [InlineData("Mass.FromSiBaseUnit(1).AsPrefixedUnits(gramPrefixExponent: 0)", 1000)] + [InlineData("Length.FromMilliMetres(2500).AsDelta().AsMilliMetres()", 2500)] public void BaseUnitPrefixesKeepTheirSiScales(string expression, double expected) { string source = $$""" @@ -71,16 +72,62 @@ public static class Consumer Assert.Equal(expected, result); } - private static CSharpCompilation Compile(string source) + [Fact] + public void AffineReadingsAndScaleOnlyDeltasUseTheirOwnConversionRules() + { + AtomicUnit celsius = new("Celsius", "Celsius", "°C"); + AtomicUnit rankine = new("Rankine", "Rankine", "°R", Scale: 5d / 9d); + AtomicUnit fahrenheit = new("Fahrenheit", "Fahrenheit", "°F", Scale: 5d / 9d); + UnitConversionSpec[] units = + [ + new("AbsoluteTemperature", "AbsoluteTemperature", + [ + new UnitDefinition("Celsius", new UnitExpression(new UnitTerm(celsius)), offsetBeforeScale: 273.15), + new UnitDefinition("Rankine", new UnitExpression(new UnitTerm(rankine)), + [new(new UnitExpression(new UnitTerm(fahrenheit)), 459.67)]) + ], true, false, false) + ]; + CSharpCompilation compilation = Compile(""" + public static class Consumer + { + public static double AbsoluteCelsius() => AbsoluteTemperature.FromCelsius(20).AsCelsius(); + public static double AbsoluteFahrenheit() => AbsoluteTemperature.FromFahrenheit(68).AsCelsius(); + public static double DifferenceCelsius() => (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(10)).AsDelta().AsCelsius(); + public static double DifferenceFahrenheit() => (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(10)).AsDelta().AsFahrenheit(); + public static double DifferenceRankine() => (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(10)).AsDelta().AsRankine(); + public static double ZeroDifference() => (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(20)).AsDelta().AsFahrenheit(); + public static double NegativeDifference() => (AbsoluteTemperature.FromCelsius(10) - AbsoluteTemperature.FromCelsius(20)).AsDelta().AsFahrenheit(); + } + """, units); + using MemoryStream assemblyBytes = new(); + EmitResult emit = compilation.Emit(assemblyBytes, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + Assembly assembly = Assembly.Load(assemblyBytes.ToArray()); + Type consumer = assembly.GetType("Consumer")!; + Assert.Multiple( + () => Assert.Equal(20, Invoke(consumer, "AbsoluteCelsius"), 10), + () => Assert.Equal(20, Invoke(consumer, "AbsoluteFahrenheit"), 10), + () => Assert.Equal(10, Invoke(consumer, "DifferenceCelsius"), 10), + () => Assert.Equal(18, Invoke(consumer, "DifferenceFahrenheit"), 10), + () => Assert.Equal(18, Invoke(consumer, "DifferenceRankine"), 10), + () => Assert.Equal(0, Invoke(consumer, "ZeroDifference"), 10), + () => Assert.Equal(-18, Invoke(consumer, "NegativeDifference"), 10)); + } + + private static double Invoke(Type consumer, string method) => (double)consumer.GetMethod(method)!.Invoke(null, null)!; + + private static CSharpCompilation Compile(string source, UnitConversionSpec[]? units = null) { string lengthType = QuantityTypeNameFormatter.Format(new Dimension(Length: 1)); string massType = QuantityTypeNameFormatter.Format(new Dimension(Mass: 1)); + string temperatureType = QuantityTypeNameFormatter.Format(new Dimension(Temperature: 1)); string generatedSource = $""" global using Length = {lengthType}; global using Mass = {massType}; + global using AbsoluteTemperature = {temperatureType}; global using DimensionalQuantities.Generated; - {UnitConversionSourceEmitter.Emit(_units, "DimensionalQuantities.Generated", "internal")} + {UnitConversionSourceEmitter.Emit(units ?? _units, "DimensionalQuantities.Generated", "internal")} """; string[] platformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); IEnumerable references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path)) diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs index 5bfe6e5..cb7f06e 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs @@ -45,6 +45,36 @@ public static class Consumer () => Assert.Equal(2, result[1], 12)); } + [Fact] + public void AffineConversionsApplyOffsetOnlyToAbsoluteQuantities() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + [UnitProvider(GenerateSiPrefixes = true, GeneratePrefixConversions = true)] + internal static class CustomUnits + { + [QuantityUnit(typeof(Length))] + [UnitFactor(typeof(OffsetMetre))] + private sealed class LengthUnit; + [UnitSymbol("OffsetMetre", "OffsetMetres", "om", Scale = 2)] + [AffineUnit(5)] + private sealed class OffsetMetre; + } + public static class Consumer + { + public static double[] Values() + { + Length first = Length.FromOffsetMetres(3); + Length second = Length.FromOffsetMetres(2); + return [first.AsMetres(), first.AsOffsetMetres(), (first - second).AsDelta().AsOffsetMetres()]; + } + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(source, ""); + Assert.Empty(diagnostics); + Assert.Equal([16d, 3d, 1d], Evaluate(compilation, "Consumer")); + } + [Fact] public void ProviderConversionsArePublicAndNotRegeneratedByConsumers() { diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs index 2131d20..7c27f51 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs @@ -1,6 +1,7 @@ using DimensionalQuantities.Roslyn.Generators; using DimensionalQuantities.TypeSystem; using DimensionalQuantities.Numerics; +using DimensionalQuantities.Units; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Xunit; @@ -77,6 +78,63 @@ private sealed class Pascal; () => Assert.Equal("Pascal", result.ExtensionName)); } + [Fact] + public void AffineOffsetIsReadForAnAdditionalUnit() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + [UnitProvider] + internal static class Units + { + [QuantityUnit(typeof(AbsoluteTemperature))] + [UnitFactor(typeof(Rankine))] + [AdditionalUnit(typeof(FahrenheitUnit))] + private sealed class TemperatureUnit; + [UnitFactor(typeof(Fahrenheit))] + private sealed class FahrenheitUnit; + [UnitSymbol("Rankine", "Rankine", "°R", Scale = 5d / 9d)] + private sealed class Rankine; + [UnitSymbol("Fahrenheit", "Fahrenheit", "°F", Scale = 5d / 9d)] + [AffineUnit(459.67)] + private sealed class Fahrenheit; + } + """; + (IReadOnlyList units, List diagnostics) = Read(source); + UnitDefinition unit = Assert.Single(Assert.Single(units).Units); + Assert.Multiple( + () => Assert.Empty(diagnostics), + () => Assert.Null(unit.OffsetBeforeScale), + () => Assert.Equal(459.67, Assert.Single(unit.AdditionalConversions).OffsetBeforeScale)); + } + + [Theory] + [InlineData("[UnitFactor(typeof(Celsius), Power = 2)]", "")] + [InlineData("[UnitFactor(typeof(Celsius), PrefixExponent = 3)]", "")] + [InlineData("[UnitFactor(typeof(Celsius))]", "[UnitFactor(typeof(Metre))]")] + public void AffineUnitsRejectPoweredPrefixedAndCompoundDeclarations(string factor, string additionalFactor) + { + string source = $$""" + using DimensionalQuantities.Units.Declarations; + [UnitProvider] + internal static class Units + { + [QuantityUnit(typeof(AbsoluteTemperature))] + {{factor}} + {{additionalFactor}} + private sealed class TemperatureUnit; + [UnitSymbol("Celsius", "Celsius", "°C")] + [AffineUnit(273.15)] + private sealed class Celsius; + [UnitSymbol("Metre", "Metres", "m")] + private sealed class Metre; + } + """; + (IReadOnlyList units, List diagnostics) = Read(source); + Assert.Multiple( + () => Assert.Empty(units), + () => Assert.Equal("DQ0008", Assert.Single(diagnostics).Id)); + } + [Fact] public void OneDeclarationCanAssociateDistinctQuantityTypes() { @@ -114,7 +172,8 @@ private static (IReadOnlyList Units, List Diagno string lengthType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Length: 1)); string timeType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Time: 1)); string pressureType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Length: -1, Mass: 1, Time: -2)); - source = $"global using Length = {lengthType};\nglobal using Time = {timeType};\nglobal using Pressure = {pressureType};\nglobal using Stress = {pressureType};\n" + source; + string temperatureType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Temperature: 1)); + source = $"global using Length = {lengthType};\nglobal using Time = {timeType};\nglobal using Pressure = {pressureType};\nglobal using Stress = {pressureType};\nglobal using AbsoluteTemperature = {temperatureType};\n" + source; CSharpCompilation input = CSharpCompilation.Create("UnitProviderReaderTest" + Guid.NewGuid().ToString("N"), [CSharpSyntaxTree.ParseText(source, options)], references, new(OutputKind.DynamicallyLinkedLibrary)); UnitProviderReader reader = new(input); diff --git a/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs index 49dccee..2361bf7 100644 --- a/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs +++ b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs @@ -9,5 +9,6 @@ internal static class DimensionNames public static string Get(Dimension dimension) => EngineeringQuantityAliases.Entries.TryGetValue(dimension, out string[]? aliases) ? aliases[0] - : dimension == default ? "Dimensionless" : DimensionNameGenerator.GetQuantityName(dimension); + : dimension == default ? "Dimensionless" + : dimension == new Dimension(Temperature: 1) ? "AbsoluteTemperature" : DimensionNameGenerator.GetQuantityName(dimension); } diff --git a/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs index 5f6b978..44cc520 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs @@ -6,7 +6,7 @@ namespace DimensionalQuantities.Roslyn.Discovery; internal sealed record PlannedQuantity(Dimension Dimension, ImmutableArray Names, ImmutableArray Units, - bool IncludeNamedConversions) + bool IncludeNamedConversions, bool IncludeDimensionNameAlias = true) { public PlannedQuantity(Dimension dimension, ImmutableArray units, bool includeNamedConversions) : this(dimension, [.. units.Select(unit => unit.Name)], units, includeNamedConversions) { } @@ -14,5 +14,6 @@ public PlannedQuantity(Dimension dimension, ImmutableArray units public string Name => Names[0]; public ImmutableArray Aliases => - [.. Names.Append(DimensionNameGenerator.GetQuantityName(Dimension)).Distinct(StringComparer.Ordinal)]; + [.. (IncludeDimensionNameAlias ? Names.Append(DimensionNameGenerator.GetQuantityName(Dimension)) : Names) + .Distinct(StringComparer.Ordinal)]; } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs index 5b987db..266da1a 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs @@ -57,7 +57,10 @@ private static QuantityGenerationPlan Discover(IReadOnlyList pr foreach (Dimension dimension in baseDimensions) { - knownQuantities.Add(new(dimension, [dimension.GetComponents().Single().Unit], true)); + UnitDefinition unit = dimension.GetComponents().Single().Unit; + knownQuantities.Add(dimension == new Dimension(Temperature: 1) + ? new(dimension, ["AbsoluteTemperature"], [unit], true, false) + : new(dimension, [unit], true)); dimensions.Add(dimension); } diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSourceEmitter.cs index bd78079..805a119 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSourceEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSourceEmitter.cs @@ -29,14 +29,15 @@ private static string RenderQuantity(UnitConversionSpec quantity, int[] prefixes UnitExpression canonicalExpression = quantity.Units[0].Expression; ConversionDefinition generic = new("FromPrefixedUnits", "AsPrefixedUnits", GetTermParameters(canonicalExpression), GetTermScaleExponent(canonicalExpression), - canonicalExpression.GetScale()); + canonicalExpression.GetScale(), null); ConversionDefinition[] named = quantity.IncludeNamed ? CollectConversions(quantity.Units, quantity.GenerateSiPrefixes ? prefixes : []) .Select(conversion => new ConversionDefinition($"From{conversion.Key}", $"As{conversion.Key}", [], - FormatExponent(conversion.Value.Exponent), conversion.Value.Scale)) + FormatExponent(conversion.Value.Exponent), conversion.Value.Scale, conversion.Value.OffsetBeforeScale)) .ToArray() : []; - ConversionDefinition[] conversions = quantity.GeneratePrefixConversions ? [generic, .. named] : named; + ConversionDefinition[] conversions = quantity.GeneratePrefixConversions && quantity.Units[0].OffsetBeforeScale is null + ? [generic, .. named] : named; string variableName = char.ToLowerInvariant(quantity.ExtensionName[0]) + quantity.ExtensionName[1..]; string className = classNamePrefix + (quantity.ExtensionClassName ?? quantity.ExtensionName) + "Extensions"; if (!SyntaxFacts.IsValidIdentifier(className) || !SyntaxFacts.IsValidIdentifier(variableName) @@ -49,6 +50,7 @@ private static string RenderQuantity(UnitConversionSpec quantity, int[] prefixes string factories = string.Join("\n\t\t", conversions.Select(conversion => RenderFactory(quantity.ReceiverType, conversion))); string accessors = string.Join("\n\t\t", conversions.Select(conversion => RenderAccessor(variableName, conversion))); + string deltaAccessors = string.Join("\n\t\t", conversions.Select(RenderDeltaAccessor)); return $$""" {{accessibility}} static class {{className}} @@ -62,50 +64,57 @@ private static string RenderQuantity(UnitConversionSpec quantity, int[] prefixes { {{accessors}} } + + extension(global::DimensionalQuantities.TypeSystem.Delta<{{quantity.ReceiverType}}> delta) + { + {{deltaAccessors}} + } } """; } - private static IReadOnlyDictionary CollectConversions( + private static IReadOnlyDictionary CollectConversions( IReadOnlyList units, int[] generatedPrefixes) { - Dictionary conversions = new(StringComparer.Ordinal); - foreach ((string name, Rational exponent, double scale) in units.SelectMany(unit => GetConversions(unit, generatedPrefixes))) + Dictionary conversions = new(StringComparer.Ordinal); + foreach ((string name, Rational exponent, double scale, double? offset) in units.SelectMany(unit => GetConversions(unit, generatedPrefixes))) { - if (conversions.TryGetValue(name, out (Rational Exponent, double Scale) existing) && existing != (exponent, scale)) + if (conversions.TryGetValue(name, out (Rational Exponent, double Scale, double? OffsetBeforeScale) existing) + && existing != (exponent, scale, offset)) { - throw new InvalidOperationException($"Unit {name} has conflicting SI scales."); + throw new InvalidOperationException($"Unit {name} has conflicting SI conversions."); } if (!conversions.ContainsKey(name)) { - conversions.Add(name, (exponent, scale)); + conversions.Add(name, (exponent, scale, offset)); } } return conversions; } - private static IEnumerable<(string Name, Rational Exponent, double Scale)> GetConversions( + private static IEnumerable<(string Name, Rational Exponent, double Scale, double? OffsetBeforeScale)> GetConversions( UnitDefinition unit, int[] generatedPrefixes) => GetStandardConversions(unit, generatedPrefixes).Concat(unit.AdditionalConversions - .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent(), expression.GetScale()))); + .Select(conversion => (conversion.Expression.GetName(unit.Expression), conversion.Expression.GetSiScaleExponent(), + conversion.Expression.GetScale(), conversion.OffsetBeforeScale))); - private static IEnumerable<(string Name, Rational Exponent, double Scale)> GetStandardConversions( + private static IEnumerable<(string Name, Rational Exponent, double Scale, double? OffsetBeforeScale)> GetStandardConversions( UnitDefinition unit, int[] generatedPrefixes) { UnitExpression expression = unit.Expression; int firstNumerator = Array.FindIndex(expression.Terms, term => term.Power > Rational.Zero); - if (firstNumerator < 0 || generatedPrefixes.Length == 0) + if (firstNumerator < 0 || generatedPrefixes.Length == 0 || unit.OffsetBeforeScale is not null) { - yield return (expression.GetName(), expression.GetSiScaleExponent(), expression.GetScale()); + yield return (expression.GetName(), expression.GetSiScaleExponent(), expression.GetScale(), unit.OffsetBeforeScale); yield break; } foreach (int prefix in generatedPrefixes.Prepend(0)) { UnitExpression prefixed = expression.WithPrefix(firstNumerator, prefix); - yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent(), prefixed.GetScale()); + yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent(), prefixed.GetScale(), null); } } @@ -139,6 +148,11 @@ private static string FormatTermScaleExponent(UnitTerm term) private static string RenderFactory(string quantityName, ConversionDefinition conversion) { string parameters = string.Concat(conversion.PrefixParameters.Select(parameter => $", int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); + if (conversion.OffsetBeforeScale is double offset) + { + return $"/// Converts an affine reading to its absolute SI value. Arithmetic does not preserve affine offsets.\n\t\t\tpublic static {quantityName} {conversion.FactoryName}(double value{parameters}) => {quantityName}.FromSiBaseUnit((value + {FormatDouble(offset)}) * {GetScaleExpression(conversion)});"; + } + if (conversion.Scale == 1) { return $"public static {quantityName} {conversion.FactoryName}(double value{parameters}) => {quantityName}.FromArbitrarySiComponents(value, overallScaleExponent: {conversion.ScaleExponentExpression});"; @@ -150,6 +164,11 @@ private static string RenderFactory(string quantityName, ConversionDefinition co private static string RenderAccessor(string variableName, ConversionDefinition conversion) { string parameters = string.Join(", ", conversion.PrefixParameters.Select(parameter => $"int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); + if (conversion.OffsetBeforeScale is double offset) + { + return $"/// Interprets this value as an absolute reading. Use AsDelta() before converting a difference.\n\t\t\tpublic double {conversion.AccessorName}({parameters}) => {variableName}.AsSiBaseUnit() / {GetScaleExpression(conversion)} - {FormatDouble(offset)};"; + } + if (conversion.Scale == 1) { return $"public double {conversion.AccessorName}({parameters}) => {variableName}.AsArbitrarySiComponents(overallScaleExponent: {conversion.ScaleExponentExpression});"; @@ -158,11 +177,20 @@ private static string RenderAccessor(string variableName, ConversionDefinition c return $"public double {conversion.AccessorName}({parameters}) => {variableName}.AsSiBaseUnit() / ({FormatDouble(conversion.Scale)} * global::System.Math.Pow(10, {conversion.ScaleExponentExpression}));"; } + private static string RenderDeltaAccessor(ConversionDefinition conversion) + { + string parameters = string.Join(", ", conversion.PrefixParameters.Select(parameter => $"int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); + return $"/// Converts a difference using scale alone; affine offsets are omitted.\n\t\t\tpublic double {conversion.AccessorName}({parameters}) => delta.AsSiBaseUnit() / {GetScaleExpression(conversion)};"; + } + + private static string GetScaleExpression(ConversionDefinition conversion) + => $"({FormatDouble(conversion.Scale)} * global::System.Math.Pow(10, {conversion.ScaleExponentExpression}))"; + private static string FormatExponent(Rational exponent) => ((double)exponent).ToString("R", CultureInfo.InvariantCulture) + "d"; private static string FormatDouble(double value) => value.ToString("R", CultureInfo.InvariantCulture) + "d"; private sealed record ConversionDefinition(string FactoryName, string AccessorName, PrefixParameterDefinition[] PrefixParameters, - string ScaleExponentExpression, double Scale); + string ScaleExponentExpression, double Scale, double? OffsetBeforeScale); private sealed record PrefixParameterDefinition(string Name, int DefaultExponent); } diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs index 2591c28..2dad706 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs @@ -13,6 +13,7 @@ internal sealed class UnitProviderReader private readonly INamedTypeSymbol _quantityAttribute; private readonly INamedTypeSymbol _factorAttribute; private readonly INamedTypeSymbol _symbolAttribute; + private readonly INamedTypeSymbol _affineAttribute; private readonly INamedTypeSymbol _additionalAttribute; private readonly QuantityTypeReader _quantityReader; @@ -22,6 +23,7 @@ public UnitProviderReader(Compilation compilation) _quantityAttribute = compilation.GetRequiredType(typeof(QuantityUnitAttribute)); _factorAttribute = compilation.GetRequiredType(typeof(UnitFactorAttribute)); _symbolAttribute = compilation.GetRequiredType(typeof(UnitSymbolAttribute)); + _affineAttribute = compilation.GetRequiredType(typeof(AffineUnitAttribute)); _additionalAttribute = compilation.GetRequiredType(typeof(AdditionalUnitAttribute)); _quantityReader = new(compilation); } @@ -94,12 +96,12 @@ private bool TryReadUnitDefinition(INamedTypeSymbol declaration, INamedTypeSymbo Action report, out UnitDefinition unit) { unit = default; - if (!TryReadExpression(declaration, report, out UnitExpression expression)) + if (!TryReadExpression(declaration, report, out UnitConversion conversion)) { return false; } - if (!TryReadAlternatives(declaration, provider, report, out UnitExpression[] alternatives)) + if (!TryReadAlternatives(declaration, provider, report, out UnitConversion[] alternatives)) { return false; } @@ -112,14 +114,14 @@ private bool TryReadUnitDefinition(INamedTypeSymbol declaration, INamedTypeSymbo return false; } - unit = new(name, expression, alternatives); + unit = new(name, conversion.Expression, alternatives, conversion.OffsetBeforeScale); return true; } private bool TryReadAlternatives(INamedTypeSymbol declaration, INamedTypeSymbol provider, - Action report, out UnitExpression[] alternatives) + Action report, out UnitConversion[] alternatives) { - List results = []; + List results = []; foreach (AttributeData addition in FindAll(declaration, _additionalAttribute)) { if (addition.ConstructorArguments[0].Value is not INamedTypeSymbol additionalType @@ -130,34 +132,42 @@ private bool TryReadAlternatives(INamedTypeSymbol declaration, INamedTypeSymbol return false; } - if (!TryReadExpression(additionalType, report, out UnitExpression expression)) + if (!TryReadExpression(additionalType, report, out UnitConversion conversion)) { alternatives = []; return false; } - results.Add(expression); + results.Add(conversion); } alternatives = [.. results]; return true; } - private bool TryReadExpression(INamedTypeSymbol declaration, Action report, out UnitExpression expression) + private bool TryReadExpression(INamedTypeSymbol declaration, Action report, out UnitConversion conversion) { List terms = []; + double? offsetBeforeScale = null; foreach (AttributeData factor in FindAll(declaration, _factorAttribute)) { - if (!TryReadFactor(factor, report, out UnitTerm term)) + if (!TryReadFactor(factor, report, out UnitTerm term, out double? factorOffset)) { - expression = default; + conversion = default; return false; } terms.Add(term); + offsetBeforeScale ??= factorOffset; + } + + conversion = new(new UnitExpression([.. terms]), offsetBeforeScale); + if (offsetBeforeScale is not null && terms.Count != 1) + { + Report(report, declaration.GetAttributes().FirstOrDefault(), "An affine unit must be a single, unprefixed factor with power one."); + return false; } - expression = new([.. terms]); if (terms.Count > 0) { return true; @@ -168,9 +178,10 @@ private bool TryReadExpression(INamedTypeSymbol declaration, Action return false; } - private bool TryReadFactor(AttributeData factor, Action report, out UnitTerm term) + private bool TryReadFactor(AttributeData factor, Action report, out UnitTerm term, out double? offsetBeforeScale) { term = default; + offsetBeforeScale = null; if (factor.ConstructorArguments[0].Value is not INamedTypeSymbol symbolType || Find(symbolType, _symbolAttribute) is not { } symbol) { @@ -183,6 +194,18 @@ private bool TryReadFactor(AttributeData factor, Action report, out int prefix = GetNamed(factor, nameof(UnitFactorAttribute.PrefixExponent), 0); int basePrefix = GetNamed(symbol, nameof(UnitSymbolAttribute.SiBasePrefixExponent), 0); double scale = GetNamed(symbol, nameof(UnitSymbolAttribute.Scale), 1d); + AttributeData? affine = Find(symbolType, _affineAttribute); + if (affine is not null) + { + if (affine.ConstructorArguments[0].Value is not double offset || !IsFinite(offset) + || power != 1 || denominator != 1 || prefix != 0 || basePrefix != 0) + { + Report(report, factor, "An affine unit must have a finite offset and be an unprefixed factor with power one."); + return false; + } + + offsetBeforeScale = offset; + } string? singular = symbol.ConstructorArguments[0].Value as string; string? plural = symbol.ConstructorArguments[1].Value as string; string? glyph = symbol.ConstructorArguments[2].Value as string; diff --git a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs index e27c681..863a24c 100644 --- a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs +++ b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs @@ -1,3 +1,4 @@ +using DimensionalQuantities.TypeSystem; using Xunit; using AllAxes = DimensionalQuantities.TypeSystem.Quantity result = original.AsDelta(); + + Assert.Equal(expected, result.AsSiBaseUnit()); + } + + [Theory] + [InlineData(20, 10, 10)] + [InlineData(10, 10, 0)] + [InlineData(10, 20, -10)] + public void AsDeltaUsesTheMagnitudeAfterSubtraction(double firstValue, double secondValue, double expected) + { + Length first = Length.FromSiBaseUnit(firstValue); + Length second = Length.FromSiBaseUnit(secondValue); + Delta result = (first - second).AsDelta(); + + Assert.Equal(expected, result.AsSiBaseUnit()); + } + [Fact] public void ArbitraryComponentsUseKilogramsByDefaultAndAllowGrams() { diff --git a/src/DimensionalQuantities/TypeSystem/Delta.cs b/src/DimensionalQuantities/TypeSystem/Delta.cs new file mode 100644 index 0000000..e4541b5 --- /dev/null +++ b/src/DimensionalQuantities/TypeSystem/Delta.cs @@ -0,0 +1,12 @@ +namespace DimensionalQuantities.TypeSystem; + +/// A quantity interpreted as a difference, retaining its magnitude in coherent SI base units. +public readonly struct Delta +{ + private readonly double _valueInSiBaseUnit; + + internal Delta(double valueInSiBaseUnit) => _valueInSiBaseUnit = valueInSiBaseUnit; + + /// Returns the difference in coherent SI base units for its quantity dimension. + public double AsSiBaseUnit() => _valueInSiBaseUnit; +} diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index fde1c2e..4f921ad 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -43,6 +43,9 @@ public static Quantity FromArbitrarySiComponents( /// Returns the magnitude in coherent SI base units for its dimension. public double AsSiBaseUnit() => ValueInSiBaseUnit; + /// Interprets this quantity's stored SI magnitude as a difference without changing its value. + public Delta> AsDelta() => new(ValueInSiBaseUnit); + /// Returns the magnitude in SI base components with the selected decimal prefixes. public double AsArbitrarySiComponents( int lengthPrefixExponent = SiBasePrefixExponents.Length, diff --git a/src/DimensionalQuantities/Units/Declarations/AffineUnitAttribute.cs b/src/DimensionalQuantities/Units/Declarations/AffineUnitAttribute.cs new file mode 100644 index 0000000..8a51960 --- /dev/null +++ b/src/DimensionalQuantities/Units/Declarations/AffineUnitAttribute.cs @@ -0,0 +1,10 @@ +namespace DimensionalQuantities.Units.Declarations; + +/// Defines an offset applied before scaling a unit value to coherent SI base units. +[AttributeUsage(AttributeTargets.Class)] +public sealed class AffineUnitAttribute : Attribute +{ + public AffineUnitAttribute(double offsetBeforeScale) => OffsetBeforeScale = offsetBeforeScale; + + public double OffsetBeforeScale { get; } +} diff --git a/src/DimensionalQuantities/Units/UnitConversion.cs b/src/DimensionalQuantities/Units/UnitConversion.cs new file mode 100644 index 0000000..11818e8 --- /dev/null +++ b/src/DimensionalQuantities/Units/UnitConversion.cs @@ -0,0 +1,3 @@ +namespace DimensionalQuantities.Units; + +internal readonly record struct UnitConversion(UnitExpression Expression, double? OffsetBeforeScale = null); diff --git a/src/DimensionalQuantities/Units/UnitDefinition.cs b/src/DimensionalQuantities/Units/UnitDefinition.cs index 103d3b4..358ccec 100644 --- a/src/DimensionalQuantities/Units/UnitDefinition.cs +++ b/src/DimensionalQuantities/Units/UnitDefinition.cs @@ -2,16 +2,19 @@ namespace DimensionalQuantities.Units; internal readonly record struct UnitDefinition { - public UnitDefinition(string name, UnitExpression expression, UnitExpression[]? additionalConversions = null) + public UnitDefinition(string name, UnitExpression expression, UnitConversion[]? additionalConversions = null, + double? offsetBeforeScale = null) { Name = name; Expression = expression; AdditionalConversions = additionalConversions ?? []; + OffsetBeforeScale = offsetBeforeScale; } public UnitDefinition(string name, UnitTerm[] terms) : this(name, new UnitExpression(terms)) { } public string Name { get; } public UnitExpression Expression { get; } - public UnitExpression[] AdditionalConversions { get; } + public UnitConversion[] AdditionalConversions { get; } + public double? OffsetBeforeScale { get; } } From 668b49328ed42fbf19f7465f5c584ea3a788cdb6 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sun, 25 Oct 2026 15:48:31 +0000 Subject: [PATCH 2/2] Use Temperature as the standalone quantity alias --- README.md | 10 +++++----- .../ImperialConversionsTests.cs | 8 ++++---- .../ImperialUnits.cs | 2 +- .../MetricConversionsTests.cs | 4 ++-- .../MetricTemperatureUnits.cs | 2 +- .../Packaging/PackagedGeneratorTests.cs | 14 +++++++------- .../Generators/QuantityGeneratorTests.cs | 2 +- .../Generators/QuantitySourceEmitterTests.cs | 6 ++---- .../UnitConversionSourceEmitterTests.cs | 18 +++++++++--------- .../Generators/UnitProviderReaderTests.cs | 6 +++--- .../Analyzers/DimensionNames.cs | 2 +- .../Discovery/PlannedQuantity.cs | 5 ++--- .../Generators/QuantityDiscovery.cs | 4 +--- 13 files changed, 39 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 75c4f0d..bedcdf7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A C# library for dimensional quantities, with generated type aliases, SI unit co The runtime library targets .NET Standard 2.0. Consumers need a C# 14 compiler for the generated extension blocks. The `DimensionalQuantities` NuGet package includes the runtime library, source generator, analysers and an alias quick fix. -Core includes the seven SI base quantities' conversion helpers. Add `DimensionalQuantities.Extensions.Metric` for catalogue conversions such as volts, watts and pascals, plus Celsius conversions for `AbsoluteTemperature`, and import its namespace: +Core includes the seven SI base quantities' conversion helpers. Add `DimensionalQuantities.Extensions.Metric` for catalogue conversions such as volts, watts and pascals, plus Celsius conversions for `Temperature`, and import its namespace: ```csharp using DimensionalQuantities.Extensions.Metric; @@ -23,7 +23,7 @@ using DimensionalQuantities.Extensions.Metric; Metric depends on the matching core version, so referencing Metric also supplies core and its compiler tooling. Quantity names and dimensional arithmetic are available with core alone. -Add `DimensionalQuantities.Extensions.Imperial` for British and US customary conversions. It includes feet, miles, stones, pounds-force, US and Imperial liquid measures, Rankine and Fahrenheit conversions for `AbsoluteTemperature`, mechanical horsepower and the International Table British thermal unit. Import `DimensionalQuantities.Extensions.Imperial` to use its helpers: +Add `DimensionalQuantities.Extensions.Imperial` for British and US customary conversions. It includes feet, miles, stones, pounds-force, US and Imperial liquid measures, Rankine and Fahrenheit conversions for `Temperature`, mechanical horsepower and the International Table British thermal unit. Import `DimensionalQuantities.Extensions.Imperial` to use its helpers: ```csharp Length depth = Length.FromFeet(6); @@ -96,13 +96,13 @@ Named compound units remain a single factor where appropriate: `Voltage.FromPref ### Affine temperature units and deltas -Temperature magnitudes are stored internally in kelvin. `AbsoluteTemperature.FromCelsius(20)` stores 293.15 K, and `AbsoluteTemperature.FromFahrenheit(68)` stores the same value. Their `AsCelsius()` and `AsFahrenheit()` accessors apply the corresponding offsets when converting an absolute value. +Temperature magnitudes are stored internally in kelvin. `Temperature.FromCelsius(20)` stores 293.15 K, and `Temperature.FromFahrenheit(68)` stores the same value. Their `AsCelsius()` and `AsFahrenheit()` accessors apply the corresponding offsets when converting an absolute value. Subtraction operates on those stored kelvin values. To convert the result as a temperature difference, call `.AsDelta()` and then use a delta accessor. Delta accessors apply scale only, without an offset: ```csharp -AbsoluteTemperature warm = AbsoluteTemperature.FromCelsius(20); -AbsoluteTemperature cool = AbsoluteTemperature.FromFahrenheit(50); +Temperature warm = Temperature.FromCelsius(20); +Temperature cool = Temperature.FromFahrenheit(50); var difference = (warm - cool).AsDelta(); double celsiusDifference = difference.AsCelsius(); // 10 diff --git a/src/DimensionalQuantities.Extensions.Imperial.Tests/ImperialConversionsTests.cs b/src/DimensionalQuantities.Extensions.Imperial.Tests/ImperialConversionsTests.cs index cadf5f1..eddeeb8 100644 --- a/src/DimensionalQuantities.Extensions.Imperial.Tests/ImperialConversionsTests.cs +++ b/src/DimensionalQuantities.Extensions.Imperial.Tests/ImperialConversionsTests.cs @@ -73,16 +73,16 @@ public void EnergyAndPowerUseExplicitBritishThermalUnitDefinition() public void RankineUsesFiveNinthsKelvinPerDegree() { Assert.Multiple( - () => Assert.Equal(273.15, AbsoluteTemperature.FromRankine(491.67).AsKelvin(), 10), - () => Assert.Equal(491.67, AbsoluteTemperature.FromKelvin(273.15).AsRankine(), 10)); + () => Assert.Equal(273.15, Temperature.FromRankine(491.67).AsKelvin(), 10), + () => Assert.Equal(491.67, Temperature.FromKelvin(273.15).AsRankine(), 10)); } [Fact] public void FahrenheitConvertsToAndFromAbsoluteKelvin() { Assert.Multiple( - () => Assert.Equal(273.15, AbsoluteTemperature.FromFahrenheit(32).AsKelvin(), 10), - () => Assert.Equal(32, AbsoluteTemperature.FromKelvin(273.15).AsFahrenheit(), 10)); + () => Assert.Equal(273.15, Temperature.FromFahrenheit(32).AsKelvin(), 10), + () => Assert.Equal(32, Temperature.FromKelvin(273.15).AsFahrenheit(), 10)); } [Fact] diff --git a/src/DimensionalQuantities.Extensions.Imperial/ImperialUnits.cs b/src/DimensionalQuantities.Extensions.Imperial/ImperialUnits.cs index e394eb2..e4ca716 100644 --- a/src/DimensionalQuantities.Extensions.Imperial/ImperialUnits.cs +++ b/src/DimensionalQuantities.Extensions.Imperial/ImperialUnits.cs @@ -117,7 +117,7 @@ private sealed class PowerUnit { } [UnitFactor(typeof(ImperialSymbols.Hour), Power = -1)] private sealed class BritishThermalUnitsITPerHourUnit { } - [QuantityUnit(typeof(AbsoluteTemperature))] + [QuantityUnit(typeof(Temperature))] [UnitFactor(typeof(ImperialSymbols.Rankine))] [AdditionalUnit(typeof(FahrenheitUnit))] private sealed class TemperatureUnit { } diff --git a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs index e8b3740..5b2188b 100644 --- a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs +++ b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs @@ -45,8 +45,8 @@ public void CatalogueFactoriesConvertToExpectedSiValues() public void CelsiusConvertsToAndFromAbsoluteKelvin() { Assert.Multiple( - () => Assert.Equal(273.15, AbsoluteTemperature.FromCelsius(0).AsKelvin(), 10), - () => Assert.Equal(0, AbsoluteTemperature.FromKelvin(273.15).AsCelsius(), 10)); + () => Assert.Equal(273.15, Temperature.FromCelsius(0).AsKelvin(), 10), + () => Assert.Equal(0, Temperature.FromKelvin(273.15).AsCelsius(), 10)); } [Fact] diff --git a/src/DimensionalQuantities.Extensions.Metric/MetricTemperatureUnits.cs b/src/DimensionalQuantities.Extensions.Metric/MetricTemperatureUnits.cs index 0dfcc3d..7949578 100644 --- a/src/DimensionalQuantities.Extensions.Metric/MetricTemperatureUnits.cs +++ b/src/DimensionalQuantities.Extensions.Metric/MetricTemperatureUnits.cs @@ -6,7 +6,7 @@ namespace DimensionalQuantities.Extensions.Metric; [UnitProvider] internal static class MetricTemperatureUnits { - [QuantityUnit(typeof(AbsoluteTemperature))] + [QuantityUnit(typeof(Temperature))] [UnitFactor(typeof(MetricSymbols.Celsius))] private sealed class TemperatureUnit { } } diff --git a/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs index 6854a54..3413f08 100644 --- a/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs @@ -86,7 +86,7 @@ public void ImperialPackageProvidesConversionsThroughCoreDependency() public static class Consumer { public static double ConvertLength() => Length.FromFeet(6).AsSiBaseUnit(); - public static double ConvertTemperature() => AbsoluteTemperature.FromRankine(491.67).AsSiBaseUnit(); + public static double ConvertTemperature() => Temperature.FromRankine(491.67).AsSiBaseUnit(); } """, imperial); Build(consumer); @@ -114,8 +114,8 @@ public void AffineTemperatureConversionsAndDeltasWorkAcrossPackages() namespace TemperatureMeasurements; public static class Readings { - public static Delta Difference() => - (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(10)).AsDelta(); + public static Delta Difference() => + (Temperature.FromCelsius(20) - Temperature.FromCelsius(10)).AsDelta(); } """, metric); Pack(measurements, "TemperatureMeasurements", repository, out Package measurementPackage); @@ -135,10 +135,10 @@ public static double[] DifferenceInEachUnit() public static double[] AbsoluteRoundTrips() => [ - AbsoluteTemperature.FromCelsius(20).AsCelsius(), - AbsoluteTemperature.FromFahrenheit(68).AsFahrenheit(), - AbsoluteTemperature.FromKelvin(293.15).AsCelsius(), - AbsoluteTemperature.FromRankine(527.67).AsFahrenheit() + Temperature.FromCelsius(20).AsCelsius(), + Temperature.FromFahrenheit(68).AsFahrenheit(), + Temperature.FromKelvin(293.15).AsCelsius(), + Temperature.FromRankine(527.67).AsFahrenheit() ]; } """, metric, imperial, measurementPackage); diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs index f2e7bbb..32849f3 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs @@ -30,7 +30,7 @@ public static double UseBaseQuantities() Mass mass = Mass.FromGrams(1000); Time time = Time.FromSeconds(1); Current current = Current.FromAmps(1); - AbsoluteTemperature temperature = AbsoluteTemperature.FromKelvin(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() diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs index d4f28eb..d18a506 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs @@ -15,12 +15,10 @@ public sealed class QuantitySourceEmitterTests private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.Preview); [Fact] - public void StandaloneTemperatureUsesOnlyTheAbsoluteAlias() + public void StandaloneTemperatureUsesTheDimensionAlias() { string source = QuantitySourceEmitter.Emit(QuantityDiscovery.Discover([]).KnownQuantities); - Assert.Multiple( - () => Assert.Contains("global using AbsoluteTemperature =", source), - () => Assert.DoesNotContain("global using Temperature =", source)); + Assert.Contains("global using Temperature =", source); } [Fact] diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs index 361f03c..a1b0d98 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs @@ -80,7 +80,7 @@ public void AffineReadingsAndScaleOnlyDeltasUseTheirOwnConversionRules() AtomicUnit fahrenheit = new("Fahrenheit", "Fahrenheit", "°F", Scale: 5d / 9d); UnitConversionSpec[] units = [ - new("AbsoluteTemperature", "AbsoluteTemperature", + new("Temperature", "Temperature", [ new UnitDefinition("Celsius", new UnitExpression(new UnitTerm(celsius)), offsetBeforeScale: 273.15), new UnitDefinition("Rankine", new UnitExpression(new UnitTerm(rankine)), @@ -90,13 +90,13 @@ public void AffineReadingsAndScaleOnlyDeltasUseTheirOwnConversionRules() CSharpCompilation compilation = Compile(""" public static class Consumer { - public static double AbsoluteCelsius() => AbsoluteTemperature.FromCelsius(20).AsCelsius(); - public static double AbsoluteFahrenheit() => AbsoluteTemperature.FromFahrenheit(68).AsCelsius(); - public static double DifferenceCelsius() => (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(10)).AsDelta().AsCelsius(); - public static double DifferenceFahrenheit() => (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(10)).AsDelta().AsFahrenheit(); - public static double DifferenceRankine() => (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(10)).AsDelta().AsRankine(); - public static double ZeroDifference() => (AbsoluteTemperature.FromCelsius(20) - AbsoluteTemperature.FromCelsius(20)).AsDelta().AsFahrenheit(); - public static double NegativeDifference() => (AbsoluteTemperature.FromCelsius(10) - AbsoluteTemperature.FromCelsius(20)).AsDelta().AsFahrenheit(); + public static double AbsoluteCelsius() => Temperature.FromCelsius(20).AsCelsius(); + public static double AbsoluteFahrenheit() => Temperature.FromFahrenheit(68).AsCelsius(); + public static double DifferenceCelsius() => (Temperature.FromCelsius(20) - Temperature.FromCelsius(10)).AsDelta().AsCelsius(); + public static double DifferenceFahrenheit() => (Temperature.FromCelsius(20) - Temperature.FromCelsius(10)).AsDelta().AsFahrenheit(); + public static double DifferenceRankine() => (Temperature.FromCelsius(20) - Temperature.FromCelsius(10)).AsDelta().AsRankine(); + public static double ZeroDifference() => (Temperature.FromCelsius(20) - Temperature.FromCelsius(20)).AsDelta().AsFahrenheit(); + public static double NegativeDifference() => (Temperature.FromCelsius(10) - Temperature.FromCelsius(20)).AsDelta().AsFahrenheit(); } """, units); using MemoryStream assemblyBytes = new(); @@ -124,7 +124,7 @@ private static CSharpCompilation Compile(string source, UnitConversionSpec[]? un string generatedSource = $""" global using Length = {lengthType}; global using Mass = {massType}; - global using AbsoluteTemperature = {temperatureType}; + global using Temperature = {temperatureType}; global using DimensionalQuantities.Generated; {UnitConversionSourceEmitter.Emit(units ?? _units, "DimensionalQuantities.Generated", "internal")} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs index 7c27f51..a556e11 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs @@ -86,7 +86,7 @@ public void AffineOffsetIsReadForAnAdditionalUnit() [UnitProvider] internal static class Units { - [QuantityUnit(typeof(AbsoluteTemperature))] + [QuantityUnit(typeof(Temperature))] [UnitFactor(typeof(Rankine))] [AdditionalUnit(typeof(FahrenheitUnit))] private sealed class TemperatureUnit; @@ -118,7 +118,7 @@ public void AffineUnitsRejectPoweredPrefixedAndCompoundDeclarations(string facto [UnitProvider] internal static class Units { - [QuantityUnit(typeof(AbsoluteTemperature))] + [QuantityUnit(typeof(Temperature))] {{factor}} {{additionalFactor}} private sealed class TemperatureUnit; @@ -173,7 +173,7 @@ private static (IReadOnlyList Units, List Diagno string timeType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Time: 1)); string pressureType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Length: -1, Mass: 1, Time: -2)); string temperatureType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Temperature: 1)); - source = $"global using Length = {lengthType};\nglobal using Time = {timeType};\nglobal using Pressure = {pressureType};\nglobal using Stress = {pressureType};\nglobal using AbsoluteTemperature = {temperatureType};\n" + source; + source = $"global using Length = {lengthType};\nglobal using Time = {timeType};\nglobal using Pressure = {pressureType};\nglobal using Stress = {pressureType};\nglobal using Temperature = {temperatureType};\n" + source; CSharpCompilation input = CSharpCompilation.Create("UnitProviderReaderTest" + Guid.NewGuid().ToString("N"), [CSharpSyntaxTree.ParseText(source, options)], references, new(OutputKind.DynamicallyLinkedLibrary)); UnitProviderReader reader = new(input); diff --git a/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs index 2361bf7..47775fc 100644 --- a/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs +++ b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs @@ -10,5 +10,5 @@ public static string Get(Dimension dimension) => EngineeringQuantityAliases.Entries.TryGetValue(dimension, out string[]? aliases) ? aliases[0] : dimension == default ? "Dimensionless" - : dimension == new Dimension(Temperature: 1) ? "AbsoluteTemperature" : DimensionNameGenerator.GetQuantityName(dimension); + : DimensionNameGenerator.GetQuantityName(dimension); } diff --git a/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs index 44cc520..5f6b978 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs @@ -6,7 +6,7 @@ namespace DimensionalQuantities.Roslyn.Discovery; internal sealed record PlannedQuantity(Dimension Dimension, ImmutableArray Names, ImmutableArray Units, - bool IncludeNamedConversions, bool IncludeDimensionNameAlias = true) + bool IncludeNamedConversions) { public PlannedQuantity(Dimension dimension, ImmutableArray units, bool includeNamedConversions) : this(dimension, [.. units.Select(unit => unit.Name)], units, includeNamedConversions) { } @@ -14,6 +14,5 @@ public PlannedQuantity(Dimension dimension, ImmutableArray units public string Name => Names[0]; public ImmutableArray Aliases => - [.. (IncludeDimensionNameAlias ? Names.Append(DimensionNameGenerator.GetQuantityName(Dimension)) : Names) - .Distinct(StringComparer.Ordinal)]; + [.. Names.Append(DimensionNameGenerator.GetQuantityName(Dimension)).Distinct(StringComparer.Ordinal)]; } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs index 266da1a..f9a05ac 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs @@ -58,9 +58,7 @@ private static QuantityGenerationPlan Discover(IReadOnlyList pr foreach (Dimension dimension in baseDimensions) { UnitDefinition unit = dimension.GetComponents().Single().Unit; - knownQuantities.Add(dimension == new Dimension(Temperature: 1) - ? new(dimension, ["AbsoluteTemperature"], [unit], true, false) - : new(dimension, [unit], true)); + knownQuantities.Add(new(dimension, [unit], true)); dimensions.Add(dimension); }