Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ 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 `Temperature`, and import its namespace:

```csharp
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 `Temperature`, mechanical horsepower and the International Table British thermal unit. Import `DimensionalQuantities.Extensions.Imperial` to use its helpers:

```csharp
Length depth = Length.FromFeet(6);
Expand Down Expand Up @@ -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. `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
Temperature warm = Temperature.FromCelsius(20);
Temperature cool = Temperature.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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ public void RankineUsesFiveNinthsKelvinPerDegree()
() => Assert.Equal(491.67, Temperature.FromKelvin(273.15).AsRankine(), 10));
}

[Fact]
public void FahrenheitConvertsToAndFromAbsoluteKelvin()
{
Assert.Multiple(
() => Assert.Equal(273.15, Temperature.FromFahrenheit(32).AsKelvin(), 10),
() => Assert.Equal(32, Temperature.FromKelvin(273.15).AsFahrenheit(), 10));
}

[Fact]
public void ProviderDoesNotGenerateSiPrefixHelpers()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,8 @@ private sealed class BritishThermalUnitsITPerHourUnit { }

[QuantityUnit(typeof(Temperature))]
[UnitFactor(typeof(ImperialSymbols.Rankine))]
[AdditionalUnit(typeof(FahrenheitUnit))]
private sealed class TemperatureUnit { }
[UnitFactor(typeof(ImperialSymbols.Fahrenheit))]
private sealed class FahrenheitUnit { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ public void CatalogueFactoriesConvertToExpectedSiValues()
Assert.Equal(expected, result);
}

[Fact]
public void CelsiusConvertsToAndFromAbsoluteKelvin()
{
Assert.Multiple(
() => Assert.Equal(273.15, Temperature.FromCelsius(0).AsKelvin(), 10),
() => Assert.Equal(0, Temperature.FromKelvin(273.15).AsCelsius(), 10));
}

[Fact]
public void CatalogueAccessorsConvertFromKnownSiValues()
{
Expand Down
3 changes: 3 additions & 0 deletions src/DimensionalQuantities.Extensions.Metric/MetricSymbols.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using DimensionalQuantities.Units.Declarations;
using DimensionalQuantities.Units.Symbols;

namespace DimensionalQuantities.Extensions.Metric;

[UnitProvider]
internal static class MetricTemperatureUnits
{
[QuantityUnit(typeof(Temperature))]
[UnitFactor(typeof(MetricSymbols.Celsius))]
private sealed class TemperatureUnit { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Temperature> Difference() =>
(Temperature.FromCelsius(20) - Temperature.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() =>
[
Temperature.FromCelsius(20).AsCelsius(),
Temperature.FromFahrenheit(68).AsFahrenheit(),
Temperature.FromKelvin(293.15).AsCelsius(),
Temperature.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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ public sealed class QuantitySourceEmitterTests
{
private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.Preview);

[Fact]
public void StandaloneTemperatureUsesTheDimensionAlias()
{
string source = QuantitySourceEmitter.Emit(QuantityDiscovery.Discover([]).KnownQuantities);
Assert.Contains("global using Temperature =", source);
}

[Fact]
public void DiscoveredMechanicalQuantityUsesItsGenericPrefixConversion()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = $$"""
Expand All @@ -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("Temperature", "Temperature",
[
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() => 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();
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 Temperature = {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<PortableExecutableReference> references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Diagnostic> diagnostics) = Generate(source, "");
Assert.Empty(diagnostics);
Assert.Equal([16d, 3d, 1d], Evaluate(compilation, "Consumer"));
}

[Fact]
public void ProviderConversionsArePublicAndNotRegeneratedByConsumers()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(Temperature))]
[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<UnitConversionSpec> units, List<Diagnostic> 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(Temperature))]
{{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<UnitConversionSpec> units, List<Diagnostic> diagnostics) = Read(source);
Assert.Multiple(
() => Assert.Empty(units),
() => Assert.Equal("DQ0008", Assert.Single(diagnostics).Id));
}

[Fact]
public void OneDeclarationCanAssociateDistinctQuantityTypes()
{
Expand Down Expand Up @@ -114,7 +172,8 @@ private static (IReadOnlyList<UnitConversionSpec> Units, List<Diagnostic> 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 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);
Expand Down
3 changes: 2 additions & 1 deletion src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
: DimensionNameGenerator.GetQuantityName(dimension);
}
Loading
Loading