Przeglądaj źródła

Merge pull request #491 from nightscout/fix-unit-tests

Fix Bolus Calculator Unit Tests +  Extend Calculator Input
Sam King 1 rok temu
rodzic
commit
a11211dcc2

+ 1 - 0
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -380,6 +380,7 @@ extension Treatments {
                 carbs: carbs,
                 useFattyMealCorrection: useFattyMealCorrectionFactor,
                 useSuperBolus: useSuperBolus,
+                lastLoopDate: apsManager.lastLoopDate,
                 minPredBG: localMinPredBG
             )
 

+ 14 - 3
Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift

@@ -4,7 +4,13 @@ import Swinject
 
 protocol BolusCalculationManager {
     func calculateInsulin(input: CalculationInput) async -> CalculationResult
-    func handleBolusCalculation(carbs: Decimal, useFattyMealCorrection: Bool, useSuperBolus: Bool, minPredBG: Decimal?) async
+    func handleBolusCalculation(
+        carbs: Decimal,
+        useFattyMealCorrection: Bool,
+        useSuperBolus: Bool,
+        lastLoopDate: Date,
+        minPredBG: Decimal?
+    ) async
         -> CalculationResult
 }
 
@@ -282,6 +288,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
         carbs: Decimal,
         useFattyMealCorrection: Bool,
         useSuperBolus: Bool,
+        lastLoopDate: Date,
         minPredBG: Decimal?
     ) async throws -> CalculationInput {
         do {
@@ -348,7 +355,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
                 maxBolus: maxBolus,
                 maxIOB: maxIOB,
                 maxCOB: maxCOB,
-                minPredBG: minPredBG ?? bolusVars.minPredBG
+                minPredBG: minPredBG ?? bolusVars.minPredBG,
+                lastLoopDate: lastLoopDate
             )
         } catch {
             debug(
@@ -429,7 +437,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
 
         // the final result for recommended insulin amount
         var insulinCalculated: Decimal
-        let isLoopStale = Date().timeIntervalSince(apsManager.lastLoopDate) > 15 * 60
+        let isLoopStale = Date().timeIntervalSince(input.lastLoopDate) > 15 * 60
         debug(.default, "Loop stale: \(isLoopStale), currentBG: \(input.currentBG), minPredBG: \(input.minPredBG)")
 
         // don't recommend insulin when current glucose or minPredBG is < 54 or last sucessful loop was over 15 minutes ago
@@ -480,6 +488,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
         carbs: Decimal,
         useFattyMealCorrection: Bool,
         useSuperBolus: Bool,
+        lastLoopDate: Date,
         minPredBG: Decimal? = nil
     ) async -> CalculationResult {
         do {
@@ -487,6 +496,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
                 carbs: carbs,
                 useFattyMealCorrection: useFattyMealCorrection,
                 useSuperBolus: useSuperBolus,
+                lastLoopDate: lastLoopDate,
                 minPredBG: minPredBG
             )
             let result = await calculateInsulin(input: input)
@@ -534,6 +544,7 @@ struct CalculationInput: Sendable {
     let maxIOB: Decimal // Maximum allowed IOB to be used for rec. bolus calculation
     let maxCOB: Decimal // Maximum allowed COB to be used for rec. bolus calculation
     let minPredBG: Decimal // Minimum Predicted Glucose determined by Oref
+    let lastLoopDate: Date // Date at which loop last completed successfully
 }
 
 /// Results of the bolus calculation

+ 24 - 1
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -591,15 +591,38 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true {
                 let carbs = message[WatchMessageKeys.carbs] as? Int ?? 0
 
+                var minPredBG: Decimal = 54
+
                 Task { [weak self] in
                     guard let self = self else { return }
 
+                    do {
+                        // Fetch determination data
+                        let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
+                            predicate: NSPredicate.predicateFor30MinAgoForDetermination
+                        )
+                        let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared.getNSManagedObject(
+                            with: determinationIds,
+                            context: backgroundContext
+                        )
+
+                        await MainActor.run {
+                            minPredBG = determinationObjects.first?.minPredBGFromReason ?? 54
+                        }
+
+                    } catch let error as CoreDataError {
+                        debug(.default, "Core Data error: \(error.localizedDescription)")
+                    } catch {
+                        debug(.default, "Unexpected error: \(error.localizedDescription)")
+                    }
+
                     // Get recommendation from BolusCalculationManager
                     let result = await bolusCalculationManager.handleBolusCalculation(
                         carbs: Decimal(carbs),
                         useFattyMealCorrection: false,
                         useSuperBolus: false,
-                        minPredBG: 60 // TODO:
+                        lastLoopDate: apsManager.lastLoopDate,
+                        minPredBG: minPredBG
                     )
 
                     // Send recommendation back to watch

+ 157 - 5
TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift

@@ -3,6 +3,13 @@ import Testing
 
 @testable import Trio
 
+/// ⚠️ NOTE:
+/// If tests in this suite are failing unexpectedly (e.g. sudden unexplainable mismatches for decimal places for calculated values),
+/// try running the test suite on a clean simulator.
+///
+/// You can reset the simulator from the menu: **Device > Erase All Content and Settings**
+/// or by launching with `-com.apple.CoreData.SQLDebug 1` for more insight into the issue.
+///
 @Suite("Bolus Calculator Tests") struct BolusCalculatorTests: Injectable {
     @Injected() var calculator: BolusCalculationManager!
     @Injected() var settingsManager: SettingsManager!
@@ -60,7 +67,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
 
         // STEP 3: Calculate insulin
@@ -173,7 +181,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
 
         // STEP 3: Calculate insulin with fatty meal enabled
@@ -198,7 +207,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
         let standardResult = await calculator.calculateInsulin(input: standardInput)
 
@@ -262,7 +272,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
 
         // STEP 3: Calculate insulin with super bolus enabled
@@ -287,7 +298,8 @@ import Testing
             maxBolus: maxBolus,
             maxIOB: maxIOB,
             maxCOB: maxCOB,
-            minPredBG: minPredBG
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
         )
         let standardResult = await calculator.calculateInsulin(input: standardInput)
 
@@ -330,6 +342,145 @@ import Testing
         )
     }
 
+    @Test("Calculate insulin with low glucose forecast (minPredBG < 54)") func testMinPredBGGuardBolusCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = false
+        let useSuperBolus: Bool = false
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5 // Will be added to insulin calculation when super bolus is enabled
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+        let maxIOB: Decimal = 15.0
+        let maxCOB: Decimal = 120.0
+        let minPredBG: Decimal = 45.0 // Severe Hypo forecasted
+
+        // STEP 2: Create calculation input with severe hypo forecasted minPredBG
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: minPredBG,
+            lastLoopDate: Date()
+        )
+
+        // STEP 3: Calculate insulin with super bolus enabled
+        let minPredBGResult = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Calculate insulin with super bolus disabled for comparison
+        let standardInput = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: false, // Disabled for comparison
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: 80,
+            lastLoopDate: Date()
+        )
+        let standardResult = await calculator.calculateInsulin(input: standardInput)
+
+        // STEP 5: Verify results
+        #expect(minPredBGResult.insulinCalculated == 0, "Severe Hypo forecasted; insulin calculated set to 0 U for safety!")
+
+        #expect(
+            standardResult.insulinCalculated > minPredBGResult.insulinCalculated,
+            """
+            Super bolus calculation incorrect
+            Expected super bolus calculation to be higher than standard
+            MinPred <54 bolus: \(minPredBGResult.insulinCalculated) U
+            Standard: \(standardResult.insulinCalculated) U
+            Difference: \(standardResult.insulinCalculated - minPredBGResult.insulinCalculated) U
+            """
+        )
+    }
+
+    @Test("Calculate insulin with stale loop (longer than 15min ago)") func testStaleLoopBolusCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = false
+        let useSuperBolus: Bool = false
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5 // Will be added to insulin calculation when super bolus is enabled
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+        let maxIOB: Decimal = 15.0
+        let maxCOB: Decimal = 120.0
+        let minPredBG: Decimal = 80
+
+        // STEP 2: Create calculation input with severe hypo forecasted minPredBG
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: minPredBG,
+            lastLoopDate: Date().addingTimeInterval(TimeInterval(-15 * 60)) // 15min ago
+        )
+
+        // STEP 3: Calculate insulin with super bolus enabled
+        let result = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Verify results
+        #expect(result.insulinCalculated == 0, "Loop is stale; insulin calculated set to 0 U for safety!")
+    }
+
     @Test("Calculate insulin with zero carbs") func testZeroCarbsCalculation() async throws {
         // Given
         let carbs: Decimal = 0
@@ -339,6 +490,7 @@ import Testing
             carbs: carbs,
             useFattyMealCorrection: false,
             useSuperBolus: false,
+            lastLoopDate: Date(),
             minPredBG: nil
         )