Skip to content

Commit ce43010

Browse files
committed
adds Evaluate methods to alerts.Config
These methods return Note objects that can be sent as push notifications. NotLooping evaluation will be handled in a later commit. BACK-2554
1 parent c9a23e7 commit ce43010

File tree

2 files changed

+933
-21
lines changed

2 files changed

+933
-21
lines changed

alerts/config.go

Lines changed: 261 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ import (
66
"bytes"
77
"context"
88
"encoding/json"
9+
"slices"
910
"time"
1011

1112
"github.com/tidepool-org/platform/data"
12-
"github.com/tidepool-org/platform/data/blood/glucose"
13+
nontypesglucose "github.com/tidepool-org/platform/data/blood/glucose"
14+
"github.com/tidepool-org/platform/data/types/blood/glucose"
15+
"github.com/tidepool-org/platform/data/types/dosingdecision"
16+
"github.com/tidepool-org/platform/errors"
17+
"github.com/tidepool-org/platform/log"
1318
"github.com/tidepool-org/platform/structure"
1419
"github.com/tidepool-org/platform/structure/validator"
1520
"github.com/tidepool-org/platform/user"
@@ -50,6 +55,45 @@ func (c Config) Validate(validator structure.Validator) {
5055
c.Alerts.Validate(validator)
5156
}
5257

58+
// Evaluate alerts in the context of the provided data.
59+
//
60+
// While this method, or the methods it calls, can fail, there's no point in returning an
61+
// error. Instead errors are logged before continuing. This is to ensure that any possible alert
62+
// that should be triggered, will be triggered.
63+
func (c Config) Evaluate(ctx context.Context, gd []*glucose.Glucose, dd []*dosingdecision.DosingDecision) *Note {
64+
n := c.Alerts.Evaluate(ctx, gd, dd)
65+
if n != nil {
66+
n.FollowedUserID = c.FollowedUserID
67+
n.RecipientUserID = c.UserID
68+
}
69+
if lgr := log.LoggerFromContext(ctx); lgr != nil {
70+
lgr.WithField("note", n).Info("evaluated alert")
71+
}
72+
73+
return n
74+
}
75+
76+
// LongestDelay of the delays set on enabled alerts.
77+
func (a Alerts) LongestDelay() time.Duration {
78+
delays := []time.Duration{}
79+
if a.Low != nil && a.Low.Enabled {
80+
delays = append(delays, a.Low.Delay.Duration())
81+
}
82+
if a.High != nil && a.High.Enabled {
83+
delays = append(delays, a.High.Delay.Duration())
84+
}
85+
if a.NotLooping != nil && a.NotLooping.Enabled {
86+
delays = append(delays, a.NotLooping.Delay.Duration())
87+
}
88+
if a.NoCommunication != nil && a.NoCommunication.Enabled {
89+
delays = append(delays, a.NoCommunication.Delay.Duration())
90+
}
91+
if len(delays) == 0 {
92+
return 0
93+
}
94+
return slices.Max(delays)
95+
}
96+
5397
func (a Alerts) Validate(validator structure.Validator) {
5498
if a.UrgentLow != nil {
5599
a.UrgentLow.Validate(validator)
@@ -68,6 +112,41 @@ func (a Alerts) Validate(validator structure.Validator) {
68112
}
69113
}
70114

115+
// Evaluate a user's data to determine if notifications are indicated.
116+
//
117+
// Evaluations are performed according to priority. The process is
118+
// "short-circuited" at the first indicated notification.
119+
func (a Alerts) Evaluate(ctx context.Context,
120+
gd []*glucose.Glucose, dd []*dosingdecision.DosingDecision) *Note {
121+
122+
if a.NoCommunication != nil && a.NoCommunication.Enabled {
123+
if n := a.NoCommunication.Evaluate(ctx, gd); n != nil {
124+
return n
125+
}
126+
}
127+
if a.UrgentLow != nil && a.UrgentLow.Enabled {
128+
if n := a.UrgentLow.Evaluate(ctx, gd); n != nil {
129+
return n
130+
}
131+
}
132+
if a.Low != nil && a.Low.Enabled {
133+
if n := a.Low.Evaluate(ctx, gd); n != nil {
134+
return n
135+
}
136+
}
137+
if a.High != nil && a.High.Enabled {
138+
if n := a.High.Evaluate(ctx, gd); n != nil {
139+
return n
140+
}
141+
}
142+
if a.NotLooping != nil && a.NotLooping.Enabled {
143+
if n := a.NotLooping.Evaluate(ctx, dd); n != nil {
144+
return n
145+
}
146+
}
147+
return nil
148+
}
149+
71150
// Base describes the minimum specifics of a desired alert.
72151
type Base struct {
73152
// Enabled controls whether notifications should be sent for this alert.
@@ -81,6 +160,13 @@ func (b Base) Validate(validator structure.Validator) {
81160
validator.Bool("enabled", &b.Enabled)
82161
}
83162

163+
func (b Base) Evaluate(ctx context.Context, data []*glucose.Glucose) *Note {
164+
if lgr := log.LoggerFromContext(ctx); lgr != nil {
165+
lgr.Warn("alerts.Base.Evaluate called, this shouldn't happen!")
166+
}
167+
return nil
168+
}
169+
84170
type Activity struct {
85171
// Triggered records the last time this alert was triggered.
86172
Triggered time.Time `json:"triggered" bson:"triggered"`
@@ -132,6 +218,46 @@ func (a UrgentLowAlert) Validate(validator structure.Validator) {
132218
a.Threshold.Validate(validator)
133219
}
134220

221+
// Evaluate urgent low condition.
222+
//
223+
// Assumes data is pre-sorted in descending order by Time.
224+
func (a *UrgentLowAlert) Evaluate(ctx context.Context, data []*glucose.Glucose) (note *Note) {
225+
lgr := log.LoggerFromContext(ctx)
226+
if len(data) == 0 {
227+
lgr.Debug("no data to evaluate for urgent low")
228+
return nil
229+
}
230+
datum := data[len(data)-1]
231+
okDatum, okThreshold, err := validateGlucoseAlertDatum(datum, a.Threshold)
232+
if err != nil {
233+
lgr.WithError(err).Warn("Unable to evaluate urgent low")
234+
return nil
235+
}
236+
defer func() { logGlucoseAlertEvaluation(lgr, "urgent low", note, okDatum, okThreshold) }()
237+
active := okDatum < okThreshold
238+
if !active {
239+
if a.IsActive() {
240+
a.Resolved = time.Now()
241+
}
242+
return nil
243+
}
244+
if !a.IsActive() {
245+
a.Triggered = time.Now()
246+
}
247+
return &Note{Message: genGlucoseThresholdMessage("below urgent low")}
248+
}
249+
250+
func validateGlucoseAlertDatum(datum *glucose.Glucose, t Threshold) (float64, float64, error) {
251+
if datum.Blood.Units == nil || datum.Blood.Value == nil || datum.Blood.Time == nil {
252+
return 0, 0, errors.Newf("Unable to evaluate datum: Units, Value, or Time is nil")
253+
}
254+
threshold := nontypesglucose.NormalizeValueForUnits(&t.Value, datum.Blood.Units)
255+
if threshold == nil {
256+
return 0, 0, errors.Newf("Unable to normalize threshold units: normalized to nil")
257+
}
258+
return *datum.Blood.Value, *threshold, nil
259+
}
260+
135261
// NotLoopingAlert extends Base with a delay.
136262
type NotLoopingAlert struct {
137263
Base `bson:",inline"`
@@ -144,6 +270,16 @@ func (a NotLoopingAlert) Validate(validator structure.Validator) {
144270
validator.Duration("delay", &dur).InRange(0, 2*time.Hour)
145271
}
146272

273+
// Evaluate if the device is looping.
274+
func (a NotLoopingAlert) Evaluate(ctx context.Context, decisions []*dosingdecision.DosingDecision) (note *Note) {
275+
// TODO will be implemented in the near future.
276+
return nil
277+
}
278+
279+
// DosingDecisionReasonLoop is specified in a [dosingdecision.DosingDecision] to indicate that
280+
// the decision is part of a loop adjustment (as opposed to bolus or something else).
281+
const DosingDecisionReasonLoop string = "loop"
282+
147283
// NoCommunicationAlert extends Base with a delay.
148284
type NoCommunicationAlert struct {
149285
Base `bson:",inline"`
@@ -156,6 +292,26 @@ func (a NoCommunicationAlert) Validate(validator structure.Validator) {
156292
validator.Duration("delay", &dur).InRange(0, 6*time.Hour)
157293
}
158294

295+
// Evaluate if CGM data is being received by Tidepool.
296+
//
297+
// Assumes data is pre-sorted by Time in descending order.
298+
func (a NoCommunicationAlert) Evaluate(ctx context.Context, data []*glucose.Glucose) *Note {
299+
var newest time.Time
300+
for _, d := range data {
301+
if d != nil && d.Time != nil && !(*d.Time).IsZero() {
302+
newest = *d.Time
303+
break
304+
}
305+
}
306+
if time.Since(newest) > a.Delay.Duration() {
307+
return &Note{Message: NoCommunicationMessage}
308+
}
309+
310+
return nil
311+
}
312+
313+
const NoCommunicationMessage = "Tidepool is unable to communicate with a user's device"
314+
159315
// LowAlert extends Base with threshold and a delay.
160316
type LowAlert struct {
161317
Base `bson:",inline"`
@@ -178,6 +334,51 @@ func (a LowAlert) Validate(validator structure.Validator) {
178334
validator.Duration("repeat", &repeatDur).Using(validateRepeat)
179335
}
180336

337+
// Evaluate the given data to determine if an alert should be sent.
338+
//
339+
// Assumes data is pre-sorted in descending order by Time.
340+
func (a *LowAlert) Evaluate(ctx context.Context, data []*glucose.Glucose) (note *Note) {
341+
lgr := log.LoggerFromContext(ctx)
342+
if len(data) == 0 {
343+
lgr.Debug("no data to evaluate for low")
344+
return nil
345+
}
346+
var eventBegan time.Time
347+
var okDatum, okThreshold float64
348+
var err error
349+
defer func() { logGlucoseAlertEvaluation(lgr, "low", note, okDatum, okThreshold) }()
350+
for _, datum := range data {
351+
okDatum, okThreshold, err = validateGlucoseAlertDatum(datum, a.Threshold)
352+
if err != nil {
353+
lgr.WithError(err).Debug("Skipping low alert datum evaluation")
354+
continue
355+
}
356+
active := okDatum < okThreshold
357+
if !active {
358+
break
359+
}
360+
if (*datum.Time).Before(eventBegan) || eventBegan.IsZero() {
361+
eventBegan = *datum.Time
362+
}
363+
}
364+
if eventBegan.IsZero() {
365+
if a.IsActive() {
366+
a.Resolved = time.Now()
367+
}
368+
return nil
369+
}
370+
if !a.IsActive() {
371+
if time.Since(eventBegan) > a.Delay.Duration() {
372+
a.Triggered = time.Now()
373+
}
374+
}
375+
return &Note{Message: genGlucoseThresholdMessage("below low")}
376+
}
377+
378+
func genGlucoseThresholdMessage(alertType string) string {
379+
return "Glucose reading " + alertType + " threshold"
380+
}
381+
181382
// HighAlert extends Base with a threshold and a delay.
182383
type HighAlert struct {
183384
Base `bson:",inline"`
@@ -200,6 +401,57 @@ func (a HighAlert) Validate(validator structure.Validator) {
200401
validator.Duration("repeat", &repeatDur).Using(validateRepeat)
201402
}
202403

404+
// Evaluate the given data to determine if an alert should be sent.
405+
//
406+
// Assumes data is pre-sorted in descending order by Time.
407+
func (a *HighAlert) Evaluate(ctx context.Context, data []*glucose.Glucose) (note *Note) {
408+
lgr := log.LoggerFromContext(ctx)
409+
if len(data) == 0 {
410+
lgr.Debug("no data to evaluate for high")
411+
return nil
412+
}
413+
var eventBegan time.Time
414+
var okDatum, okThreshold float64
415+
var err error
416+
defer func() { logGlucoseAlertEvaluation(lgr, "high", note, okDatum, okThreshold) }()
417+
for _, datum := range data {
418+
okDatum, okThreshold, err = validateGlucoseAlertDatum(datum, a.Threshold)
419+
if err != nil {
420+
lgr.WithError(err).Debug("Skipping high alert datum evaluation")
421+
continue
422+
}
423+
active := okDatum > okThreshold
424+
if !active {
425+
break
426+
}
427+
if (*datum.Time).Before(eventBegan) || eventBegan.IsZero() {
428+
eventBegan = *datum.Time
429+
}
430+
}
431+
if eventBegan.IsZero() {
432+
if a.IsActive() {
433+
a.Resolved = time.Now()
434+
}
435+
return nil
436+
}
437+
if !a.IsActive() {
438+
if time.Since(eventBegan) > a.Delay.Duration() {
439+
a.Triggered = time.Now()
440+
}
441+
}
442+
return &Note{Message: genGlucoseThresholdMessage("above high")}
443+
}
444+
445+
// logGlucoseAlertEvaluation is called during each glucose-based evaluation for record-keeping.
446+
func logGlucoseAlertEvaluation(lgr log.Logger, alertType string, note *Note, value, threshold float64) {
447+
fields := log.Fields{
448+
"isAlerting?": note != nil,
449+
"threshold": threshold,
450+
"value": value,
451+
}
452+
lgr.WithFields(fields).Info(alertType)
453+
}
454+
203455
// DurationMinutes reads a JSON integer and converts it to a time.Duration.
204456
//
205457
// Values are specified in minutes.
@@ -227,7 +479,7 @@ func (m DurationMinutes) Duration() time.Duration {
227479
return time.Duration(m)
228480
}
229481

230-
// ValueWithUnits binds a value to its units.
482+
// ValueWithUnits binds a value with its units.
231483
//
232484
// Other types can extend it to parse and validate the Units.
233485
type ValueWithUnits struct {
@@ -240,20 +492,20 @@ type Threshold ValueWithUnits
240492

241493
// Validate implements structure.Validatable
242494
func (t Threshold) Validate(v structure.Validator) {
243-
v.String("units", &t.Units).OneOf(glucose.MgdL, glucose.MmolL)
495+
v.String("units", &t.Units).OneOf(nontypesglucose.MgdL, nontypesglucose.MmolL)
244496
// This is a sanity check. Client software will likely further constrain these values. The
245497
// broadness of these values allows clients to change their own min and max values
246498
// independently, and it sidesteps rounding and conversion conflicts between the backend and
247499
// clients.
248500
var max, min float64
249501
switch t.Units {
250-
case glucose.MgdL, glucose.Mgdl:
251-
max = glucose.MgdLMaximum
252-
min = glucose.MgdLMinimum
502+
case nontypesglucose.MgdL, nontypesglucose.Mgdl:
503+
max = nontypesglucose.MgdLMaximum
504+
min = nontypesglucose.MgdLMinimum
253505
v.Float64("value", &t.Value).InRange(min, max)
254-
case glucose.MmolL, glucose.Mmoll:
255-
max = glucose.MmolLMaximum
256-
min = glucose.MmolLMinimum
506+
case nontypesglucose.MmolL, nontypesglucose.Mmoll:
507+
max = nontypesglucose.MmolLMaximum
508+
min = nontypesglucose.MmolLMinimum
257509
v.Float64("value", &t.Value).InRange(min, max)
258510
default:
259511
v.WithReference("value").ReportError(validator.ErrorValueNotValid())

0 commit comments

Comments
 (0)