diff --git a/CLAUDE.md b/CLAUDE.md index 5166473..f12b043 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.UnitsGenerator/Units.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.UnitsGenerator/Units.g.cs index 73ae11f..b25cf38 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.UnitsGenerator/Units.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.UnitsGenerator/Units.g.cs @@ -858,7 +858,7 @@ public sealed record Radian : IUnit, IDimensionlessUnit, IAngularDisplacementUni public UnitSystem System => UnitSystem.SIDerived; /// Gets the physical dimension this unit measures. - public DimensionInfo Dimension => PhysicalDimensions.Dimensionless; + public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement; /// Gets the multiplication factor used in the to-base affine conversion. public double ToBaseFactor => 1d; @@ -885,7 +885,7 @@ public sealed record Degree : IUnit, IDimensionlessUnit, IAngularDisplacementUni public UnitSystem System => UnitSystem.Other; /// Gets the physical dimension this unit measures. - public DimensionInfo Dimension => PhysicalDimensions.Dimensionless; + public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement; /// Gets the multiplication factor used in the to-base affine conversion. public double ToBaseFactor => DegreeToRadians; @@ -1398,7 +1398,7 @@ public sealed record Gradian : IUnit, IDimensionlessUnit, IAngularDisplacementUn public UnitSystem System => UnitSystem.Other; /// Gets the physical dimension this unit measures. - public DimensionInfo Dimension => PhysicalDimensions.Dimensionless; + public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement; /// Gets the multiplication factor used in the to-base affine conversion. public double ToBaseFactor => GradianToRadians; @@ -1425,7 +1425,7 @@ public sealed record Revolution : IUnit, IDimensionlessUnit, IAngularDisplacemen public UnitSystem System => UnitSystem.Other; /// Gets the physical dimension this unit measures. - public DimensionInfo Dimension => PhysicalDimensions.Dimensionless; + public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement; /// Gets the multiplication factor used in the to-base affine conversion. public double ToBaseFactor => RevolutionToRadians; @@ -1452,7 +1452,7 @@ public sealed record Milliradian : IUnit, IDimensionlessUnit, IAngularDisplaceme public UnitSystem System => UnitSystem.SIDerived; /// Gets the physical dimension this unit measures. - public DimensionInfo Dimension => PhysicalDimensions.Dimensionless; + public DimensionInfo Dimension => PhysicalDimensions.AngularDisplacement; /// Gets the multiplication factor used in the to-base affine conversion. public double ToBaseFactor => MetricMagnitudes.Milli; diff --git a/Semantics.SourceGenerators/Generators/UnitsGenerator.cs b/Semantics.SourceGenerators/Generators/UnitsGenerator.cs index 6f6ca76..acc1ce5 100644 --- a/Semantics.SourceGenerators/Generators/UnitsGenerator.cs +++ b/Semantics.SourceGenerators/Generators/UnitsGenerator.cs @@ -71,6 +71,8 @@ private static void GenerateInner(SourceProductionContext context, UnitsMetadata }, }; + HashSet withoutExponents = BuildDimensionsWithoutExponents(dimensions); + List catalogueUnitNames = []; foreach (UnitCategory category in units.UnitCategories) @@ -79,7 +81,7 @@ private static void GenerateInner(SourceProductionContext context, UnitsMetadata { List dims = unitToDimensions.TryGetValue(unit.Name, out List? d) ? d : []; - sourceFileTemplate.Classes.Add(BuildUnitClass(unit, dims)); + sourceFileTemplate.Classes.Add(BuildUnitClass(unit, dims, withoutExponents)); catalogueUnitNames.Add(unit.Name); } } @@ -114,11 +116,48 @@ private static Dictionary> BuildUnitToDimensionsMap(Dimensi return unitToDimensions; } + /// + /// The dimensions whose dimensionalFormula is empty, which is to say the ones that + /// measure nothing. + /// + /// + /// Today that is Dimensionless alone, and the set is built rather than named because a + /// second one would otherwise have to be remembered here. + /// + private static HashSet BuildDimensionsWithoutExponents(DimensionsMetadata dimensions) => + new((dimensions.PhysicalDimensions ?? []) + .Where(static dim => dim.DimensionalFormula.Count == 0) + .Select(static dim => dim.Name)); + + /// + /// The one dimension a unit reports, out of every dimension that claims it. + /// + /// + /// + /// A unit may be claimed by several dimensions, and the marker interfaces carry all of them — + /// it is only the singular Dimension property that has to choose. Choosing the first + /// declared made that choice by file position: Dimensionless is the first entry in + /// dimensions.json, so a radian reported no exponents at all, which is the same answer + /// a unitless count gives. That is the conflation the angle axis was added to prevent, + /// and a consumer deriving a member's dimension from its unit — ktsu.Schema's C++ + /// reflection table does exactly that — could not tell an angle from a flag. + /// + /// + /// 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 Area and NuclearCrossSection, 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. + /// + /// + private static string? ReportedDimension(List dims, HashSet withoutExponents) => + dims.FirstOrDefault(dim => !withoutExponents.Contains(dim)) ?? dims.FirstOrDefault(); + /// /// Builds the sealed record for one unit, carrying its name, symbol, system, dimension, and /// the affine to-base conversion (factor plus offset). /// - private static ClassTemplate BuildUnitClass(UnitDefinition unit, List dims) + private static ClassTemplate BuildUnitClass(UnitDefinition unit, List dims, HashSet withoutExponents) { List interfaces = ["IUnit"]; foreach (string dimName in dims) @@ -130,8 +169,8 @@ private static ClassTemplate BuildUnitClass(UnitDefinition unit, List 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 diff --git a/Semantics.Test/Quantities/UnitDimensionTests.cs b/Semantics.Test/Quantities/UnitDimensionTests.cs new file mode 100644 index 0000000..010b9e5 --- /dev/null +++ b/Semantics.Test/Quantities/UnitDimensionTests.cs @@ -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; + +/// +/// Covers which dimension a unit reports when more than one claims it. +/// +/// +/// A unit's marker interfaces carry every dimension whose availableUnits names it, so the +/// singular 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 +/// Dimensionless is the first entry in dimensions.json. +/// +[TestClass] +public sealed class UnitDimensionTests +{ + /// + /// A radian is an angle, not a ratio. + /// + /// + /// The whole of what the angle 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. + /// + [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"); + } + } + + /// + /// A claim that says something beats one that says nothing, for every unit rather than the + /// five that prompted it. + /// + /// + /// Stated over the assembly because the fix is a rule rather than a list: a unit added to both + /// a real dimension and Dimensionless 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. + /// + [TestMethod] + public void AUnitClaimedTwiceReportsTheDimensionWithExponents() + { + Dictionary byName = + PhysicalDimensions.All.ToDictionary(static dimension => dimension.Name, StringComparer.Ordinal); + + int claimedTwice = 0; + + foreach (IUnit unit in EveryUnit()) + { + List 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 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; + } + } + } +} diff --git a/docs/physics-generator.md b/docs/physics-generator.md index 753c5e3..976a252 100644 --- a/docs/physics-generator.md +++ b/docs/physics-generator.md @@ -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.