@@ -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+
5397func (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.
72151type 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+
84170type 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.
136262type 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.
148284type 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.
160316type 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.
182383type 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.
233485type ValueWithUnits struct {
@@ -240,20 +492,20 @@ type Threshold ValueWithUnits
240492
241493// Validate implements structure.Validatable
242494func (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