Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
75 changes: 75 additions & 0 deletions data/types/blood/glucose/glucose.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package glucose

import (
"math"

"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 +36,75 @@ func (g *Glucose) Normalize(normalizer data.Normalizer) {
g.Value = dataBloodGlucose.NormalizeValueForUnits(g.Value, units)
}
}

func roundToEvenWithDecimalPlaces(v float64, decimals int) float64 {
if decimals < 1 {
return math.RoundToEven(v)
}
coef := math.Pow(10, float64(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"
OnTarget = "on target"
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) {
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)
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.

}

// TidepoolADAClassificationThresholdsMmolL for classifying glucose values.
//
// All values are normalized to MmolL before classification.
//
// In addition to the standard ADA ranges, the Tidepool-specifiic "extremely high" range is
// added.
//
// It is the author's responsibility to ensure the thresholds remain sorted from smallest to
// largest.
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: OnTarget, Value: 10, Inclusive: true}, // Source: https://doi.org/10.2337/dc24-S006
Copy link
Contributor

Choose a reason for hiding this comment

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

Will we ever have different values for Inclusive for a specific classifier or will the classification itself determine the inclusiveness. That is, is High always inclusive, or can I have a classifier where it is not?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will we ever have different values for Inclusive for a specific classifier or will the classification itself determine the inclusiveness. That is, is High always inclusive, or can I have a classifier where it is not?

I don't know. I know there are "custom" ranges coming down the pipe, which is why I've kept the possibility.

{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 on target", func() {
checkClassification(3.9, "on target")
})

It("classifies 10.0 as on target", func() {
checkClassification(10.0, "on target")
})

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 on target", func() {
checkClassification(10.05, "on target")
})
})

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, "on target")
})

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, "on target")
})

It("classifies 70.0 mg/dL as On Target", func() {
checkClassificationMgdL(70, "on target")
})

It("classifies 180.4 mg/dL as On Target", func() {
checkClassificationMgdL(180.4, "on target")
})

It("classifies 180.5 mg/dL as On Target", func() {
checkClassificationMgdL(180.5, "on target")
})

It("classifies 181.0 mg/dL as On Target", func() {
checkClassificationMgdL(181, "on target")
})

It("classifies 10.05 mmol/L as On Target", func() {
checkClassification(10.05, "on target")
})

It("classifies 10.15 mmol/L as High", func() {
checkClassification(10.15, "high")
})
})
})
})
49 changes: 29 additions & 20 deletions summary/types/glucose.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package types

import (
"context"
"errors"
"fmt"
"math"
"strconv"
Expand All @@ -14,6 +13,7 @@ import (
"github.com/tidepool-org/platform/data/blood/glucose"
glucoseDatum "github.com/tidepool-org/platform/data/types/blood/glucose"
"github.com/tidepool-org/platform/data/types/blood/glucose/continuous"
"github.com/tidepool-org/platform/errors"
)

const MaxRecordsPerBucket = 60 // one per minute max
Expand Down Expand Up @@ -162,31 +162,38 @@ func (rs *GlucoseRanges) Finalize(days int) {
}
}

func (rs *GlucoseRanges) Update(record *glucoseDatum.Glucose) {
normalizedValue := *glucose.NormalizeValueForUnits(record.Value, record.Units)
func (rs *GlucoseRanges) Update(record *glucoseDatum.Glucose) error {
classification, err := glucoseDatum.TidepoolADAClassificationThresholdsMmolL.Classify(record)
Copy link
Contributor

@toddkazakov toddkazakov Jun 24, 2025

Choose a reason for hiding this comment

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

I think you need to replace the "config" values in the summary with the new thresholds

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My fix for this is somewhat ... awkward maybe? I welcome other ideas, but I think this is going to all be changing soonish with the introduction of alternate ranges, so I'm not sure it's worth spending a ton of time on.

if err != nil {
return err
}

rs.Total.UpdateTotal(record)

if normalizedValue < veryLowBloodGlucose {
switch classification {
case glucoseDatum.VeryLow:
rs.VeryLow.Update(record)
rs.AnyLow.Update(record)
} else if normalizedValue > veryHighBloodGlucose {
rs.VeryHigh.Update(record)
rs.AnyHigh.Update(record)

// VeryHigh is inclusive of extreme high, this is intentional
if normalizedValue >= extremeHighBloodGlucose {
rs.ExtremeHigh.Update(record)
}
} else if normalizedValue < lowBloodGlucose {
case glucoseDatum.Low:
rs.Low.Update(record)
rs.AnyLow.Update(record)
} else if normalizedValue > highBloodGlucose {
rs.AnyHigh.Update(record)
rs.High.Update(record)
} else {
case glucoseDatum.OnTarget:
rs.Target.Update(record)
case glucoseDatum.High:
rs.High.Update(record)
rs.AnyHigh.Update(record)
case glucoseDatum.VeryHigh:
rs.VeryHigh.Update(record)
rs.AnyHigh.Update(record)
case glucoseDatum.ExtremelyHigh:
rs.ExtremeHigh.Update(record)
rs.VeryHigh.Update(record)
rs.AnyHigh.Update(record)
default:
errMsg := "WARNING: unhandled classification %v; THIS SHOULD NEVER OCCUR"
return errors.Newf(errMsg, classification)
}

rs.Total.UpdateTotal(record)
return nil
}

func (rs *GlucoseRanges) CalculateDelta(current, previous *GlucoseRanges) {
Expand Down Expand Up @@ -237,7 +244,9 @@ func (b *GlucoseBucket) Update(r data.Datum, lastData *time.Time) (bool, error)
return false, nil
}

b.GlucoseRanges.Update(record)
if err := b.GlucoseRanges.Update(record); err != nil {
return false, err
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this mean that if a single value has an error then it will cause the entire summary to fail?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does this mean that if a single value has an error then it will cause the entire summary to fail?

A good question. does quick checking At the moment, yes. However, the only possible errors are unhandled classifications or failures to normalize a value, i.e. coding errors.

Coding errors typically fall into the "fail fast and early" category for me, so I feel like this is an appropriate response. Let me know if there's a different convention. I fear that a simple log and forget would lead to bad reports and we might not know why... Though I'd be surprised if there were ever an error raised from this code that wasn't caught via a unit test or QA.

}
b.LastRecordDuration = GetDuration(record)

return true, nil
Expand Down
Loading