From 0ba5cb576614d3f075776fae7b135d34d1b01284 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 12:42:09 +0000 Subject: [PATCH] [patch] Scale Milliliter from the litre, not from cubic metres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milliliter declared only a Milli magnitude, which the generator applies to the dimension's base unit. For Volume that is m³, so 1 mL was 1e-3 m³ (one litre). Give it the LiterToCubicMeters factor as well, making it 1e-6 m³, and regenerate. The rest of the magnitude-only units are prefixes of their dimension's base unit and were already right. A new test checks every SI-prefixed unit in the catalogue against its unprefixed unit, so this class of mistake fails the build. Fixes #286 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MZE6sRR8wRTGhthCM8sL2M --- .../Capacity.g.cs | 2 +- .../Volume.g.cs | 2 +- .../Units.g.cs | 4 +- .../Metadata/units.json | 3 +- .../Quantities/UnitBackfillTests.cs | 73 +++++++++++++++++++ 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Capacity.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Capacity.g.cs index c06801b9..25c1751f 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Capacity.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Capacity.g.cs @@ -112,7 +112,7 @@ namespace ktsu.Semantics.Quantities; /// The value in Milliliter. /// A new Capacity instance. /// Thrown when the resulting magnitude would be negative. - public static Capacity FromMilliliter(T value) => Create(Vector0Guards.EnsureNonNegative((value * MetricMagnitudes.Values.Milli), nameof(value))); + public static Capacity FromMilliliter(T value) => Create(Vector0Guards.EnsureNonNegative((value * (MetricMagnitudes.Values.Milli * Units.ConversionConstants.Values.LiterToCubicMeters)), nameof(value))); /// /// Creates a new Capacity from a value in CubicCentimeter. diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Volume.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Volume.g.cs index a90a46c0..798bc33e 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Volume.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.QuantitiesGenerator/Volume.g.cs @@ -111,7 +111,7 @@ namespace ktsu.Semantics.Quantities; /// The value in Milliliter. /// A new instance. /// Thrown when the resulting magnitude would be negative. - public static Volume FromMilliliter(T value) => Create(Vector0Guards.EnsureNonNegative((value * MetricMagnitudes.Values.Milli), nameof(value))); + public static Volume FromMilliliter(T value) => Create(Vector0Guards.EnsureNonNegative((value * (MetricMagnitudes.Values.Milli * Units.ConversionConstants.Values.LiterToCubicMeters)), nameof(value))); /// /// Creates a new from a value in CubicCentimeter. 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 911ec212..4c3bc3b8 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 @@ -948,7 +948,7 @@ public sealed record Milliliter : IUnit, IVolumeUnit public DimensionInfo Dimension => PhysicalDimensions.Volume; /// Gets the multiplication factor used in the to-base affine conversion. - public double ToBaseFactor => MetricMagnitudes.Milli; + public double ToBaseFactor => MetricMagnitudes.Milli * LiterToCubicMeters; /// Gets the additive offset used in the to-base affine conversion. public double ToBaseOffset => 0d; @@ -957,7 +957,7 @@ public sealed record Milliliter : IUnit, IVolumeUnit public Milliliter() { } /// - T IUnit.ToBaseFactorAs() => MetricMagnitudes.Values.Milli; + T IUnit.ToBaseFactorAs() => MetricMagnitudes.Values.Milli * ConversionConstants.Values.LiterToCubicMeters; /// T IUnit.ToBaseOffsetAs() => T.Zero; diff --git a/Semantics.SourceGenerators/Metadata/units.json b/Semantics.SourceGenerators/Metadata/units.json index e3550a64..6f416b48 100644 --- a/Semantics.SourceGenerators/Metadata/units.json +++ b/Semantics.SourceGenerators/Metadata/units.json @@ -200,7 +200,8 @@ "symbol": "mL", "description": "Milliliter - 0.001 liters.", "system": "SIDerived", - "magnitude": "Milli" + "magnitude": "Milli", + "conversionFactor": "LiterToCubicMeters" }, { "name": "Gallon", diff --git a/Semantics.Test/Quantities/UnitBackfillTests.cs b/Semantics.Test/Quantities/UnitBackfillTests.cs index c36d8d26..e0844e49 100644 --- a/Semantics.Test/Quantities/UnitBackfillTests.cs +++ b/Semantics.Test/Quantities/UnitBackfillTests.cs @@ -2,6 +2,10 @@ namespace ktsu.Semantics.Test.Quantities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; using ktsu.Semantics.Quantities; using ktsu.Semantics.Quantities.Units; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -45,6 +49,75 @@ public void Temperature_FromRankine_Is_Absolute_With_Fahrenheit_Degrees() Assert.AreEqual(273.15, t.Value, Tolerance); } + // ---- Volume: the litre is not the base unit, so a prefix alone is not enough ---- + + [TestMethod] + public void Volume_FromMilliliter_Is_1e6_CubicMeters() + { + Volume v = Volume.FromMilliliter(1.0); + Assert.AreEqual(1e-6, v.Value, Tolerance); + Assert.AreEqual(1e-6, Units.Milliliter.ToBaseFactor, Tolerance); + } + + [TestMethod] + public void Volume_FromMilliliter_1000_Is_1_Liter() + { + Volume v = Volume.FromMilliliter(1000.0); + Assert.AreEqual(1.0, v.In(Units.Liter), Tolerance); + Assert.AreEqual(1.0, Capacity.FromMilliliter(1000.0).In(Units.Liter), Tolerance); + } + + /// + /// Every SI-prefixed unit whose unprefixed form is also in the catalogue is exactly the prefix times + /// that unit. A prefixed unit declared with only a magnitude is scaled from the dimension's base unit, + /// which is right for Millimeter (from Meter) and wrong for Milliliter (the litre is not m³). + /// + [TestMethod] + public void EveryPrefixedUnit_Is_ThePrefixTimesItsUnprefixedUnit() + { + (string Name, double Scale)[] prefixes = + [ + ("Pico", 1e-12), ("Nano", 1e-9), ("Micro", 1e-6), ("Milli", 1e-3), ("Centi", 1e-2), + ("Kilo", 1e3), ("Mega", 1e6), + ]; + + Dictionary units = typeof(Units) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => field.GetValue(null)) + .OfType() + .GroupBy(static unit => unit.Name, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); + + List checkedUnits = []; + List wrong = []; + foreach (IUnit unit in units.Values) + { + foreach ((string prefix, double scale) in prefixes) + { + if (!unit.Name.StartsWith(prefix, StringComparison.Ordinal) || unit.Name.Length == prefix.Length) + { + continue; + } + + string rest = unit.Name[prefix.Length..]; + if (!units.TryGetValue(char.ToUpperInvariant(rest[0]) + rest[1..], out IUnit? unprefixed)) + { + continue; + } + + checkedUnits.Add(unit.Name); + double expected = scale * unprefixed.ToBaseFactor; + if (Math.Abs(unit.ToBaseFactor - expected) > Math.Abs(expected) * 1e-12) + { + wrong.Add($"{unit.Name} ({unit.ToBaseFactor}, expected {expected})"); + } + } + } + + CollectionAssert.Contains(checkedUnits, "Milliliter", "The scan should pair Milliliter with Liter."); + Assert.IsEmpty(wrong, $"These prefixed units are not the prefix times their unprefixed unit: {string.Join(", ", wrong)}"); + } + // ---- Length / Area / Volume ---- [TestMethod]