Sfoglia il codice sorgente

Merge pull request #951 from nightscout/refactor-fpu-handling

Refactor FPU handling
Sam King 2 mesi fa
parent
commit
6f6c2534bb

+ 114 - 59
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -102,38 +102,40 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     /**
-     Calculates the duration for processing FPUs (fat and protein units) based on the FPUs and the time cap.
+     Converts fat and protein into delayed carb-equivalent entries (FPU handling).
 
-     - The function uses predefined rules to determine the duration based on the number of FPUs.
-     - Ensures that the duration does not exceed the time cap.
+     Behavior:
 
-     - Parameters:
-       - fpus: The number of FPUs calculated from fat and protein.
-       - timeCap: The maximum allowed duration.
+     - Calculates carb equivalents from fat and protein
+       ((fat × 9 + protein × 4) / 10 × adjustment factor).
+     - Rounds down to whole grams.
+     - Drops values below 10 g.
+     - Caps total equivalents at 99 g.
+     - Splits into up to 3 entries.
+     - Caps each entry at 33 g.
+     - Distributes grams as evenly as possible.
 
-     - Returns: The computed duration in hours.
-     */
-    private func calculateComputedDuration(fpus: Decimal, timeCap: Decimal) -> Decimal {
-        switch fpus {
-        case ..<2:
-            return 3
-        case 2 ..< 3:
-            return 4
-        case 3 ..< 4:
-            return 5
-        default:
-            return timeCap
-        }
-    }
+     Timing:
 
-    /**
-     Processes fat and protein entries to generate future carb equivalents, ensuring each equivalent is at least 1.0 grams.
+     - First entry is scheduled after the configured delay
+       (default: 60 minutes) from the carb entry timestamp.
+     - Additional entries are spaced 30 minutes apart.
+
+     Example (default):
+
+     - Carb entry at T
+     - 1st equivalent at T + 60 min
+     - 2nd equivalent at T + 90 min
+     - 3rd equivalent at T + 120 min
 
-     - The function calculates the equivalent carb dosage size and adjusts the interval to ensure each equivalent is at least 1.0 grams.
-     - Creates future carb entries based on the adjusted carb equivalent size and interval.
+     Generated entries:
+
+     - Are marked with `isFPU = true`
+     - Contain only carbs (fat and protein set to 0)
+     - Share the same `fpuID` as the original carb entry
 
      - Parameters:
-       - entries: An array of `CarbsEntry` objects representing the carbohydrate entries to be processed.
+       - entries: An array of `CarbsEntry` objects representing the carb equivalent entries to be processed.
        - fat: The amount of fat in the last entry.
        - protein: The amount of protein in the last entry.
        - createdAt: The creation date of the last entry.
@@ -150,46 +152,48 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         let trioSettings = settings.settings
         let providerSettings = settingsProvider.settings
 
-        let interval = trioSettings.minuteInterval.clamp(to: providerSettings.minuteInterval)
-        let timeCap = trioSettings.timeCap.clamp(to: providerSettings.timeCap)
-        let adjustment = trioSettings.individualAdjustmentFactor.clamp(to: providerSettings.individualAdjustmentFactor)
-        let delay = trioSettings.delay.clamp(to: providerSettings.delay)
+        let adjustment = trioSettings.individualAdjustmentFactor
+            .clamp(to: providerSettings.individualAdjustmentFactor)
 
-        let kcal = protein * 4 + fat * 9
-        let carbEquivalents = (kcal / 10) * adjustment
-        let fpus = carbEquivalents / 10
-        var computedDuration = calculateComputedDuration(fpus: fpus, timeCap: timeCap)
+        let delayMinutes = trioSettings.delay
+            .clamp(to: providerSettings.delay)
+        
+        let spreadInterval = trioSettings.minuteInterval
+            .clamp(to: providerSettings.minuteInterval)
+
+        // Constraints
+        let maxTotalGrams = 99
+        let maxEntries = 3
+        let maxPerEntry = 33
+        let minPerEntry = 10
+        let spacing: TimeInterval = TimeInterval(spreadInterval * 60)
 
-        var carbEquivalentSize: Decimal = carbEquivalents / computedDuration
-        carbEquivalentSize /= Decimal(60) / interval
+        // kcal -> carb equivalents (kcal/10 * adjustment), rounded down to whole grams
+        let kcal = protein * 4 + fat * 9
+        let rawEquivalents = Int((kcal / 10) * adjustment)
+        let totalGrams = min(maxTotalGrams, max(0, rawEquivalents))
 
-        if carbEquivalentSize < 1.0 {
-            carbEquivalentSize = 1.0
-            computedDuration = min(carbEquivalents / carbEquivalentSize, timeCap)
+        guard totalGrams >= minPerEntry else {
+            return ([], Decimal(totalGrams))
         }
 
-        let roundedEquivalent: Double = round(Double(carbEquivalentSize * 10)) / 10
-        carbEquivalentSize = Decimal(roundedEquivalent)
-        var numberOfEquivalents = carbEquivalents / carbEquivalentSize
+        let amounts = splitIntoCarbEquivalents(
+            total: totalGrams,
+            maxEntries: maxEntries,
+            maxPerEntry: maxPerEntry,
+            minPerEntry: minPerEntry
+        )
 
-        var useDate = actualDate ?? createdAt
+        let baseDate = actualDate ?? createdAt
+        let start = baseDate.addingTimeInterval(TimeInterval(delayMinutes * 60))
         let fpuID = entries.first?.fpuID ?? UUID().uuidString
-        var futureCarbArray = [CarbsEntry]()
-        var firstIndex = true
-
-        // convert Decimal minutes to TimeInterval in seconds
-        let delayTimeInterval = TimeInterval(delay * 60)
-        let intervalTimeInterval = TimeInterval(interval * 60)
-        while carbEquivalents > 0, numberOfEquivalents > 0 {
-            useDate = firstIndex ? useDate.addingTimeInterval(delayTimeInterval) : useDate
-                .addingTimeInterval(intervalTimeInterval)
-            firstIndex = false
-
-            let eachCarbEntry = CarbsEntry(
+
+        let futureEntries: [CarbsEntry] = amounts.enumerated().map { idx, grams in
+            CarbsEntry(
                 id: UUID().uuidString,
                 createdAt: createdAt,
-                actualDate: useDate,
-                carbs: carbEquivalentSize,
+                actualDate: start.addingTimeInterval(TimeInterval(idx) * spacing),
+                carbs: Decimal(grams),
                 fat: 0,
                 protein: 0,
                 note: nil,
@@ -197,11 +201,62 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                 isFPU: true,
                 fpuID: fpuID
             )
-            futureCarbArray.append(eachCarbEntry)
-            numberOfEquivalents -= 1
         }
 
-        return (futureCarbArray, carbEquivalents)
+        let totalScheduled = futureEntries.reduce(into: Decimal(0)) { $0 += $1.carbs }
+        return (futureEntries, totalScheduled)
+    }
+
+    /**
+     Splits a total carb-equivalent value into multiple integer entries.
+
+     - Returns no entries if `total` is below `minPerEntry`.
+     - Limits output to `maxEntries`.
+     - Caps each entry at `maxPerEntry`.
+     - Distributes grams evenly (difference ≤ 1 g).
+     - Merges or removes entries below `minPerEntry`.
+
+     - Returns:
+       Integer gram values representing the split carb equivalents.
+     */
+    private func splitIntoCarbEquivalents(
+        total: Int,
+        maxEntries: Int,
+        maxPerEntry: Int,
+        minPerEntry: Int
+    ) -> [Int] {
+        guard total >= minPerEntry else { return [] }
+
+        // Choose an entry count that *guarantees* each entry can be <= maxPerEntry
+        let needed = (total + maxPerEntry - 1) / maxPerEntry
+        let count = min(maxEntries, max(1, needed))
+
+        // Even split (difference between buckets is at most 1)
+        func evenSplit(_ total: Int, count: Int) -> [Int] {
+            let base = total / count
+            let rem = total % count
+            return (0 ..< count).map { base + ($0 < rem ? 1 : 0) }
+        }
+
+        var buckets = evenSplit(total, count: count)
+
+        // Enforce minPerEntry by merging any too-small tail bucket into the previous one
+        // This should be rare, but it keeps the invariant
+        if buckets.count > 1 {
+            for i in stride(from: buckets.count - 1, through: 1, by: -1) {
+                let v = buckets[i]
+                guard v > 0, v < minPerEntry else { continue }
+                buckets[i - 1] += v
+                buckets[i] = 0
+            }
+            buckets = buckets.filter { $0 > 0 }
+        }
+
+        // Guarantee not to exceed maxPerEntry if merging a reduced count
+        // Clamp as final guard here
+        buckets = buckets.map { min(maxPerEntry, $0) }.filter { $0 >= minPerEntry }
+
+        return buckets
     }
 
     private func saveCarbEquivalents(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {

+ 1 - 1
Trio/Sources/Models/DecimalPickerSettings.swift

@@ -132,7 +132,7 @@ struct DecimalPickerSettings {
     var threshold_setting = PickerSetting(value: 60, step: 1, min: 60, max: 120, type: PickerSetting.PickerSettingType.glucose)
     var updateInterval = PickerSetting(value: 20, step: 5, min: 1, max: 60, type: PickerSetting.PickerSettingType.minute)
     var delay = PickerSetting(value: 60, step: 5, min: 15, max: 120, type: PickerSetting.PickerSettingType.minute)
-    var minuteInterval = PickerSetting(value: 30, step: 5, min: 10, max: 60, type: PickerSetting.PickerSettingType.minute)
+    var minuteInterval = PickerSetting(value: 30, step: 5, min: 30, max: 60, type: PickerSetting.PickerSettingType.minute)
     var timeCap = PickerSetting(value: 8, step: 1, min: 5, max: 12, type: PickerSetting.PickerSettingType.hour)
     var hours = PickerSetting(value: 6, step: 0.5, min: 2, max: 24, type: PickerSetting.PickerSettingType.hour)
     var dia = PickerSetting(value: 10, step: 0.5, min: 5, max: 10, type: PickerSetting.PickerSettingType.hour)

+ 234 - 0
TrioTests/CoreDataTests/CarbsStorageTests.swift

@@ -123,6 +123,240 @@ import Testing
         #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
     }
 
+    @Test(
+        "Store carb entry with fat/protein creates capped, spaced FPU entries (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreFatProteinCarbEntryCreatesFPUEntries() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
+
+        // Defaults:
+        // adjustment = 0.5, delay = 60
+        //
+        // fat=50g -> 450 kcal
+        // protein=100g -> 400 kcal
+        // kcal total = 850
+        // (kcal/10) = 85
+        // 85 * 0.5 = 42.5
+        // Int(42.5) = 42 equivalents -> two FPU entries: 21g each
+        let mealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 50,
+            protein: 100,
+            note: "FPU deterministic default split test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([mealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored entries")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 50, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 100, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.count == 2, "Expected exactly one FPU entry under default settings")
+        #expect(Int(fpuEntries[0].carbs) == 21, "Expected 20g carb equivalents under default settings")
+
+        for fpuEntry in fpuEntries {
+            #expect(fpuEntry.fat == 0, "FPU fat must be 0")
+            #expect(fpuEntry.protein == 0, "FPU protein must be 0")
+            #expect(fpuEntry.carbs >= 10, "FPU carbs must be >= 10g")
+            #expect(fpuEntry.carbs <= 33, "FPU carbs must be <= 33g")
+            #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
+        }
+
+        let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
+            partialResult + Int(fpuEntry.carbs)
+        }
+        #expect(scheduledTotal <= 99, "Scheduled FPU carbs must be capped at 99g")
+
+        // Timing: stable assertions
+        // - first FPU entry must be at least +60m after the *input* timestamp (createdAt/actualDate),
+        //   but storage may choose a different internal baseDate, so don't assert exact equality.
+        let fpuDates = fpuEntries.compactMap(\.date).sorted()
+        #expect(fpuDates.count == 2, "FPU entry should have a date")
+
+        let firstFpuDate = fpuDates[0]
+        #expect(
+            firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
+            "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
+        )
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
+    @Test(
+        "Store very large fat/protein meal caps FPU equivalents at 99g and splits into 3×33g (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreVeryLargeFatProteinMealCapsAndSplits() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_001_000)
+
+        // Defaults:
+        // adjustment = 0.5, delay = 60
+        //
+        // fat=200g -> 1800 kcal
+        // protein=200g -> 800 kcal
+        // kcal total = 2600
+        // (kcal/10) = 260
+        // 260 * 0.5 = 130
+        // Int(130) = 130 -> capped to 99 -> split into [33, 33, 33]
+        let heftyMealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 200,
+            protein: 200,
+            note: "Hefty BBQ meal - cap test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([heftyMealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored entries")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 200, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 200, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.count == 3, "Capped large meal should create exactly 3 FPU entries")
+
+        let fpuGrams = fpuEntries.map { Int($0.carbs) }
+        #expect(fpuGrams == [33, 33, 33], "Expected capped split to be [33, 33, 33]")
+
+        let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
+            partialResult + Int(fpuEntry.carbs)
+        }
+        #expect(scheduledTotal == 99, "Total scheduled FPU grams should be exactly 99g after cap")
+
+        for fpuEntry in fpuEntries {
+            #expect(fpuEntry.fat == 0, "FPU entry fat must be 0")
+            #expect(fpuEntry.protein == 0, "FPU entry protein must be 0")
+            #expect(fpuEntry.carbs >= 10, "FPU entry carbs must be >= 10g")
+            #expect(fpuEntry.carbs <= 33, "FPU entry carbs must be <= 33g")
+            #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
+        }
+
+        // Timing: stable assertions
+        let fpuDates = fpuEntries.compactMap(\.date).sorted()
+        #expect(fpuDates.count == 3, "All FPU entries should have a date")
+
+        let firstFpuDate = fpuDates[0]
+        #expect(
+            firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
+            "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
+        )
+
+        for index in 1 ..< fpuDates.count {
+            let spacingSeconds = fpuDates[index].timeIntervalSince(fpuDates[index - 1])
+            #expect(Int(spacingSeconds) == 30 * 60, "FPU entries should be spaced +30 minutes apart")
+        }
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
+    @Test(
+        "Store small fat/protein meal drops FPU equivalents when total would be <10g (defaults: adjustment=0.5, delay=60m)"
+    ) func testStoreSmallFatProteinMealDropsFPUBelowMinimum() async throws {
+        let fpuID = UUID().uuidString
+        let baseDate = Date(timeIntervalSince1970: 1_700_002_000)
+
+        // Defaults:
+        // adjustment = 0.5
+        //
+        // fat=2g -> 18 kcal
+        // protein=2g -> 8 kcal
+        // kcal total = 26
+        // (kcal/10) = 2.6
+        // 2.6 * 0.5 = 1.3
+        // Int(1.3) = 1 (<10) -> should be dropped (no FPU entries)
+        let smallMealEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: baseDate,
+            actualDate: baseDate,
+            carbs: 30,
+            fat: 2,
+            protein: 2,
+            note: "Tiny macros - min threshold test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        try await storage.storeCarbs([smallMealEntry], areFetchedFromRemote: false)
+
+        let storedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        guard let storedEntries else {
+            throw TestError("Failed to fetch entries for fpuID")
+        }
+
+        #expect(!storedEntries.isEmpty, "Should have stored at least the original entry")
+
+        let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
+        #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
+        #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
+        #expect(originalCarbEntry?.fat == 2, "Original fat should match")
+        #expect(originalCarbEntry?.protein == 2, "Original protein should match")
+
+        let fpuEntries = storedEntries.filter { $0.isFPU == true }
+        #expect(fpuEntries.isEmpty == true, "No FPU entries should be created when equivalents are <10g")
+
+        #expect(
+            storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
+            "All entries should share the same fpuID"
+        )
+    }
+
     @Test("Get carbs not yet uploaded to Nightscout") func testGetCarbsNotYetUploadedToNightscout() async throws {
         // Given
         let testEntry = CarbsEntry(