Skip to content

Inject IMetricService into CQL runtime for UCUM unit arithmetic#1282

Open
ewoutkramer wants to merge 15 commits into
developfrom
feature/ucum-metric-service-injection
Open

Inject IMetricService into CQL runtime for UCUM unit arithmetic#1282
ewoutkramer wants to merge 15 commits into
developfrom
feature/ucum-metric-service-injection

Conversation

@ewoutkramer

@ewoutkramer ewoutkramer commented May 27, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds an IMetricService injection point to FhirCqlContext/FhirCqlContextOptions so callers can supply a custom UCUM implementation instead of the built-in default.
  • Introduces DefaultUcumMetricService (internal) — wraps FhirMetricService and fills in TryConvertTo using SystemOfUnits.Convert directly, since FhirMetricService.TryConvertTo throws NotImplementedException.
  • Improves several CqlOperators methods that operate on CqlQuantity to route through the injected IMetricService where it was previously ignored:
    • Add and Subtract: cross-unit calendar aliases (day/days) and commensurable UCUM units (kg+g via TryAdd/TrySubtract)
    • Divide: cross-unit UCUM division via TryDivide (e.g. 2 kg / 1 g → 2000)
    • Between: uses Comparer which already routes through the service for cross-unit comparisons
    • CanConvertQuantity: fixed to return false instead of throwing for unconvertible units
  • Distinguishes two failure modes in cross-unit arithmetic: false from Try* means genuinely incompatible units; NotImplementedException from Try* means the configured service doesn't implement the operation — each produces a distinct NotSupportedException message.
  • Fixes pre-existing copy/paste bugs in Subtract and Modulo (both called Add internally in their mixed-unit branch).
  • Adds CqlQuantityTests covering 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) wraps FhirMetricService from the open-source Fhir.Metrics package. It handles TryConvertTo and TryDivide/TryCompare/TryCanonicalize, but TryAdd and TrySubtract are 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 complete IMetricService implementation via FhirCqlContextOptions:

// Default: uses the built-in FhirMetricService wrapper
var context = FhirCqlContext.ForBundle(bundle);

// Custom: inject an improved custom IMetricService 
var context = FhirCqlContext.ForBundle(bundle, options: new FhirCqlContextOptions
{
    MetricService = new MyFullUcumMetricService()
});

// Same pattern for WithDataSource:
var context = FhirCqlContext.WithDataSource(source, options: new FhirCqlContextOptions
{
    MetricService = new MyFullUcumMetricService()
});

IMetricService is defined in the Fhir.Metrics NuGet package.

Test plan

  • CqlQuantityTests — 42 passing, 2 skipped (cross-unit Add/Subtract require a full IMetricService; marked [Ignore] with explanation)
  • Existing UcumTests still pass
  • No regressions in CqlContextOperatorTests or ExpressionBuilderTests

🤖 Generated with Claude Code

ewoutkramer and others added 7 commits May 20, 2026 14:52
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>
Copilot AI review requested due to automatic review settings May 27, 2026 13:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 IMetricService plumbing through runtime comparers, unit conversion, and Firely context options.
  • Reworks UCUM conversion helpers around IMetricService, including a default wrapper for FhirMetricService.
  • 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.

Comment thread Cql/Cql.Firely/FhirCqlContext.cs
Comment thread Cql/Cql.Firely/FhirCqlContextOptions.cs
Comment thread Cql/Cql.Firely/Cql.Firely.csproj
Comment thread Cql/CoreTests/UcumTests.cs Outdated
Comment thread Cql/Cql.Firely/FhirCqlContextOptions.cs Outdated
Comment thread Cql/Cql.Runtime/Operators/CqlOperators.ArithmeticOperators.cs Outdated
ewoutkramer and others added 2 commits May 27, 2026 16:04
… 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>
@ewoutkramer
ewoutkramer requested a review from alexzautke May 27, 2026 14:26
@ewoutkramer
ewoutkramer marked this pull request as draft May 27, 2026 14:27
@alexzautke
alexzautke requested a review from baseTwo May 27, 2026 14:29
… 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>
@ewoutkramer
ewoutkramer marked this pull request as ready for review May 27, 2026 15:36
@baseTwo
baseTwo requested a review from Copilot May 28, 2026 07:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Comment on lines +49 to +50
else if (Comparer.Compare(argument, low, null) >= 0 && Comparer.Compare(argument, high, null) <= 0)
return true;
Comment on lines +115 to +116
[TestMethod, Ignore("Requires a full IMetricService implementation (e.g. Firely.UCUM); the default FhirMetricService does not implement TryAdd.")]
public void Add_CommensurableUcumUnits_ReturnsSumInCanonicalUnit()
Comment on lines +191 to +200
/// 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
baseTwo added 3 commits June 8, 2026 11:47
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.

@baseTwo baseTwo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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");
        }

Comment thread Cql/CoreTests/CqlQuantityTests.cs Outdated
#region Multiply

[TestMethod]
public void Multiply_ByScalar_ReturnsProductWithLeftUnit()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix test name - it says result keeps left unit, but assertion checks dimensionless unit on the right

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

@baseTwo baseTwo Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this is still not correct, see #1282 (review)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace the old Fhir.Metrics library with the faster Firely UCUM library

4 participants