From fc16e8c673d59c4387cb2eeb900ffb69cb860162 Mon Sep 17 00:00:00 2001 From: Eric Wollesen Date: Wed, 18 Jun 2025 11:56:34 -0600 Subject: [PATCH 1/5] round glucose values via "round-to-even" before classifying The aim is to maintain consistency with the front end, and be easy to explain when values don't align with other interpretations due to rounding differences. BACK-3800 --- data/blood/glucose/glucose.go | 33 +++++++ data/types/blood/glucose/glucose.go | 75 ++++++++++++++++ data/types/blood/glucose/glucose_test.go | 104 +++++++++++++++++++++++ summary/types/glucose.go | 49 ++++++----- summary/types/glucose_test.go | 12 +-- 5 files changed, 247 insertions(+), 26 deletions(-) diff --git a/data/blood/glucose/glucose.go b/data/blood/glucose/glucose.go index f91f0d6716..6174423dff 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 422db011c7..9bd42ec36f 100644 --- a/data/types/blood/glucose/glucose.go +++ b/data/types/blood/glucose/glucose.go @@ -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" ) @@ -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" + + 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) +} + +// 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{ + {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 + {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 c343f6ce9f..1c42a73ebe 100644 --- a/data/types/blood/glucose/glucose_test.go +++ b/data/types/blood/glucose/glucose_test.go @@ -481,4 +481,108 @@ 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)) + } + + 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() { + datum := dataTypesBloodGlucoseTest.NewGlucose(MgdL) + datum.Value = pointer.FromAny(100.0) + got, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum) + Expect(err).To(Succeed()) + Expect(string(got)).To(Equal("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"))) + }) + }) + }) }) diff --git a/summary/types/glucose.go b/summary/types/glucose.go index 91db53f4ff..47ed492350 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.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) { @@ -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 5bfa39ce60..cfb24aeceb 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)) From 291c31d46096c074a187df0b831a23dce5d19e5f Mon Sep 17 00:00:00 2001 From: Eric Wollesen Date: Mon, 23 Jun 2025 17:27:11 -0600 Subject: [PATCH 2/5] adds test cases specified by product in BACK-3800 BACK-3800 --- data/types/blood/glucose/glucose_test.go | 45 +++++++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/data/types/blood/glucose/glucose_test.go b/data/types/blood/glucose/glucose_test.go index 1c42a73ebe..07669df4b3 100644 --- a/data/types/blood/glucose/glucose_test.go +++ b/data/types/blood/glucose/glucose_test.go @@ -495,6 +495,15 @@ var _ = Describe("Glucose", func() { 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") }) @@ -560,11 +569,7 @@ var _ = Describe("Glucose", func() { }) It("can handle values in mg/dL", func() { - datum := dataTypesBloodGlucoseTest.NewGlucose(MgdL) - datum.Value = pointer.FromAny(100.0) - got, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum) - Expect(err).To(Succeed()) - Expect(string(got)).To(Equal("on target")) + checkClassificationMgdL(100.0, "on target") }) When("it's value is nil", func() { @@ -584,5 +589,35 @@ var _ = Describe("Glucose", func() { 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") + }) + }) }) }) From 0e055a665c150c9ec6ff0f77f9005f1064952187 Mon Sep 17 00:00:00 2001 From: Eric Wollesen Date: Wed, 25 Jun 2025 13:17:17 -0600 Subject: [PATCH 3/5] address code review issues (detailed list below) - modify roundToEventWithDecimalPlaces work for values < 0 It was never intended to be used that way, but it costs nothing to allow it. - use math.Pow10 > math.Pow(10,x) I don't think it makes a difference, but it's certainly possible that Pow10 has some optimizations, so why not. - rename "on target" => "in range" While some Tidepool docs used "target", Darin indicates that it's awkward and strange sounding, while "in range" is much more common. A quick review of some of the ADA materials correlates with Darin so ... change made. - Adjust the returned summary Config to use the threshold values actually used in the classification These used to be constants in another part of the code. Now they're pulled from the same place, so they'll hopefully not get out of sync. BACK-3800 --- data/types/blood/glucose/glucose.go | 21 +++++++++++--- data/types/blood/glucose/glucose_test.go | 36 ++++++++++++------------ summary/types/glucose.go | 2 +- summary/types/summary.go | 17 +++++------ 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/data/types/blood/glucose/glucose.go b/data/types/blood/glucose/glucose.go index 9bd42ec36f..bedc2be59c 100644 --- a/data/types/blood/glucose/glucose.go +++ b/data/types/blood/glucose/glucose.go @@ -38,10 +38,10 @@ func (g *Glucose) Normalize(normalizer data.Normalizer) { } func roundToEvenWithDecimalPlaces(v float64, decimals int) float64 { - if decimals < 1 { + if decimals == 0 { return math.RoundToEven(v) } - coef := math.Pow(10, float64(decimals)) + coef := math.Pow10(decimals) return math.RoundToEven(v*coef) / coef } @@ -52,7 +52,7 @@ const ( VeryLow = "very low" Low = "low" - OnTarget = "on target" + InRange = "in range" High = "high" VeryHigh = "very high" ExtremelyHigh = "extremely high" @@ -84,6 +84,19 @@ func (c Classifier) Classify(g *Glucose) (Classification, error) { return ClassificationInvalid, errors.Newf("unable to classify value: %v", *g) } +// 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. // // All values are normalized to MmolL before classification. @@ -96,7 +109,7 @@ func (c Classifier) Classify(g *Glucose) (Classification, error) { 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: OnTarget, Value: 10, Inclusive: true}, // 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 diff --git a/data/types/blood/glucose/glucose_test.go b/data/types/blood/glucose/glucose_test.go index 07669df4b3..ab7994d88a 100644 --- a/data/types/blood/glucose/glucose_test.go +++ b/data/types/blood/glucose/glucose_test.go @@ -516,12 +516,12 @@ var _ = Describe("Glucose", func() { checkClassification(3.8, "low") }) - It("classifies 3.9 as on target", func() { - checkClassification(3.9, "on target") + It("classifies 3.9 as in range", func() { + checkClassification(3.9, "in range") }) - It("classifies 10.0 as on target", func() { - checkClassification(10.0, "on target") + It("classifies 10.0 as in range", func() { + checkClassification(10.0, "in range") }) It("classifies 10.1 as high", func() { @@ -553,8 +553,8 @@ var _ = Describe("Glucose", func() { checkClassification(3.85, "low") }) - It("classifies 10.05 as on target", func() { - checkClassification(10.05, "on target") + It("classifies 10.05 as in range", func() { + checkClassification(10.05, "in range") }) }) @@ -569,7 +569,7 @@ var _ = Describe("Glucose", func() { }) It("can handle values in mg/dL", func() { - checkClassificationMgdL(100.0, "on target") + checkClassificationMgdL(100.0, "in range") }) When("it's value is nil", func() { @@ -592,27 +592,27 @@ var _ = Describe("Glucose", func() { Context("tests from product", func() { It("classifies 69.5 mg/dL as Low", func() { - checkClassificationMgdL(69.5, "on target") + checkClassificationMgdL(69.5, "in range") }) - It("classifies 70.0 mg/dL as On Target", func() { - checkClassificationMgdL(70, "on target") + It("classifies 70.0 mg/dL as In Range", func() { + checkClassificationMgdL(70, "in range") }) - It("classifies 180.4 mg/dL as On Target", func() { - checkClassificationMgdL(180.4, "on target") + It("classifies 180.4 mg/dL as In Range", func() { + checkClassificationMgdL(180.4, "in range") }) - It("classifies 180.5 mg/dL as On Target", func() { - checkClassificationMgdL(180.5, "on target") + It("classifies 180.5 mg/dL as In Range", func() { + checkClassificationMgdL(180.5, "in range") }) - It("classifies 181.0 mg/dL as On Target", func() { - checkClassificationMgdL(181, "on target") + It("classifies 181.0 mg/dL as In Range", func() { + checkClassificationMgdL(181, "in range") }) - It("classifies 10.05 mmol/L as On Target", func() { - checkClassification(10.05, "on target") + It("classifies 10.05 mmol/L as in range", func() { + checkClassification(10.05, "in range") }) It("classifies 10.15 mmol/L as High", func() { diff --git a/summary/types/glucose.go b/summary/types/glucose.go index 47ed492350..da309817b3 100644 --- a/summary/types/glucose.go +++ b/summary/types/glucose.go @@ -177,7 +177,7 @@ func (rs *GlucoseRanges) Update(record *glucoseDatum.Glucose) error { case glucoseDatum.Low: rs.Low.Update(record) rs.AnyLow.Update(record) - case glucoseDatum.OnTarget: + case glucoseDatum.InRange: rs.Target.Update(record) case glucoseDatum.High: rs.High.Update(record) diff --git a/summary/types/summary.go b/summary/types/summary.go index 8a8b28f354..df8706f62b 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], } } From ef35b13ca2269c4c4e9ecb46d59e9699fc3fc6e9 Mon Sep 17 00:00:00 2001 From: Eric Wollesen Date: Wed, 25 Jun 2025 13:31:07 -0600 Subject: [PATCH 4/5] tweak some comment wording BACK-3800 --- data/types/blood/glucose/glucose.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/data/types/blood/glucose/glucose.go b/data/types/blood/glucose/glucose.go index bedc2be59c..5d94995c81 100644 --- a/data/types/blood/glucose/glucose.go +++ b/data/types/blood/glucose/glucose.go @@ -67,6 +67,9 @@ type classificationThreshold struct { 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") @@ -99,8 +102,6 @@ func (c Classifier) Config() map[Classification]float64 { // 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. // From c1ed79ddf4759becc784f7de2693e6cb09359b6f Mon Sep 17 00:00:00 2001 From: Eric Wollesen Date: Wed, 25 Jun 2025 13:31:30 -0600 Subject: [PATCH 5/5] sort classificationThresholds to (try to) prevent ordering mistakes Sorts by Value ascending, then exclusive before inclusive. This should have a negligible performance impact, while providing extra safety, should someone mistakenly re-arrange the threshold classifications. One has to understand the sorting, but it doesn't even matter unless there are multiple classifications with the same Value, so that's a very small risk. BACK-3800 --- data/types/blood/glucose/glucose.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/data/types/blood/glucose/glucose.go b/data/types/blood/glucose/glucose.go index 5d94995c81..75ea9dcc67 100644 --- a/data/types/blood/glucose/glucose.go +++ b/data/types/blood/glucose/glucose.go @@ -1,7 +1,9 @@ package glucose import ( + "cmp" "math" + "slices" "github.com/tidepool-org/platform/data" dataBloodGlucose "github.com/tidepool-org/platform/data/blood/glucose" @@ -78,6 +80,7 @@ func (c Classifier) Classify(g *Glucose) (Classification, error) { // 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 @@ -87,6 +90,21 @@ func (c Classifier) Classify(g *Glucose) (Classification, error) { 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 @@ -104,9 +122,6 @@ func (c Classifier) Config() map[Classification]float64 { // // 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{ {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