Skip to content

Commit e8e00d6

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 e8e00d6

File tree

4 files changed

+250
-26
lines changed

4 files changed

+250
-26
lines changed

data/types/blood/glucose/glucose.go

Lines changed: 112 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,112 @@ func (g *Glucose) Normalize(normalizer data.Normalizer) {
3336
g.Value = dataBloodGlucose.NormalizeValueForUnits(g.Value, units)
3437
}
3538
}
39+
40+
// Classify the datum based on ADA thresholds.
41+
//
42+
// It pretends to handle values in mg/dL, but all values received by platform are normalized
43+
// into mmol/L upon reception, so being able to classify values in other units is of limited
44+
// use.
45+
func (g *Glucose) Classify() (RangeClassification, error) {
46+
switch {
47+
case g.Units == nil:
48+
return RangeInvalid, errors.New("unhandled units: nil")
49+
case g.Value == nil:
50+
return RangeInvalid, errors.New("unhandled value: nil")
51+
case *g.Units == dataBloodGlucose.MgdL:
52+
return g.classify(thresholdsMgdL)
53+
case *g.Units == dataBloodGlucose.MmolL:
54+
return g.classify(thresholdsMmolL)
55+
default:
56+
return RangeInvalid, errors.Newf("unhandled units: %s", *g.Units)
57+
}
58+
}
59+
60+
// RangeClassification of a blood glucose value, e.g. Low, Very Low, etc.
61+
type RangeClassification int
62+
63+
const (
64+
RangeInvalid RangeClassification = iota
65+
RangeVeryLow
66+
RangeLow
67+
RangeTarget
68+
RangeHigh
69+
RangeVeryHigh
70+
RangeExtremelyHigh
71+
)
72+
73+
type classificationThresholds struct {
74+
VeryLow float64
75+
Low float64
76+
Target float64
77+
High float64
78+
VeryHigh float64
79+
// Precision to round to for these thresholds/units.
80+
Precision int
81+
}
82+
83+
func (g *Glucose) classify(thresholds classificationThresholds) (RangeClassification, error) {
84+
if g.Value == nil {
85+
return RangeInvalid, errors.New("unhandled value: nil")
86+
}
87+
// Rounded values are used for all classifications. To not do so risks introducing
88+
// inconsistency between frontend, backend, and other reports. See BACK-3800 for
89+
// details.
90+
rounded := roundToEvenWithPrecision(*g.Value, thresholds.Precision)
91+
switch {
92+
case rounded < thresholds.VeryLow:
93+
return RangeVeryLow, nil
94+
case rounded < thresholds.Low:
95+
return RangeLow, nil
96+
case rounded <= thresholds.Target:
97+
return RangeTarget, nil
98+
case rounded <= thresholds.High:
99+
return RangeHigh, nil
100+
case rounded <= thresholds.VeryHigh:
101+
return RangeVeryHigh, nil
102+
default:
103+
return RangeExtremelyHigh, nil
104+
}
105+
}
106+
107+
func roundToEvenWithPrecision(v float64, decimals int) float64 {
108+
if decimals < 1 {
109+
return math.RoundToEven(v)
110+
}
111+
coef := math.Pow(10, float64(decimals))
112+
return math.RoundToEven(v*coef) / coef
113+
}
114+
115+
const (
116+
ThresholdMmolLVeryLow float64 = 3 // Source: https://doi.org/10.2337/dc24-S006
117+
ThresholdMmolLLow float64 = 3.9 // Source: https://doi.org/10.2337/dc24-S006
118+
ThresholdMmolLTarget float64 = 10 // Source: https://doi.org/10.2337/dc24-S006
119+
ThresholdMmolLHigh float64 = 13.9 // Source: https://doi.org/10.2337/dc24-S006
120+
ThresholdMmolLVeryHigh float64 = 19.4 // Source: BACK-2963
121+
)
122+
123+
var thresholdsMmolL = classificationThresholds{
124+
VeryLow: ThresholdMmolLVeryLow,
125+
Low: ThresholdMmolLLow,
126+
Target: ThresholdMmolLTarget,
127+
High: ThresholdMmolLHigh,
128+
VeryHigh: ThresholdMmolLVeryHigh,
129+
Precision: 1,
130+
}
131+
132+
const (
133+
ThresholdMgdLVeryLow float64 = 54 // Source: https://doi.org/10.2337/dc24-S006
134+
ThresholdMgdLLow float64 = 70 // Source: https://doi.org/10.2337/dc24-S006
135+
ThresholdMgdLTarget float64 = 180 // Source: https://doi.org/10.2337/dc24-S006
136+
ThresholdMgdLHigh float64 = 250 // Source: https://doi.org/10.2337/dc24-S006
137+
ThresholdMgdLVeryHigh float64 = 350 // Source: BACK-2963
138+
)
139+
140+
var thresholdsMgdL = classificationThresholds{
141+
VeryLow: ThresholdMgdLVeryLow,
142+
Low: ThresholdMgdLLow,
143+
Target: ThresholdMgdLTarget,
144+
High: ThresholdMgdLHigh,
145+
VeryHigh: ThresholdMgdLVeryHigh,
146+
Precision: 0,
147+
}

data/types/blood/glucose/glucose_test.go

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

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 := record.Classify()
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.RangeVeryLow:
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.RangeLow:
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.RangeTarget:
186181
rs.Target.Update(record)
182+
case glucoseDatum.RangeHigh:
183+
rs.High.Update(record)
184+
rs.AnyHigh.Update(record)
185+
case glucoseDatum.RangeVeryHigh:
186+
rs.VeryHigh.Update(record)
187+
rs.AnyHigh.Update(record)
188+
case glucoseDatum.RangeExtremelyHigh:
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)