Skip to content
Open
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
33 changes: 33 additions & 0 deletions data/blood/glucose/glucose.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package glucose

import (
"math"
"slices"

"github.com/tidepool-org/platform/errors"
"github.com/tidepool-org/platform/pointer"
)

Expand Down Expand Up @@ -70,6 +72,10 @@ func NormalizeUnits(units *string) *string {
return units
}

// NormalizeValueForUnits converts Mg/dL values to Mmol/L.
//
// Values paired with any other units (including nil or typos) are NOT normalized or
// modified, but are returned as-is.
func NormalizeValueForUnits(value *float64, units *string) *float64 {
if value != nil && units != nil {
switch *units {
Expand All @@ -82,6 +88,33 @@ func NormalizeValueForUnits(value *float64, units *string) *float64 {
return value
}

// NormalizeValueForUnitsSafer behaves like NormalizeValueForUnits, but with more safety.
//
// Where "safety" means returning an error in certain cases where its namesake would return
// the original value.
//
// Notable cases include:
// - when units is nil but values is not
// - when units are not a recognized value in Units()
func NormalizeValueForUnitsSafer(value *float64, units *string) (float64, error) {
if units == nil {
return 0, errors.New("unable to normalize: unhandled units: nil")
}
if !slices.Contains(Units(), *units) {
return 0, errors.Newf("unable to normalize: unhandled units: %s", *units)
}
if value == nil {
return 0, errors.New("unable to normalize: unhandled value: nil")
}
normalized := NormalizeValueForUnits(value, units)
if normalized == nil {
// The only time this should happen is when a nil value was passed, and that case is
// covered above, but it's better to be safe than sorry.
return 0, errors.New("unable to normalize: normalization returned nil")
}
return *normalized, nil
}

func ValueRangeForRateUnits(rateUnits *string) (float64, float64) {
if rateUnits != nil {
switch *rateUnits {
Expand Down
104 changes: 104 additions & 0 deletions data/types/blood/glucose/glucose.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package glucose

import (
"cmp"
"math"
"slices"

"github.com/tidepool-org/platform/data"
dataBloodGlucose "github.com/tidepool-org/platform/data/blood/glucose"
"github.com/tidepool-org/platform/data/types/blood"
"github.com/tidepool-org/platform/errors"
"github.com/tidepool-org/platform/structure"
)

Expand Down Expand Up @@ -33,3 +38,102 @@ func (g *Glucose) Normalize(normalizer data.Normalizer) {
g.Value = dataBloodGlucose.NormalizeValueForUnits(g.Value, units)
}
}

func roundToEvenWithDecimalPlaces(v float64, decimals int) float64 {
if decimals == 0 {
return math.RoundToEven(v)
}
coef := math.Pow10(decimals)
return math.RoundToEven(v*coef) / coef
}

type Classification string

const (
ClassificationInvalid Classification = "invalid"
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we get rid of this edge case?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nope. Go's type system isn't expressive enough.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

...unless you have a secret you're not sharing? 🤔


VeryLow = "very low"
Low = "low"
InRange = "in range"
High = "high"
VeryHigh = "very high"
ExtremelyHigh = "extremely high"
)

type classificationThreshold struct {
Name Classification
Value float64
Inclusive bool
}

type Classifier []classificationThreshold

func (c Classifier) Classify(g *Glucose) (Classification, error) {
// All values are normalized to MmolL before classification. To not do so risks
// introducing inconsistency between frontend, backend, and/or other reports. See
// BACK-3800 for details.
normalized, err := dataBloodGlucose.NormalizeValueForUnitsSafer(g.Value, g.Units)
if err != nil {
return ClassificationInvalid, errors.Wrap(err, "unable to classify")
}
// Rounded values are used for all classifications. To not do so risks introducing
// inconsistency between frontend, backend, and/or other reports. See BACK-3800 for
// details.
rounded := roundToEvenWithDecimalPlaces(normalized, 1)
sortThresholds(c)
for _, threshold := range c {
if threshold.Includes(rounded) {
return threshold.Name, nil
}
}
// Ensure your highest threshold has a value like math.MaxFloat64 to avoid this.
return ClassificationInvalid, errors.Newf("unable to classify value: %v", *g)
Copy link
Contributor

Choose a reason for hiding this comment

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

To remove this edge case (and remove the math.MaxFloat64) can we just remove the last classificationThreshold below and assume that if it isn't yet classified then in must be ExtremelyHigh?

Copy link
Contributor Author

@ewollesen ewollesen Jun 25, 2025

Choose a reason for hiding this comment

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

I considered that, but then there's no way to intentionally return an error if a value is too high (out of range). Trade-offs.

I don't know how/when either is desirable, but it seemed easy enough to leave the capability, and there are sharp edges either way (hence the comment, warning of sharp edges). They can be removed later if that seems more appropriate.

}

func sortThresholds(ts []classificationThreshold) {
slices.SortFunc(ts, func(i, j classificationThreshold) int {
if valueCmp := cmp.Compare(i.Value, j.Value); valueCmp != 0 {
return valueCmp
}
if !i.Inclusive && j.Inclusive {
return -1
} else if i.Inclusive == j.Inclusive {
return 0
} else {
return 1
}
})
}

// Config helps summaries report the configured thresholds.
//
// These will get wrapped up into a Config returned with the summary report. A simple map
// provides flexibility until we better know how custom classification ranges are going to
// work out.
func (c Classifier) Config() map[Classification]float64 {
config := map[Classification]float64{}
for _, classification := range c {
config[classification.Name] = classification.Value
}
return config
}

// TidepoolADAClassificationThresholdsMmolL for classifying glucose values.
//
// In addition to the standard ADA ranges, the Tidepool-specifiic "extremely high" range is
// added.
var TidepoolADAClassificationThresholdsMmolL = Classifier([]classificationThreshold{
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, including the Name in the classificationThreshold could lead to some future errors. For example, not realizing that the order was important, someone could reorder these and cause havoc. Consider a map and explicitly classifying in the correct order (i.e. [VeryLow, Low, InRange, High, VeryHigh] and anything else is ExtremelyHigh)?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, and if a map doesn't include a classification (e.g. 'Low') skip it and go to the next one (e.g. 'In Range').

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll take a look at what I can do. Using a map doesn't prevent errors (the user could miss a key, or leave a key out of the explicit ordering), and I don't wanna overcomplicate it too much.

If someone doesn't read the comment and misses the fact that it's using an ordered data structure and ignores all the unit tests and gets the change past code review... Forgive me, but that's on them, don't you think? There are limits to what I can prevent. I feel like I've taken reasonable steps.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've applied a sort to the thresholds. I dunno that its worth it, but... it's... something. Let me know what you think.

{Name: VeryLow, Value: 3, Inclusive: false}, // Source: https://doi.org/10.2337/dc24-S006
{Name: Low, Value: 3.9, Inclusive: false}, // Source: https://doi.org/10.2337/dc24-S006
{Name: InRange, Value: 10, Inclusive: true}, // Source: https://doi.org/10.2337/dc24-S006
{Name: High, Value: 13.9, Inclusive: true}, // Source: https://doi.org/10.2337/dc24-S006
{Name: VeryHigh, Value: 19.4, Inclusive: true}, // Source: https://doi.org/10.2337/dc24-S006
{Name: ExtremelyHigh, Value: math.MaxFloat64, Inclusive: true}, // Source: BACK-2963
})

func (c classificationThreshold) Includes(value float64) bool {
if c.Inclusive && value <= c.Value {
return true
}
return value < c.Value
}
139 changes: 139 additions & 0 deletions data/types/blood/glucose/glucose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,4 +481,143 @@ var _ = Describe("Glucose", func() {
),
)
})

Context("Classify", func() {
var MmolL = pointer.FromAny(dataBloodGlucose.MmolL)
var MgdL = pointer.FromAny(dataBloodGlucose.MgdL)

checkClassification := func(value float64, expected glucose.Classification) {
GinkgoHelper()
datum := dataTypesBloodGlucoseTest.NewGlucose(MmolL)
datum.Value = pointer.FromAny(value)
got, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
Expect(err).To(Succeed())
Expect(got).To(Equal(expected))
}

checkClassificationMgdL := func(value float64, expected glucose.Classification) {
GinkgoHelper()
datum := dataTypesBloodGlucoseTest.NewGlucose(MgdL)
datum.Value = pointer.FromAny(value)
got, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
Expect(err).To(Succeed())
Expect(got).To(Equal(expected))
}

It("classifies 2.9 as very low", func() {
checkClassification(2.9, "very low")
})

It("classifies 3.0 as low", func() {
checkClassification(3.0, "low")
})

It("classifies 3.8 as low", func() {
checkClassification(3.8, "low")
})

It("classifies 3.9 as in range", func() {
checkClassification(3.9, "in range")
})

It("classifies 10.0 as in range", func() {
checkClassification(10.0, "in range")
})

It("classifies 10.1 as high", func() {
checkClassification(10.1, "high")
})

It("classifies 13.9 as high", func() {
checkClassification(13.9, "high")
})

It("classifies 14.0 as very high", func() {
checkClassification(14.0, "very high")
})

It("classifies 19.4 as very high", func() {
checkClassification(19.4, "very high")
})

It("classifies 19.5 as extremely high", func() {
checkClassification(19.5, "extremely high")
})

When("its classification depends on rounding", func() {
It("classifies 2.95 as low", func() {
checkClassification(2.95, "low")
})

It("classifies 3.85 as low", func() {
checkClassification(3.85, "low")
})

It("classifies 10.05 as in range", func() {
checkClassification(10.05, "in range")
})
})

When("it doesn't recognize the units", func() {
It("returns an error", func() {
badUnits := "blah"
datum := dataTypesBloodGlucoseTest.NewGlucose(&badUnits)
datum.Value = pointer.FromAny(5.0)
_, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
Expect(err).To(MatchError(ContainSubstring("unable to normalize: unhandled units")))
})
})

It("can handle values in mg/dL", func() {
checkClassificationMgdL(100.0, "in range")
})

When("it's value is nil", func() {
It("returns an error", func() {
datum := dataTypesBloodGlucoseTest.NewGlucose(MmolL)
datum.Value = nil
_, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
Expect(err).To(MatchError(ContainSubstring("unable to normalize: unhandled value")))
})
})

When("it's units are nil", func() {
It("returns an error", func() {
datum := dataTypesBloodGlucoseTest.NewGlucose(nil)
datum.Value = pointer.FromAny(5.0)
_, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
Expect(err).To(MatchError(ContainSubstring("unable to normalize: unhandled units")))
})
})

Context("tests from product", func() {
It("classifies 69.5 mg/dL as Low", func() {
checkClassificationMgdL(69.5, "in range")
})

It("classifies 70.0 mg/dL as In Range", func() {
checkClassificationMgdL(70, "in range")
})

It("classifies 180.4 mg/dL as In Range", func() {
checkClassificationMgdL(180.4, "in range")
})

It("classifies 180.5 mg/dL as In Range", func() {
checkClassificationMgdL(180.5, "in range")
})

It("classifies 181.0 mg/dL as In Range", func() {
checkClassificationMgdL(181, "in range")
})

It("classifies 10.05 mmol/L as in range", func() {
checkClassification(10.05, "in range")
})

It("classifies 10.15 mmol/L as High", func() {
checkClassification(10.15, "high")
})
})
})
})
Loading