Inject IMetricService into CQL runtime for UCUM unit arithmetic#1282
Inject IMetricService into CQL runtime for UCUM unit arithmetic#1282ewoutkramer wants to merge 15 commits into
Conversation
Replace the hardwired FhirMetricService singleton with an injectable IMetricService that flows from FhirCqlContextOptions through CqlOperators, CqlComparers, and UnitConverter to the actual UCUM call sites. The default implementation (DefaultUcumMetricService) wraps FhirMetricService for TryCanonicalize and falls back to SystemOfUnits for TryConvertTo, since FhirMetricService.TryConvertTo throws NotImplementedException. This allows commercial Firely.UCUM to be injected via FhirCqlContextOptions.MetricService without any dependency on it in this repo. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the no-arg overloads of TryCanonicalize and TryConvert so callers must always supply a service. Expose UcumConversionExtensions.Default as the shared fallback; CqlQuantityCqlComparer and UnitConverter use it when no custom service is injected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix three copy-paste bugs in quantity arithmetic (Subtract and Modulo called Add on their values), wire IMetricService into CqlOperators so Add/Subtract can perform UCUM cross-unit arithmetic (e.g. kg + g), fix Between to use Comparer.Compare like all other typed Between overloads, and fix CanConvertQuantity to actually test convertibility instead of always returning false. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Quantity Includes regression tests for the Add-instead-of-correct-operation bugs in the mixed CQL calendar alias branches (e.g. 'days' - 'day'), a cross-unit Between test that exercises canonicalisation via IMetricService (g vs kg), and CanConvertQuantity tests that cover null inputs, same-unit, pre-configured unit converter pairs, and incompatible units. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nst NotImplementedException Add tests for Add, Subtract, Divide, and Multiply on CqlQuantity covering same-unit, CQL calendar alias, incompatible-unit (NotSupportedException), and null inputs. Cross-unit commensurable Add/Subtract tests are included but marked [Ignore] because FhirMetricService.TryAdd/TrySubtract throw NotImplementedException; DefaultUcumMetricService now catches that and returns false, consistent with the existing TryConvertTo override. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…erent units FhirMetricService.TryDivide is implemented (unlike TryAdd/TrySubtract), so cross-unit division now works with the default service: commensurable units cancel (2 kg / 1 g = 2000 dimensionless) and non-commensurable units produce a compound-unit result (6 kg / 2 s = 3000 g/s, after canonical normalisation). Tests added for both paths; the two cross-unit Add/Subtract tests remain [Ignore] since FhirMetricService.TryAdd/TrySubtract are not implemented. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… arithmetic DefaultUcumMetricService now lets NotImplementedException propagate from FhirMetricService instead of swallowing it as false. CqlOperators.Add, Subtract, and Divide each catch NotImplementedException separately and rethrow as NotSupportedException with a message that clearly tells the caller to inject a full IMetricService implementation, rather than misleadingly reporting that the units are incompatible. Also fix a pre-existing PublicAPI.Shipped.txt mismatch where Contains<T> was declared with a non-nullable list but the interface uses nullable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds injectable UCUM metric-service support to quantity conversion/comparison/arithmetic paths in the CQL runtime and exposes that injection through the Firely CQL context options.
Changes:
- Adds
IMetricServiceplumbing through runtime comparers, unit conversion, and Firely context options. - Reworks UCUM conversion helpers around
IMetricService, including a default wrapper forFhirMetricService. - Adds and updates quantity/operator tests for conversion, arithmetic, comparison, and null/error behavior.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
Cql/Cql.Runtime/PublicAPI.Shipped.txt |
Updates shipped API nullability metadata for Contains<T>. |
Cql/Cql.Runtime/Operators/CqlOperators.TypeOperators.cs |
Implements CanConvertQuantity using UnitConverter.ChangeUnits. |
Cql/Cql.Runtime/Operators/CqlOperators.cs |
Adds metric-service support to CqlOperators.Create and stores it for arithmetic. |
Cql/Cql.Runtime/Operators/CqlOperators.ComparisonOperators.cs |
Routes quantity Between through the configured comparer. |
Cql/Cql.Runtime/Operators/CqlOperators.ArithmeticOperators.cs |
Uses metric-service UCUM operations for mixed-unit add/subtract/divide paths. |
Cql/Cql.Runtime/Cql.Runtime.csproj |
Bumps Fhir.Metrics to 1.3.1. |
Cql/Cql.Runtime/Conversion/UnitConverter.cs |
Allows UCUM fallback conversions to use an injected metric service. |
Cql/Cql.Runtime/Conversion/UcumConversionExtensions.cs |
Refactors UCUM helpers to accept IMetricService and adds the default wrapper service. |
Cql/Cql.Runtime/Comparers/CqlComparers.cs |
Passes metric service into the quantity comparer. |
Cql/Cql.Runtime/Comparers/CqlComparers.CqlQuantityCqlComparer.cs |
Canonicalizes quantities using the configured metric service. |
Cql/Cql.Firely/FhirCqlContextOptions.cs |
Exposes MetricService as a Firely context option. |
Cql/Cql.Firely/FhirCqlContext.cs |
Wires the metric service into Firely comparers/unit conversion setup. |
Cql/Cql.Firely/Cql.Firely.csproj |
Adds a direct Fhir.Metrics dependency. |
Cql/CoreTests/UcumTests.cs |
Updates UCUM tests for metric-service based conversion APIs. |
Cql/CoreTests/CqlQuantityTests.cs |
Adds quantity arithmetic/comparison/conversion operator coverage. |
Cql/CoreTests/CoreTests.csproj |
Adds Fhir.Metrics to the test project dependencies. |
…c-service-injection
… code duplication - Pass metricService to CqlOperators.Create so injected services are used for Add/Subtract/Divide arithmetic (was falling back to default) - Add MetricService property to Cql.Firely/PublicAPI.Unshipped.txt - Add Fhir.Metrics to Cql.Firely README dependencies - Remove mention of 'commercial UCUM service' from doc comment - Extract TryUcumBinaryOp helper to eliminate repeated try/catch pattern across Add, Subtract and Divide quantity paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…erands Multiply(CqlQuantity, CqlQuantity) was throwing NotSupportedException whenever both operands had a non-scalar unit. It now routes through TryUcumBinaryOp, enabling operations like m * m = m2 via the configured IMetricService. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… code - Add `UcumSystemUrl` constant replacing 4 magic string literals - Remove `DefaultUcumMetricService._system` (was loaded only to fail, since TryConvertTo always returned false) - Replace old `CompareNormalizedUnits` usage in Modulo with `AreSameCqlCalendarUnit` - Remove what-style inline comments before TryUcumBinaryOp call sites - Inline single-use TryCanonicalize wrapper in CqlQuantityCqlComparer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…vertsUnits test The previous simplification left TryConvertTo returning false unconditionally. Add back _system and a local ToQuantity helper to implement conversion using Canonical + dimension-check + Divide, matching the FhirMetricService PR #41 approach. Remove the now-contradictory TryConvert_UnsupportedUnits_ReturnsFalse test and un-skip ConvertsUnits. Add/Subtract still skip pending Fhir.Metrics NuGet update. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| else if (Comparer.Compare(argument, low, null) >= 0 && Comparer.Compare(argument, high, null) <= 0) | ||
| return true; |
| [TestMethod, Ignore("Requires a full IMetricService implementation (e.g. Firely.UCUM); the default FhirMetricService does not implement TryAdd.")] | ||
| public void Add_CommensurableUcumUnits_ReturnsSumInCanonicalUnit() |
| /// Subtracting commensurable UCUM quantities (kg - g) delegates to IMetricService.TrySubtract, | ||
| /// which canonicalises both operands and returns the result in canonical units. | ||
| /// </summary> | ||
| [TestMethod, Ignore("Requires a full IMetricService implementation (e.g. Firely.UCUM); the default FhirMetricService does not implement TrySubtract.")] | ||
| public void Subtract_CommensurableUcumUnits_ReturnsDifferenceInCanonicalUnit() | ||
| { | ||
| var ops = GetOperators(); | ||
| var result = ops.Subtract(new CqlQuantity(2m, "kg"), new CqlQuantity(500m, "g")); | ||
| Assert.IsNotNull(result); | ||
| // FhirMetricService canonicalises kg and g to g; 2 kg = 2000 g - 500 g = 1500 g |
Renamed Multiply_ByScalar_ReturnsProductWithLeftUnit to Multiply_ByScalar_ReturnsScalarProduct. Expanded the test to verify both operand orders (united × scalar and scalar × united), asserting correct value and unit in each case.
There was a problem hiding this comment.
@alexzautke / @ewoutkramer the assertions in the unit tests do not seem correct. I had a chat with Ewout yesterday, and we determined that more work is needed on this PR.
Specific focus is needed on
- Multiply_ByScalar_ReturnsScalarProduct (previously Multiply_ByScalar_ReturnsProductWithLeftUnit)
- A scalar product with unit-quantity should also be a unit-quantity
- Divide_ByScalar_ReturnsQuotientWithLeftUnit.
- When dividing a scalar with a unit-quantity, is it the idea that the si prefix is shifted from mg to g?
There is a possible logic error in CqlOperators.ArithmeticOperators.cs method Multiply(CqlQuantity? left, CqlQuantity? right)
- There is a lot of pre-optimization on cql quantities done here, before it even goes to the ucum binary operator logic. I believe this is the correct logic
public CqlQuantity? Multiply(CqlQuantity? left, CqlQuantity? right)
{
if (left == null || right == null)
return null;
else if (left.value == null || right.value == null)
return null;
else
return TryUcumBinaryOp(left.value.Value, left.unit!, right.value.Value, right.unit!, MetricServiceExtensions.TryMultiply, "Multiply");
}
| #region Multiply | ||
|
|
||
| [TestMethod] | ||
| public void Multiply_ByScalar_ReturnsProductWithLeftUnit() |
There was a problem hiding this comment.
Fix test name - it says result keeps left unit, but assertion checks dimensionless unit on the right
There was a problem hiding this comment.
Actually this is still not correct, see #1282 (review)
Summary
IMetricServiceinjection point toFhirCqlContext/FhirCqlContextOptionsso callers can supply a custom UCUM implementation instead of the built-in default.DefaultUcumMetricService(internal) — wrapsFhirMetricServiceand fills inTryConvertTousingSystemOfUnits.Convertdirectly, sinceFhirMetricService.TryConvertTothrowsNotImplementedException.CqlOperatorsmethods that operate onCqlQuantityto route through the injectedIMetricServicewhere it was previously ignored:AddandSubtract: cross-unit calendar aliases (day/days) and commensurable UCUM units (kg+g viaTryAdd/TrySubtract)Divide: cross-unit UCUM division viaTryDivide(e.g.2 kg / 1 g → 2000)Between: usesComparerwhich already routes through the service for cross-unit comparisonsCanConvertQuantity: fixed to returnfalseinstead of throwing for unconvertible unitsfalsefromTry*means genuinely incompatible units;NotImplementedExceptionfromTry*means the configured service doesn't implement the operation — each produces a distinctNotSupportedExceptionmessage.SubtractandModulo(both calledAddinternally in their mixed-unit branch).CqlQuantityTestscovering Add, Subtract, Modulo, Divide, Multiply, Between, and CanConvertQuantity with same-unit, calendar-alias, cross-unit, null, and error cases.Injecting a custom IMetricService
The default service (
DefaultUcumMetricService) wrapsFhirMetricServicefrom the open-sourceFhir.Metricspackage. It handlesTryConvertToandTryDivide/TryCompare/TryCanonicalize, butTryAddandTrySubtractare not implemented — cross-unit addition/subtraction will throw with a clear message.To enable full cross-unit arithmetic (e.g.
1 kg + 500 g), supply a completeIMetricServiceimplementation viaFhirCqlContextOptions:IMetricServiceis defined in theFhir.MetricsNuGet package.Test plan
CqlQuantityTests— 42 passing, 2 skipped (cross-unit Add/Subtract require a full IMetricService; marked[Ignore]with explanation)UcumTestsstill passCqlContextOperatorTestsorExpressionBuilderTests🤖 Generated with Claude Code