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
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,18 @@ one driving it. Two layers, and both earn their place:
`AngularDisplacement`, `AngularVelocity`, `AngularAcceleration` and `AngularJerk`, and that is the
whole of it. Without it an angle is the same type as a ratio and an angular speed the same type as
a frequency; with it, 61 distinct exponent vectors become 63. It is read by the C++ projection and
carried through `DimensionInfo` on the .NET side, where nothing depends on it yet.
carried through `DimensionInfo` on the .NET side, where `ktsu.Schema` reads it off a unit to fill
the eight exponents in its C++ reflection table.

**A unit claimed by two dimensions reports the one with exponents.** `Radian`, `Degree`, `Gradian`,
`Milliradian` and `Revolution` are in `availableUnits` on both `AngularDisplacement` and
`Dimensionless`, and the marker interfaces carry both — it is the singular `IUnit.Dimension` that
has to pick one. Picking the first declared picked by file position, and `Dimensionless` is the
first entry in `dimensions.json`, so every angular unit reported no exponents at all: the same
answer a unitless count gives, which is the conflation the axis was added to prevent. A claim that
says something now beats one that says nothing. Where several say something the first still wins,
which decides the only other unit claimed twice: `SquareMeter` is `Area` and `NuclearCrossSection`,
one of the 72-over-63 collisions, so the two answers differ in name and not in exponents.

**A relationship is checked before it is emitted.** The operator is written as
`Result{ lhs.value() * rhs.value() }`, so the exponents have to agree with the declared result or it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -858,7 +858,7 @@ public sealed record Radian : IUnit, IDimensionlessUnit, IAngularDisplacementUni
public UnitSystem System => UnitSystem.SIDerived;

/// <summary>Gets the physical dimension this unit measures.</summary>
public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;

/// <summary>Gets the multiplication factor used in the to-base affine conversion.</summary>
public double ToBaseFactor => 1d;
Expand All @@ -885,7 +885,7 @@ public sealed record Degree : IUnit, IDimensionlessUnit, IAngularDisplacementUni
public UnitSystem System => UnitSystem.Other;

/// <summary>Gets the physical dimension this unit measures.</summary>
public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;

/// <summary>Gets the multiplication factor used in the to-base affine conversion.</summary>
public double ToBaseFactor => DegreeToRadians;
Expand Down Expand Up @@ -1398,7 +1398,7 @@ public sealed record Gradian : IUnit, IDimensionlessUnit, IAngularDisplacementUn
public UnitSystem System => UnitSystem.Other;

/// <summary>Gets the physical dimension this unit measures.</summary>
public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;

/// <summary>Gets the multiplication factor used in the to-base affine conversion.</summary>
public double ToBaseFactor => GradianToRadians;
Expand All @@ -1425,7 +1425,7 @@ public sealed record Revolution : IUnit, IDimensionlessUnit, IAngularDisplacemen
public UnitSystem System => UnitSystem.Other;

/// <summary>Gets the physical dimension this unit measures.</summary>
public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;

/// <summary>Gets the multiplication factor used in the to-base affine conversion.</summary>
public double ToBaseFactor => RevolutionToRadians;
Expand All @@ -1452,7 +1452,7 @@ public sealed record Milliradian : IUnit, IDimensionlessUnit, IAngularDisplaceme
public UnitSystem System => UnitSystem.SIDerived;

/// <summary>Gets the physical dimension this unit measures.</summary>
public DimensionInfo Dimension => PhysicalDimensions.Dimensionless;
public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement;

/// <summary>Gets the multiplication factor used in the to-base affine conversion.</summary>
public double ToBaseFactor => MetricMagnitudes.Milli;
Expand Down
47 changes: 43 additions & 4 deletions Semantics.SourceGenerators/Generators/UnitsGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ private static void GenerateInner(SourceProductionContext context, UnitsMetadata
},
};

HashSet<string> withoutExponents = BuildDimensionsWithoutExponents(dimensions);

List<string> catalogueUnitNames = [];

foreach (UnitCategory category in units.UnitCategories)
Expand All @@ -79,7 +81,7 @@ private static void GenerateInner(SourceProductionContext context, UnitsMetadata
{
List<string> dims = unitToDimensions.TryGetValue(unit.Name, out List<string>? d) ? d : [];

sourceFileTemplate.Classes.Add(BuildUnitClass(unit, dims));
sourceFileTemplate.Classes.Add(BuildUnitClass(unit, dims, withoutExponents));
catalogueUnitNames.Add(unit.Name);
}
}
Expand Down Expand Up @@ -114,11 +116,48 @@ private static Dictionary<string, List<string>> BuildUnitToDimensionsMap(Dimensi
return unitToDimensions;
}

/// <summary>
/// The dimensions whose <c>dimensionalFormula</c> is empty, which is to say the ones that
/// measure nothing.
/// </summary>
/// <remarks>
/// Today that is <c>Dimensionless</c> alone, and the set is built rather than named because a
/// second one would otherwise have to be remembered here.
/// </remarks>
private static HashSet<string> BuildDimensionsWithoutExponents(DimensionsMetadata dimensions) =>
new((dimensions.PhysicalDimensions ?? [])
.Where(static dim => dim.DimensionalFormula.Count == 0)
.Select(static dim => dim.Name));

/// <summary>
/// The one dimension a unit reports, out of every dimension that claims it.
/// </summary>
/// <remarks>
/// <para>
/// A unit may be claimed by several dimensions, and the marker interfaces carry all of them —
/// it is only the singular <c>Dimension</c> property that has to choose. Choosing the first
/// declared made that choice by file position: <c>Dimensionless</c> is the first entry in
/// <c>dimensions.json</c>, so a radian reported no exponents at all, which is the same answer
/// a unitless count gives. That is the conflation the <c>angle</c> axis was added to prevent,
/// and a consumer deriving a member's dimension from its unit — <c>ktsu.Schema</c>'s C++
/// reflection table does exactly that — could not tell an angle from a flag.
/// </para>
/// <para>
/// So a claim that says something is preferred to one that says nothing. Where several claims
/// say something the first still wins, which is right for the one case there is: a square metre
/// is claimed by <c>Area</c> and <c>NuclearCrossSection</c>, and those are the same exponents
/// under two names — one of the collisions the nominal layer exists for — so only the name
/// differs and neither answer is wrong.
/// </para>
/// </remarks>
private static string? ReportedDimension(List<string> dims, HashSet<string> withoutExponents) =>
dims.FirstOrDefault(dim => !withoutExponents.Contains(dim)) ?? dims.FirstOrDefault();

/// <summary>
/// Builds the sealed record for one unit, carrying its name, symbol, system, dimension, and
/// the affine to-base conversion (factor plus offset).
/// </summary>
private static ClassTemplate BuildUnitClass(UnitDefinition unit, List<string> dims)
private static ClassTemplate BuildUnitClass(UnitDefinition unit, List<string> dims, HashSet<string> withoutExponents)
{
List<string> interfaces = ["IUnit"];
foreach (string dimName in dims)
Expand All @@ -130,8 +169,8 @@ private static ClassTemplate BuildUnitClass(UnitDefinition unit, List<string> di
string offsetExpr = string.IsNullOrEmpty(unit.Offset) || unit.Offset == "0"
? "0d"
: unit.Offset;
string dimensionExpr = dims.Count > 0
? $"PhysicalDimensions.{dims[0]}"
string dimensionExpr = ReportedDimension(dims, withoutExponents) is string reported
? $"PhysicalDimensions.{reported}"
: "null!";

return new ClassTemplate
Expand Down
108 changes: 108 additions & 0 deletions Semantics.Test/Quantities/UnitDimensionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Test.Quantities;

using System;
using System.Collections.Generic;
using System.Linq;
using ktsu.Semantics.Quantities;
using ktsu.Semantics.Quantities.Units;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Covers which dimension a unit reports when more than one claims it.
/// </summary>
/// <remarks>
/// A unit's marker interfaces carry every dimension whose <c>availableUnits</c> names it, so the
/// singular <see cref="IUnit.Dimension"/> is the only place that has to choose. It used to choose
/// the first declared, which is a choice by file position rather than by meaning, and
/// <c>Dimensionless</c> is the first entry in <c>dimensions.json</c>.
/// </remarks>
[TestClass]
public sealed class UnitDimensionTests
{
/// <summary>
/// A radian is an angle, not a ratio.
/// </summary>
/// <remarks>
/// The whole of what the <c>angle</c> axis is for: without it an angle is the same thing as a
/// ratio, and a unit reporting no exponents says exactly that however the axis is spelled
/// elsewhere.
/// </remarks>
[TestMethod]
public void AnAngularUnitReportsTheAngularDimension()
{
foreach (IUnit unit in (IUnit[])[new Radian(), new Degree(), new Gradian(), new Milliradian(), new Revolution()])
{
Assert.AreEqual("AngularDisplacement", unit.Dimension.Name, $"{unit.Name} reports the wrong dimension");
Assert.AreEqual(1, unit.Dimension.DimensionalFormula["angle"], $"{unit.Name} is not one angle");
Assert.HasCount(1, unit.Dimension.DimensionalFormula, $"{unit.Name} measures something besides an angle");
}
}

/// <summary>
/// A claim that says something beats one that says nothing, for every unit rather than the
/// five that prompted it.
/// </summary>
/// <remarks>
/// Stated over the assembly because the fix is a rule rather than a list: a unit added to both
/// a real dimension and <c>Dimensionless</c> tomorrow is the same bug, and naming today's five
/// would not catch it. Walks the compiled types rather than the metadata, since what a consumer
/// reads is the property and not the JSON behind it.
/// </remarks>
[TestMethod]
public void AUnitClaimedTwiceReportsTheDimensionWithExponents()
{
Dictionary<string, DimensionInfo> byName =
PhysicalDimensions.All.ToDictionary(static dimension => dimension.Name, StringComparer.Ordinal);

int claimedTwice = 0;

foreach (IUnit unit in EveryUnit())
{
List<DimensionInfo> claims = [.. unit.GetType()
.GetInterfaces()
.Where(static marker => marker != typeof(IUnit) && marker.Name.StartsWith('I') && marker.Name.EndsWith("Unit", StringComparison.Ordinal))
.Select(marker => marker.Name[1..^"Unit".Length])
.Where(byName.ContainsKey)
.Select(name => byName[name])];

if (claims.Count < 2)
{
continue;
}

claimedTwice++;

if (claims.Exists(static claim => claim.DimensionalFormula.Count > 0))
{
Assert.IsNotEmpty(
unit.Dimension.DimensionalFormula,
$"{unit.Name} is claimed by {string.Join(", ", claims.Select(static claim => claim.Name))} and reports the one with no exponents");
}
}

Assert.IsGreaterThan(0, claimedTwice, "no unit is claimed twice, so this asserts nothing");
}

private static IEnumerable<IUnit> EveryUnit()
{
foreach (Type type in typeof(Meter).Assembly.GetTypes())
{
if (type.IsAbstract || !type.IsClass || !typeof(IUnit).IsAssignableFrom(type))
{
continue;
}

if (type.GetConstructor(Type.EmptyTypes) is null)
{
continue;
}

if (Activator.CreateInstance(type) is IUnit unit)
{
yield return unit;
}
}
}
}
1 change: 1 addition & 0 deletions docs/physics-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ type.

Adding one means adding it to `SemanticsDiagnostics` and to `AnalyzerReleases.Unshipped.md`; `AnalyzerReleaseTrackingTests` fails if the second step is forgotten. `GeneratorDiagnosticTests` proves each one still fires on the input it is meant to catch.
- `availableUnits` order matters: the first entry is treated as the SI base unit by `UnitsGenerator`.
- A unit may appear in `availableUnits` on more than one dimension. It implements an `I{Dimension}Unit` marker for each, and its singular `Dimension` property reports the first claim whose `dimensionalFormula` is non-empty — falling back to the first claim of any kind. Without that preference a unit shared with `Dimensionless`, which is the first entry in the file, could never report anything else.
- `relationships` expressions are emitted verbatim into method bodies. Use `Value` for the current quantity and `T.CreateChecked(...)` (not literal numerics) for constants so all storage types stay correct.
- Generator output is committed. CI must catch metadata/code drift; `git status` should be clean after a build.

Expand Down
Loading