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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ var converted = sourceString.As<SourceType, TargetType>();
- **SEM007** — a metadata file could not be parsed. Replaces the base generator's `CONV001` in category `SourceGenerator`, and covers the path that used to swallow the exception, where a malformed `units.json` silently produced factories with no scale factor.
- **SEM008** — a relationship's declared result does not follow from the dimensions of its operands, or its value is signed and the declared result is a magnitude. The check comes from `Semantics.Vocabulary`, shared with the C++ projection; before that this side checked the names (SEM001) and the forms (SEM003) and then emitted the operator, so `Sensitivity * Pressure -> ElectricPotential` shipped as a working C# operator computing the wrong physics — which is what found that bug, and it is now fixed. **No operator is generated** for a refused relationship, in any of the directions C# spells a product in — that followed from making the vocabulary drive emission rather than only check it, and the removal is documented in `docs/migration-guide-5.0.md`. Suppressed in `Semantics.Quantities.csproj` because ktsu.Sdk builds warnings as errors and the four below are outstanding; `UnkeepableRelationshipTests` pins the set, and asserts that none of them is in the compiled surface, so a fifth fails there rather than disappearing into the suppression.
- **SEM009**: a factor's `value` in `conversions.json` is neither a decimal literal nor a fraction of two with a non-zero denominator, or a `double` cannot hold it (a literal, operand, or quotient beyond its range, or a non-zero value that rounds to zero). An error, and no constant is generated for it, because every unit using the factor would otherwise fail to compile far from the metadata line that caused it, or convert with a wrong factor.
- **SEM010**: a dimension declares both a vector form and a unit converting with an additive offset. Adding 273.15 to each component of a displacement is not a unit change, so the whole per-unit surface — every `From{Unit}` factory and the `In(unit)` reader — is withheld from that dimension's vector types rather than emitted quietly wrong. Withheld as a whole rather than per-unit, because `In` takes the dimension's `I{Dimension}Unit` and would accept the offset unit at runtime even if only its factory were skipped. The scalar forms are unaffected: the offset is correct for a V0 or V1. Defensive — no dimension declaring a vector form has an offset unit today, and `TheRealMetadataReportsNothingUnexpected` is what keeps that true.
- **SEM010**: a dimension declares both a vector form and a unit converting with an additive offset. Adding 273.15 to each component of a displacement is not a unit change, so the whole per-unit surface — every `From{Unit}` factory and the `In(unit)` reader — is withheld from that dimension's vector types rather than emitted quietly wrong. Withheld as a whole rather than per-unit, because `In` takes the dimension's `I{Dimension}Unit` and would accept the offset unit at runtime even if only its factory were skipped. The scalar forms are unaffected: a V0 applies the offset, and a V1 (a difference, such as `TemperatureDelta`) converts with the factor alone in both `From{Unit}` and `In(unit)` (#283). Defensive — no dimension declaring a vector form has an offset unit today, and `TheRealMetadataReportsNothingUnexpected` is what keeps that true.
- Descriptors are allocated from `SemanticsDiagnostics`, which is the one place to add a new one. `AnalyzerReleaseTrackingTests` fails if the identifier is missing from `AnalyzerReleases.Unshipped.md`, so RS2008 no longer surfaces only after a push.
- See `docs/physics-generator.md` for the full schema and an end-to-end "add a dimension" walk-through.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,14 @@ namespace ktsu.Semantics.Quantities;
/// </summary>
/// <param name="value">The value in Celsius.</param>
/// <returns>A new <see cref="TemperatureDelta{T}"/> instance.</returns>
public static TemperatureDelta<T> FromCelsius(T value) => Create((value + Units.ConversionConstants.Values<T>.CelsiusToKelvinOffset));
public static TemperatureDelta<T> FromCelsius(T value) => Create(value);

/// <summary>
/// Creates a new <see cref="TemperatureDelta{T}"/> from a value in Fahrenheit.
/// </summary>
/// <param name="value">The value in Fahrenheit.</param>
/// <returns>A new <see cref="TemperatureDelta{T}"/> instance.</returns>
public static TemperatureDelta<T> FromFahrenheit(T value) => Create(((value * Units.ConversionConstants.Values<T>.FahrenheitScale) + Units.ConversionConstants.Values<T>.FahrenheitToKelvinOffset));
public static TemperatureDelta<T> FromFahrenheit(T value) => Create((value * Units.ConversionConstants.Values<T>.FahrenheitScale));

/// <summary>
/// Creates a new <see cref="TemperatureDelta{T}"/> from a value in Rankine.
Expand All @@ -131,7 +131,7 @@ namespace ktsu.Semantics.Quantities;
/// </summary>
/// <param name="unit">The dimensionally-compatible target unit.</param>
/// <returns>The value expressed in <paramref name="unit"/>.</returns>
public T In(global::ktsu.Semantics.Quantities.ITemperatureUnit unit) => unit.FromBase(Value);
public T In(global::ktsu.Semantics.Quantities.ITemperatureUnit unit) => Value / unit.ToBaseFactorAs<T>();

/// <summary>
/// Gets the magnitude of this quantity as a <see cref="Temperature{T}"/>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,14 @@ namespace ktsu.Semantics.Quantities;
/// </summary>
/// <param name="value">The value in Celsius.</param>
/// <returns>A new TemperatureDrop instance.</returns>
public static TemperatureDrop<T> FromCelsius(T value) => Create((value + Units.ConversionConstants.Values<T>.CelsiusToKelvinOffset));
public static TemperatureDrop<T> FromCelsius(T value) => Create(value);

/// <summary>
/// Creates a new TemperatureDrop from a value in Fahrenheit.
/// </summary>
/// <param name="value">The value in Fahrenheit.</param>
/// <returns>A new TemperatureDrop instance.</returns>
public static TemperatureDrop<T> FromFahrenheit(T value) => Create(((value * Units.ConversionConstants.Values<T>.FahrenheitScale) + Units.ConversionConstants.Values<T>.FahrenheitToKelvinOffset));
public static TemperatureDrop<T> FromFahrenheit(T value) => Create((value * Units.ConversionConstants.Values<T>.FahrenheitScale));

/// <summary>
/// Creates a new TemperatureDrop from a value in Rankine.
Expand All @@ -132,7 +132,7 @@ namespace ktsu.Semantics.Quantities;
/// </summary>
/// <param name="unit">The dimensionally-compatible target unit.</param>
/// <returns>The value expressed in <paramref name="unit"/>.</returns>
public T In(global::ktsu.Semantics.Quantities.ITemperatureUnit unit) => unit.FromBase(Value);
public T In(global::ktsu.Semantics.Quantities.ITemperatureUnit unit) => Value / unit.ToBaseFactorAs<T>();

/// <summary>Implicit conversion to TemperatureDelta.</summary>
public static implicit operator TemperatureDelta<T>(TemperatureDrop<T> value) => TemperatureDelta<T>.Create(value.Value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,14 @@ namespace ktsu.Semantics.Quantities;
/// </summary>
/// <param name="value">The value in Celsius.</param>
/// <returns>A new TemperatureRise instance.</returns>
public static TemperatureRise<T> FromCelsius(T value) => Create((value + Units.ConversionConstants.Values<T>.CelsiusToKelvinOffset));
public static TemperatureRise<T> FromCelsius(T value) => Create(value);

/// <summary>
/// Creates a new TemperatureRise from a value in Fahrenheit.
/// </summary>
/// <param name="value">The value in Fahrenheit.</param>
/// <returns>A new TemperatureRise instance.</returns>
public static TemperatureRise<T> FromFahrenheit(T value) => Create(((value * Units.ConversionConstants.Values<T>.FahrenheitScale) + Units.ConversionConstants.Values<T>.FahrenheitToKelvinOffset));
public static TemperatureRise<T> FromFahrenheit(T value) => Create((value * Units.ConversionConstants.Values<T>.FahrenheitScale));

/// <summary>
/// Creates a new TemperatureRise from a value in Rankine.
Expand All @@ -132,7 +132,7 @@ namespace ktsu.Semantics.Quantities;
/// </summary>
/// <param name="unit">The dimensionally-compatible target unit.</param>
/// <returns>The value expressed in <paramref name="unit"/>.</returns>
public T In(global::ktsu.Semantics.Quantities.ITemperatureUnit unit) => unit.FromBase(Value);
public T In(global::ktsu.Semantics.Quantities.ITemperatureUnit unit) => Value / unit.ToBaseFactorAs<T>();

/// <summary>Implicit conversion to TemperatureDelta.</summary>
public static implicit operator TemperatureDelta<T>(TemperatureRise<T> value) => TemperatureDelta<T>.Create(value.Value);
Expand Down
44 changes: 34 additions & 10 deletions Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,9 @@ private static void ReportUnknownUnitReferences(
/// <c>physicalConstraints.minExclusive: "0"</c> per #51) the guard is upgraded to
/// <c>Vector0Guards.EnsurePositive</c>, which rejects zero as well as negative values.
/// <paramref name="strictPositive"/> is ignored when <paramref name="applyV0Guard"/> is false.
/// When <paramref name="applyV0Guard"/> is false the form is a V1, which holds a signed
/// difference rather than a position on the scale, so the unit's offset is left out:
/// <c>TemperatureDelta.FromCelsius(10)</c> is 10 K rather than 283.15 K (#283).
/// </summary>
private static void AddUnitFactories(
ClassTemplate cls,
Expand All @@ -597,7 +600,7 @@ private static void AddUnitFactories(
bool isBase = unitName == baseUnit;
string conversionExpr = isBase
? Emit.ValueParameter
: BuildToBaseExpression(unitName, unitMap, Emit.ValueParameter);
: BuildToBaseExpression(unitName, unitMap, Emit.ValueParameter, applyOffset: applyV0Guard);

string body = applyV0Guard
? $"=> Create(Vector0Guards.{guardMethod}({conversionExpr}, nameof(value)));"
Expand Down Expand Up @@ -651,10 +654,15 @@ private static void AddUnitFactories(
/// vector factories (#237) pass one component parameter at a time, which is the only reason
/// this is a parameter rather than the constant it used to be.
/// </param>
/// <param name="applyOffset">
/// Whether to add the unit's offset. False for a form that holds a difference (#283): 10 °C
/// warmer is 10 K warmer, not 283.15 K.
/// </param>
private static string BuildToBaseExpression(
string unitName,
IReadOnlyDictionary<string, UnitDefinition> unitMap,
string operand)
string operand,
bool applyOffset = true)
{
// If we don't have unit metadata, fall back to identity. The dimensions.json author is
// responsible for keeping availableUnits in sync with units.json; if a unit is missing,
Expand Down Expand Up @@ -684,7 +692,7 @@ private static string BuildToBaseExpression(
scaled = $"({operand} * Units.ConversionConstants.Values<T>.{unit.ConversionFactor})";
}

if (HasOffset(unit))
if (applyOffset && HasOffset(unit))
{
scaled = $"({scaled} + Units.ConversionConstants.Values<T>.{unit.Offset})";
}
Expand All @@ -711,8 +719,24 @@ private static bool HasOffset(UnitDefinition? unit) =>
/// caller's unit. Emitted for V0 and V1 (scalar-storage) types only; vector V2+ types
/// have per-component conversion needs and are deferred.
/// </summary>
private static void AddDimensionAndInMembers(ClassTemplate cls, PhysicalDimension dim)
/// <remarks>
/// A difference form (<paramref name="isDifference"/>) of a dimension with an offset unit reads
/// with the factor alone, the counterpart of its factories leaving the offset out (#283).
/// <c>unit.FromBase</c> would subtract the offset, reading a 10 K rise as −263.15 °C. Every other
/// form keeps <c>FromBase</c>, which for a unit without an offset is the same division.
/// </remarks>
private static void AddDimensionAndInMembers(
ClassTemplate cls,
PhysicalDimension dim,
IReadOnlyDictionary<string, UnitDefinition> unitMap,
bool isDifference)
{
bool factorOnly = isDifference
&& dim.AvailableUnits.Any(u => unitMap.TryGetValue(u, out UnitDefinition? d) && HasOffset(d));
string inBody = factorOnly
? "=> Value / unit.ToBaseFactorAs<T>();"
: "=> unit.FromBase(Value);";

cls.Members.Add(new FieldTemplate()
{
Comments = {$"/// <summary>Gets the physical dimension this quantity belongs to.</summary>"},
Expand All @@ -737,7 +761,7 @@ private static void AddDimensionAndInMembers(ClassTemplate cls, PhysicalDimensio
{
new ParameterTemplate { Type = $"global::ktsu.Semantics.Quantities.I{dim.Name}Unit", Name = "unit" },
},
BodyFactory = (body) => body.Write("=> unit.FromBase(Value);"),
BodyFactory = (body) => body.Write(inBody),
});
}

Expand Down Expand Up @@ -1005,7 +1029,7 @@ private void EmitV0BaseType(
applyV0Guard: true);

// Dimension override + typed In() (#59).
AddDimensionAndInMembers(cls, dim);
AddDimensionAndInMembers(cls, dim, emission.Units, isDifference: false);

// V0 - V0 returns the same V0 of T.Abs(left - right) (locked decision in #52).
// We emit this on every V0 base type so the derived operator wins overload resolution
Expand Down Expand Up @@ -1089,7 +1113,7 @@ private void EmitV1BaseType(
});

// Factory methods for every available unit.
// V1 quantities are signed; no V0 non-negativity guard.
// V1 quantities are signed differences: no V0 non-negativity guard, and no unit offset (#283).
AddUnitFactories(
cls,
dim.AvailableUnits,
Expand All @@ -1099,7 +1123,7 @@ private void EmitV1BaseType(
applyV0Guard: false);

// Dimension override + typed In() (#59).
AddDimensionAndInMembers(cls, dim);
AddDimensionAndInMembers(cls, dim, emission.Units, isDifference: true);

// Magnitude method returning V0 base
cls.Members.Add(new MethodTemplate()
Expand Down Expand Up @@ -1259,7 +1283,7 @@ private void EmitOverloadType(
// type (#50). V0 overloads that declare physicalConstraints.minExclusive in
// dimensions.json (#51, e.g. Wavelength, Period, HalfLife) get the stricter
// EnsurePositive guard so a zero input is rejected too. V1 overloads accept
// any sign.
// any sign, and like their V1 base hold a difference, so they leave out the unit offset (#283).
bool strictPositive = type.Magnitude == Magnitude.Positive;
AddUnitFactories(
cls,
Expand All @@ -1271,7 +1295,7 @@ private void EmitOverloadType(
strictPositive: strictPositive);

// Dimension override + typed In() (#59).
AddDimensionAndInMembers(cls, dim);
AddDimensionAndInMembers(cls, dim, emission.Units, isDifference: vectorForm != 0);

// Implicit widening to base type
cls.Members.Add(new MethodTemplate()
Expand Down
21 changes: 21 additions & 0 deletions Semantics.Test/Quantities/StorageConversionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,27 @@ public void FahrenheitConvertsToKelvinAndCelsius()
AssertValue("212", boiling.In(Units.Fahrenheit), terminates: false);
}

/// <summary>
/// A temperature difference converts with the factor alone (#283): 10 °C warmer is 10 K warmer,
/// not 283.15 K, and a 10 K rise reads as 10 °C rather than −263.15 °C.
/// </summary>
[TestMethod]
public void TemperatureDifferencesLeaveOutTheScaleOffset()
{
AssertValue("10", TemperatureDelta<T>.FromCelsius(Of("10")).Value, terminates: true);
AssertValue("10", TemperatureDelta<T>.FromFahrenheit(Of("18")).Value, terminates: false);
AssertValue("10", TemperatureDelta<T>.FromKelvin(Of("10")).In(Units.Celsius), terminates: true);
AssertValue("18", TemperatureDelta<T>.FromKelvin(Of("10")).In(Units.Fahrenheit), terminates: false);

AssertValue("10", TemperatureRise<T>.FromCelsius(Of("10")).Value, terminates: true);
AssertValue("10", TemperatureRise<T>.FromFahrenheit(Of("18")).Value, terminates: false);
AssertValue("10", TemperatureRise<T>.FromKelvin(Of("10")).In(Units.Celsius), terminates: true);

AssertValue("10", TemperatureDrop<T>.FromCelsius(Of("10")).Value, terminates: true);
AssertValue("10", TemperatureDrop<T>.FromFahrenheit(Of("18")).Value, terminates: false);
AssertValue("10", TemperatureDrop<T>.FromKelvin(Of("10")).In(Units.Celsius), terminates: true);
}

[TestMethod]
public void AnglesUsePiToThePrecisionOfTheStorageType()
{
Expand Down
2 changes: 1 addition & 1 deletion docs/physics-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ exist at all is the open question in
| SEM007 | A metadata file that could not be parsed. |
| SEM008 | A relationship whose declared result does not follow from the dimensions of its operands, or whose signed value cannot land in a magnitude result. No operator is generated for it. |
| SEM009 | A `conversions.json` factor whose `value` is neither a decimal literal nor a fraction of two with a non-zero denominator, or that a `double` cannot hold. An error, and no constant is generated for it. |
| SEM010 | A dimension declaring both a vector form and an offset unit. Its vector types get no `From{Unit}` factories and no `In(unit)` reader, because an additive offset applied componentwise is not a unit change. The scalar forms keep theirs, where the offset is correct. |
| SEM010 | A dimension declaring both a vector form and an offset unit. Its vector types get no `From{Unit}` factories and no `In(unit)` reader, because an additive offset applied componentwise is not a unit change. The scalar forms keep theirs: a V0 applies the offset, and a V1 holds a difference, so it converts with the factor alone (#283). |

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`.
Expand Down
Loading