Skip to content

Commit 94cedb3

Browse files
Merge pull request #106 from vfrank66:issue-105/add-ucum-conversion-support
PiperOrigin-RevId: 765392994
2 parents ccdaaaf + be44af6 commit 94cedb3

20 files changed

+1168
-79
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ Language:
5757
- No support for related context retrieves
5858
- No support for uncertainties
5959
- No support for importing or exporting ELM
60-
- No support for Quantity unit conversion
6160

6261
## Getting Started
6362

interpreter/operator_arithmetic.go

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/google/cql/model"
2525
"github.com/google/cql/result"
2626
"github.com/google/cql/types"
27+
"github.com/google/cql/ucum"
2728
)
2829

2930
// ARITHMETIC OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#arithmetic-operators-4
@@ -461,31 +462,61 @@ func bigIntPow(l, r int64) any {
461462
return bigResult.Int64()
462463
}
463464

464-
// TODO(b/319156186): Add support for converting quantities between different units.
465-
// TODO(b/319333058): Add support for Date + Quantity arithmetic.
466-
// TODO(b/319525986): Add support for additional arithmetic for Quantities.
465+
// Arithmetic operations for Quantity values.
466+
// Has support for conversion between units.
467467
func arithmeticQuantity(m model.IBinaryExpression, l, r result.Quantity) (result.Value, error) {
468-
if l.Unit != r.Unit {
469-
return result.Value{}, fmt.Errorf("internal error - quantity unit conversion unsupported, got units: %s and %s", l.Unit, r.Unit)
470-
}
471468
switch m.(type) {
472-
case *model.Add:
473-
return result.New(result.Quantity{Value: l.Value + r.Value, Unit: l.Unit})
474-
case *model.Subtract:
469+
case *model.Add, *model.Subtract:
470+
// For addition and subtraction, convert the right operand to the left's unit if needed.
471+
if l.Unit != r.Unit {
472+
convertedVal, err := ucum.ConvertUnit(r.Value, string(r.Unit), string(l.Unit))
473+
if err != nil {
474+
return result.Value{}, fmt.Errorf("cannot convert between units: %s and %s: %v", r.Unit, l.Unit, err)
475+
}
476+
r.Value = convertedVal
477+
r.Unit = l.Unit
478+
}
479+
480+
// Now perform the operation with matching units
481+
if m.GetName() == "Add" {
482+
return result.New(result.Quantity{Value: l.Value + r.Value, Unit: l.Unit})
483+
}
475484
return result.New(result.Quantity{Value: l.Value - r.Value, Unit: l.Unit})
476485
case *model.Multiply:
477-
return result.Value{}, fmt.Errorf("internal error - quantity multiplication unsupported, got: %v and %v", l, r)
486+
resultUnit := ucum.GetProductOfUnits(string(l.Unit), string(r.Unit))
487+
return result.New(result.Quantity{Value: l.Value * r.Value, Unit: model.Unit(resultUnit)})
478488
case *model.TruncatedDivide:
479-
return result.New(result.Quantity{Value: float64(int64(l.Value / r.Value)), Unit: model.ONEUNIT})
489+
if r.Value == 0 {
490+
return result.New(nil)
491+
}
492+
// Otherwise, the result unit is the quotient of the units
493+
resultUnit := ucum.GetQuotientOfUnits(string(l.Unit), string(r.Unit))
494+
return result.New(result.Quantity{Value: float64(int64(l.Value / r.Value)), Unit: model.Unit(resultUnit)})
495+
480496
case *model.Divide:
481-
return result.New(result.Quantity{Value: l.Value / r.Value, Unit: model.ONEUNIT})
482-
case *model.Modulo:
483-
if l.Unit != r.Unit {
484-
return result.Value{}, fmt.Errorf("internal error - quantity modulo with different units unsupported, got units: %s and %s", l.Unit, r.Unit)
497+
if r.Value == 0 {
498+
return result.New(nil)
485499
}
500+
// For division, the result unit is dimensionless "1" if units are the same.
501+
if l.Unit == r.Unit {
502+
return result.New(result.Quantity{Value: l.Value / r.Value, Unit: model.ONEUNIT})
503+
}
504+
// Otherwise, the result unit is the quotient of the units
505+
resultUnit := ucum.GetQuotientOfUnits(string(l.Unit), string(r.Unit))
506+
return result.New(result.Quantity{Value: l.Value / r.Value, Unit: model.Unit(resultUnit)})
507+
case *model.Modulo:
486508
if r.Value == 0 {
487509
return result.New(nil)
488510
}
511+
// For modulo, units must match or be convertible
512+
if l.Unit != r.Unit {
513+
// Try to convert r to l's unit
514+
convertedVal, err := ucum.ConvertUnit(r.Value, string(r.Unit), string(l.Unit))
515+
if err != nil {
516+
return result.New(nil)
517+
}
518+
r.Value = convertedVal
519+
}
489520
return result.New(result.Quantity{Value: math.Mod(l.Value, r.Value), Unit: l.Unit})
490521
}
491522
return result.Value{}, fmt.Errorf("internal error - unsupported Binary Arithmetic Expression %v", m)

interpreter/operator_comparison.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ import (
1818
"cmp"
1919
"errors"
2020
"fmt"
21+
"math"
22+
"strconv"
2123
"strings"
2224
"unicode"
2325

2426
"github.com/google/cql/internal/convert"
2527
"github.com/google/cql/model"
2628
"github.com/google/cql/result"
2729
"github.com/google/cql/types"
30+
"github.com/google/cql/ucum"
2831
)
2932

3033
// COMPARISON OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#comparison-operators-4
@@ -36,9 +39,51 @@ func (i *interpreter) evalEqual(_ model.IBinaryExpression, lObj, rObj result.Val
3639
if result.IsNull(lObj) || result.IsNull(rObj) {
3740
return result.New(nil)
3841
}
42+
43+
// Special handling for Quantity types with different units
44+
_, lIsQuantity := lObj.GolangValue().(result.Quantity)
45+
_, rIsQuantity := rObj.GolangValue().(result.Quantity)
46+
if lIsQuantity && rIsQuantity {
47+
return evalEqualQuantity(nil, lObj, rObj)
48+
}
49+
3950
return result.New(lObj.Equal(rObj))
4051
}
4152

53+
// =(left Quantity, right Quantity) Boolean
54+
// https://cql.hl7.org/09-b-cqlreference.html#equal
55+
// If either unit is invalid, returns null.
56+
func evalEqualQuantity(_ model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) {
57+
if result.IsNull(lObj) || result.IsNull(rObj) {
58+
return result.New(nil)
59+
}
60+
l, r, err := applyToValues(lObj, rObj, result.ToQuantity)
61+
if err != nil {
62+
return result.Value{}, err
63+
}
64+
// If units are the same, compare values directly
65+
if l.Unit == r.Unit {
66+
return result.New(l.Value == r.Value)
67+
}
68+
69+
// If units are different, try to convert
70+
fromVal := l.Value
71+
fromUnit := string(l.Unit)
72+
toUnit := string(r.Unit)
73+
74+
// Try to convert left value to right unit for comparison.
75+
// It's not clear if this should return null for all failures here, for incompatible DateTime
76+
// unites this is true, but for other units it may not be.
77+
convertedVal, err := ucum.ConvertUnit(fromVal, fromUnit, toUnit)
78+
if err != nil {
79+
return result.New(nil)
80+
}
81+
82+
// Compare with converted value using epsilon comparison for floating point values.
83+
const epsilon = 1e-10
84+
return result.New(math.Abs(convertedVal-r.Value) < epsilon)
85+
}
86+
4287
// =(left DateTime, right DateTime) Boolean
4388
// =(left Date, right Date) Boolean
4489
// https://cql.hl7.org/09-b-cqlreference.html#equal
@@ -183,6 +228,33 @@ func (i *interpreter) evalEquivalentList(_ model.IBinaryExpression, lObj, rObj r
183228
return result.New(true)
184229
}
185230

231+
// getDecimalPrecision returns the number of significant digits after the decimal point.
232+
// It trims trailing zeros according to the CQL specification.
233+
func getDecimalPrecision(value float64) int {
234+
// Convert to string to determine precision.
235+
str := strconv.FormatFloat(value, 'f', -1, 64)
236+
237+
// Find the decimal point.
238+
decimalPos := strings.IndexRune(str, '.')
239+
if decimalPos == -1 {
240+
return 0
241+
}
242+
243+
// Extract the decimal part and trim trailing zeros.
244+
decimalPart := strings.TrimRight(str[decimalPos+1:], "0")
245+
return len(decimalPart)
246+
}
247+
248+
// roundToDecimalPlaces rounds a float64 to the specified number of decimal places.
249+
func roundToDecimalPlaces(num float64, places int) float64 {
250+
if places <= 0 {
251+
return math.Round(num)
252+
}
253+
254+
factor := math.Pow(10, float64(places))
255+
return math.Round(num*factor) / factor
256+
}
257+
186258
// ~(left String, right String) Boolean
187259
// https://cql.hl7.org/09-b-cqlreference.html#equivalent
188260
func evalEquivalentString(_ model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) {
@@ -213,6 +285,41 @@ func equivalentString(input string) string {
213285
return out.String()
214286
}
215287

288+
// ~(left Quantity, right Quantity) Boolean
289+
// https://cql.hl7.org/09-b-cqlreference.html#equivalent
290+
func evalEquivalentQuantity(_ model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) {
291+
if result.IsNull(lObj) && result.IsNull(rObj) {
292+
return result.New(true)
293+
}
294+
if result.IsNull(lObj) != result.IsNull(rObj) {
295+
return result.New(false)
296+
}
297+
298+
l, r, err := applyToValues(lObj, rObj, result.ToQuantity)
299+
if err != nil {
300+
return result.Value{}, err
301+
}
302+
303+
// If units are the same, compare values directly.
304+
if l.Unit == r.Unit {
305+
return result.New(l.Value == r.Value)
306+
}
307+
308+
// Try to convert l to r's unit for comparison.
309+
fromVal := l.Value
310+
fromUnit := string(l.Unit)
311+
toUnit := string(r.Unit)
312+
313+
// Convert left value to right unit.
314+
convertedVal, err := ucum.ConvertUnit(fromVal, fromUnit, toUnit)
315+
if err != nil {
316+
return result.New(nil)
317+
}
318+
// Compare with converted value using epsilon comparison.
319+
const epsilon = 1e-10
320+
return result.New(math.Abs(convertedVal-r.Value) < epsilon)
321+
}
322+
216323
// ~(left Interval<T>, right Interval<T>) Boolean
217324
// https://cql.hl7.org/09-b-cqlreference.html#equivalent-1
218325
func (i *interpreter) evalEquivalentInterval(_ model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) {
@@ -457,3 +564,37 @@ func compare[n cmp.Ordered](m model.IBinaryExpression, l, r n) (result.Value, er
457564
}
458565
return result.Value{}, fmt.Errorf("internal error - unsupported Binary Comparison Expression %v", m)
459566
}
567+
568+
// op(left Quantity, right Quantity) Boolean
569+
// https://cql.hl7.org/09-b-cqlreference.html#less
570+
// https://cql.hl7.org/09-b-cqlreference.html#less-or-equal
571+
// https://cql.hl7.org/09-b-cqlreference.html#greater
572+
// https://cql.hl7.org/09-b-cqlreference.html#greater-or-equal
573+
func evalCompareQuantity(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) {
574+
if result.IsNull(lObj) || result.IsNull(rObj) {
575+
return result.New(nil)
576+
}
577+
578+
l, r, err := applyToValues(lObj, rObj, result.ToQuantity)
579+
if err != nil {
580+
return result.Value{}, err
581+
}
582+
583+
// If units are the same, compare values directly.
584+
// In the future we should calculate the smaller unit instead.
585+
if l.Unit == r.Unit {
586+
return compare(m, l.Value, r.Value)
587+
}
588+
589+
// If units are different, try to convert.
590+
fromVal := l.Value
591+
fromUnit := string(l.Unit)
592+
toUnit := string(r.Unit)
593+
594+
// Try to convert left value to right unit for comparison.
595+
convertedVal, err := ucum.ConvertUnit(fromVal, fromUnit, toUnit)
596+
if err != nil {
597+
return result.New(nil)
598+
}
599+
return compare(m, convertedVal, r.Value)
600+
}

interpreter/operator_datetime.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/google/cql/model"
2424
"github.com/google/cql/result"
2525
"github.com/google/cql/types"
26+
"github.com/google/cql/ucum"
2627
)
2728

2829
// DATETIME OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#datetime-operators-2
@@ -282,13 +283,22 @@ func beforeOrEqualDateTimeWithPrecision(l, r result.DateTime, p model.DateTimePr
282283
// CanConvertQuantity(left Quantity, right String) Boolean
283284
// https://cql.hl7.org/09-b-cqlreference.html#canconvertquantity
284285
// Returns whether or not a Quantity can be converted into the given unit string.
285-
// This is not a required function to implement, and for the time being we are
286-
// choosing to not implement unit conversion so this function always returns false.
287286
func evalCanConvertQuantity(b model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) {
288287
if result.IsNull(lObj) || result.IsNull(rObj) {
289288
return result.New(nil)
290289
}
291-
return result.New(false)
290+
l, err := result.ToQuantity(lObj)
291+
if err != nil {
292+
return result.Value{}, err
293+
}
294+
r, err := result.ToString(rObj)
295+
if err != nil {
296+
return result.Value{}, err
297+
}
298+
if _, err := ucum.ConvertUnit(l.Value, string(l.Unit), r); err != nil {
299+
return result.New(false)
300+
}
301+
return result.New(true)
292302
}
293303

294304
// difference in _precision_ between(left Date, right Date) Integer

interpreter/operator_dispatcher.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,14 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over
919919
Operands: []types.IType{types.Long, types.Long},
920920
Result: evalEquivalentSimpleType,
921921
},
922+
{
923+
Operands: []types.IType{types.Decimal, types.Decimal},
924+
Result: evalEquivalentSimpleType,
925+
},
926+
{
927+
Operands: []types.IType{types.Quantity, types.Quantity},
928+
Result: evalEquivalentQuantity,
929+
},
922930
{
923931
Operands: []types.IType{types.String, types.String},
924932
Result: evalEquivalentString,
@@ -976,6 +984,10 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over
976984
Operands: []types.IType{types.DateTime, types.DateTime},
977985
Result: evalCompareDateTime,
978986
},
987+
{
988+
Operands: []types.IType{types.Quantity, types.Quantity},
989+
Result: evalCompareQuantity,
990+
},
979991
}, nil
980992
case *model.After, *model.Before, *model.SameOrAfter, *model.SameOrBefore:
981993
return []convert.Overload[evalBinarySignature]{
@@ -1022,6 +1034,13 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over
10221034
Result: evalCanConvertQuantity,
10231035
},
10241036
}, nil
1037+
case *model.ConvertQuantity:
1038+
return []convert.Overload[evalBinarySignature]{
1039+
{
1040+
Operands: []types.IType{types.Quantity, types.String},
1041+
Result: evalConvertQuantity,
1042+
},
1043+
}, nil
10251044
case *model.DifferenceBetween:
10261045
return []convert.Overload[evalBinarySignature]{
10271046
{

0 commit comments

Comments
 (0)