Skip to content

Commit d0d9012

Browse files
Merge pull request #80 from ktsu-dev/claude/review-vectors-branch-pgIZG
feat(quantities): IPhysicalQuantity surface + typed In() (closes #59)
2 parents 8cb35c7 + d629c82 commit d0d9012

12 files changed

Lines changed: 509 additions & 124 deletions

File tree

.github/workflows/dotnet.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ jobs:
187187
path: |
188188
./coverage/*
189189
retention-days: 7
190+
if-no-files-found: ignore
190191

191192
winget:
192193
name: Update Winget Manifests

Semantics.Quantities/IPhysicalQuantity.cs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,32 @@
44

55
namespace ktsu.Semantics.Quantities;
66

7+
using System;
78
using System.Numerics;
89

910
/// <summary>
10-
/// Base interface for all physical quantities with compile-time type safety.
11+
/// Common surface for every physical quantity. Exposes the underlying
12+
/// <see cref="Value"/>, a structural validity check, the <see cref="Dimension"/>
13+
/// the quantity belongs to, and same-dimension comparison.
1114
/// </summary>
12-
/// <typeparam name="T">The storage type for the quantity value (e.g., double, float, decimal).</typeparam>
13-
public interface IPhysicalQuantity<T> : ISemanticQuantity<T>
15+
/// <remarks>
16+
/// Concrete generated quantity types also expose a dimensionally-typed
17+
/// <c>In(I&lt;Dim&gt;Unit)</c> method — kept off this interface so cross-dimension
18+
/// comparison via the slim contract stays compile-time clean.
19+
/// </remarks>
20+
/// <typeparam name="T">The storage type for the quantity value.</typeparam>
21+
public interface IPhysicalQuantity<T>
22+
: ISemanticQuantity<T>
23+
, IComparable<IPhysicalQuantity<T>>
24+
, IEquatable<IPhysicalQuantity<T>>
1425
where T : struct, INumber<T>
1526
{
16-
/// <summary>Gets the value stored in this quantity.</summary>
17-
public T Value { get; }
27+
/// <summary>Gets the value stored in this quantity (in the dimension's SI base unit).</summary>
28+
T Value { get; }
1829

19-
/// <summary>Gets whether this quantity satisfies physical constraints (e.g., finite, non-NaN).</summary>
20-
public bool IsPhysicallyValid { get; }
30+
/// <summary>Gets whether this quantity satisfies structural physical constraints (finite, non-NaN).</summary>
31+
bool IsPhysicallyValid { get; }
32+
33+
/// <summary>Gets the physical dimension this quantity belongs to.</summary>
34+
DimensionInfo Dimension { get; }
2135
}

Semantics.Quantities/IUnit.cs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,52 @@
44

55
namespace ktsu.Semantics.Quantities;
66

7+
using System.Numerics;
8+
79
/// <summary>
8-
/// Interface for all physical units.
10+
/// Common surface for every physical unit. Carries the unit's name, symbol,
11+
/// the <see cref="DimensionInfo"/> it belongs to, and the affine conversion
12+
/// (<c>base = value × ToBaseFactor + ToBaseOffset</c>) that maps values in
13+
/// this unit to the SI base unit of the dimension.
914
/// </summary>
10-
public interface IUnit { }
15+
/// <remarks>
16+
/// <para>
17+
/// For dimensional compile-time safety, generated quantity types do not accept
18+
/// the raw <see cref="IUnit"/> on <c>In(...)</c>. Each dimension has its own
19+
/// marker interface (e.g. <c>ILengthUnit : IUnit</c>) which only its units
20+
/// implement, and the generated <c>In(I&lt;Dim&gt;Unit)</c> overload accepts
21+
/// only that family. So <c>length.In(Units.Kilogram)</c> fails to compile.
22+
/// </para>
23+
/// <para>
24+
/// <c>ToBase</c> / <c>FromBase</c> are default-implemented; concrete units only
25+
/// have to provide <see cref="ToBaseFactor"/> and <see cref="ToBaseOffset"/>.
26+
/// </para>
27+
/// </remarks>
28+
public interface IUnit
29+
{
30+
/// <summary>Gets the full name of the unit (e.g. <c>"Kilometer"</c>).</summary>
31+
string Name { get; }
32+
33+
/// <summary>Gets the unit's symbol/abbreviation (e.g. <c>"km"</c>).</summary>
34+
string Symbol { get; }
35+
36+
/// <summary>Gets the unit system this unit belongs to.</summary>
37+
UnitSystem System { get; }
38+
39+
/// <summary>Gets the dimension this unit measures.</summary>
40+
DimensionInfo Dimension { get; }
41+
42+
/// <summary>Gets the multiplication factor used in the to-base affine conversion.</summary>
43+
double ToBaseFactor { get; }
44+
45+
/// <summary>Gets the additive offset used in the to-base affine conversion.</summary>
46+
double ToBaseOffset { get; }
47+
48+
/// <summary>Converts a value expressed in this unit to the dimension's SI base unit.</summary>
49+
T ToBase<T>(T value) where T : struct, INumber<T>
50+
=> (value * T.CreateChecked(ToBaseFactor)) + T.CreateChecked(ToBaseOffset);
51+
52+
/// <summary>Converts a value expressed in the dimension's SI base unit to this unit.</summary>
53+
T FromBase<T>(T baseValue) where T : struct, INumber<T>
54+
=> (baseValue - T.CreateChecked(ToBaseOffset)) / T.CreateChecked(ToBaseFactor);
55+
}

Semantics.Quantities/PhysicalQuantity.cs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
namespace ktsu.Semantics.Quantities;
66

7+
using System;
78
using System.Numerics;
89

910
/// <summary>
1011
/// Base record for all physical quantity types. Inherits arithmetic operators from
11-
/// <see cref="SemanticQuantity{TSelf, TStorage}"/> and adds a <see cref="Value"/> property
12-
/// as an alias for the underlying storage.
12+
/// <see cref="SemanticQuantity{TSelf, TStorage}"/> and adds <see cref="Value"/>,
13+
/// <see cref="Dimension"/>, and same-dimension comparison.
1314
/// </summary>
1415
/// <typeparam name="TSelf">The derived physical quantity type.</typeparam>
1516
/// <typeparam name="T">The storage type for the quantity value.</typeparam>
@@ -19,14 +20,54 @@ public abstract record PhysicalQuantity<TSelf, T>
1920
where TSelf : PhysicalQuantity<TSelf, T>, new()
2021
where T : struct, INumber<T>
2122
{
22-
/// <summary>Gets the value stored in this quantity.</summary>
23+
/// <summary>Gets the value stored in this quantity (in the dimension's SI base unit).</summary>
2324
public T Value => Quantity;
2425

25-
/// <summary>Gets whether this quantity satisfies physical constraints.</summary>
26+
/// <summary>Gets whether this quantity satisfies structural physical constraints.</summary>
2627
public virtual bool IsPhysicallyValid => !T.IsNaN(Value) && T.IsFinite(Value);
2728

29+
/// <summary>Gets the physical dimension this quantity belongs to. Implemented by generated types.</summary>
30+
public abstract DimensionInfo Dimension { get; }
31+
2832
/// <summary>
2933
/// Initializes a new instance of the <see cref="PhysicalQuantity{TSelf, T}"/> class.
3034
/// </summary>
3135
protected PhysicalQuantity() : base() { }
36+
37+
/// <summary>
38+
/// Compares this quantity to another of the same physical dimension. Throws
39+
/// <see cref="ArgumentException"/> if dimensions differ — quantities of different
40+
/// dimensions are not ordered.
41+
/// </summary>
42+
public int CompareTo(IPhysicalQuantity<T>? other)
43+
{
44+
if (other is null)
45+
{
46+
return 1;
47+
}
48+
49+
if (!Equals(Dimension, other.Dimension))
50+
{
51+
throw new ArgumentException(
52+
$"Cannot compare quantity of dimension '{Dimension.Name}' to quantity of dimension '{other.Dimension.Name}'.",
53+
nameof(other));
54+
}
55+
56+
return Value.CompareTo(other.Value);
57+
}
58+
59+
/// <summary>
60+
/// Equality across the <see cref="IPhysicalQuantity{T}"/> surface — two quantities
61+
/// are equal iff they share a dimension and a value. Cross-dimension comparisons
62+
/// return <c>false</c> (they don't throw — equality is total).
63+
/// </summary>
64+
public virtual bool Equals(IPhysicalQuantity<T>? other)
65+
{
66+
if (other is null)
67+
{
68+
return false;
69+
}
70+
71+
return Equals(Dimension, other.Dimension) && Value.Equals(other.Value);
72+
}
3273
}

Semantics.Quantities/SemanticQuantity.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public static TResult Multiply<TResult>(SemanticQuantity<TStorage> self, Semanti
6767
{
6868
Ensure.NotNull(self);
6969
Ensure.NotNull(other);
70-
return Create<TResult>(self.Quantity * other.Quantity)!;
70+
return Create<TResult>(self.Quantity * other.Quantity);
7171
}
7272

7373
/// <summary>
@@ -81,7 +81,7 @@ public static TResult Multiply<TResult>(SemanticQuantity<TStorage> self, TStorag
8181
where TResult : SemanticQuantity<TStorage>, new()
8282
{
8383
Ensure.NotNull(self);
84-
return Create<TResult>(self.Quantity * other)!;
84+
return Create<TResult>(self.Quantity * other);
8585
}
8686

8787
/// <summary>
@@ -96,7 +96,7 @@ public static TResult Divide<TResult>(SemanticQuantity<TStorage> self, SemanticQ
9696
{
9797
Ensure.NotNull(self);
9898
Ensure.NotNull(other);
99-
return Create<TResult>(self.Quantity / other.Quantity)!;
99+
return Create<TResult>(self.Quantity / other.Quantity);
100100
}
101101

102102
/// <summary>
@@ -110,7 +110,7 @@ public static TResult Divide<TResult>(SemanticQuantity<TStorage> self, TStorage
110110
where TResult : SemanticQuantity<TStorage>, new()
111111
{
112112
Ensure.NotNull(self);
113-
return Create<TResult>(self.Quantity / other)!;
113+
return Create<TResult>(self.Quantity / other);
114114
}
115115

116116
/// <summary>
@@ -142,7 +142,7 @@ public static TResult Add<TResult>(SemanticQuantity<TStorage> self, SemanticQuan
142142
{
143143
Ensure.NotNull(self);
144144
Ensure.NotNull(other);
145-
return Create<TResult>(self.Quantity + other.Quantity)!;
145+
return Create<TResult>(self.Quantity + other.Quantity);
146146
}
147147

148148
/// <summary>
@@ -157,7 +157,7 @@ public static TResult Subtract<TResult>(SemanticQuantity<TStorage> self, Semanti
157157
{
158158
Ensure.NotNull(self);
159159
Ensure.NotNull(other);
160-
return Create<TResult>(self.Quantity - other.Quantity)!;
160+
return Create<TResult>(self.Quantity - other.Quantity);
161161
}
162162

163163
/// <summary>
@@ -170,7 +170,7 @@ public static TResult Negate<TResult>(SemanticQuantity<TStorage> self)
170170
where TResult : SemanticQuantity<TStorage>, new()
171171
{
172172
Ensure.NotNull(self);
173-
return Create<TResult>(-self.Quantity)!;
173+
return Create<TResult>(-self.Quantity);
174174
}
175175

176176
/// <inheritdoc/>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Quantities;
6+
7+
using System;
8+
9+
/// <summary>
10+
/// Thrown when a unit conversion cannot be performed — typically because the
11+
/// target unit's dimension does not match the source quantity's dimension.
12+
/// </summary>
13+
/// <remarks>
14+
/// In the typed compile-time path (<see cref="IPhysicalQuantity{T}"/> → <c>In(I&lt;Dim&gt;Unit)</c>),
15+
/// dimension mismatches fail at compile time. This exception remains for runtime
16+
/// scenarios where a quantity is converted via the untyped <see cref="IUnit"/> surface.
17+
/// </remarks>
18+
public sealed class UnitConversionException : ArgumentException
19+
{
20+
public UnitConversionException() { }
21+
22+
public UnitConversionException(string message) : base(message) { }
23+
24+
public UnitConversionException(string message, Exception inner) : base(message, inner) { }
25+
}

Semantics.SourceGenerators/Generators/DimensionsGenerator.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,25 @@ protected override void Generate(SourceProductionContext context, DimensionsMeta
129129

130130
sourceFileTemplate.Classes.Add(dimensionsClass);
131131

132+
// Emit per-dimension marker interfaces (I{Dim}Unit : IUnit) so generated
133+
// quantity types can accept dimensionally-compatible units only.
134+
foreach (PhysicalDimension dimension in sortedDimensions)
135+
{
136+
sourceFileTemplate.Classes.Add(new ClassTemplate()
137+
{
138+
Comments =
139+
[
140+
"/// <summary>",
141+
$"/// Marker interface implemented by every unit of the <c>{dimension.Name}</c> dimension.",
142+
"/// Generated quantities use this to make <c>In(...)</c> dimensionally type-safe at compile time.",
143+
"/// </summary>",
144+
],
145+
Keywords = ["public", "interface"],
146+
Name = $"I{dimension.Name}Unit",
147+
Interfaces = ["IUnit"],
148+
});
149+
}
150+
132151
WriteSourceFileTo(codeBlocker, sourceFileTemplate);
133152
context.AddSource(sourceFileTemplate.FileName, codeBlocker.ToString());
134153
}

Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,43 @@ private static string BuildToBaseExpression(string unitName, Dictionary<string,
724724
return scaled;
725725
}
726726

727+
/// <summary>
728+
/// Adds the per-quantity surface required by <see cref="ktsu.Semantics.Quantities.IPhysicalQuantity{T}"/>
729+
/// (#59): a <c>Dimension</c> override returning <c>PhysicalDimensions.{dim}</c>, plus a
730+
/// typed <c>In(I{dim}Unit)</c> method that converts the stored SI-base value into the
731+
/// caller's unit. Emitted for V0 and V1 (scalar-storage) types only; vector V2+ types
732+
/// have per-component conversion needs and are deferred.
733+
/// </summary>
734+
private static void AddDimensionAndInMembers(ClassTemplate cls, PhysicalDimension dim)
735+
{
736+
cls.Members.Add(new FieldTemplate()
737+
{
738+
Comments = [$"/// <summary>Gets the physical dimension this quantity belongs to.</summary>"],
739+
Keywords = ["public", "override", "DimensionInfo"],
740+
Name = $"Dimension => PhysicalDimensions.{dim.Name}",
741+
});
742+
743+
cls.Members.Add(new MethodTemplate()
744+
{
745+
Comments =
746+
[
747+
"/// <summary>",
748+
$"/// Converts this quantity's SI-base value to the value in <paramref name=\"unit\"/>.",
749+
"/// Cross-dimension calls (e.g. passing a non-" + dim.Name + " unit) fail at compile time.",
750+
"/// </summary>",
751+
"/// <param name=\"unit\">The dimensionally-compatible target unit.</param>",
752+
"/// <returns>The value expressed in <paramref name=\"unit\"/>.</returns>",
753+
],
754+
Keywords = ["public", "T"],
755+
Name = "In",
756+
Parameters =
757+
[
758+
new ParameterTemplate { Type = $"global::ktsu.Semantics.Quantities.I{dim.Name}Unit", Name = "unit" },
759+
],
760+
BodyFactory = (body) => body.Write(" => unit.FromBase(Value);"),
761+
});
762+
}
763+
727764
private static Dictionary<string, List<T>> GroupBy<T>(List<T> items, Func<T, string> keySelector)
728765
{
729766
Dictionary<string, List<T>> groups = [];
@@ -803,6 +840,9 @@ private void EmitV0BaseType(
803840
"<see cref=\"" + typeName + "{T}\"/>",
804841
applyV0Guard: true);
805842

843+
// Dimension override + typed In() (#59).
844+
AddDimensionAndInMembers(cls, dim);
845+
806846
// V0 - V0 returns the same V0 of T.Abs(left - right) (locked decision in #52).
807847
// We emit this on every V0 base type so the derived operator wins overload resolution
808848
// over PhysicalQuantity's plain subtraction (which can produce a negative magnitude
@@ -892,6 +932,9 @@ private void EmitV1BaseType(
892932
"<see cref=\"" + typeName + "{T}\"/>",
893933
applyV0Guard: false);
894934

935+
// Dimension override + typed In() (#59).
936+
AddDimensionAndInMembers(cls, dim);
937+
895938
// Magnitude method returning V0 base
896939
if (v0TypeName != null)
897940
{
@@ -1080,6 +1123,9 @@ private void EmitOverloadType(
10801123
applyV0Guard: vectorForm == 0,
10811124
strictPositive: strictPositive);
10821125

1126+
// Dimension override + typed In() (#59).
1127+
AddDimensionAndInMembers(cls, dim);
1128+
10831129
// Implicit widening to base type
10841130
cls.Members.Add(new MethodTemplate()
10851131
{

0 commit comments

Comments
 (0)