Skip to content

Commit fc16e8c

Browse files
committed
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
1 parent 0892271 commit fc16e8c

File tree

5 files changed

+247
-26
lines changed

5 files changed

+247
-26
lines changed

data/blood/glucose/glucose.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package glucose
22

33
import (
44
"math"
5+
"slices"
56

7+
"github.com/tidepool-org/platform/errors"
68
"github.com/tidepool-org/platform/pointer"
79
)
810

@@ -70,6 +72,10 @@ func NormalizeUnits(units *string) *string {
7072
return units
7173
}
7274

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

91+
// NormalizeValueForUnitsSafer behaves like NormalizeValueForUnits, but with more safety.
92+
//
93+
// Where "safety" means returning an error in certain cases where its namesake would return
94+
// the original value.
95+
//
96+
// Notable cases include:
97+
// - when units is nil but values is not
98+
// - when units are not a recognized value in Units()
99+
func NormalizeValueForUnitsSafer(value *float64, units *string) (float64, error) {
100+
if units == nil {
101+
return 0, errors.New("unable to normalize: unhandled units: nil")
102+
}
103+
if !slices.Contains(Units(), *units) {
104+
return 0, errors.Newf("unable to normalize: unhandled units: %s", *units)
105+
}
106+
if value == nil {
107+
return 0, errors.New("unable to normalize: unhandled value: nil")
108+
}
109+
normalized := NormalizeValueForUnits(value, units)
110+
if normalized == nil {
111+
// The only time this should happen is when a nil value was passed, and that case is
112+
// covered above, but it's better to be safe than sorry.
113+
return 0, errors.New("unable to normalize: normalization returned nil")
114+
}
115+
return *normalized, nil
116+
}
117+
85118
func ValueRangeForRateUnits(rateUnits *string) (float64, float64) {
86119
if rateUnits != nil {
87120
switch *rateUnits {

data/types/blood/glucose/glucose.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package glucose
22

33
import (
4+
"math"
5+
46
"github.com/tidepool-org/platform/data"
57
dataBloodGlucose "github.com/tidepool-org/platform/data/blood/glucose"
68
"github.com/tidepool-org/platform/data/types/blood"
9+
"github.com/tidepool-org/platform/errors"
710
"github.com/tidepool-org/platform/structure"
811
)
912

@@ -33,3 +36,75 @@ func (g *Glucose) Normalize(normalizer data.Normalizer) {
3336
g.Value = dataBloodGlucose.NormalizeValueForUnits(g.Value, units)
3437
}
3538
}
39+
40+
func roundToEvenWithDecimalPlaces(v float64, decimals int) float64 {
41+
if decimals < 1 {
42+
return math.RoundToEven(v)
43+
}
44+
coef := math.Pow(10, float64(decimals))
45+
return math.RoundToEven(v*coef) / coef
46+
}
47+
48+
type Classification string
49+
50+
const (
51+
ClassificationInvalid Classification = "invalid"
52+
53+
VeryLow = "very low"
54+
Low = "low"
55+
OnTarget = "on target"
56+
High = "high"
57+
VeryHigh = "very high"
58+
ExtremelyHigh = "extremely high"
59+
)
60+
61+
type classificationThreshold struct {
62+
Name Classification
63+
Value float64
64+
Inclusive bool
65+
}
66+
67+
type Classifier []classificationThreshold
68+
69+
func (c Classifier) Classify(g *Glucose) (Classification, error) {
70+
normalized, err := dataBloodGlucose.NormalizeValueForUnitsSafer(g.Value, g.Units)
71+
if err != nil {
72+
return ClassificationInvalid, errors.Wrap(err, "unable to classify")
73+
}
74+
// Rounded values are used for all classifications. To not do so risks introducing
75+
// inconsistency between frontend, backend, and/or other reports. See BACK-3800 for
76+
// details.
77+
rounded := roundToEvenWithDecimalPlaces(normalized, 1)
78+
for _, threshold := range c {
79+
if threshold.Includes(rounded) {
80+
return threshold.Name, nil
81+
}
82+
}
83+
// Ensure your highest threshold has a value like math.MaxFloat64 to avoid this.
84+
return ClassificationInvalid, errors.Newf("unable to classify value: %v", *g)
85+
}
86+
87+
// TidepoolADAClassificationThresholdsMmolL for classifying glucose values.
88+
//
89+
// All values are normalized to MmolL before classification.
90+
//
91+
// In addition to the standard ADA ranges, the Tidepool-specifiic "extremely high" range is
92+
// added.
93+
//
94+
// It is the author's responsibility to ensure the thresholds remain sorted from smallest to
95+
// largest.
96+
var TidepoolADAClassificationThresholdsMmolL = Classifier([]classificationThreshold{
97+
{Name: VeryLow, Value: 3, Inclusive: false}, // Source: https://doi.org/10.2337/dc24-S006
98+
{Name: Low, Value: 3.9, Inclusive: false}, // Source: https://doi.org/10.2337/dc24-S006
99+
{Name: OnTarget, Value: 10, Inclusive: true}, // Source: https://doi.org/10.2337/dc24-S006
100+
{Name: High, Value: 13.9, Inclusive: true}, // Source: https://doi.org/10.2337/dc24-S006
101+
{Name: VeryHigh, Value: 19.4, Inclusive: true}, // Source: https://doi.org/10.2337/dc24-S006
102+
{Name: ExtremelyHigh, Value: math.MaxFloat64, Inclusive: true}, // Source: BACK-2963
103+
})
104+
105+
func (c classificationThreshold) Includes(value float64) bool {
106+
if c.Inclusive && value <= c.Value {
107+
return true
108+
}
109+
return value < c.Value
110+
}

data/types/blood/glucose/glucose_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,4 +481,108 @@ var _ = Describe("Glucose", func() {
481481
),
482482
)
483483
})
484+
485+
Context("Classify", func() {
486+
var MmolL = pointer.FromAny(dataBloodGlucose.MmolL)
487+
var MgdL = pointer.FromAny(dataBloodGlucose.MgdL)
488+
489+
checkClassification := func(value float64, expected glucose.Classification) {
490+
GinkgoHelper()
491+
datum := dataTypesBloodGlucoseTest.NewGlucose(MmolL)
492+
datum.Value = pointer.FromAny(value)
493+
got, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
494+
Expect(err).To(Succeed())
495+
Expect(got).To(Equal(expected))
496+
}
497+
498+
It("classifies 2.9 as very low", func() {
499+
checkClassification(2.9, "very low")
500+
})
501+
502+
It("classifies 3.0 as low", func() {
503+
checkClassification(3.0, "low")
504+
})
505+
506+
It("classifies 3.8 as low", func() {
507+
checkClassification(3.8, "low")
508+
})
509+
510+
It("classifies 3.9 as on target", func() {
511+
checkClassification(3.9, "on target")
512+
})
513+
514+
It("classifies 10.0 as on target", func() {
515+
checkClassification(10.0, "on target")
516+
})
517+
518+
It("classifies 10.1 as high", func() {
519+
checkClassification(10.1, "high")
520+
})
521+
522+
It("classifies 13.9 as high", func() {
523+
checkClassification(13.9, "high")
524+
})
525+
526+
It("classifies 14.0 as very high", func() {
527+
checkClassification(14.0, "very high")
528+
})
529+
530+
It("classifies 19.4 as very high", func() {
531+
checkClassification(19.4, "very high")
532+
})
533+
534+
It("classifies 19.5 as extremely high", func() {
535+
checkClassification(19.5, "extremely high")
536+
})
537+
538+
When("its classification depends on rounding", func() {
539+
It("classifies 2.95 as low", func() {
540+
checkClassification(2.95, "low")
541+
})
542+
543+
It("classifies 3.85 as low", func() {
544+
checkClassification(3.85, "low")
545+
})
546+
547+
It("classifies 10.05 as on target", func() {
548+
checkClassification(10.05, "on target")
549+
})
550+
})
551+
552+
When("it doesn't recognize the units", func() {
553+
It("returns an error", func() {
554+
badUnits := "blah"
555+
datum := dataTypesBloodGlucoseTest.NewGlucose(&badUnits)
556+
datum.Value = pointer.FromAny(5.0)
557+
_, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
558+
Expect(err).To(MatchError(ContainSubstring("unable to normalize: unhandled units")))
559+
})
560+
})
561+
562+
It("can handle values in mg/dL", func() {
563+
datum := dataTypesBloodGlucoseTest.NewGlucose(MgdL)
564+
datum.Value = pointer.FromAny(100.0)
565+
got, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
566+
Expect(err).To(Succeed())
567+
Expect(string(got)).To(Equal("on target"))
568+
})
569+
570+
When("it's value is nil", func() {
571+
It("returns an error", func() {
572+
datum := dataTypesBloodGlucoseTest.NewGlucose(MmolL)
573+
datum.Value = nil
574+
_, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
575+
Expect(err).To(MatchError(ContainSubstring("unable to normalize: unhandled value")))
576+
})
577+
})
578+
579+
When("it's units are nil", func() {
580+
It("returns an error", func() {
581+
datum := dataTypesBloodGlucoseTest.NewGlucose(nil)
582+
datum.Value = pointer.FromAny(5.0)
583+
_, err := glucose.TidepoolADAClassificationThresholdsMmolL.Classify(datum)
584+
Expect(err).To(MatchError(ContainSubstring("unable to normalize: unhandled units")))
585+
})
586+
})
587+
})
484588
})

summary/types/glucose.go

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package types
22

33
import (
44
"context"
5-
"errors"
65
"fmt"
76
"math"
87
"strconv"
@@ -14,6 +13,7 @@ import (
1413
"github.com/tidepool-org/platform/data/blood/glucose"
1514
glucoseDatum "github.com/tidepool-org/platform/data/types/blood/glucose"
1615
"github.com/tidepool-org/platform/data/types/blood/glucose/continuous"
16+
"github.com/tidepool-org/platform/errors"
1717
)
1818

1919
const MaxRecordsPerBucket = 60 // one per minute max
@@ -162,31 +162,38 @@ func (rs *GlucoseRanges) Finalize(days int) {
162162
}
163163
}
164164

165-
func (rs *GlucoseRanges) Update(record *glucoseDatum.Glucose) {
166-
normalizedValue := *glucose.NormalizeValueForUnits(record.Value, record.Units)
165+
func (rs *GlucoseRanges) Update(record *glucoseDatum.Glucose) error {
166+
classification, err := glucoseDatum.TidepoolADAClassificationThresholdsMmolL.Classify(record)
167+
if err != nil {
168+
return err
169+
}
170+
171+
rs.Total.UpdateTotal(record)
167172

168-
if normalizedValue < veryLowBloodGlucose {
173+
switch classification {
174+
case glucoseDatum.VeryLow:
169175
rs.VeryLow.Update(record)
170176
rs.AnyLow.Update(record)
171-
} else if normalizedValue > veryHighBloodGlucose {
172-
rs.VeryHigh.Update(record)
173-
rs.AnyHigh.Update(record)
174-
175-
// VeryHigh is inclusive of extreme high, this is intentional
176-
if normalizedValue >= extremeHighBloodGlucose {
177-
rs.ExtremeHigh.Update(record)
178-
}
179-
} else if normalizedValue < lowBloodGlucose {
177+
case glucoseDatum.Low:
180178
rs.Low.Update(record)
181179
rs.AnyLow.Update(record)
182-
} else if normalizedValue > highBloodGlucose {
183-
rs.AnyHigh.Update(record)
184-
rs.High.Update(record)
185-
} else {
180+
case glucoseDatum.OnTarget:
186181
rs.Target.Update(record)
182+
case glucoseDatum.High:
183+
rs.High.Update(record)
184+
rs.AnyHigh.Update(record)
185+
case glucoseDatum.VeryHigh:
186+
rs.VeryHigh.Update(record)
187+
rs.AnyHigh.Update(record)
188+
case glucoseDatum.ExtremelyHigh:
189+
rs.ExtremeHigh.Update(record)
190+
rs.VeryHigh.Update(record)
191+
rs.AnyHigh.Update(record)
192+
default:
193+
errMsg := "WARNING: unhandled classification %v; THIS SHOULD NEVER OCCUR"
194+
return errors.Newf(errMsg, classification)
187195
}
188-
189-
rs.Total.UpdateTotal(record)
196+
return nil
190197
}
191198

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

240-
b.GlucoseRanges.Update(record)
247+
if err := b.GlucoseRanges.Update(record); err != nil {
248+
return false, err
249+
}
241250
b.LastRecordDuration = GetDuration(record)
242251

243252
return true, nil

summary/types/glucose_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,42 +337,42 @@ var _ = Describe("Glucose", func() {
337337
glucoseRecord := NewGlucoseWithValue(continuous.Type, bucketTime, VeryLowBloodGlucose-0.1)
338338
Expect(glucoseRanges.Total.Records).To(Equal(0))
339339
Expect(glucoseRanges.VeryLow.Records).To(Equal(0))
340-
glucoseRanges.Update(glucoseRecord)
340+
Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed())
341341
Expect(glucoseRanges.VeryLow.Records).To(Equal(1))
342342
Expect(glucoseRanges.Total.Records).To(Equal(1))
343343

344344
By("adding a Low value")
345345
glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, LowBloodGlucose-0.1)
346346
Expect(glucoseRanges.Low.Records).To(Equal(0))
347-
glucoseRanges.Update(glucoseRecord)
347+
Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed())
348348
Expect(glucoseRanges.Low.Records).To(Equal(1))
349349
Expect(glucoseRanges.Total.Records).To(Equal(2))
350350

351351
By("adding a Target value")
352352
glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, InTargetBloodGlucose+0.1)
353353
Expect(glucoseRanges.Target.Records).To(Equal(0))
354-
glucoseRanges.Update(glucoseRecord)
354+
Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed())
355355
Expect(glucoseRanges.Target.Records).To(Equal(1))
356356
Expect(glucoseRanges.Total.Records).To(Equal(3))
357357

358358
By("adding a High value")
359359
glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, HighBloodGlucose+0.1)
360360
Expect(glucoseRanges.High.Records).To(Equal(0))
361-
glucoseRanges.Update(glucoseRecord)
361+
Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed())
362362
Expect(glucoseRanges.High.Records).To(Equal(1))
363363
Expect(glucoseRanges.Total.Records).To(Equal(4))
364364

365365
By("adding a VeryHigh value")
366366
glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, VeryHighBloodGlucose+0.1)
367367
Expect(glucoseRanges.VeryHigh.Records).To(Equal(0))
368-
glucoseRanges.Update(glucoseRecord)
368+
Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed())
369369
Expect(glucoseRanges.VeryHigh.Records).To(Equal(1))
370370
Expect(glucoseRanges.Total.Records).To(Equal(5))
371371

372372
By("adding a High value")
373373
glucoseRecord = NewGlucoseWithValue(continuous.Type, bucketTime, ExtremeHighBloodGlucose+0.1)
374374
Expect(glucoseRanges.ExtremeHigh.Records).To(Equal(0))
375-
glucoseRanges.Update(glucoseRecord)
375+
Expect(glucoseRanges.Update(glucoseRecord)).To(Succeed())
376376
Expect(glucoseRanges.ExtremeHigh.Records).To(Equal(1))
377377
Expect(glucoseRanges.VeryHigh.Records).To(Equal(2))
378378
Expect(glucoseRanges.Total.Records).To(Equal(6))

0 commit comments

Comments
 (0)