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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 79 additions & 11 deletions .github/scripts/check-package.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,87 @@
from pathlib import Path
import os
import sys
import xml.etree.ElementTree as ET
import zipfile


version = sys.argv[1]
packages = list(Path("artifacts/packages").glob("*.nupkg"))
assert len(packages) == 1, packages
with zipfile.ZipFile(packages[0]) as package:
names = package.namelist()
assert "lib/netstandard2.0/DimensionalQuantities.dll" in names
assert "analyzers/dotnet/cs/DimensionalQuantities.Roslyn.dll" in names
assert "analyzers/dotnet/cs/DimensionalQuantities.Roslyn.CodeFixes.dll" in names
package_directory = Path("artifacts/packages")
core_id = "DimensionalQuantities"
metric_id = "DimensionalQuantities.Extensions.Metric"
expected_packages = {
f"{core_id}.{version}.nupkg",
f"{metric_id}.{version}.nupkg",
}
expected_symbols = {
f"{core_id}.{version}.snupkg",
f"{metric_id}.{version}.snupkg",
}
actual_packages = {package.name for package in package_directory.glob("*.nupkg")}
actual_symbols = {package.name for package in package_directory.glob("*.snupkg")}
assert actual_packages == expected_packages, (actual_packages, expected_packages)
assert actual_symbols == expected_symbols, (actual_symbols, expected_symbols)


def read_package(package_id, extension):
path = package_directory / f"{package_id}.{version}.{extension}"
with zipfile.ZipFile(path) as package:
names = set(package.namelist())
specification = ET.fromstring(package.read(f"{package_id}.nuspec"))
metadata = specification.find("{*}metadata")
assert metadata is not None
assert metadata.find("{*}id").text == package_id
assert metadata.find("{*}version").text == version
return names, metadata


core_files, core_metadata = read_package(core_id, "nupkg")
metric_files, metric_metadata = read_package(metric_id, "nupkg")
core_symbols, _ = read_package(core_id, "snupkg")
metric_symbols, _ = read_package(metric_id, "snupkg")

core_runtime = "lib/netstandard2.0/DimensionalQuantities.dll"
metric_runtime = "lib/netstandard2.0/DimensionalQuantities.Extensions.Metric.dll"
analyzer_directory = "analyzers/dotnet/cs/"
tooling = {
f"{analyzer_directory}DimensionalQuantities.dll",
f"{analyzer_directory}DimensionalQuantities.Roslyn.dll",
f"{analyzer_directory}DimensionalQuantities.Roslyn.CodeFixes.dll",
}
assert core_runtime in core_files
assert tooling <= core_files, tooling - core_files
assert metric_runtime in metric_files
assert core_runtime not in metric_files
assert not any(name.startswith(analyzer_directory) for name in metric_files)

for names in (core_files, metric_files):
assert not any("Microsoft.CodeAnalysis" in name for name in names)
specification = ET.fromstring(package.read("DimensionalQuantities.nuspec"))
assert specification.find(".//{*}version").text == version
dependencies = specification.findall(".//{*}dependency")
assert not any(".Tests" in name for name in names)

for metadata in (core_metadata, metric_metadata):
repository = metadata.find("{*}repository")
assert repository is not None
assert repository.attrib["type"] == "git"
assert repository.attrib["url"].removesuffix(".git") == "https://github.com/GoSimulate/DimensionalQuantities"
commit = repository.attrib["commit"]
assert len(commit) == 40 and all(character in "0123456789abcdef" for character in commit)
if "GITHUB_SHA" in os.environ:
assert commit == os.environ["GITHUB_SHA"], (commit, os.environ["GITHUB_SHA"])
dependencies = metadata.findall(".//{*}dependency")
assert not any("Roslyn" in item.attrib["id"] or "CodeAnalysis" in item.attrib["id"] for item in dependencies)
print(f"Verified {packages[0]}: runtime plus compiler tooling, version {version}")

core_dependencies = core_metadata.findall(".//{*}dependency")
assert not any(item.attrib["id"] == metric_id for item in core_dependencies)
metric_dependencies = metric_metadata.findall(".//{*}dependency")
core_dependency = [item for item in metric_dependencies if item.attrib["id"] == core_id]
assert len(core_dependency) == 1, core_dependency
assert core_dependency[0].attrib["version"] == f"[{version}]", core_dependency[0].attrib
assert core_dependency[0].attrib.get("include", "All").lower() == "all", core_dependency[0].attrib
assert "analyzers" not in core_dependency[0].attrib.get("exclude", "").lower(), core_dependency[0].attrib

assert "lib/netstandard2.0/DimensionalQuantities.pdb" in core_symbols
assert "lib/netstandard2.0/DimensionalQuantities.Extensions.Metric.pdb" in metric_symbols
for assembly in tooling:
assert assembly.removesuffix(".dll") + ".pdb" in core_symbols

print(f"Verified {core_id} and {metric_id} packages and symbols at {version}")
11 changes: 6 additions & 5 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- 'src/**'
- '!src/*Tests/**'
- '.github/workflows/cd.yml'
- '.github/scripts/check-package.py'
workflow_dispatch:
permissions:
contents: read
Expand Down Expand Up @@ -61,10 +62,10 @@ jobs:
run: dotnet test --solution src/DimensionalQuantities.slnx -c Release --no-build --no-restore --report-trx --results-directory artifacts/test-results
- name: Pack
run: >-
dotnet pack src/DimensionalQuantities/DimensionalQuantities.csproj
-c Release --no-build -o artifacts/packages
dotnet pack src/DimensionalQuantities.slnx
-c Release --no-build --no-restore -o artifacts/packages
-p:Version="$VERSION" -p:ContinuousIntegrationBuild=true -p:RepositoryUrl="$REPOSITORY_URL"
- name: Check NuGet package
- name: Check NuGet packages
run: python3 .github/scripts/check-package.py "$VERSION"
- name: Generate release notes
id: notes
Expand Down Expand Up @@ -96,15 +97,15 @@ jobs:
run: >-
dotnet nuget push 'artifacts/packages/*.nupkg'
--source "$PACKAGE_SOURCE"
--api-key "$GITHUB_TOKEN" --skip-duplicate
--api-key "$GITHUB_TOKEN" --skip-duplicate --no-symbols
- name: Create GitHub release
uses: ncipollo/release-action@v1.21.0
with:
token: ${{ github.token }}
tag: ${{ needs.version.outputs.next }}
commit: ${{ github.sha }}
body: ${{ steps.notes.outputs.changes }}
artifacts: artifacts/packages/*.nupkg
artifacts: artifacts/packages/*.nupkg,artifacts/packages/*.snupkg
artifactErrorsFailBuild: true
makeLatest: true
- uses: actions/upload-artifact@v7.0.2
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ jobs:
run: dotnet test --solution src/DimensionalQuantities.slnx -c Release --no-build --no-restore --report-trx --results-directory artifacts/test-results
- name: Pack
run: >-
dotnet pack src/DimensionalQuantities/DimensionalQuantities.csproj
-c Release --no-build -o artifacts/packages
dotnet pack src/DimensionalQuantities.slnx
-c Release --no-build --no-restore -o artifacts/packages
-p:Version="$VERSION" -p:ContinuousIntegrationBuild=true -p:RepositoryUrl="$REPOSITORY_URL"
- name: Check NuGet package
- name: Check NuGet packages
run: python3 .github/scripts/check-package.py "$VERSION"
- uses: actions/upload-artifact@v7.0.2
if: always()
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ A C# library for dimensional quantities, with generated type aliases, SI unit co

The runtime library targets .NET Standard 2.0. Consumers need a C# 14 compiler for the generated extension blocks. The `DimensionalQuantities` NuGet package includes the runtime library, source generator, analysers and an alias quick fix.

Core includes the seven SI base quantities' conversion helpers. Add `DimensionalQuantities.Extensions.Metric` for catalogue conversions such as volts, watts and pascals, and import its namespace:

```csharp
using DimensionalQuantities.Extensions.Metric;
```

Metric depends on the matching core version, so referencing Metric also supplies core and its compiler tooling. Quantity names and dimensional arithmetic are available with core alone.

```csharp
Voltage voltage = Voltage.FromVolts(12);
Current current = Current.FromAmps(3);
Expand Down Expand Up @@ -162,13 +170,53 @@ The analysers and generator report dimensional mistakes and problems with genera
| DQ0005 | Error | A closed type-system encoding is invalid. |
| DQ0006 | Warning | A generated alias conflicts with a declared name or shadows an imported type. |
| DQ0007 | Error | An unresolved English exponent in `Power<Number...>.Of(...)` cannot be parsed, for example `NumberTwoOverZero`. |
| DQ0008 | Error | A unit provider declaration is invalid, for example a missing unit symbol or a zero power denominator. |

DQ0003 has an IDE quick fix to replace the raw type with its readable alias, such as `Length`. The fix is offered only when the alias resolves to the same type; it is withheld inside `nameof`, where replacing the type could change the resulting string.

Alias collision warnings do not suppress or rename generated aliases. Use qualified names or distinct explicit aliases to disambiguate your own types.

### Defining an extension

An extension project references core and declares its units using attributes. The generator compiles public conversion helpers into that extension's assembly; consumers reference the extension and import its namespace.

```csharp
using DimensionalQuantities.Units.Declarations;

namespace Example.Units;

[UnitProvider]
internal static class CustomUnits
{
[QuantityUnit(typeof(Length))]
[UnitFactor(typeof(CustomSymbols.Foot))]
private sealed class LengthUnit;

[QuantityUnit(typeof(Pressure))]
[QuantityUnit(typeof(Stress))]
[QuantityUnit(typeof(YoungsModulus))]
[UnitFactor(typeof(CustomSymbols.Pascal))]
private sealed class PascalUnit;
}

internal static class CustomSymbols
{
[UnitSymbol("Foot", "Feet", "ft", Scale = 0.3048)]
internal sealed class Foot;

[UnitSymbol("Pascal", "Pascals", "Pa")]
internal sealed class Pascal;
}
```

Consumers can then use `Length.FromFeet(6)` and `length.AsFeet()` with `using Example.Units;`. The Pascal declaration associates the same unit with Pressure, Stress and Young’s modulus; their shared underlying type receives one set of conversion helpers.

`Scale` converts each atomic unit to SI. Ordered `UnitFactor` attributes describe compound units, with `Power`, `PowerDenominator` and `PrefixExponent` controlling each factor. An optional `[UnitConversion(Offset = ...)]` on the unit declaration supplies an offset added after scaling into SI and subtracted before converting back; it applies to every associated quantity. A provider can opt into named SI prefixes and parameterised prefix conversions with `GenerateSiPrefixes` and `GeneratePrefixConversions`; both default to false. `AdditionalUnit` references another factor declaration for an alternative conversion.

### Current scope

When upgrading from the combined package, add the Metric package and namespace wherever you use catalogue conversions, including their `FromPrefixedUnits` and `AsPrefixedUnits` helpers. Their names and conversion behaviour are unchanged. Core-only consumers can use `FromSiBaseUnit`, `AsSiBaseUnit`, `FromArbitrarySiComponents` and `AsArbitrarySiComponents` for every quantity.

Discovery uses the types and operations that the compiler can resolve as generated quantities become available. It supports async methods and iterators with quantity return or element types, but does not infer a new variable type from arbitrary reassignment chains.

The library is under active development; the API and supported expression patterns are still evolving.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="4.0.2" />
<PackageReference Include="Microsoft.Testing.Extensions.TrxReport" Version="2.5.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj" />
<ProjectReference Include="../DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<Analyzer Include="../DimensionalQuantities/bin/$(Configuration)/netstandard2.0/DimensionalQuantities.dll" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using System.Reflection;
using DimensionalQuantities.Extensions.Metric;
using Xunit;

namespace DimensionalQuantities.Extensions.Metric.Tests;

public sealed class MetricConversionsTests
{
[Fact]
public void CatalogueFactoriesConvertToExpectedSiValues()
{
double[] result =
[
Area.FromSquareMetres(1).AsSiBaseUnit(),
Volume.FromCubicMetres(1).AsSiBaseUnit(),
Frequency.FromHertz(1).AsSiBaseUnit(),
AngularVelocity.FromRadiansPerSecond(1).AsSiBaseUnit(),
Density.FromGramsPerCubicMetre(1).AsSiBaseUnit(),
Pressure.FromPascals(1).AsSiBaseUnit(),
Energy.FromJoules(1).AsSiBaseUnit(),
Torque.FromNewtonMetres(1).AsSiBaseUnit(),
Power.FromWatts(1).AsSiBaseUnit(),
SpecificPower.FromWattsPerKilogram(1).AsSiBaseUnit(),
TemperatureCoefficient.FromInverseKelvin(1).AsSiBaseUnit(),
PowerDensity.FromWattsPerCubicMetre(1).AsSiBaseUnit(),
Voltage.FromVolts(1).AsSiBaseUnit(),
Resistance.FromOhms(1).AsSiBaseUnit(),
Inductance.FromHenries(1).AsSiBaseUnit(),
MagneticFlux.FromWebers(1).AsSiBaseUnit(),
MagneticFluxDensity.FromTeslas(1).AsSiBaseUnit(),
MagneticFieldStrength.FromAmpsPerMetre(1).AsSiBaseUnit(),
CurrentDensity.FromAmpsPerSquareMetre(1).AsSiBaseUnit(),
Permeability.FromHenriesPerMetre(1).AsSiBaseUnit(),
Resistivity.FromOhmMetres(1).AsSiBaseUnit(),
Conductivity.FromSiemensPerMetre(1).AsSiBaseUnit(),
MagneticReluctivity.FromMetresPerHenry(1).AsSiBaseUnit(),
MagneticVectorPotential.FromWebersPerMetre(1).AsSiBaseUnit(),
];

double[] expected = [1d, 1d, 1d, 1d, 0.001d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d];
Assert.Equal(expected, result);
}

[Fact]
public void CatalogueAccessorsConvertFromKnownSiValues()
{
double[] result =
[
Area.FromSiBaseUnit(1).AsSquareMetres(),
Volume.FromSiBaseUnit(1).AsCubicMetres(),
Frequency.FromSiBaseUnit(1).AsHertz(),
AngularVelocity.FromSiBaseUnit(1).AsRadiansPerSecond(),
Density.FromSiBaseUnit(1).AsGramsPerCubicMetre(),
Pressure.FromSiBaseUnit(1).AsPascals(),
Energy.FromSiBaseUnit(1).AsJoules(),
Torque.FromSiBaseUnit(1).AsNewtonMetres(),
Power.FromSiBaseUnit(1).AsWatts(),
SpecificPower.FromSiBaseUnit(1).AsWattsPerKilogram(),
TemperatureCoefficient.FromSiBaseUnit(1).AsInverseKelvin(),
PowerDensity.FromSiBaseUnit(1).AsWattsPerCubicMetre(),
Voltage.FromSiBaseUnit(1).AsVolts(),
Resistance.FromSiBaseUnit(1).AsOhms(),
Inductance.FromSiBaseUnit(1).AsHenries(),
MagneticFlux.FromSiBaseUnit(1).AsWebers(),
MagneticFluxDensity.FromSiBaseUnit(1).AsTeslas(),
MagneticFieldStrength.FromSiBaseUnit(1).AsAmpsPerMetre(),
CurrentDensity.FromSiBaseUnit(1).AsAmpsPerSquareMetre(),
Permeability.FromSiBaseUnit(1).AsHenriesPerMetre(),
Resistivity.FromSiBaseUnit(1).AsOhmMetres(),
Conductivity.FromSiBaseUnit(1).AsSiemensPerMetre(),
MagneticReluctivity.FromSiBaseUnit(1).AsMetresPerHenry(),
MagneticVectorPotential.FromSiBaseUnit(1).AsWebersPerMetre(),
];

double[] expected = [1d, 1d, 1d, 1d, 1000d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d];
Assert.Equal(expected, result);
}

[Fact]
public void AliasesShareNamedConversionsWithoutDuplicateHelpers()
{
Pressure pressure = Pressure.FromPascals(1);
Stress stress = Stress.FromPascals(1);
YoungsModulus modulus = YoungsModulus.FromKiloPascals(1);
double[] result =
[
pressure.AsPascals(), stress.AsPascals(), modulus.AsKiloPascals(),
SpecificPower.FromWattsPerKilogram(1).AsWattsPerKilogram(),
SpecificPowerLoss.FromWattsPerKilogram(1).AsWattsPerKilogram(),
PowerDensity.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre(),
VolumetricPowerLoss.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre(),
MagneticFlux.FromWebers(1).AsWebers(), FluxLinkage.FromWebers(1).AsWebers(),
MagneticFluxDensity.FromTeslas(1).AsTeslas(), MagneticPolarisation.FromTeslas(1).AsTeslas(),
RemanentFluxDensity.FromTeslas(1).AsTeslas(),
MagneticFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre(),
CoerciveFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre()
];
Assert.Equal(Enumerable.Repeat(1d, result.Length), result);
}

[Fact]
public void NamedConversionsApplyMetricScale()
{
Assert.Multiple(
() => Assert.Equal(0.0025, Voltage.FromSiBaseUnit(2.5).AsKiloVolts()),
() => Assert.Equal(1000, Density.FromGramsPerCubicCentiMetre(1).AsSiBaseUnit()));
}

[Fact]
public void GenericPrefixConversionsApplyMetricScale()
{
Assert.Multiple(
() => Assert.Equal(0.002, Voltage.FromPrefixedUnits(2, -3).AsSiBaseUnit()),
() => Assert.Equal(1000000, CurrentDensity.FromPrefixedUnits(1, ampPrefixExponent: 0, metrePrefixExponent: -3).AsSiBaseUnit()),
() => Assert.Equal(1000, SpecificPower.FromPrefixedUnits(1, wattPrefixExponent: 3, gramPrefixExponent: 3).AsSiBaseUnit()),
() => Assert.Equal(1000, TemperatureCoefficient.FromPrefixedUnits(1, kelvinPrefixExponent: -3).AsSiBaseUnit()),
() => Assert.Equal(2, Torque.FromPrefixedUnits(2).AsSiBaseUnit()),
() => Assert.Equal(2, Voltage.FromSiBaseUnit(0.002).AsPrefixedUnits(-3)),
() => Assert.Equal(1, CurrentDensity.FromSiBaseUnit(1000000).AsPrefixedUnits(ampPrefixExponent: 0, metrePrefixExponent: -3)),
() => Assert.Equal(2, Torque.FromSiBaseUnit(2).AsPrefixedUnits()));
}

[Fact]
public void PrefixesOnlyApplyToSupportedFactorPlacements()
{
string[] names = Assembly.Load("DimensionalQuantities.Extensions.Metric").GetExportedTypes()
.Where(type => type.Namespace == "DimensionalQuantities.Extensions.Metric")
.SelectMany(type => type.GetMethods()).Select(method => method.Name).ToArray();
Assert.Multiple(
() => Assert.DoesNotContain("FromInverseMilliKelvin", names),
() => Assert.DoesNotContain("AsInverseMilliKelvin", names),
() => Assert.DoesNotContain("FromMilliAmpsPerSquareMilliMetre", names),
() => Assert.DoesNotContain("FromKiloGramsPerCubicCentiMetre", names));
}
}
Loading
Loading