Просмотр исходного кода

First cut at the core enable SMB logic in determineBasal

Sam King 9 месяцев назад
Родитель
Сommit
87f8e110e5

+ 2 - 1
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -877,7 +877,8 @@ final class OpenAPS {
             preferences: preferences,
             basalProfile: basalProfile,
             trioCustomOrefVariables: trioCustomOrefVariables,
-            clock: clock
+            clock: clock,
+            includeDebugOutputs: false
         )
         let swiftDuration = Date().timeIntervalSince(startSwiftAt)
 

+ 21 - 2
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -21,7 +21,8 @@ enum DeterminationGenerator {
         reservoirData _: Decimal,
         glucose: [BloodGlucose],
         trioCustomOrefVariables: TrioCustomOrefVariables,
-        currentTime: Date
+        currentTime: Date,
+        includeDebugOutputs: Bool
     ) throws -> Determination? {
         var autosensData = autosensData
         let glucoseStatus = try Self.getGlucoseStatus(glucoseReadings: glucose)
@@ -254,10 +255,24 @@ enum DeterminationGenerator {
             targetLog: "" // Placeholder
         )
 
+        let smbDecision = try DosingEngine.shouldEnableSmb(
+            profile: profile,
+            meal: mealData,
+            currentGlucose: currentGlucose,
+            adjustedTargetGlucose: adjustedGlucoseTargets.targetGlucose,
+            adjustedSensitivity: adjustedSensitivity,
+            minGuardGlucose: forecastResult.minGuardGlucose,
+            eventualGlucose: eventualGlucose,
+            threshold: threshold,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            clock: currentTime
+        )
+
         // TODO: STOPPING at LINE 1264
 
         // FIXME: properly populate all fields!
-        let temporaryResult = Determination(
+        var temporaryResult = Determination(
             id: UUID(),
             reason: dosingInputs.reason,
             units: nil,
@@ -294,6 +309,10 @@ enum DeterminationGenerator {
             received: false,
         )
 
+        if includeDebugOutputs {
+            temporaryResult.enableSMB = smbDecision.isEnabled
+        }
+
         // TODO: how to handle output?
         // TODO: how to handle logging?
 

+ 153 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift

@@ -6,6 +6,159 @@ enum DosingEngine {
         let carbsRequired: (carbs: Decimal, minutes: Decimal)?
     }
 
+    /// struct to keep the relevant state needed for the output of the SMB decision logic
+    struct SMBDecision {
+        let isEnabled: Bool
+        let manualBolusError: Int?
+        let insulinForManualBolus: Decimal?
+        let minGuardGlucose: Decimal?
+        let reason: String?
+    }
+
+    /// checks to see if SMB are enabled via the profile
+    private static func isProfileSmbEnabled(
+        currentGlucose: Decimal,
+        adjustedTargetGlucose: Decimal,
+        profile: Profile,
+        meal: ComputedCarbs,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        clock: Date
+    ) throws -> Bool {
+        if trioCustomOrefVariables.smbIsOff {
+            return false
+        }
+
+        if try isSmbScheduledOff(trioCustomOrefVariables: trioCustomOrefVariables, clock: clock) {
+            return false
+        }
+
+        if trioCustomOrefVariables.shouldProtectDueToHIGH {
+            return false
+        }
+
+        if !profile.allowSMBWithHighTemptarget, profile.temptargetSet == true, adjustedTargetGlucose > 100 {
+            return false
+        }
+
+        if profile.enableSMBAlways {
+            return true
+        }
+
+        if profile.enableSMBWithCOB, meal.mealCOB > 0 {
+            return true
+        }
+
+        if profile.enableSMBAfterCarbs, meal.carbs > 0 {
+            return true
+        }
+
+        if profile.enableSMBWithTemptarget, profile.temptargetSet == true, adjustedTargetGlucose < 100 {
+            return true
+        }
+
+        if profile.enableSMBHighBg, currentGlucose >= profile.enableSMBHighBgTarget {
+            return true
+        }
+
+        return false
+    }
+
+    /// helper function to check if SMB is scheduled off given the current timezone
+    private static func isSmbScheduledOff(trioCustomOrefVariables: TrioCustomOrefVariables, clock: Date) throws -> Bool {
+        guard trioCustomOrefVariables.smbIsScheduledOff else {
+            return false
+        }
+
+        guard let currentHour = clock.hourInLocalTime.map({ Decimal($0) }) else {
+            throw CalendarError.invalidCalendarHourOnly
+        }
+        let startHour = trioCustomOrefVariables.start
+        let endHour = trioCustomOrefVariables.end
+
+        // SMBs will be disabled from [start, end) local time
+        if startHour < endHour, currentHour >= startHour && currentHour < endHour {
+            // disable when the schedule does not wrap around midnight
+            return true
+        } else if startHour > endHour, currentHour >= startHour || currentHour < endHour {
+            // disable when the schedule does wrap around midnight
+            return true
+        } else if startHour == 0, endHour == 0 {
+            // schedule specifies the entire day
+            return true
+        } else if startHour == endHour, currentHour == startHour {
+            // one hour of scheduled off SMB
+            return true
+        }
+
+        return false
+    }
+
+    /// helper function for reason string glucose output
+    private static func convertGlucose(profile: Profile, glucose: Decimal) -> Decimal {
+        if profile.outUnits == "mmol/L" {
+            return (glucose * 0.0555).jsRounded(scale: 1)
+        } else {
+            return glucose.jsRounded()
+        }
+    }
+
+    /// Top level smb enabling logic
+    ///
+    /// This function includes both the profile / customOrefVariable checks from JS `enable_smb` as
+    /// well as some of the later checks from `determineBasal` that can disable SMB
+    static func shouldEnableSmb(
+        profile: Profile,
+        meal: ComputedCarbs,
+        currentGlucose: Decimal,
+        adjustedTargetGlucose: Decimal,
+        adjustedSensitivity: Decimal,
+        minGuardGlucose: Decimal,
+        eventualGlucose: Decimal,
+        threshold: Decimal,
+        glucoseStatus: GlucoseStatus,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        clock: Date
+    ) throws -> SMBDecision {
+        var smbIsEnabled = try isProfileSmbEnabled(
+            currentGlucose: currentGlucose,
+            adjustedTargetGlucose: adjustedTargetGlucose,
+            profile: profile,
+            meal: meal,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            clock: clock
+        )
+
+        // these last two checks are implemented outside of the core enable_smb
+        // function in JS but we should keep all of the smb enabling logic
+        // in one place. Note: We can't shortcut the return value because
+        // the determineBasal logic always evaluates this logic
+        var manualBolusError: Int?
+        var insulinForManualBolus: Decimal?
+        var minGuardGlucoseDecision: Decimal?
+        var reason: String?
+        if smbIsEnabled, minGuardGlucose < threshold {
+            manualBolusError = 1
+            insulinForManualBolus = ((eventualGlucose - adjustedTargetGlucose) / adjustedSensitivity).jsRounded(scale: 2)
+            minGuardGlucoseDecision = minGuardGlucose
+            smbIsEnabled = false
+        }
+
+        let maxDeltaGlucoseThreshold = min(profile.maxDeltaBgThreshold, 0.4)
+        if glucoseStatus.maxDelta > maxDeltaGlucoseThreshold * currentGlucose {
+            reason =
+                "maxDelta \(convertGlucose(profile: profile, glucose: glucoseStatus.maxDelta)) > \(100 * maxDeltaGlucoseThreshold)% of BG \(convertGlucose(profile: profile, glucose: currentGlucose)) - SMB disabled!, "
+            smbIsEnabled = false
+        }
+
+        return SMBDecision(
+            isEnabled: smbIsEnabled,
+            manualBolusError: manualBolusError,
+            insulinForManualBolus: insulinForManualBolus,
+            minGuardGlucose: minGuardGlucoseDecision,
+            reason: reason
+        )
+    }
+
     static func prepareDosingInputs(
         profile: Profile,
         mealData: ComputedCarbs,

+ 12 - 2
Trio/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift

@@ -1,17 +1,27 @@
 import Foundation
 
-enum MinutesFromMidnightError: LocalizedError, Equatable {
+enum CalendarError: LocalizedError, Equatable {
     case invalidCalendar
+    case invalidCalendarHourOnly
 
     var errorDescription: String? {
         switch self {
         case .invalidCalendar:
             return "Unable to extract hours and minutes from the current calendar"
+        case .invalidCalendarHourOnly:
+            return "Unable to extract hours from the current calendar"
         }
     }
 }
 
 extension Date {
+    /// Returns the hour component for the date using the current timezone
+    var hourInLocalTime: Int? {
+        let calendar = Calendar.current
+        let components = calendar.dateComponents([.hour], from: self)
+        return components.hour
+    }
+
     /// Returns the total minutes elapsed since midnight for the current date
     var minutesSinceMidnight: Int? {
         let calendar = Calendar.current
@@ -51,7 +61,7 @@ extension Date {
     /// - Returns: Boolean indicating if the current time is within the specified range
     func isMinutesFromMidnightWithinRange(lowerBound: Int, upperBound: Int) throws -> Bool {
         guard let currentMinutes = minutesSinceMidnight else {
-            throw MinutesFromMidnightError.invalidCalendar
+            throw CalendarError.invalidCalendar
         }
         return currentMinutes >= lowerBound && currentMinutes < upperBound
     }

+ 3 - 3
Trio/Sources/APS/OpenAPSSwift/Iob/IobHistory.swift

@@ -301,7 +301,7 @@ struct IobHistory {
         // be small but at least it matches
         // the fix it to use minutesSinceMidnightWithPrecision
         guard let startMinutes = tempBasal.timestamp.minutesSinceMidnight.map({ Decimal($0) }) else {
-            throw MinutesFromMidnightError.invalidCalendar
+            throw CalendarError.invalidCalendar
         }
 
         guard let duration = tempBasal.duration else {
@@ -327,7 +327,7 @@ struct IobHistory {
         }
 
         guard let startMinutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision else {
-            throw MinutesFromMidnightError.invalidCalendar
+            throw CalendarError.invalidCalendar
         }
 
         let endMinutes = startMinutes + duration
@@ -344,7 +344,7 @@ struct IobHistory {
     private static func splitAtMidnight(tempBasal: ComputedPumpHistoryEvent) throws -> [ComputedPumpHistoryEvent] {
         let minutesPerDay = Decimal(24 * 60)
         guard let startMinutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision else {
-            throw MinutesFromMidnightError.invalidCalendar
+            throw CalendarError.invalidCalendar
         }
 
         guard let duration = tempBasal.duration else {

+ 5 - 0
Trio/Sources/APS/OpenAPSSwift/Models/GlucoseStatus.swift

@@ -20,4 +20,9 @@ public struct GlucoseStatus: Codable {
     public let lastCalIndex: Int?
     /// The original device/type string (e.g. “sgv” or “cal”)
     public let device: String?
+
+    /// helper function to calculate the maxDelta variable from JS
+    public var maxDelta: Decimal {
+        max(delta, shortAvgDelta, longAvgDelta)
+    }
 }

+ 4 - 2
Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -54,7 +54,8 @@ struct OpenAPSSwift {
         preferences: JSON,
         basalProfile: JSON,
         trioCustomOrefVariables: JSON,
-        clock: Date
+        clock: Date,
+        includeDebugOutputs: Bool
     ) -> (OrefFunctionResult, DetermineBasalInputs?) {
         var determineBasalInputs: DetermineBasalInputs?
 
@@ -104,7 +105,8 @@ struct OpenAPSSwift {
                 reservoirData: reservoir ?? 100,
                 glucose: glucose,
                 trioCustomOrefVariables: trioCustomOrefVariables,
-                currentTime: clock
+                currentTime: clock,
+                includeDebugOutputs: includeDebugOutputs
             )
 
             return (try .success(JSONBridge.to(rawDetermination)), determineBasalInputs)

+ 3 - 0
Trio/Sources/Models/Determination.swift

@@ -34,6 +34,9 @@ struct Determination: JSON, Equatable {
     var threshold: Decimal?
     let carbRatio: Decimal?
     let received: Bool?
+
+    // only used for debugging
+    var enableSMB: Bool? = nil
 }
 
 struct Predictions: JSON, Equatable {

+ 4 - 2
TrioTests/OpenAPSSwiftTests/DetermineBasalJsonTests.swift

@@ -55,7 +55,8 @@ import Testing
             preferences: determineBasalInput.preferences,
             basalProfile: determineBasalInput.basalProfile,
             trioCustomOrefVariables: determineBasalInput.trioCustomOrefVariables,
-            clock: determineBasalInput.clock
+            clock: determineBasalInput.clock,
+            includeDebugOutputs: true
         )
 
         let determineBasalResultJavascript = try await openAps.determineBasalJavascript(
@@ -130,7 +131,8 @@ import Testing
             preferences: determineBasalInput.preferences,
             basalProfile: determineBasalInput.basalProfile,
             trioCustomOrefVariables: determineBasalInput.trioCustomOrefVariables,
-            clock: determineBasalInput.clock
+            clock: determineBasalInput.clock,
+            includeDebugOutputs: true
         )
 
         print("Swift result")