Browse Source

Updates to the algorithm after testing with a real device

Sam King 1 year ago
parent
commit
ca5021026b

+ 10 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift

@@ -56,3 +56,13 @@ extension Date {
         return currentMinutes >= lowerBound && currentMinutes < upperBound
     }
 }
+
+extension Date {
+    /// Rounds the date to the nearest minute boundary by rounding the Unix timestamp
+    /// - Returns: A new Date with seconds rounded to the nearest minute
+    func roundedToNearestMinute() -> Date {
+        let timestampInMinutes = timeIntervalSince1970.secondsToMinutes
+        let timestampRounded = timestampInMinutes.rounded()
+        return Date(timeIntervalSince1970: timestampRounded.minutesToSeconds)
+    }
+}

+ 2 - 1
Trio/Sources/APS/OpenAPSSwift/Extensions/PumpHistory+copy.swift

@@ -161,7 +161,8 @@ extension ComputedPumpHistoryEvent {
             note: nil,
             isSMB: nil,
             isExternal: nil,
-            insulin: insulin
+            insulin: insulin,
+            isTempBolus: true
         )
     }
 

+ 9 - 7
Trio/Sources/APS/OpenAPSSwift/Iob/IobCalculation.swift

@@ -94,13 +94,15 @@ enum IobCalculation {
             }
             iob += tIOB.iobContrib
             activity += tIOB.activityContrib
-            if insulin < 0.1 {
-                // bolus to represent temp basal, which can only be 0.05 or -0.05
-                basaliob += tIOB.iobContrib
-                netbasalinsulin += insulin
-            } else {
-                bolusiob += tIOB.iobContrib
-                bolusinsulin += insulin
+            if tIOB.iobContrib != 0 {
+                if insulin < 0.1 {
+                    // bolus to represent temp basal, which can only be 0.05 or -0.05
+                    basaliob += tIOB.iobContrib
+                    netbasalinsulin += insulin
+                } else {
+                    bolusiob += tIOB.iobContrib
+                    bolusinsulin += insulin
+                }
             }
         }
 

+ 4 - 1
Trio/Sources/APS/OpenAPSSwift/Iob/IobGenerator.swift

@@ -18,7 +18,10 @@ struct IobGenerator {
             zeroTempDuration: 240
         )
 
-        let lastBolusTime = treatments.filter({ $0.insulin != nil }).map(\.timestamp).max() ?? Date(timeIntervalSince1970: 0)
+        // In Javascript it checks for `started_at` to separate tempBolus
+        // from bolus but we explicitly track tempBolus instead
+        let lastBolusTime = treatments.filter({ $0.insulin != nil && $0.isTempBolus == false }).map(\.timestamp)
+            .max() ?? Date(timeIntervalSince1970: 0)
         let lastTemp = treatments.filter({ $0.rate != nil && ($0.duration ?? 0) > 0 }).sorted(by: { $0.timestamp < $1.timestamp })
             .last
 

+ 99 - 36
Trio/Sources/APS/OpenAPSSwift/Iob/IobHistory.swift

@@ -6,14 +6,24 @@ import Foundation
 ///  - We ignore event types that Trio won't send us
 ///  - We exclude some redundant events (shouldn't impact the IoB calculation)
 ///
-///  There are two areas where we changed the implementation that could impact IoB calculations
+///  There is one area where we changed the implementation that could impact IoB calculations
 ///  - We don't split temp basals that cross suspends -- after a suspend resumes we assume that
 ///     it goes back to the profile basal rate
-///  - We don't split temp basals into 30 minute durations. This doesn't impact the total insulin accounting
-///    but could give the temp basal bolus entries slightly different timing
 ///
 ///  From looking at the implementat, the `suspendZerosIob` should just be on by default to
 ///  handle pump suspensions correctly
+///
+///  The current Javascript implementation is an approximation of IoB, but we have an issue
+///  open to update to more accurate pump events: https://github.com/nightscout/Trio-dev/issues/325
+///
+///  Also, the current Javascript implementation implements the approximate algorithm incorrectly in
+///  a few corner cases:
+///  - If a tempBasal is longer than 30 minutes and has a profile basal rate change in the middle, it will
+///   miss this split resulting in incorrect insulin calculations.
+///  - When splitting events, it uses minutes instead of seconds or milliseconds to calculate durations,
+///   which can lead to incorrect durations.
+///
+/// These seem like small issues, and they are, but I have seen both in my data over a few days of running.
 
 struct IobHistory {
     struct PumpSuspended {
@@ -179,44 +189,98 @@ struct IobHistory {
         return adjustedTempHistory + (tempHistory.last.map { [$0] } ?? [])
     }
 
-    /// Returns the relative offset of a profile break in the middle of the event, if one exists
-    private static func findIntersectionOffset(tempBasal: ComputedPumpHistoryEvent, profileBreaks: [Decimal]) -> TimeInterval? {
-        let minutesPerDay = Decimal(24 * 60)
-        if let minutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision {
-            let endMinutes = minutes + (tempBasal.duration ?? 0)
-            for profileBreak in profileBreaks {
-                let breakPlusOneDay = profileBreak + minutesPerDay
-                if profileBreak > minutes, profileBreak < endMinutes {
-                    return (profileBreak - minutes).minutesToSeconds
-                } else if breakPlusOneDay > minutes, breakPlusOneDay < endMinutes {
-                    return (breakPlusOneDay - minutes).minutesToSeconds
-                }
-            }
+    private static func splitAtMinutesSinceMidnight(
+        tempBasal: ComputedPumpHistoryEvent,
+        splitPoint: Decimal
+    ) throws -> [ComputedPumpHistoryEvent] {
+        // FIXME: bug in JS where they only use minute precision for startMinutes
+        // The net effect is that it truncates the startMinutes. The differences should
+        // be small but at least it matches
+        guard let startMinutes = tempBasal.timestamp.minutesSinceMidnight.map({ Decimal($0) }) else {
+            throw MinutesFromMidnightError.invalidCalendar
         }
 
-        return nil
+        guard let duration = tempBasal.duration else {
+            throw IobError.tempBasalDurationMissingDuration(timestamp: tempBasal.timestamp)
+        }
+
+        let event1Duration = splitPoint - startMinutes
+        let event2Duration = duration - event1Duration
+        let event2Start = tempBasal.timestamp + event1Duration.minutesToSeconds
+
+        return [
+            tempBasal.copyWith(duration: event1Duration),
+            tempBasal.copyWith(duration: event2Duration, timestamp: event2Start)
+        ]
     }
 
-    /// Splits any temp basal commands that cross profile break points to simplify the IoB calculation
-    private static func splitTempBasal(
+    private static func splitAtProfileBreak(
         tempBasal: ComputedPumpHistoryEvent,
-        profileBreaks: [Decimal],
-        accumulator: [ComputedPumpHistoryEvent] = []
-    ) -> [ComputedPumpHistoryEvent] {
-        guard let offset = findIntersectionOffset(tempBasal: tempBasal, profileBreaks: profileBreaks),
-              let duration = tempBasal.duration
-        else {
-            return accumulator + [tempBasal]
+        profileBreaks: [Decimal]
+    ) throws -> [ComputedPumpHistoryEvent] {
+        guard let duration = tempBasal.duration else {
+            throw IobError.tempBasalMissingDuration(timestamp: tempBasal.timestamp)
         }
 
-        let firstEvent = tempBasal.copyWith(duration: offset.secondsToMinutes)
-        let secondEvent = tempBasal.copyWith(
-            duration: duration - offset.secondsToMinutes,
-            timestamp: tempBasal.timestamp + offset
-        )
+        guard let startMinutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision else {
+            throw MinutesFromMidnightError.invalidCalendar
+        }
+
+        let endMinutes = startMinutes + duration
+        for profileBreak in profileBreaks {
+            if profileBreak > startMinutes, profileBreak < endMinutes {
+                return try splitAtMinutesSinceMidnight(tempBasal: tempBasal, splitPoint: profileBreak)
+            }
+        }
+
+        return [tempBasal]
+    }
+
+    // we know that these are all at most 30 minutes since we split by 30m first
+    private static func splitAtMidnight(tempBasal: ComputedPumpHistoryEvent) throws -> [ComputedPumpHistoryEvent] {
+        let minutesPerDay = Decimal(24 * 60)
+        guard let startMinutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision else {
+            throw MinutesFromMidnightError.invalidCalendar
+        }
+
+        guard let duration = tempBasal.duration else {
+            throw IobError.tempBasalMissingDuration(timestamp: tempBasal.timestamp)
+        }
+
+        let endMinutes = startMinutes + duration
+        if endMinutes > minutesPerDay {
+            return try splitAtMinutesSinceMidnight(tempBasal: tempBasal, splitPoint: minutesPerDay)
+        } else {
+            return [tempBasal]
+        }
+    }
+
+    private static func splitBy30mDuration(tempBasal: ComputedPumpHistoryEvent) throws -> [ComputedPumpHistoryEvent] {
+        guard let duration = tempBasal.duration else {
+            throw IobError.tempBasalMissingDuration(timestamp: tempBasal.timestamp)
+        }
+
+        return stride(from: tempBasal.timestamp, to: tempBasal.timestamp + duration.minutesToSeconds, by: 30.minutesToSeconds)
+            .map { start in
+
+                // Calculate the duration for this chunk
+                let endOfChunk = start + 30.minutesToSeconds
+                let endOfTempBasal = tempBasal.timestamp + duration.minutesToSeconds
+                let end = min(endOfChunk, endOfTempBasal)
+                let durationInSeconds = end.timeIntervalSince(start)
 
-        // use tail recursion to give the compiler a chance to optimize
-        return splitTempBasal(tempBasal: secondEvent, profileBreaks: profileBreaks, accumulator: accumulator + [firstEvent])
+                return tempBasal.copyWith(duration: durationInSeconds.secondsToMinutes, timestamp: start)
+            }
+    }
+
+    /// Splits any temp basal commands that cross profile break points to simplify the IoB calculation
+    private static func splitTempBasal(
+        tempBasal: ComputedPumpHistoryEvent,
+        profileBreaks: [Decimal]
+    ) throws -> [ComputedPumpHistoryEvent] {
+        try splitBy30mDuration(tempBasal: tempBasal)
+            .flatMap({ try splitAtMidnight(tempBasal: $0) })
+            .flatMap({ try splitAtProfileBreak(tempBasal: $0, profileBreaks: profileBreaks) })
     }
 
     /// Converts tempBasal commands to bolus commands with roughly equal insulin delivered
@@ -267,9 +331,8 @@ struct IobHistory {
         autosens: Autosens?
     ) throws -> [ComputedPumpHistoryEvent] {
         let profileBreaksMinutesSinceMidnight = profile.basalprofile?.map({ Decimal($0.minutes) }) ?? []
-        let splitTempBasals = tempHistory
-            .flatMap { splitTempBasal(tempBasal: $0, profileBreaks: profileBreaksMinutesSinceMidnight) }
-
+        let splitTempBasals = try tempHistory
+            .flatMap { try splitTempBasal(tempBasal: $0, profileBreaks: profileBreaksMinutesSinceMidnight) }
         return try splitTempBasals
             .flatMap { try extractTempBoluses(from: $0, profile: profile, autosens: autosens) }
     }

+ 24 - 0
Trio/Sources/APS/OpenAPSSwift/JSONBridge.swift

@@ -2,6 +2,7 @@ import Foundation
 
 enum JSONError: Error {
     case invalidString
+    case invalidDate(String)
     case decodingFailed(Error)
     case encodingFailed
 }
@@ -43,6 +44,29 @@ enum JSONBridge {
         try JSONBridge.from(string: from.rawJSON)
     }
 
+    static func pumpHistory(from: JSON) throws -> [PumpHistoryEvent] {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func profile(from: JSON) throws -> Profile {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func autosens(from: JSON) throws -> Autosens? {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func clock(from: JSON) throws -> Date {
+        let dateJson = from.rawJSON.replacingOccurrences(of: "\"", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
+        if let date = Formatter.iso8601withFractionalSeconds.date(from: dateJson) ?? Formatter.iso8601
+            .date(from: dateJson)
+        {
+            return date
+        }
+
+        throw JSONError.invalidDate(from.rawJSON)
+    }
+
     static func from<T: Decodable>(string: String) throws -> T {
         guard let data = string.data(using: .utf8) else {
             throw JSONError.invalidString

+ 5 - 1
Trio/Sources/APS/OpenAPSSwift/Models/ComputedPumpHistoryEvent.swift

@@ -16,6 +16,7 @@ struct ComputedPumpHistoryEvent: Codable, Equatable, Identifiable {
     let isSMB: Bool?
     let isExternal: Bool?
     let insulin: Decimal?
+    let isTempBolus: Bool
 
     // Make these non-computed properties to ensure they're always set
     let started_at: Date
@@ -36,7 +37,8 @@ struct ComputedPumpHistoryEvent: Codable, Equatable, Identifiable {
         note: String?,
         isSMB: Bool?,
         isExternal: Bool?,
-        insulin: Decimal?
+        insulin: Decimal?,
+        isTempBolus: Bool = false
     ) {
         self.id = id
         self.type = type
@@ -53,6 +55,7 @@ struct ComputedPumpHistoryEvent: Codable, Equatable, Identifiable {
         self.isSMB = isSMB
         self.isExternal = isExternal
         self.insulin = insulin
+        self.isTempBolus = isTempBolus
 
         // Explicitly set started_at and date as required by history.js
         started_at = timestamp // This matches behavior of new Date(tz(timestamp))
@@ -79,5 +82,6 @@ extension ComputedPumpHistoryEvent {
         case started_at
         case date
         case insulin
+        case isTempBolus
     }
 }