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
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
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/.gitignore b/.gitignore
index e50800c..0c24bdf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ TestResults/
*.snupkg
artifacts/
node_modules/
+HANDOFF*.md
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.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj b/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj
index 36e211d..e942021 100644
--- a/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj
+++ b/src/DimensionalQuantities.IntegrationTests/DimensionalQuantities.IntegrationTests.csproj
@@ -12,9 +12,12 @@
-
+
+
-
+
+
+
diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/MsbuildCollection.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/MsbuildCollection.cs
new file mode 100644
index 0000000..b362707
--- /dev/null
+++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/MsbuildCollection.cs
@@ -0,0 +1,6 @@
+using Xunit;
+
+namespace DimensionalQuantities.IntegrationTests.GeneratedQuantityTests;
+
+[CollectionDefinition("MSBuild", DisableParallelization = true)]
+public sealed class MsbuildCollection;
diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs
new file mode 100644
index 0000000..defcdd0
--- /dev/null
+++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs
@@ -0,0 +1,97 @@
+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.GeneratedQuantityTests;
+
+[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 PackagedGeneratorConvertsVoltageInConsumer()
+ {
+ 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"), """
+ namespace ConsumerProject;
+
+ public static class Consumer
+ {
+ public static double ConvertVoltage(double volts)
+ {
+ Voltage voltage = Voltage.FromVolts(volts);
+ return voltage.AsVolts();
+ }
+ }
+ """);
+ 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.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 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);
+ }
+
+ 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.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj b/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj
index b7b9602..663e063 100644
--- a/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj
+++ b/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj
@@ -11,9 +11,14 @@
+
+
+
+
+
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
new file mode 100644
index 0000000..8598a48
--- /dev/null
+++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs
@@ -0,0 +1,158 @@
+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 partial class QuantityGeneratorTests
+{
+ private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.CSharp14);
+
+ [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 SemanticPipelineResolvesPostInitialisationQuantityAliasesAndFactories()
+ {
+ 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]
+ 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);
+ }
+
+ 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, IIncrementalGenerator? additionalGenerator = null)
+ {
+ 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();
+ Assert.Multiple(
+ () => Assert.True(generatorDiagnostics.IsEmpty, string.Join(Environment.NewLine, generatorDiagnostics)),
+ () => 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/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/DimensionalQuantities.Roslyn.csproj b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj
index f43c504..4b95c43 100644
--- a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj
+++ b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj
@@ -1,11 +1,15 @@
- netstandard2.0
+ net10.0
true
true
false
+
+
+
+
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/.gitkeep b/src/DimensionalQuantities.Roslyn/Generators/.gitkeep
deleted file mode 100644
index e69de29..0000000
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
new file mode 100644
index 0000000..bd7a43d
--- /dev/null
+++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs
@@ -0,0 +1,69 @@
+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 QuantityGenerationPlan Discover(IEnumerable operations)
+ {
+ List knownQuantities = [];
+ List additionalQuantities = [];
+ HashSet dimensions = [];
+ HashSet<(ArithmeticOperationKind Kind, Dimension Left, Dimension Right)> seenOperations = [];
+ List orderedOperations = [];
+ 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)
+ {
+ knownQuantities.Add(new(dimension, [dimension.GetComponents().Single().Unit], true));
+ dimensions.Add(dimension);
+ }
+
+ foreach (KeyValuePair entry in EngineeringCatalogue.Entries)
+ {
+ knownQuantities.Add(new(entry.Key, [.. entry.Value], true));
+ dimensions.Add(entry.Key);
+ }
+
+ 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
new file mode 100644
index 0000000..b1ad3bd
--- /dev/null
+++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs
@@ -0,0 +1,52 @@
+using System.Collections.Immutable;
+using System.Text;
+using DimensionalQuantities.Roslyn.Discovery;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace DimensionalQuantities.Roslyn.Generators;
+
+[Generator]
+public sealed class QuantityGenerator : IIncrementalGenerator
+{
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ context.RegisterPostInitializationOutput(static output =>
+ {
+ 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
new file mode 100644
index 0000000..c26b4a0
--- /dev/null
+++ b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs
@@ -0,0 +1,169 @@
+using System.Globalization;
+using DimensionalQuantities.Dimensions;
+using DimensionalQuantities.Naming;
+using DimensionalQuantities.Numerics;
+using DimensionalQuantities.Roslyn.Discovery;
+using DimensionalQuantities.TypeSystem;
+using DimensionalQuantities.Units;
+
+namespace DimensionalQuantities.Roslyn.Generators;
+
+internal static class QuantitySourceEmitter
+{
+ public static string Emit(IReadOnlyList quantities)
+ {
+ int[] prefixes = SiPrefixes.Entries.Keys.OrderByDescending(exponent => exponent).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));
+
+ return $"""
+ //
+ {aliases}
+ global using DimensionalQuantities.Generated;
+
+ namespace DimensionalQuantities.Generated;
+
+ {extensions}
+ """;
+ }
+
+ 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 = 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(IReadOnlyList 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 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 PrefixParameterDefinition[] GetTermParameters(UnitExpression expression) => expression.Terms
+ .Select(term => new PrefixParameterDefinition(GetPrefixParameterName(term), term.PrefixExponent)).ToArray();
+
+ private static string GetTermScaleExponent(UnitExpression expression) => string.Join(" + ", expression.Terms.Select(FormatTermScaleExponent));
+
+ private static string FormatTermScaleExponent(UnitTerm term)
+ {
+ string prefix = $"((double){GetPrefixParameterName(term)}";
+ if (term.Unit.SiBasePrefixExponent != 0)
+ {
+ prefix += " - " + term.Unit.SiBasePrefixExponent.ToString(CultureInfo.InvariantCulture);
+ }
+
+ prefix += ")";
+ if (term.Power == Rational.One)
+ {
+ 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 string RenderQuantity(QuantityDefinition definition)
+ {
+ 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)));
+
+ return $$"""
+ internal static class {{definition.Name}}Extensions
+ {
+ extension({{definition.Name}})
+ {
+ {{factories}}
+ }
+
+ extension({{definition.Name}} {{variableName}})
+ {
+ {{accessors}}
+ }
+ }
+ """;
+ }
+
+ private static string RenderFactory(string quantityName, ConversionDefinition conversion)
+ {
+ 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});";
+ }
+
+ 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";
+
+ 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;
+ }
+
+ 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);
+}
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/Dimensions/DimensionTests.cs b/src/DimensionalQuantities.Tests/Dimensions/DimensionTests.cs
new file mode 100644
index 0000000..e68faf8
--- /dev/null
+++ b/src/DimensionalQuantities.Tests/Dimensions/DimensionTests.cs
@@ -0,0 +1,230 @@
+using DimensionalQuantities.Numerics;
+using DimensionalQuantities.Dimensions;
+using DimensionalQuantities.Units;
+using Xunit;
+
+namespace DimensionalQuantities.Tests.Dimensions;
+
+public sealed class DimensionTests
+{
+ [Fact]
+ public void ComponentsPairNonzeroExponentsWithTheirNamesInSiOrder()
+ {
+ Dimension original = new(1, -2, new(3, 2), 4, 0, -6, 7);
+ IEnumerable result = original.GetComponents();
+ DimensionComponent[] expected =
+ [
+ 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);
+ }
+
+ [Fact]
+ public void DimensionlessHasNoComponents()
+ {
+ Dimension original = default;
+ IEnumerable result = original.GetComponents();
+
+ Assert.Empty(result);
+ }
+
+ [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.Tests/Naming/DimensionNameGeneratorTests.cs b/src/DimensionalQuantities.Tests/Naming/DimensionNameGeneratorTests.cs
new file mode 100644
index 0000000..cac1559
--- /dev/null
+++ b/src/DimensionalQuantities.Tests/Naming/DimensionNameGeneratorTests.cs
@@ -0,0 +1,232 @@
+using DimensionalQuantities.Dimensions;
+using DimensionalQuantities.Naming;
+using Xunit;
+
+namespace DimensionalQuantities.Tests.Naming;
+
+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/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.Tests/TypeSystem/QuantityTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs
new file mode 100644
index 0000000..fd27a52
--- /dev/null
+++ b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs
@@ -0,0 +1,146 @@
+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;
+
+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)]
+ [InlineData(1234.5)]
+ public void ConstructionPreservesSiMagnitude(double expected)
+ {
+ Current original = Current.FromSiBaseUnit(expected);
+ double result = original.AsSiBaseUnit();
+
+ 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()
+ {
+ 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.Tests/Units/UnitExpressionTests.cs b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs
new file mode 100644
index 0000000..74b66e5
--- /dev/null
+++ b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs
@@ -0,0 +1,265 @@
+using DimensionalQuantities.Units;
+using DimensionalQuantities.Numerics;
+using Xunit;
+
+namespace DimensionalQuantities.Tests.Units;
+
+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);
+ }
+
+ [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()
+ {
+ 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);
+ }
+
+ [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()
+ {
+ 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));
+ 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);
+ }
+
+ [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));
+ Rational 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));
+ Rational 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));
+ Rational currentDensityResult = ampPerSquareMillimetre.GetSiScaleExponent();
+ Rational 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));
+ Rational result = original.GetSiScaleExponent();
+ Rational expected = Rational.Zero;
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void GetSiScaleExponentIncludesKilowattPrefix()
+ {
+ 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);
+ }
+
+ [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.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/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj
index 944a90d..b80e131 100644
--- a/src/DimensionalQuantities/DimensionalQuantities.csproj
+++ b/src/DimensionalQuantities/DimensionalQuantities.csproj
@@ -1,13 +1,43 @@
net10.0
+ GoSim
+ MIT
+ README.md
+ https://github.com/GoSimulate/DimensionalQuantities
Compile-time dimensional quantities with generated aliases and arithmetic.
git
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rational.cs
+
+
+ Rational.cs
+
diff --git a/src/DimensionalQuantities/Dimensions/Dimension.cs b/src/DimensionalQuantities/Dimensions/Dimension.cs
new file mode 100644
index 0000000..8ca3f86
--- /dev/null
+++ b/src/DimensionalQuantities/Dimensions/Dimension.cs
@@ -0,0 +1,99 @@
+using DimensionalQuantities.Numerics;
+using DimensionalQuantities.Units;
+
+namespace DimensionalQuantities.Dimensions;
+
+/// 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)
+{
+ 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);
+
+ /// Pairs nonzero SI exponents with their quantity and unit names, in canonical SI axis order.
+ public IEnumerable GetComponents()
+ {
+ if (Length != Rational.Zero)
+ {
+ yield return new(Length, _lengthUnit);
+ }
+
+ if (Mass != Rational.Zero)
+ {
+ yield return new(Mass, _massUnit);
+ }
+
+ if (Time != Rational.Zero)
+ {
+ yield return new(Time, _timeUnit);
+ }
+
+ if (ElectricCurrent != Rational.Zero)
+ {
+ yield return new(ElectricCurrent, _electricCurrentUnit);
+ }
+
+ if (Temperature != Rational.Zero)
+ {
+ yield return new(Temperature, _temperatureUnit);
+ }
+
+ if (AmountOfSubstance != Rational.Zero)
+ {
+ yield return new(AmountOfSubstance, _amountOfSubstanceUnit);
+ }
+
+ if (LuminousIntensity != Rational.Zero)
+ {
+ yield return new(LuminousIntensity, _luminousIntensityUnit);
+ }
+ }
+
+ /// 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)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(degree);
+
+ return Pow(new(1, degree));
+ }
+}
diff --git a/src/DimensionalQuantities/Dimensions/DimensionComponent.cs b/src/DimensionalQuantities/Dimensions/DimensionComponent.cs
new file mode 100644
index 0000000..a544957
--- /dev/null
+++ b/src/DimensionalQuantities/Dimensions/DimensionComponent.cs
@@ -0,0 +1,6 @@
+using DimensionalQuantities.Numerics;
+using DimensionalQuantities.Units;
+
+namespace DimensionalQuantities.Dimensions;
+
+internal readonly record struct DimensionComponent(Rational Exponent, UnitDefinition Unit);
diff --git a/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs b/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs
new file mode 100644
index 0000000..906b53e
--- /dev/null
+++ b/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs
@@ -0,0 +1,102 @@
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Numerics;
+using System.Text;
+using DimensionalQuantities.Dimensions;
+using DimensionalQuantities.Numerics;
+
+namespace DimensionalQuantities.Naming;
+
+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.Expression.GetName());
+
+ 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 components)
+ {
+ expression.Append(component.Unit.Expression.GetSymbol());
+ if (component.Exponent != Rational.One)
+ {
+ expression.Append("").Append(component.Exponent.ToString(null, CultureInfo.InvariantCulture)).Append("");
+ }
+ }
+
+ 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 components)
+ {
+ Rational exponent = component.Exponent;
+ if (exponent.Numerator.Sign > 0)
+ {
+ AppendFactor(numerator, getFactorName(component), exponent);
+ }
+ else
+ {
+ AppendFactor(denominator, getFactorName(component), -exponent);
+ }
+ }
+
+ if (!hasNumerator)
+ {
+ return "Inverse" + denominator;
+ }
+
+ return hasDenominator ? numerator + "Per" + denominator : numerator.ToString();
+ }
+
+ 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));
+ }
+ }
+}
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.")
+ {
+ }
+}
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/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs
new file mode 100644
index 0000000..0428dcb
--- /dev/null
+++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs
@@ -0,0 +1,90 @@
+using DimensionalQuantities.Numerics;
+using DimensionalQuantities.Units;
+using JetBrains.Annotations;
+using MathematicalDimension = DimensionalQuantities.Dimensions.Dimension;
+
+namespace DimensionalQuantities.TypeSystem;
+
+/// 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
+{
+ 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);
+
+ /// 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;
+ }
+
+ internal double ValueInSiBaseUnit { get; }
+
+ /// Returns the magnitude in coherent SI base units for its dimension.
+ public double AsSiBaseUnit() => ValueInSiBaseUnit;
+
+ /// Returns the magnitude in SI base components with the selected decimal prefixes.
+ public double AsArbitrarySiComponents(
+ int lengthPrefixExponent = SiBasePrefixExponents.Length,
+ int massPrefixExponent = SiBasePrefixExponents.Mass,
+ int timePrefixExponent = SiBasePrefixExponents.Time,
+ int electricCurrentPrefixExponent = SiBasePrefixExponents.ElectricCurrent,
+ int temperaturePrefixExponent = SiBasePrefixExponents.Temperature,
+ int amountOfSubstancePrefixExponent = SiBasePrefixExponents.AmountOfSubstance,
+ int luminousIntensityPrefixExponent = SiBasePrefixExponents.LuminousIntensity,
+ double overallScaleExponent = 0)
+ {
+ double exponent = GetScaleExponent(lengthPrefixExponent, massPrefixExponent, timePrefixExponent, electricCurrentPrefixExponent,
+ temperaturePrefixExponent, amountOfSubstancePrefixExponent, luminousIntensityPrefixExponent, overallScaleExponent);
+ return ValueInSiBaseUnit / Math.Pow(10, exponent);
+ }
+
+ private static double GetScaleExponent(int length, int mass, int time, int electricCurrent, int temperature, int amountOfSubstance, int luminousIntensity, double overall)
+ {
+ MathematicalDimension dimension = _dimension.Value;
+ 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/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;
diff --git a/src/DimensionalQuantities/Units/AtomicUnit.cs b/src/DimensionalQuantities/Units/AtomicUnit.cs
new file mode 100644
index 0000000..25ce1d6
--- /dev/null
+++ b/src/DimensionalQuantities/Units/AtomicUnit.cs
@@ -0,0 +1,3 @@
+namespace DimensionalQuantities.Units;
+
+internal readonly record struct AtomicUnit(string NameSingular, string NamePlural, string Symbol, int SiBasePrefixExponent = 0);
diff --git a/src/DimensionalQuantities/Units/AtomicUnits.cs b/src/DimensionalQuantities/Units/AtomicUnits.cs
new file mode 100644
index 0000000..f114a19
--- /dev/null
+++ b/src/DimensionalQuantities/Units/AtomicUnits.cs
@@ -0,0 +1,24 @@
+namespace DimensionalQuantities.Units;
+
+internal static class AtomicUnits
+{
+ 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");
+ 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/Units/EngineeringCatalogue.cs b/src/DimensionalQuantities/Units/EngineeringCatalogue.cs
new file mode 100644
index 0000000..5133e39
--- /dev/null
+++ b/src/DimensionalQuantities/Units/EngineeringCatalogue.cs
@@ -0,0 +1,53 @@
+using DimensionalQuantities.Dimensions;
+
+namespace DimensionalQuantities.Units;
+
+/// 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 _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));
+
+ public static readonly Dictionary Entries = new()
+ {
+ { 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: -1, Mass: 1, Time: -2),
+ [
+ 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("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, [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)])] },
+ { 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/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;
+}
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..ee7e7ed
--- /dev/null
+++ b/src/DimensionalQuantities/Units/SiPrefixes.cs
@@ -0,0 +1,57 @@
+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()
+ {
+ [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")
+ };
+}
diff --git a/src/DimensionalQuantities/Units/UnitDefinition.cs b/src/DimensionalQuantities/Units/UnitDefinition.cs
new file mode 100644
index 0000000..b8d7454
--- /dev/null
+++ b/src/DimensionalQuantities/Units/UnitDefinition.cs
@@ -0,0 +1,17 @@
+namespace DimensionalQuantities.Units;
+
+internal readonly record struct UnitDefinition
+{
+ public UnitDefinition(string quantityName, UnitExpression expression, UnitExpression[]? additionalConversions = null)
+ {
+ QuantityName = quantityName;
+ Expression = expression;
+ AdditionalConversions = additionalConversions ?? [];
+ }
+
+ public UnitDefinition(string quantityName, UnitTerm[] terms) : this(quantityName, new UnitExpression(terms)) { }
+
+ public string QuantityName { get; }
+ public UnitExpression Expression { get; }
+ public UnitExpression[] AdditionalConversions { get; }
+}
diff --git a/src/DimensionalQuantities/Units/UnitExpression.cs b/src/DimensionalQuantities/Units/UnitExpression.cs
new file mode 100644
index 0000000..7852a20
--- /dev/null
+++ b/src/DimensionalQuantities/Units/UnitExpression.cs
@@ -0,0 +1,172 @@
+using System.Globalization;
+using System.Numerics;
+using System.Text;
+using DimensionalQuantities.Numerics;
+
+namespace DimensionalQuantities.Units;
+
+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 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 > 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 > 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);
+ }
+
+ 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 > Rational.Zero);
+ bool hasDenominator = Terms.Any(term => term.Power < Rational.Zero);
+ foreach (UnitTerm term in Terms)
+ {
+ 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 (Rational.Abs(term.Power) != Rational.One || !hasNumerator)
+ {
+ AppendSuperscript(destination, hasNumerator ? Rational.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 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)
+ {
+ 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, Rational exponent)
+ {
+ const string digits = "⁰¹²³⁴⁵⁶⁷⁸⁹";
+ if (exponent < Rational.Zero)
+ {
+ symbol.Append('⁻');
+ }
+
+ 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/Units/UnitTerm.cs b/src/DimensionalQuantities/Units/UnitTerm.cs
new file mode 100644
index 0000000..be48d08
--- /dev/null
+++ b/src/DimensionalQuantities/Units/UnitTerm.cs
@@ -0,0 +1,17 @@
+using DimensionalQuantities.Numerics;
+
+namespace DimensionalQuantities.Units;
+
+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; }
+}