diff --git a/data/blood/glucose/glucose.go b/data/blood/glucose/glucose.go index f91f0d671..6174423df 100644 --- a/data/blood/glucose/glucose.go +++ b/data/blood/glucose/glucose.go @@ -2,7 +2,9 @@ package glucose import ( "math" + "slices" + "github.com/tidepool-org/platform/errors" "github.com/tidepool-org/platform/pointer" ) @@ -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 { @@ -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 { diff --git a/data/types/blood/glucose/glucose.go b/data/types/blood/glucose/glucose.go index 422db011c..75ea9dcc6 100644 --- a/data/types/blood/glucose/glucose.go +++ b/data/types/blood/glucose/glucose.go @@ -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" ) @@ -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" + + 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) +} + +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{ + {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 +} diff --git a/data/types/blood/glucose/glucose_test.go b/data/types/blood/glucose/glucose_test.go index c343f6ce9..ab7994d88 100644 --- a/data/types/blood/glucose/glucose_test.go +++ b/data/types/blood/glucose/glucose_test.go @@ -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") + }) + }) + }) }) diff --git a/summary/types/glucose.go b/summary/types/glucose.go index 91db53f4f..da309817b 100644 --- a/summary/types/glucose.go +++ b/summary/types/glucose.go @@ -2,7 +2,6 @@ package types import ( "context" - "errors" "fmt" "math" "strconv" @@ -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 @@ -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) + 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.InRange: 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) { @@ -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 + } b.LastRecordDuration = GetDuration(record) return true, nil diff --git a/summary/types/glucose_test.go b/summary/types/glucose_test.go index 5bfa39ce6..cfb24aece 100644 --- a/summary/types/glucose_test.go +++ b/summary/types/glucose_test.go @@ -337,42 +337,42 @@ var _ = Describe("Glucose", func() { glucoseRecord := NewGlucoseWithValue(continuous.Type, bucketTime, VeryLowBloodGlucose-0.1) Expect(glucoseRanges.Total.Records).To(Equal(0)) Expect(glucoseRanges.VeryLow.Records).To(Equal(0)) - glucoseRanges.Update(glucoseRecord) + Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed()) Expect(glucoseRanges.VeryLow.Records).To(Equal(1)) Expect(glucoseRanges.Total.Records).To(Equal(1)) By("adding a Low value") glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, LowBloodGlucose-0.1) Expect(glucoseRanges.Low.Records).To(Equal(0)) - glucoseRanges.Update(glucoseRecord) + Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed()) Expect(glucoseRanges.Low.Records).To(Equal(1)) Expect(glucoseRanges.Total.Records).To(Equal(2)) By("adding a Target value") glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, InTargetBloodGlucose+0.1) Expect(glucoseRanges.Target.Records).To(Equal(0)) - glucoseRanges.Update(glucoseRecord) + Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed()) Expect(glucoseRanges.Target.Records).To(Equal(1)) Expect(glucoseRanges.Total.Records).To(Equal(3)) By("adding a High value") glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, HighBloodGlucose+0.1) Expect(glucoseRanges.High.Records).To(Equal(0)) - glucoseRanges.Update(glucoseRecord) + Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed()) Expect(glucoseRanges.High.Records).To(Equal(1)) Expect(glucoseRanges.Total.Records).To(Equal(4)) By("adding a VeryHigh value") glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, VeryHighBloodGlucose+0.1) Expect(glucoseRanges.VeryHigh.Records).To(Equal(0)) - glucoseRanges.Update(glucoseRecord) + Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed()) Expect(glucoseRanges.VeryHigh.Records).To(Equal(1)) Expect(glucoseRanges.Total.Records).To(Equal(5)) By("adding a High value") glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, ExtremeHighBloodGlucose+0.1) Expect(glucoseRanges.ExtremeHigh.Records).To(Equal(0)) - glucoseRanges.Update(glucoseRecord) + Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed()) Expect(glucoseRanges.ExtremeHigh.Records).To(Equal(1)) Expect(glucoseRanges.VeryHigh.Records).To(Equal(2)) Expect(glucoseRanges.Total.Records).To(Equal(6)) diff --git a/summary/types/summary.go b/summary/types/summary.go index 8a8b28f35..df8706f62 100644 --- a/summary/types/summary.go +++ b/summary/types/summary.go @@ -9,6 +9,7 @@ import ( "go.mongodb.org/mongo-driver/mongo" "github.com/tidepool-org/platform/data" + "github.com/tidepool-org/platform/data/types/blood/glucose" "github.com/tidepool-org/platform/data/types/blood/glucose/continuous" "github.com/tidepool-org/platform/data/types/blood/glucose/selfmonitored" "github.com/tidepool-org/platform/pointer" @@ -20,12 +21,7 @@ const ( SummaryTypeContinuous = "con" SchemaVersion = 5 - lowBloodGlucose = 3.9 - veryLowBloodGlucose = 3.0 - highBloodGlucose = 10.0 - veryHighBloodGlucose = 13.9 - extremeHighBloodGlucose = 19.4 - HoursAgoToKeep = 60 * 24 + HoursAgoToKeep = 60 * 24 OutdatedReasonUploadCompleted = "UPLOAD_COMPLETED" OutdatedReasonDataAdded = "DATA_ADDED" @@ -123,12 +119,13 @@ type Summary[PP PeriodsPt[P, PB, B], PB BucketDataPt[B], P Periods, B BucketData } func NewConfig() Config { + thresholdConfig := glucose.TidepoolADAClassificationThresholdsMmolL.Config() return Config{ SchemaVersion: SchemaVersion, - HighGlucoseThreshold: highBloodGlucose, - VeryHighGlucoseThreshold: veryHighBloodGlucose, - LowGlucoseThreshold: lowBloodGlucose, - VeryLowGlucoseThreshold: veryLowBloodGlucose, + HighGlucoseThreshold: thresholdConfig[glucose.High], + VeryHighGlucoseThreshold: thresholdConfig[glucose.VeryHigh], + LowGlucoseThreshold: thresholdConfig[glucose.Low], + VeryLowGlucoseThreshold: thresholdConfig[glucose.VeryLow], } }