Explorar o código

Early exit tests

This commit adds test cases to check all of the ways the determine
basal function can exit early before hitting the core dosing
logic. There are corresponding Javascript tests that show that these
are the right conditions to check. It also includes code to wrap up
these early exits into error objects.

We removed the `enableSMB` variable in the Determination that we used
for debugging and updated the determineBasal JS to remove it from
there as well.
Sam King hai 9 meses
pai
achega
c828d52014

+ 0 - 2
Model/JSONImporter.swift

@@ -528,7 +528,6 @@ extension Determination: Codable {
         case carbRatio = "CR"
         case carbRatio = "CR"
         case received
         case received
         case receivedAlt = "recieved"
         case receivedAlt = "recieved"
-        case enableSMB
     }
     }
 
 
     init(from decoder: Decoder) throws {
     init(from decoder: Decoder) throws {
@@ -605,7 +604,6 @@ extension Determination: Codable {
         try container.encodeIfPresent(threshold, forKey: .threshold)
         try container.encodeIfPresent(threshold, forKey: .threshold)
         try container.encodeIfPresent(carbRatio, forKey: .carbRatio)
         try container.encodeIfPresent(carbRatio, forKey: .carbRatio)
         try container.encodeIfPresent(received, forKey: .received) // always encode the correct spelling
         try container.encodeIfPresent(received, forKey: .received) // always encode the correct spelling
-        try container.encodeIfPresent(enableSMB, forKey: .enableSMB)
     }
     }
 
 
     func checkForRequiredFields() throws {
     func checkForRequiredFields() throws {

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -287,6 +287,7 @@
 		3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */; };
 		3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */; };
 		3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */; };
 		3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */; };
 		3B5F45B62D6A239500F70982 /* DoubleApproximateMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */; };
 		3B5F45B62D6A239500F70982 /* DoubleApproximateMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */; };
+		3B8221B22E5882E300585156 /* DetermineBasalEarlyExitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8221B12E5882D900585156 /* DetermineBasalEarlyExitTests.swift */; };
 		3B8B5D332DF5238000365ED3 /* as-profile.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D302DF5238000365ED3 /* as-profile.json */; };
 		3B8B5D332DF5238000365ED3 /* as-profile.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D302DF5238000365ED3 /* as-profile.json */; };
 		3B8B5D342DF5238000365ED3 /* as-glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D2F2DF5238000365ED3 /* as-glucose.json */; };
 		3B8B5D342DF5238000365ED3 /* as-glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D2F2DF5238000365ED3 /* as-glucose.json */; };
 		3B8B5D352DF5238000365ED3 /* as-pump.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D312DF5238000365ED3 /* as-pump.json */; };
 		3B8B5D352DF5238000365ED3 /* as-pump.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D312DF5238000365ED3 /* as-pump.json */; };
@@ -1213,6 +1214,7 @@
 		3B5CD2C52D4AECD500CE213C /* ProfileJavascriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileJavascriptTests.swift; sourceTree = "<group>"; };
 		3B5CD2C52D4AECD500CE213C /* ProfileJavascriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileJavascriptTests.swift; sourceTree = "<group>"; };
 		3B5CD2C62D4AECD500CE213C /* ProfileTargetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTargetsTests.swift; sourceTree = "<group>"; };
 		3B5CD2C62D4AECD500CE213C /* ProfileTargetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTargetsTests.swift; sourceTree = "<group>"; };
 		3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleApproximateMatching.swift; sourceTree = "<group>"; };
 		3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleApproximateMatching.swift; sourceTree = "<group>"; };
+		3B8221B12E5882D900585156 /* DetermineBasalEarlyExitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalEarlyExitTests.swift; sourceTree = "<group>"; };
 		3B8B5D2D2DF5238000365ED3 /* as-basal.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-basal.json"; sourceTree = "<group>"; };
 		3B8B5D2D2DF5238000365ED3 /* as-basal.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-basal.json"; sourceTree = "<group>"; };
 		3B8B5D2E2DF5238000365ED3 /* as-carbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-carbs.json"; sourceTree = "<group>"; };
 		3B8B5D2E2DF5238000365ED3 /* as-carbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-carbs.json"; sourceTree = "<group>"; };
 		3B8B5D2F2DF5238000365ED3 /* as-glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-glucose.json"; sourceTree = "<group>"; };
 		3B8B5D2F2DF5238000365ED3 /* as-glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-glucose.json"; sourceTree = "<group>"; };
@@ -2937,6 +2939,7 @@
 				3B8B5D3B2DF523B800365ED3 /* AutosensJsonTests.swift */,
 				3B8B5D3B2DF523B800365ED3 /* AutosensJsonTests.swift */,
 				3BBC22622DF5B93900169236 /* AutosensTests.swift */,
 				3BBC22622DF5B93900169236 /* AutosensTests.swift */,
 				DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */,
 				DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */,
+				3B8221B12E5882D900585156 /* DetermineBasalEarlyExitTests.swift */,
 				3BAC929A2E55FF4F00B853DA /* DetermineBasalEnableSmbTests.swift */,
 				3BAC929A2E55FF4F00B853DA /* DetermineBasalEnableSmbTests.swift */,
 				3BE9F0BA2E28C993001B14EB /* DetermineBasalJsonTests.swift */,
 				3BE9F0BA2E28C993001B14EB /* DetermineBasalJsonTests.swift */,
 				3B2A3BC22E2B19F700658FB9 /* DynamicISFTests.swift */,
 				3B2A3BC22E2B19F700658FB9 /* DynamicISFTests.swift */,
@@ -5249,6 +5252,7 @@
 				3BC4053B2D931620006A03E9 /* IobJsonTests.swift in Sources */,
 				3BC4053B2D931620006A03E9 /* IobJsonTests.swift in Sources */,
 				3BEF6AB72D9750780076089D /* MealJsonTests.swift in Sources */,
 				3BEF6AB72D9750780076089D /* MealJsonTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
+				3B8221B22E5882E300585156 /* DetermineBasalEarlyExitTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */,
 				DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,

+ 0 - 9
Trio.xcodeproj/xcshareddata/xcschemes/Trio Tests.xcscheme

@@ -53,15 +53,6 @@
       savedToolIdentifier = ""
       savedToolIdentifier = ""
       useCustomWorkingDirectory = "NO"
       useCustomWorkingDirectory = "NO"
       debugDocumentVersioning = "YES">
       debugDocumentVersioning = "YES">
-      <MacroExpansion>
-         <BuildableReference
-            BuildableIdentifier = "primary"
-            BlueprintIdentifier = "388E595725AD948C0019842D"
-            BuildableName = "Trio.app"
-            BlueprintName = "Trio"
-            ReferencedContainer = "container:Trio.xcodeproj">
-         </BuildableReference>
-      </MacroExpansion>
    </ProfileAction>
    </ProfileAction>
    <AnalyzeAction
    <AnalyzeAction
       buildConfiguration = "Debug">
       buildConfiguration = "Debug">

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

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

+ 8 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DeterminationError.swift

@@ -3,6 +3,8 @@ import Foundation
 enum DeterminationError: LocalizedError, Equatable {
 enum DeterminationError: LocalizedError, Equatable {
     case missingGlucoseStatus
     case missingGlucoseStatus
     case missingProfile
     case missingProfile
+    case missingCurrentBasal
+    case missingMinBg
     case staleGlucoseData(ageMinutes: Double)
     case staleGlucoseData(ageMinutes: Double)
     case glucoseOutOfRange(glucose: Decimal)
     case glucoseOutOfRange(glucose: Decimal)
     case cgmNoiseTooHigh(noise: Int)
     case cgmNoiseTooHigh(noise: Int)
@@ -18,6 +20,12 @@ enum DeterminationError: LocalizedError, Equatable {
             return String(localized: "No glucose status; cannot determine basal.")
             return String(localized: "No glucose status; cannot determine basal.")
         case .missingProfile:
         case .missingProfile:
             return String(localized: "No profile; cannot determine basal.")
             return String(localized: "No profile; cannot determine basal.")
+        case .missingCurrentBasal:
+            // string copied from JS
+            return String(localized: "Error: could not get current basal rate")
+        case .missingMinBg:
+            // string copied from JS including trailing space
+            return String(localized: "Error: could not determine target_bg. ")
         case let .staleGlucoseData(ageMinutes):
         case let .staleGlucoseData(ageMinutes):
             return String(localized: "Glucose data is too old (\(ageMinutes) min ago).")
             return String(localized: "Glucose data is too old (\(ageMinutes) min ago).")
         case let .glucoseOutOfRange(glucose):
         case let .glucoseOutOfRange(glucose):

+ 124 - 96
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -11,6 +11,7 @@ enum DeterminationGenerator {
     // override data can just be fetched from the DB
     // override data can just be fetched from the DB
     // handling via overrideManager ?
     // handling via overrideManager ?
 
 
+    /// Top-level determination generator, callers should use this function
     static func generate(
     static func generate(
         profile: Profile,
         profile: Profile,
         preferences: Preferences,
         preferences: Preferences,
@@ -18,14 +19,43 @@ enum DeterminationGenerator {
         iobData: [IobResult],
         iobData: [IobResult],
         mealData: ComputedCarbs,
         mealData: ComputedCarbs,
         autosensData: Autosens,
         autosensData: Autosens,
-        reservoirData _: Decimal,
+        reservoirData: Decimal,
         glucose: [BloodGlucose],
         glucose: [BloodGlucose],
         trioCustomOrefVariables: TrioCustomOrefVariables,
         trioCustomOrefVariables: TrioCustomOrefVariables,
-        currentTime: Date,
-        includeDebugOutputs: Bool
+        currentTime: Date
     ) throws -> Determination? {
     ) throws -> Determination? {
-        var autosensData = autosensData
         let glucoseStatus = try Self.getGlucoseStatus(glucoseReadings: glucose)
         let glucoseStatus = try Self.getGlucoseStatus(glucoseReadings: glucose)
+        guard let glucoseStatus = glucoseStatus else { throw DeterminationError.missingInputs }
+        return try determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+    }
+
+    /// Internal function to implement the core determine basal logic. We have a separate function
+    /// from `generate` so that we can pass GlucoseStatus values directly into the function
+    /// for testing.
+    static func determineBasal(
+        profile: Profile,
+        preferences: Preferences,
+        currentTemp: TempBasal,
+        iobData: [IobResult],
+        mealData: ComputedCarbs,
+        autosensData: Autosens,
+        reservoirData _: Decimal,
+        glucoseStatus: GlucoseStatus,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        currentTime: Date
+    ) throws -> Determination? {
+        var autosensData = autosensData
 
 
         try checkDeterminationInputs(
         try checkDeterminationInputs(
             glucoseStatus: glucoseStatus,
             glucoseStatus: glucoseStatus,
@@ -35,11 +65,9 @@ enum DeterminationGenerator {
             currentTime: currentTime,
             currentTime: currentTime,
         )
         )
 
 
-        guard let glucoseStatus = glucoseStatus else { throw DeterminationError.missingInputs }
-
         let currentGlucose: Decimal = glucoseStatus.glucose
         let currentGlucose: Decimal = glucoseStatus.glucose
 
 
-        if let errorDetermination = handleTempBasalCases(
+        if let errorDetermination = try handleTempBasalCases(
             glucoseStatus: glucoseStatus,
             glucoseStatus: glucoseStatus,
             profile: profile,
             profile: profile,
             currentTemp: currentTemp,
             currentTemp: currentTemp,
@@ -48,6 +76,50 @@ enum DeterminationGenerator {
             return errorDetermination
             return errorDetermination
         }
         }
 
 
+        // Safety check: current temp vs. last temp in iob
+        guard let lastTempTarget = iobData.first?.lastTemp else {
+            throw DeterminationError.missingIob
+        }
+        if !checkCurrentTempBasalRateSafety(
+            currentTemp: currentTemp,
+            lastTempTarget: lastTempTarget,
+            currentTime: currentTime
+        ) {
+            let reason =
+                "Safety check: currentTemp does not match lastTemp in IOB or lastTemp ended long ago; canceling temp basal."
+            return Determination(
+                id: UUID(),
+                reason: reason,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: 0,
+                duration: 0,
+                iob: iobData.first?.iob,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: .absolute,
+                bg: glucoseStatus.glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: nil,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        }
+
         let dynamicIsfResult = DynamicISF.calculate(
         let dynamicIsfResult = DynamicISF.calculate(
             profile: profile,
             profile: profile,
             preferences: preferences,
             preferences: preferences,
@@ -227,92 +299,42 @@ enum DeterminationGenerator {
 
 
         // TODO: STOPPING at LINE 1264
         // TODO: STOPPING at LINE 1264
 
 
-        var determination: Determination
-        // Safety check: current temp vs. last temp in iob
-        guard let lastTempTarget = iobData.first?.lastTemp else {
-            throw DeterminationError.missingIob
-        }
-        if !checkCurrentTempBasalRateSafety(
-            currentTemp: currentTemp,
-            lastTempTarget: lastTempTarget,
-            currentTime: currentTime
-        ) {
-            let reason =
-                "Safety check: currentTemp does not match lastTemp in IOB or lastTemp ended long ago; canceling temp basal."
-            determination = Determination(
-                id: UUID(),
-                reason: reason,
-                units: nil,
-                insulinReq: nil,
-                eventualBG: nil,
-                sensitivityRatio: nil,
-                rate: 0,
-                duration: 0,
-                iob: iobData.first?.iob,
-                cob: nil,
-                predictions: nil,
-                deliverAt: currentTime,
-                carbsReq: nil,
-                temp: .absolute,
-                bg: glucoseStatus.glucose,
-                reservoir: nil,
-                isf: profile.sens,
-                timestamp: currentTime,
-                tdd: nil,
-                current_target: profile.targetBg,
-                insulinForManualBolus: nil,
-                manualBolusErrorString: nil,
-                minDelta: nil,
-                expectedDelta: nil,
-                minGuardBG: nil,
-                minPredBG: nil,
-                threshold: nil,
-                carbRatio: profile.carbRatio,
-                received: false
-            )
-        } else {
-            // FIXME: properly populate all fields!
-            determination = Determination(
-                id: UUID(),
-                reason: dosingInputs.reason,
-                units: nil,
-                insulinReq: nil,
-                eventualBG: Int(forecastResult.eventualGlucose),
-                sensitivityRatio: sensitivityRatio, // this would only the AS-adjusted one for now
-                rate: nil,
-                duration: nil,
-                iob: iobData.first?.iob,
-                cob: mealData.mealCOB,
-                predictions: Predictions(
-                    iob: forecastResult.iob.map { Int($0.jsRounded()) },
-                    zt: forecastResult.zt.map { Int($0.jsRounded()) },
-                    cob: forecastResult.cob?.map { Int($0.jsRounded()) },
-                    uam: forecastResult.uam?.map { Int($0.jsRounded()) }
-                ),
-                deliverAt: currentTime,
-                carbsReq: dosingInputs.carbsRequired?.carbs,
-                temp: nil,
-                bg: currentGlucose,
-                reservoir: nil,
-                isf: nil,
-                timestamp: currentTime,
-                tdd: nil,
-                current_target: nil,
-                insulinForManualBolus: nil,
-                manualBolusErrorString: nil,
-                minDelta: nil,
-                expectedDelta: expectedDelta,
-                minGuardBG: forecastResult.minGuardGlucose,
-                minPredBG: forecastResult.minForecastedGlucose,
-                threshold: threshold.jsRounded(),
-                carbRatio: forecastResult.adjustedCarbRatio.jsRounded(scale: 1),
-                received: false,
-            )
-        }
-
-        if includeDebugOutputs {
-            determination.enableSMB = smbDecision.isEnabled
-        }
+        var determination = Determination(
+            id: UUID(),
+            reason: dosingInputs.reason,
+            units: nil,
+            insulinReq: nil,
+            eventualBG: Int(forecastResult.eventualGlucose),
+            sensitivityRatio: sensitivityRatio, // this would only the AS-adjusted one for now
+            rate: nil,
+            duration: nil,
+            iob: iobData.first?.iob,
+            cob: mealData.mealCOB,
+            predictions: Predictions(
+                iob: forecastResult.iob.map { Int($0.jsRounded()) },
+                zt: forecastResult.zt.map { Int($0.jsRounded()) },
+                cob: forecastResult.cob?.map { Int($0.jsRounded()) },
+                uam: forecastResult.uam?.map { Int($0.jsRounded()) }
+            ),
+            deliverAt: currentTime,
+            carbsReq: dosingInputs.carbsRequired?.carbs,
+            temp: nil,
+            bg: currentGlucose,
+            reservoir: nil,
+            isf: nil,
+            timestamp: currentTime,
+            tdd: nil,
+            current_target: nil,
+            insulinForManualBolus: nil,
+            manualBolusErrorString: nil,
+            minDelta: nil,
+            expectedDelta: expectedDelta,
+            minGuardBG: forecastResult.minGuardGlucose,
+            minPredBG: forecastResult.minForecastedGlucose,
+            threshold: threshold.jsRounded(),
+            carbRatio: forecastResult.adjustedCarbRatio.jsRounded(scale: 1),
+            received: false,
+        )
 
 
         // TODO: how to handle output?
         // TODO: how to handle output?
         // TODO: how to handle logging?
         // TODO: how to handle logging?
@@ -333,11 +355,15 @@ enum DeterminationGenerator {
         guard let profile = profile else {
         guard let profile = profile else {
             throw DeterminationError.missingProfile
             throw DeterminationError.missingProfile
         }
         }
+        guard profile.minBg != nil else {
+            throw DeterminationError.missingMinBg
+        }
         let glucoseAge = currentTime.timeIntervalSince(glucoseStatus.date)
         let glucoseAge = currentTime.timeIntervalSince(glucoseStatus.date)
         if glucoseAge > 15 * 60 {
         if glucoseAge > 15 * 60 {
             throw DeterminationError.staleGlucoseData(ageMinutes: glucoseAge / 60)
             throw DeterminationError.staleGlucoseData(ageMinutes: glucoseAge / 60)
         }
         }
-        if glucoseStatus.glucose < 39 || glucoseStatus.glucose > 600 {
+        // we have to allow 38 values so that we can cancel high temps
+        if glucoseStatus.glucose < 38 || glucoseStatus.glucose > 600 {
             throw DeterminationError.glucoseOutOfRange(glucose: glucoseStatus.glucose)
             throw DeterminationError.glucoseOutOfRange(glucose: glucoseStatus.glucose)
         }
         }
         guard let _ = iobData else {
         guard let _ = iobData else {
@@ -350,7 +376,7 @@ enum DeterminationGenerator {
         profile: Profile,
         profile: Profile,
         currentTemp: TempBasal?,
         currentTemp: TempBasal?,
         currentTime: Date
         currentTime: Date
-    ) -> Determination? {
+    ) throws -> Determination? {
         let glucose = glucoseStatus.glucose
         let glucose = glucoseStatus.glucose
         let noise = glucoseStatus.noise
         let noise = glucoseStatus.noise
         let bgTime = glucoseStatus.date
         let bgTime = glucoseStatus.date
@@ -361,7 +387,9 @@ enum DeterminationGenerator {
         let device = glucoseStatus.device
         let device = glucoseStatus.device
 
 
         // Always use profile-supplied basal
         // Always use profile-supplied basal
-        let basal = profile.currentBasal ?? profile.basalFor(time: currentTime)
+        guard let basal = profile.currentBasal else {
+            throw DeterminationError.missingCurrentBasal
+        }
 
 
         // Compose tick for log
         // Compose tick for log
         let tick: String = (delta > -0.5) ? "+\(delta.rounded(toPlaces: 0))" : "\(delta.rounded(toPlaces: 0))"
         let tick: String = (delta > -0.5) ? "+\(delta.rounded(toPlaces: 0))" : "\(delta.rounded(toPlaces: 0))"

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

@@ -54,8 +54,7 @@ struct OpenAPSSwift {
         preferences: JSON,
         preferences: JSON,
         basalProfile: JSON,
         basalProfile: JSON,
         trioCustomOrefVariables: JSON,
         trioCustomOrefVariables: JSON,
-        clock: Date,
-        includeDebugOutputs: Bool
+        clock: Date
     ) -> (OrefFunctionResult, DetermineBasalInputs?) {
     ) -> (OrefFunctionResult, DetermineBasalInputs?) {
         var determineBasalInputs: DetermineBasalInputs?
         var determineBasalInputs: DetermineBasalInputs?
 
 
@@ -105,12 +104,20 @@ struct OpenAPSSwift {
                 reservoirData: reservoir ?? 100,
                 reservoirData: reservoir ?? 100,
                 glucose: glucose,
                 glucose: glucose,
                 trioCustomOrefVariables: trioCustomOrefVariables,
                 trioCustomOrefVariables: trioCustomOrefVariables,
-                currentTime: clock,
-                includeDebugOutputs: includeDebugOutputs
+                currentTime: clock
             )
             )
 
 
             return (try .success(JSONBridge.to(rawDetermination)), determineBasalInputs)
             return (try .success(JSONBridge.to(rawDetermination)), determineBasalInputs)
 
 
+        } catch let determinationError as DeterminationError {
+            // if we get a determination error we want to return it as a JSON
+            // object that is { "error": "some error" }
+            do {
+                let response = try JSONBridge.to(DeterminationErrorResponse(error: determinationError.localizedDescription))
+                return (.success(response), determineBasalInputs)
+            } catch {
+                return (.failure(determinationError), determineBasalInputs)
+            }
         } catch {
         } catch {
             return (.failure(error), determineBasalInputs)
             return (.failure(error), determineBasalInputs)
         }
         }

+ 6 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -93591,6 +93591,12 @@
         }
         }
       }
       }
     },
     },
+    "Error: could not determine target_bg. " : {
+
+    },
+    "Error: could not get current basal rate" : {
+
+    },
     "Error! Bolus cancellation failed with error: %@" : {
     "Error! Bolus cancellation failed with error: %@" : {
       "comment" : "Error message for canceling a bolus",
       "comment" : "Error message for canceling a bolus",
       "localizations" : {
       "localizations" : {

+ 4 - 4
Trio/Sources/Models/Determination.swift

@@ -1,5 +1,9 @@
 import Foundation
 import Foundation
 
 
+struct DeterminationErrorResponse: JSON, Equatable {
+    let error: String
+}
+
 struct Determination: JSON, Equatable {
 struct Determination: JSON, Equatable {
     let id: UUID?
     let id: UUID?
     var reason: String
     var reason: String
@@ -34,9 +38,6 @@ struct Determination: JSON, Equatable {
     var threshold: Decimal?
     var threshold: Decimal?
     let carbRatio: Decimal?
     let carbRatio: Decimal?
     let received: Bool?
     let received: Bool?
-
-    // only used for debugging
-    var enableSMB: Bool? = nil
 }
 }
 
 
 struct Predictions: JSON, Equatable {
 struct Predictions: JSON, Equatable {
@@ -77,7 +78,6 @@ extension Determination {
         case threshold
         case threshold
         case carbRatio = "CR"
         case carbRatio = "CR"
         case received
         case received
-        case enableSMB
     }
     }
 }
 }
 
 

+ 470 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift

@@ -0,0 +1,470 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("DetermineBasal early exits before core dosing logic") struct DetermineBasalEarlyExitTests {
+    private func createDefaultInputs() -> (
+        profile: Profile,
+        preferences: Preferences,
+        currentTemp: TempBasal,
+        iobData: [IobResult],
+        mealData: ComputedCarbs,
+        autosensData: Autosens,
+        reservoirData: Decimal,
+        glucoseStatus: GlucoseStatus,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        currentTime: Date
+    ) {
+        let currentTime = Date()
+        var profile = Profile()
+        profile.maxIob = 2.5
+        profile.dia = 3
+        profile.currentBasal = 0.9
+        profile.maxDailyBasal = 1.3
+        profile.maxBasal = 3.5
+        profile.maxBg = 120
+        profile.minBg = 110
+        profile.sens = 40
+        profile.carbRatio = 10
+        profile.thresholdSetting = 80
+        profile.temptargetSet = false
+        profile.bolusIncrement = 0.1
+        profile.useCustomPeakTime = false
+        profile.curve = .rapidActing
+
+        var preferences = Preferences()
+        preferences.useNewFormula = false
+        preferences.sigmoid = false
+        preferences.adjustmentFactor = 0.8
+        preferences.adjustmentFactorSigmoid = 0.5
+        preferences.curve = .rapidActing
+        preferences.useCustomPeakTime = false
+
+        let currentTemp = TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: currentTime)
+
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: currentTime,
+            iobWithZeroTemp: IobResult.IobWithZeroTemp(
+                iob: 0,
+                activity: 0,
+                basaliob: 0,
+                bolusiob: 0,
+                netbasalinsulin: 0,
+                bolusinsulin: 0,
+                time: currentTime
+            ),
+            lastBolusTime: nil,
+            lastTemp: IobResult.LastTemp(
+                rate: 0,
+                timestamp: currentTime,
+                started_at: currentTime,
+                date: UInt64(currentTime.timeIntervalSince1970 * 1000),
+                duration: 30
+            )
+        )]
+
+        let mealData = ComputedCarbs(
+            carbs: 0,
+            mealCOB: 0,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [0, 0, 0, 0, 0],
+            lastCarbTime: 0
+        )
+
+        let autosensData = Autosens(ratio: 1.0, newisf: nil)
+
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 115,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: currentTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let trioCustomOrefVariables = TrioCustomOrefVariables(
+            average_total_data: 0,
+            weightedAverage: 0,
+            currentTDD: 0,
+            past2hoursAverage: 0,
+            date: currentTime,
+            overridePercentage: 100,
+            useOverride: false,
+            duration: 0,
+            unlimited: false,
+            overrideTarget: 0,
+            smbIsOff: false,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: false,
+            start: 0,
+            end: 0,
+            smbMinutes: 30,
+            uamMinutes: 30,
+            shouldProtectDueToHIGH: false
+        )
+
+        return (
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: 100,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+    }
+
+    // Test 1 from JS
+    @Test("should fail if current_basal is missing") func missingCurrentBasal() throws {
+        var (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        profile.currentBasal = nil
+        profile.basalprofile = [] // ensure basalFor also returns nil
+
+        #expect(throws: DeterminationError.missingCurrentBasal) {
+            _ = try DeterminationGenerator.determineBasal(
+                profile: profile,
+                preferences: preferences,
+                currentTemp: currentTemp,
+                iobData: iobData,
+                mealData: mealData,
+                autosensData: autosensData,
+                reservoirData: reservoirData,
+                glucoseStatus: glucoseStatus,
+                trioCustomOrefVariables: trioCustomOrefVariables,
+                currentTime: currentTime
+            )
+        }
+    }
+
+    // Test 2 from JS
+    @Test("should cancel high temp if BG is 38") func cancelHighTempBG38() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            _,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 38,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: currentTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let currentTemp = TempBasal(duration: 30, rate: 1.5, temp: .absolute, timestamp: currentTime)
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect(result?.duration == 0)
+        #expect(result?.reason.contains("Canceling high temp basal") == true)
+    }
+
+    // Test 3 from JS
+    @Test("should shorten long zero temp if BG data is too old") func shortenLongZeroTempTooOldBG() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            _,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let glucoseTime = currentTime.addingTimeInterval(-15 * 60)
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 115,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: glucoseTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let currentTemp = TempBasal(duration: 60, rate: 0, temp: .absolute, timestamp: currentTime)
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect(result?.duration == 30)
+        #expect(result?.reason.contains("Shortening") == true)
+    }
+
+    // Test 4 from JS
+    @Test("should do nothing if BG is too old and temp is not high") func doNothingOldBGNotHighTemp() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            _,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let glucoseTime = currentTime.addingTimeInterval(-15 * 60)
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 115,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: glucoseTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let currentTemp = TempBasal(duration: 30, rate: 0.5, temp: .absolute, timestamp: currentTime)
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0.5)
+        #expect(result?.duration == 30)
+        #expect(result?.reason.contains("doing nothing") == true)
+    }
+
+    // Test 5 from JS
+    @Test("should error if target_bg cannot be determined") func errorIfTargetBGMissing() throws {
+        var (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        profile.minBg = nil
+
+        #expect(throws: DeterminationError.missingMinBg) { // Using a placeholder error
+            _ = try DeterminationGenerator.determineBasal(
+                profile: profile,
+                preferences: preferences,
+                currentTemp: currentTemp,
+                iobData: iobData,
+                mealData: mealData,
+                autosensData: autosensData,
+                reservoirData: reservoirData,
+                glucoseStatus: glucoseStatus,
+                trioCustomOrefVariables: trioCustomOrefVariables,
+                currentTime: currentTime
+            )
+        }
+    }
+
+    // Test 6 from JS
+    @Test("should cancel temp if currenttemp and lastTemp from pumphistory do not match") func cancelTempMismatch() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let currentTemp = TempBasal(duration: 30, rate: 1.5, temp: .absolute, timestamp: currentTime)
+
+        let lastTempTime = currentTime.addingTimeInterval(-15 * 60)
+        let lastTemp = IobResult.LastTemp(
+            rate: 1.0,
+            timestamp: lastTempTime,
+            started_at: lastTempTime,
+            date: UInt64(lastTempTime.timeIntervalSince1970 * 1000),
+            duration: 30
+        )
+
+        var mutableIobData = iobData
+        mutableIobData[0].lastTemp = lastTemp
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: mutableIobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect(result?.duration == 0)
+        // Note: In swift we use a different reason then JS
+        #expect(
+            result?
+                .reason ==
+                "Safety check: currentTemp does not match lastTemp in IOB or lastTemp ended long ago; canceling temp basal."
+        )
+    }
+
+    // Test 7 from JS
+    @Test("should cancel temp if lastTemp from pumphistory ended long ago") func cancelTempOldLastTemp() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let currentTemp = TempBasal(duration: 30, rate: 1.5, temp: .absolute, timestamp: currentTime)
+
+        let lastTempTime = currentTime.addingTimeInterval(-40 * 60)
+        let lastTemp = IobResult.LastTemp(
+            rate: 1.5,
+            timestamp: lastTempTime,
+            started_at: lastTempTime,
+            date: UInt64(lastTempTime.timeIntervalSince1970 * 1000),
+            duration: 30
+        )
+
+        var mutableIobData = iobData
+        mutableIobData[0].lastTemp = lastTemp
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: mutableIobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect(result?.duration == 0)
+        // Note: In swift we use a different reason then JS
+        #expect(
+            result?
+                .reason ==
+                "Safety check: currentTemp does not match lastTemp in IOB or lastTemp ended long ago; canceling temp basal."
+        )
+    }
+
+    // Test 8 from JS
+    @Test("should throw error if eventualBG cannot be calculated") func eventualBGNaN() throws {
+        var (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        profile.sens = .nan
+
+        #expect(throws: DeterminationError.eventualGlucoseCalculationError(sensitivity: .nan, deviation: .nan)) {
+            _ = try DeterminationGenerator.determineBasal(
+                profile: profile,
+                preferences: preferences,
+                currentTemp: currentTemp,
+                iobData: iobData,
+                mealData: mealData,
+                autosensData: autosensData,
+                reservoirData: reservoirData,
+                glucoseStatus: glucoseStatus,
+                trioCustomOrefVariables: trioCustomOrefVariables,
+                currentTime: currentTime
+            )
+        }
+    }
+}

+ 2 - 4
TrioTests/OpenAPSSwiftTests/DetermineBasalJsonTests.swift

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
TrioTests/OpenAPSSwiftTests/javascript/bundle/determine-basal.js