Procházet zdrojové kódy

Merge branch 'dev' of github.com:nightscout/Trio-dev into stats-wip

Deniz Cengiz před 1 rokem
rodič
revize
9a73d4142c

+ 9 - 0
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -211,6 +211,15 @@
     <entity name="StatsData" representedClassName="StatsData" syncable="YES">
         <attribute name="lastrun" attributeType="Date" defaultDateTimeInterval="704497620" usesScalarValueType="NO"/>
     </entity>
+    <entity name="TDDStored" representedClassName="TDDStored" syncable="YES">
+        <attribute name="bolus" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+        <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
+        <attribute name="scheduledBasal" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="tempBasal" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="total" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+        <attribute name="weightedAverage" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
+    </entity>
     <entity name="TempBasalStored" representedClassName="TempBasalStored" syncable="YES">
         <attribute name="duration" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
         <attribute name="rate" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>

+ 4 - 0
TDDStored+CoreDataClass.swift

@@ -0,0 +1,4 @@
+import CoreData
+import Foundation
+
+@objc(TDDStored) public class TDDStored: NSManagedObject {}

+ 18 - 0
TDDStored+CoreDataProperties.swift

@@ -0,0 +1,18 @@
+import CoreData
+import Foundation
+
+public extension TDDStored {
+    @nonobjc class func fetchRequest() -> NSFetchRequest<TDDStored> {
+        NSFetchRequest<TDDStored>(entityName: "TDDStored")
+    }
+
+    @NSManaged var id: UUID?
+    @NSManaged var date: Date?
+    @NSManaged var total: NSDecimalNumber?
+    @NSManaged var bolus: NSDecimalNumber?
+    @NSManaged var tempBasal: NSDecimalNumber?
+    @NSManaged var scheduledBasal: NSDecimalNumber?
+    @NSManaged var weightedAverage: NSDecimalNumber?
+}
+
+extension TDDStored: Identifiable {}

+ 11 - 0
Trio Watch App Extension/Views/AcknowledgementPendingView.swift

@@ -43,6 +43,17 @@ struct AcknowledgementPendingView: View {
         .navigationBarBackButtonHidden(true)
         .toolbar(.hidden)
         .background(trioBackgroundColor)
+        .onChange(of: state.showCommsAnimation) { oldValue, newValue in
+            if newValue && !oldValue {
+                DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
+                    // If after 5 seconds there is still no acknowledgement banner, return to root
+                    if !state.showAcknowledgmentBanner {
+                        // Navigate back to the root
+                        navigationPath.removeLast(navigationPath.count)
+                    }
+                }
+            }
+        }
         .onChange(of: state.showAcknowledgmentBanner) { _, newValue in
             if !newValue {
                 // Navigate back to the root when acknowledgment banner disappears

+ 3 - 5
Trio Watch App Extension/WatchState.swift

@@ -110,15 +110,13 @@ import WatchConnectivity
             acknowledgmentMessage = "\(message)"
         } else {
             print("⌚️ Acknowledgment failed: \(message)")
+            DispatchQueue.main.async {
+                self.showCommsAnimation = false // Hide progress animation
+            }
             acknowledgementStatus = .failure
             acknowledgmentMessage = "\(message)"
         }
 
-        DispatchQueue.main.async {
-            self.showCommsAnimation = false // Hide progress animation
-            self.showSyncingAnimation = false // Just ensure this is 100% set to false
-        }
-
         if isFinal {
             showAcknowledgmentBanner = true
             DispatchQueue.main.asyncAfter(deadline: .now() + 2) {

+ 15 - 0
Trio.xcodeproj/project.pbxproj

@@ -310,6 +310,9 @@
 		BD2FF1A02AE29D43005D1C5D /* CheckboxToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */; };
 		BD3CC0722B0B89D50013189E /* MainChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3CC0712B0B89D50013189E /* MainChartView.swift */; };
 		BD4064D12C4ED26900582F43 /* CoreDataObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */; };
+		BD4D738D2D15A4080052227B /* TDDStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4D738B2D15A4080052227B /* TDDStored+CoreDataClass.swift */; };
+		BD4D738E2D15A4080052227B /* TDDStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4D738C2D15A4080052227B /* TDDStored+CoreDataProperties.swift */; };
+		BD4D73A22D15A42A0052227B /* TDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4D73A12D15A4220052227B /* TDDStorage.swift */; };
 		BD432CA12D2F4E3600D1EB79 /* WatchMessageKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD432CA02D2F4E3300D1EB79 /* WatchMessageKeys.swift */; };
 		BD432CA22D2F4E4000D1EB79 /* WatchMessageKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD432CA02D2F4E3300D1EB79 /* WatchMessageKeys.swift */; };
 		BD4E1A7A2D3681B700D21626 /* GlucoseTargetSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4E1A792D3681AD00D21626 /* GlucoseTargetSetup.swift */; };
@@ -1016,6 +1019,9 @@
 		BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxToggleStyle.swift; sourceTree = "<group>"; };
 		BD3CC0712B0B89D50013189E /* MainChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChartView.swift; sourceTree = "<group>"; };
 		BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataObserver.swift; sourceTree = "<group>"; };
+		BD4D738B2D15A4080052227B /* TDDStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TDDStored+CoreDataClass.swift"; sourceTree = SOURCE_ROOT; };
+		BD4D738C2D15A4080052227B /* TDDStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TDDStored+CoreDataProperties.swift"; sourceTree = SOURCE_ROOT; };
+		BD4D73A12D15A4220052227B /* TDDStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDDStorage.swift; sourceTree = "<group>"; };
 		BD432CA02D2F4E3300D1EB79 /* WatchMessageKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchMessageKeys.swift; sourceTree = "<group>"; };
 		BD4E1A792D3681AD00D21626 /* GlucoseTargetSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseTargetSetup.swift; sourceTree = "<group>"; };
 		BD4E1A7B2D3686D400D21626 /* StartEndMarkerSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartEndMarkerSetup.swift; sourceTree = "<group>"; };
@@ -2171,6 +2177,10 @@
 				BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */,
 				38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */,
 				38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */,
+				CE82E02428E867BA00473A9C /* AlertStorage.swift */,
+				BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */,
+				5864E8582C42CFAE00294306 /* DeterminationStorage.swift */,
+				BD4D73A12D15A4220052227B /* TDDStorage.swift */,
 			);
 			path = Storage;
 			sourceTree = "<group>";
@@ -3043,6 +3053,8 @@
 		DDE179112C9100FA003CDDB7 /* Classes+Properties */ = {
 			isa = PBXGroup;
 			children = (
+				BD4D738B2D15A4080052227B /* TDDStored+CoreDataClass.swift */,
+				BD4D738C2D15A4080052227B /* TDDStored+CoreDataProperties.swift */,
 				DDE179362C910127003CDDB7 /* BolusStored+CoreDataClass.swift */,
 				DDE179372C910127003CDDB7 /* BolusStored+CoreDataProperties.swift */,
 				DDE1793A2C910127003CDDB7 /* CarbEntryStored+CoreDataClass.swift */,
@@ -4071,6 +4083,8 @@
 				DDE179552C910127003CDDB7 /* LoopStatRecord+CoreDataProperties.swift in Sources */,
 				DDE179562C910127003CDDB7 /* BolusStored+CoreDataClass.swift in Sources */,
 				DDE179572C910127003CDDB7 /* BolusStored+CoreDataProperties.swift in Sources */,
+				BD4D738D2D15A4080052227B /* TDDStored+CoreDataClass.swift in Sources */,
+				BD4D738E2D15A4080052227B /* TDDStored+CoreDataProperties.swift in Sources */,
 				DDE179582C910127003CDDB7 /* ForecastValue+CoreDataClass.swift in Sources */,
 				DDE179592C910127003CDDB7 /* ForecastValue+CoreDataProperties.swift in Sources */,
 				DDE1795A2C910127003CDDB7 /* CarbEntryStored+CoreDataClass.swift in Sources */,
@@ -4089,6 +4103,7 @@
 				DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */,
 				DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift in Sources */,
 				DDE179692C910127003CDDB7 /* TempBasalStored+CoreDataProperties.swift in Sources */,
+				BD4D73A22D15A42A0052227B /* TDDStorage.swift in Sources */,
 				DDE1796C2C910127003CDDB7 /* OverrideRunStored+CoreDataClass.swift in Sources */,
 				DDE1796D2C910127003CDDB7 /* OverrideRunStored+CoreDataProperties.swift in Sources */,
 				DDE1796E2C910127003CDDB7 /* OrefDetermination+CoreDataClass.swift in Sources */,

+ 25 - 0
Trio/Sources/APS/APSManager.swift

@@ -65,6 +65,7 @@ final class BaseAPSManager: APSManager, Injectable {
     @Injected() private var deviceDataManager: DeviceDataManager!
     @Injected() private var nightscout: NightscoutManager!
     @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var tddStorage: TDDStorage!
     @Injected() private var broadcaster: Broadcaster!
     @Persisted(key: "lastLoopStartDate") private var lastLoopStartDate: Date = .distantPast
     @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
@@ -381,9 +382,32 @@ final class BaseAPSManager: APSManager, Injectable {
         return false
     }
 
+    /// Calculates and stores the Total Daily Dose (TDD)
+    private func calculateAndStoreTDD() async throws {
+        guard let pumpManager else { return }
+
+        async let pumpHistory = pumpHistoryStorage.getPumpHistory()
+        async let basalProfile = storage
+            .retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self) ??
+            [BasalProfileEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.basalProfile)) ??
+            [] // OpenAPS.defaults ensures we at least get default rate of 1u/hr for 24 hrs
+
+        // Calculate TDD
+        let tddResult = try await tddStorage.calculateTDD(
+            pumpManager: pumpManager,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile
+        )
+
+        // Store TDD in Core Data
+        await tddStorage.storeTDD(tddResult)
+    }
+
     func determineBasal() async throws {
         debug(.apsManager, "Start determine basal")
 
+        try await calculateAndStoreTDD()
+
         // Fetch glucose asynchronously
         let glucose = try await fetchGlucose(predicate: NSPredicate.predicateForOneHourAgo, fetchLimit: 6)
 
@@ -969,6 +993,7 @@ final class BaseAPSManager: APSManager, Injectable {
                 total_average: 0
             )
             guard let processedGlucoseStats = await glucoseStats else { return }
+
             let eA1cDisplayUnit = processedGlucoseStats.eA1cDisplayUnit
 
             let dailystat = await Statistics(

+ 1 - 0
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -306,6 +306,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         if let overcalibration = overcalibration {
             return entries.map { entry in
                 var entry = entry
+                guard entry.glucose != nil else { return entry }
                 entry.glucose = Int(overcalibration(entry.glucose!))
                 entry.sgv = Int(overcalibration(entry.sgv!))
                 return entry

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

@@ -754,7 +754,12 @@ final class OpenAPS {
         }
 
         if let url = Foundation.Bundle.main.url(forResource: "javascript/\(name)", withExtension: "") {
-            return Script(name: name, body: try! String(contentsOf: url))
+            do {
+                let body = try String(contentsOf: url)
+                return Script(name: name, body: body)
+            } catch {
+                debug(.openAPS, "Failed to load script \(name): \(error)")
+            }
         }
 
         return nil

+ 38 - 0
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -11,6 +11,7 @@ protocol PumpHistoryObserver {
 
 protocol PumpHistoryStorage {
     var updatePublisher: AnyPublisher<Void, Never> { get }
+    func getPumpHistory() async throws -> [PumpHistoryEvent]
     func storePumpEvents(_ events: [NewPumpEvent]) async throws
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async
     func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
@@ -251,6 +252,43 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
         }
     }
 
+    func getPumpHistory() async throws -> [PumpHistoryEvent] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: context,
+            predicate: NSPredicate.pumpHistoryLast24h,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 288
+        )
+
+        return await context.perform {
+            guard let fetchedPumpEvents = results as? [PumpEventStored] else { return [] }
+
+            return fetchedPumpEvents.map { event in
+                switch event.type {
+                case PumpEventStored.EventType.bolus.rawValue:
+                    return PumpHistoryEvent(
+                        id: event.id ?? UUID().uuidString,
+                        type: .bolus,
+                        timestamp: event.timestamp ?? Date(),
+                        amount: event.bolus?.amount as Decimal?
+                    )
+                case PumpEventStored.EventType.tempBasal.rawValue:
+                    return PumpHistoryEvent(
+                        id: event.id ?? UUID().uuidString,
+                        type: .tempBasal,
+                        timestamp: event.timestamp ?? Date(),
+                        amount: event.tempBasal?.rate as Decimal?,
+                        duration: Int(event.tempBasal?.duration ?? 0)
+                    )
+                default:
+                    return nil
+                }
+            }.compactMap { $0 }
+        }
+    }
+
     func determineBolusEventType(for event: PumpEventStored) -> PumpEventStored.EventType {
         if event.bolus!.isSMB {
             return .smb

+ 661 - 0
Trio/Sources/APS/Storage/TDDStorage.swift

@@ -0,0 +1,661 @@
+import Foundation
+import LoopKitUI
+import Swinject
+
+protocol TDDStorage {
+    func calculateTDD(
+        pumpManager: any PumpManagerUI,
+        pumpHistory: [PumpHistoryEvent],
+        basalProfile: [BasalProfileEntry]
+    ) async throws
+        -> TDDResult
+    func storeTDD(_ tddResult: TDDResult) async
+}
+
+/// Structure containing the results of TDD calculations
+struct TDDResult {
+    let total: Decimal
+    let bolus: Decimal
+    let tempBasal: Decimal
+    let scheduledBasal: Decimal
+    let weightedAverage: Decimal?
+    let hoursOfData: Double
+}
+
+/// Implementation of the TDD Calculator
+final class BaseTDDStorage: TDDStorage, Injectable {
+    @Injected() private var storage: FileStorage!
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+    }
+
+    private let privateContext = CoreDataStack.shared.newTaskContext()
+
+    /// Main function to calculate TDD from pump history and basal profile
+    /// - Parameters:
+    ///   - pumpManager: Representation of paired pump's PumpManagerUI
+    ///   - pumpHistory: Array of pump history events
+    ///   - basalProfile: Array of basal profile entries
+    /// - Returns: TDDResult containing all calculated values
+    func calculateTDD(
+        pumpManager: any PumpManagerUI,
+        pumpHistory: [PumpHistoryEvent],
+        basalProfile: [BasalProfileEntry]
+    ) async throws -> TDDResult {
+        debug(.apsManager, "Starting TDD calculation with \(pumpHistory.count) pump events")
+
+        // Log the first and last pump history events if available
+        let earliestEvent: String
+        let latestEvent: String
+
+        // We fetch descending, so invert logic
+        if let firstEvent = pumpHistory.last, let lastEvent = pumpHistory.first {
+            earliestEvent = "Type: \(firstEvent.type), Timestamp: \(firstEvent.timestamp.ISO8601Format())"
+            latestEvent = "Type: \(lastEvent.type), Timestamp: \(lastEvent.timestamp.ISO8601Format())"
+        } else {
+            earliestEvent = "No events available"
+            latestEvent = "No events available"
+            debug(.apsManager, "No pump history events available for logging.")
+        }
+
+        // Group events by type once to avoid multiple filters
+        let groupedEvents = Dictionary(grouping: pumpHistory, by: { $0.type })
+        let bolusEvents = groupedEvents[.bolus] ?? []
+        let tempBasalEvents = groupedEvents[.tempBasal] ?? []
+        let pumpSuspendEvents = groupedEvents[.pumpSuspend] ?? []
+        let pumpResumeEvents = groupedEvents[.pumpResume] ?? []
+
+        // Create pairs of suspend + resume events
+        let suspendResumePairs = zip(pumpSuspendEvents, pumpResumeEvents).filter { suspend, resume in
+            resume.timestamp > suspend.timestamp
+        }
+
+        // Calculate all components concurrently
+        async let pumpDataHours = calculatePumpDataHours(pumpHistory)
+        async let bolusInsulin = calculateBolusInsulin(bolusEvents)
+        let gaps = findBasalGaps(in: tempBasalEvents)
+        async let scheduledBasalInsulin = !gaps.isEmpty ? calculateScheduledBasalInsulin(
+            gaps: gaps,
+            profile: basalProfile,
+            roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
+        ) : 0
+        async let tempBasalInsulin = calculateTempBasalInsulin(
+            tempBasalEvents, suspendResumePairs: suspendResumePairs,
+            roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
+        )
+        async let weightedAverage = calculateWeightedAverage()
+
+        // Await all concurrent calculations
+        let (hours, bolus, scheduled, temp, weighted) = try await (
+            pumpDataHours,
+            bolusInsulin,
+            scheduledBasalInsulin,
+            tempBasalInsulin,
+            weightedAverage
+        )
+
+        // Total insulin calculation
+        let total = bolus + temp + scheduled
+
+        // Safeguard against division by zero
+        let percentage: (Decimal, Decimal) -> String = { part, total in
+            total > 0 ? String(format: "%.2f", NSDecimalNumber(decimal: (part / total * 100).rounded(toPlaces: 2)).doubleValue) :
+                "0.00"
+        }
+
+        // Store log strings in variables to avoid Xcode auto formatter from breaking up the lines in log statement
+        let totalString = String(format: "%.2f", NSDecimalNumber(decimal: total.rounded(toPlaces: 2)).doubleValue)
+        let bolusString = String(format: "%.2f", NSDecimalNumber(decimal: bolus.rounded(toPlaces: 2)).doubleValue)
+        let tempBasalString = String(format: "%.2f", NSDecimalNumber(decimal: temp.rounded(toPlaces: 2)).doubleValue)
+        let scheduledBasalString = String(format: "%.2f", NSDecimalNumber(decimal: scheduled.rounded(toPlaces: 2)).doubleValue)
+        let weightedAvgString = String(format: "%.2f", NSDecimalNumber(decimal: weighted?.rounded(toPlaces: 2) ?? 0))
+        let hoursString = String(format: "%.5f", NSDecimalNumber(decimal: Decimal(hours).truncated(toPlaces: 5)).doubleValue)
+
+        debug(.apsManager, """
+        TDD Summary:
+        +-------------------+-----------+-----------+
+        | Type\t\t\t\t| Amount U\t| Percent %\t|
+        +-------------------+-----------+-----------+
+        | Total\t\t\t\t| \(totalString)\t\t| \t\t\t|
+        | Bolus\t\t\t\t| \(bolusString)\t\t| \(percentage(bolus, total))\t\t|
+        | Temp Basal\t\t| \(tempBasalString)\t\t| \(percentage(temp, total))\t\t|
+        | Scheduled Basal\t| \(scheduledBasalString)\t\t| \(percentage(scheduled, total))\t\t|
+        | Weighted Average\t| \(weightedAvgString)\t\t| \t\t\t|
+        +-------------------+-----------+-----------+
+        - Hours of Data: \(hoursString)
+        - Earliest Event: \(earliestEvent)
+        - Latest Event: \(latestEvent)
+        """)
+
+        // Return calculated TDDResult
+        return TDDResult(
+            total: total,
+            bolus: bolus,
+            tempBasal: temp,
+            scheduledBasal: scheduled,
+            weightedAverage: weighted,
+            hoursOfData: hours
+        )
+    }
+
+    /// Stores the Total Daily Dose (TDD) result in Core Data
+    /// - Parameter tddResult: The TDD result to store, containing total insulin, bolus, temp basal, scheduled basal and weighted average
+    func storeTDD(_ tddResult: TDDResult) async {
+        await privateContext.perform {
+            let tddStored = TDDStored(context: self.privateContext)
+            tddStored.id = UUID()
+            tddStored.date = Date()
+            tddStored.total = NSDecimalNumber(decimal: tddResult.total)
+            tddStored.bolus = NSDecimalNumber(decimal: tddResult.bolus)
+            tddStored.tempBasal = NSDecimalNumber(decimal: tddResult.tempBasal)
+            tddStored.scheduledBasal = NSDecimalNumber(decimal: tddResult.scheduledBasal)
+            tddStored.weightedAverage = tddResult.weightedAverage.map { NSDecimalNumber(decimal: $0) }
+
+            do {
+                guard self.privateContext.hasChanges else { return }
+                try self.privateContext.save()
+            } catch {
+                debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to save TDD: \(error.localizedDescription)")
+            }
+        }
+    }
+
+    /// Calculates the number of hours of available pump history data
+    /// - Parameter pumpHistory: Array of pump history events
+    /// - Returns: Number of hours of available data
+    private func calculatePumpDataHours(_ pumpHistory: [PumpHistoryEvent]) -> Double {
+        guard let firstEvent = pumpHistory.last, // we are fetching in a descending order
+              let lastEvent = pumpHistory.first
+        else {
+            return 0
+        }
+
+        let startDate = firstEvent.timestamp
+        var endDate = lastEvent.timestamp
+
+        // If last event in the list is tempBasal, check if it is running longer than current time
+        // If yes, set current date, else ignore
+        if lastEvent.type == .tempBasal, lastEvent.timestamp > Date().addingTimeInterval(-1) {
+            endDate = Date()
+        }
+
+        return Double(endDate.timeIntervalSince(startDate)) / 3600.0
+    }
+
+    /// Calculates total bolus insulin from pump history
+    /// - Parameter bolusEvents: Array of pump history events of type bolus
+    /// - Returns: Total bolus insulin
+    private func calculateBolusInsulin(_ bolusEvents: [PumpHistoryEvent]) -> Decimal {
+        bolusEvents
+            .reduce(Decimal(0)) { totalBolusInsulin, event in
+//                let newTotalBolusInsulin =
+                totalBolusInsulin + (event.amount as Decimal? ?? 0)
+//                debug(
+//                    .apsManager,
+//                    "Bolus \(event.amount ?? 0) U dosed at \(event.timestamp.ISO8601Format()) added. New total bolus = \(newTotalBolusInsulin) U"
+//                )
+//                return newTotalBolusInsulin
+            }
+    }
+
+    /// Calculates temporary basal insulin delivery for a given time period, accounting for interruptions and suspensions
+    /// - Parameters:
+    ///   - tempBasalEvents: Array of temporary basal events
+    ///   - suspendResumePairs: Array of suspend and resume event pairs
+    ///   - roundToSupportedBasalRate: Closure to round rates to pump-supported values
+    /// - Returns: Total insulin delivered via temporary basal rates in units
+    private func calculateTempBasalInsulin(
+        _ tempBasalEvents: [PumpHistoryEvent],
+        suspendResumePairs: [(suspend: PumpHistoryEvent, resume: PumpHistoryEvent)],
+        roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
+    ) -> Decimal {
+        guard !tempBasalEvents.isEmpty else { return 0 }
+
+        // Merge temp basal events and suspend-resume pairs into a single timeline
+        var timeline = [(start: Date, end: Date, type: EventType, rate: Decimal?)]()
+
+        // Add temp basal events to the timeline
+        for event in tempBasalEvents {
+            guard let duration = event.duration, let rate = event.amount else { continue }
+            let end = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
+            timeline.append((start: event.timestamp, end: end, type: .tempBasal, rate: rate))
+        }
+
+        // Add suspend-resume events to the timeline
+        for suspendResume in suspendResumePairs {
+            timeline.append((
+                start: suspendResume.suspend.timestamp,
+                end: suspendResume.resume.timestamp,
+                type: .pumpSuspend,
+                rate: nil
+            ))
+        }
+
+        // Sort the timeline by start time
+        timeline.sort { $0.start < $1.start }
+
+        // Calculate insulin delivery while accounting for suspensions and premature interruptions
+        var totalInsulin: Decimal = 0
+        let currentDate = Date()
+        var lastEndTime: Date?
+
+        for (index, event) in timeline.enumerated() {
+            if event.type == .tempBasal {
+                let effectiveEnd = min(event.end, currentDate) // Adjust for ongoing temp basals
+                var actualStart = event.start
+                var actualEnd = effectiveEnd
+
+                // Check for interruption by the next event
+                if index < timeline.count - 1 {
+                    let nextEvent = timeline[index + 1]
+                    if nextEvent.start < actualEnd, nextEvent.type != .pumpSuspend {
+                        actualEnd = nextEvent.start
+                    }
+                }
+
+                // Adjust for overlapping suspensions
+                if let lastSuspendEnd = lastEndTime, lastSuspendEnd > actualStart {
+                    actualStart = lastSuspendEnd
+                }
+
+                // Calculate insulin if the duration is valid
+                let durationMinutes = max(0, actualEnd.timeIntervalSince(actualStart) / 60)
+                if durationMinutes > 0, let rate = event.rate {
+                    let durationHours = (Decimal(durationMinutes) / 60).truncated(toPlaces: 5)
+                    let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
+                    if insulin > 0 {
+                        totalInsulin += insulin
+
+//                        debug(
+//                            .apsManager,
+//                            "Temp basal: \(rate) U/hr for \(durationHours) hr (Start: \(actualStart.ISO8601Format()), End: \(actualEnd.ISO8601Format())) = \(insulin) U"
+//                        )
+                    }
+                }
+            } else if event.type == .pumpSuspend {
+                // Update the last suspend end time to adjust future temp basal durations
+                lastEndTime = event.end
+            }
+        }
+
+        return totalInsulin
+    }
+
+    /// Calculates scheduled basal insulin delivery during gaps between temporary basals
+    /// - Parameters:
+    ///   - gaps: Array of time periods where scheduled basal was active
+    ///   - profile: Basal profile entries defining rates throughout the day
+    ///   - roundToSupportedBasalRate: Closure to round rates to pump-supported values
+    /// - Returns: Total insulin delivered via scheduled basal in units
+    private func calculateScheduledBasalInsulin(
+        gaps: [(start: Date, end: Date)],
+        profile: [BasalProfileEntry],
+        roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
+    ) -> Decimal {
+        // Initialize cached formatter for time string conversion
+        let timeFormatter: DateFormatter = {
+            let formatter = DateFormatter()
+            formatter.dateFormat = "HH:mm:ss"
+            return formatter
+        }()
+
+        // Pre-calculate profile switch times for efficient lookup
+        let profileSwitches = profile.map(\.minutes)
+
+        return gaps.reduce(into: Decimal(0)) { totalInsulin, gap in
+            var currentTime = gap.start
+            let now = Date()
+
+            while currentTime < gap.end {
+                // Find applicable basal rate for current time
+                guard let rate = findBasalRate(
+                    for: timeFormatter.string(from: currentTime),
+                    in: profile
+                ) else { break }
+
+                // Determine when rate changes (either profile switch or gap end)
+                let nextSwitchTime = getNextBasalRateSwitch(
+                    after: currentTime,
+                    switches: profileSwitches,
+                    calendar: Calendar.current
+                ) ?? gap.end
+
+                // Ensure endTime does not exceed current time or gap end
+                let endTime = min(min(nextSwitchTime, gap.end), now)
+
+                // Only proceed if we have a valid time interval
+                guard endTime > currentTime else { break }
+
+                let durationHours = (Decimal(endTime.timeIntervalSince(currentTime)) / 3600).truncated(toPlaces: 5)
+                let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
+
+                if insulin > 0 {
+                    totalInsulin += insulin
+
+//                    debug(
+//                        .apsManager,
+//                        "Scheduled Insulin added: \(insulin) U. Duration: \(durationHours) hrs (Start: \(currentTime.ISO8601Format()), End: \(endTime.ISO8601Format()))"
+//                    )
+                }
+
+                currentTime = endTime
+            }
+        }
+    }
+
+    /// Finds gaps between tempBasal events where scheduled basal ran
+    /// - Parameter tempBasalEvents: Array of pump history events of type tempBasal
+    /// - Returns: Array of gaps, where each gap has a start and end time
+    private func findBasalGaps(in tempBasalEvents: [PumpHistoryEvent]) -> [(start: Date, end: Date)] {
+        guard !tempBasalEvents.isEmpty else {
+            let startOfDay = Calendar.current.startOfDay(for: Date())
+            return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
+        }
+
+        // Pre-sort events and create array with capacity
+        let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
+        var gaps = [(start: Date, end: Date)]()
+        gaps.reserveCapacity(sortedEvents.count + 1)
+
+        // Use first event's date for calendar operations
+        let startOfDay = Calendar.current.startOfDay(for: sortedEvents.first!.timestamp)
+        let endOfDay = startOfDay.addingTimeInterval(24 * 60 * 60 - 1)
+
+        // Process events in a single pass
+        var lastEndTime = sortedEvents.first!.timestamp
+
+        for i in 0 ..< sortedEvents.count {
+            let event = sortedEvents[i]
+            guard let duration = event.duration else { continue }
+
+            // Calculate end time for current event
+            var currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
+
+            // Check for cancellation by next event
+            if i < sortedEvents.count - 1 {
+                let nextEvent = sortedEvents[i + 1]
+                if nextEvent.timestamp < currentEndTime {
+                    currentEndTime = nextEvent.timestamp
+                }
+            }
+
+            // Record gap if exists
+            if event.timestamp > lastEndTime {
+                gaps.append((start: lastEndTime, end: event.timestamp))
+            }
+
+            lastEndTime = currentEndTime
+        }
+
+        // Add final gap if needed
+        if lastEndTime < endOfDay {
+            gaps.append((start: lastEndTime, end: endOfDay))
+        }
+
+        return gaps
+    }
+
+//    /// Finds gaps between tempBasal events where scheduled basal ran, excluding suspend-resume periods
+//    /// - Parameters:
+//    ///   - tempBasalEvents: Array of pump history events of type tempBasal
+//    ///   - suspendResumePairs: Array of suspend and resume event pairs
+//    /// - Returns: Array of gaps, where each gap has a start and end time
+//    private func findBasalGaps(
+//        in tempBasalEvents: [PumpHistoryEvent],
+//        excluding suspendResumePairs: [(suspend: PumpHistoryEvent, resume: PumpHistoryEvent)]
+//    ) -> [(start: Date, end: Date)] {
+//        guard !tempBasalEvents.isEmpty else {
+//            let startOfDay = Calendar.current.startOfDay(for: Date())
+//            return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
+//        }
+//
+//        // Merge temp basal and suspend-resume events into a unified timeline
+//        var timeline = [(start: Date, end: Date, type: EventType)]()
+//
+//        for event in tempBasalEvents {
+//            guard let duration = event.duration else { continue }
+//            let eventEnd = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
+//            timeline.append((start: event.timestamp, end: eventEnd, type: .tempBasal))
+//        }
+//
+//        for suspendResume in suspendResumePairs {
+//            timeline.append((start: suspendResume.suspend.timestamp, end: suspendResume.resume.timestamp, type: .pumpSuspend))
+//        }
+//
+//        // Sort the timeline by start time
+//        timeline.sort { $0.start < $1.start }
+//
+//        // Process the timeline to calculate gaps
+//        var gaps = [(start: Date, end: Date)]()
+//        var lastEndTime = Calendar.current.startOfDay(for: timeline.first!.start)
+//        let endOfDay = lastEndTime.addingTimeInterval(24 * 60 * 60 - 1)
+//
+//        for interval in timeline {
+//            if interval.type == .pumpSuspend {
+//                // Extend lastEndTime for suspend periods
+//                lastEndTime = max(lastEndTime, interval.end)
+//                continue
+//            }
+//
+//            if interval.start > lastEndTime {
+//                // Add a gap if there is a gap between lastEndTime and interval.start
+//                gaps.append((start: lastEndTime, end: interval.start))
+//            }
+//
+//            // Update lastEndTime to the maximum end time encountered
+//            lastEndTime = max(lastEndTime, interval.end)
+//        }
+//
+//        if lastEndTime < endOfDay {
+//            // Add a final gap if the lastEndTime is before the end of the day
+//            gaps.append((start: lastEndTime, end: endOfDay))
+//        }
+//
+//        return gaps
+//    }
+
+//    /// Calculates scheduled basal insulin delivery during gaps between temporary basals
+//    /// - Parameters:
+//    ///   - gaps: Array of time periods where scheduled basal was active
+//    ///   - profile: Basal profile entries defining rates throughout the day
+//    ///   - roundToSupportedBasalRate: Closure to round rates to pump-supported values
+//    /// - Returns: Total insulin delivered via scheduled basal in units
+//    private func calculateScheduledBasalInsulin(
+//        gaps: [(start: Date, end: Date)],
+//        profile: [BasalProfileEntry],
+//        roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
+//    ) -> Decimal {
+//        // Initialize cached formatter for time string conversion
+//        let timeFormatter: DateFormatter = {
+//            let formatter = DateFormatter()
+//            formatter.dateFormat = "HH:mm:ss"
+//            return formatter
+//        }()
+//
+//        // Pre-calculate profile switch times for efficient lookup
+//        let profileSwitches = profile.map(\.minutes)
+//
+//        return gaps.reduce(into: Decimal(0)) { totalInsulin, gap in
+//            var currentTime = gap.start
+//
+//            while currentTime < gap.end {
+//                // Find applicable basal rate for the current time
+//                guard let rate = findBasalRate(
+//                    for: timeFormatter.string(from: currentTime),
+//                    in: profile
+//                ) else { break }
+//
+//                // Determine when the rate changes (profile switch or gap end)
+//                let nextSwitchTime = getNextBasalRateSwitch(
+//                    after: currentTime,
+//                    switches: profileSwitches,
+//                    calendar: Calendar.current
+//                ) ?? gap.end
+//                let endTime = min(nextSwitchTime, gap.end)
+//                let durationHours = Decimal(endTime.timeIntervalSince(currentTime)) / 3600
+//
+//                let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
+//                totalInsulin += insulin
+//
+//                debug(
+//                    .apsManager,
+//                    "Scheduled Insulin added: \(insulin) U. Duration: \(durationHours) hrs (\(currentTime)-\(endTime))"
+//                )
+//
+//                currentTime = endTime
+//            }
+//        }
+//    }
+
+    /// Finds the next basal rate switch time after a given time
+    /// - Parameters:
+    ///   - time: Reference time to find next switch after
+    ///   - switches: Pre-calculated array of minutes when profile rates change
+    ///   - calendar: Calendar instance for date calculations
+    /// - Returns: Date of next basal rate switch, or nil if none found
+    private func getNextBasalRateSwitch(
+        after time: Date,
+        switches: [Int],
+        calendar: Calendar
+    ) -> Date? {
+        let timeMinutes = calendar.component(.hour, from: time) * 60 + calendar.component(.minute, from: time)
+
+        // Find first switch time after current time
+        guard let nextSwitch = switches.first(where: { $0 > timeMinutes }) else {
+            return nil
+        }
+
+        // Convert switch time to absolute date
+        return calendar.startOfDay(for: time).addingTimeInterval(TimeInterval(nextSwitch * 60))
+    }
+
+    /// Finds the basal rate for a specific time using binary search
+    /// - Parameters:
+    ///   - timeString: Time in format "HH:mm:ss"
+    ///   - profile: Array of basal profile entries sorted by time
+    /// - Returns: Basal rate in units per hour, or nil if not found
+    private func findBasalRate(for timeString: String, in profile: [BasalProfileEntry]) -> Decimal? {
+        // Parse time string in "HH:mm:ss" format into hours and minutes components
+        let timeComponents = timeString.split(separator: ":")
+        guard timeComponents.count == 3,
+              let hours = Int(timeComponents[0]),
+              let minutes = Int(timeComponents[1])
+        else { return nil }
+
+        // Convert time to total minutes since midnight for easier comparison
+        let totalMinutes = hours * 60 + minutes
+
+        // Special case: If profile has only one entry, it applies for full 24 hours
+        // Return its rate immediately without searching
+        if profile.count == 1 {
+            return profile[0].rate
+        }
+
+        // Use binary search to efficiently find the applicable basal rate
+        // Profile entries are sorted by minutes, so we can divide and conquer
+        var left = 0
+        var right = profile.count - 1
+
+        while left <= right {
+            let mid = (left + right) / 2
+            let entry = profile[mid]
+            // Get end time for current entry - either next entry's start time or end of day (1440 mins)
+            let nextMinutes = mid + 1 < profile.count ? profile[mid + 1].minutes : 1440
+
+            // Check if target time falls within current entry's time range
+            if totalMinutes >= entry.minutes, totalMinutes < nextMinutes {
+                return entry.rate
+            }
+
+            // Adjust search range based on comparison
+            if totalMinutes < entry.minutes {
+                right = mid - 1 // Search in left half if target time is earlier
+            } else {
+                left = mid + 1 // Search in right half if target time is later
+            }
+        }
+
+        // No applicable rate found for the given time
+        return nil
+    }
+
+    /// Calculates a weighted average of Total Daily Dose (TDD) based on recent and historical data
+    ///
+    /// The weighted average is calculated using two time periods:
+    /// - Recent: Last 2 hours of TDD data
+    /// - Historical: Last 10 days of TDD data
+    ///
+    /// The formula used is:
+    /// ```
+    /// weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
+    /// ```
+    /// where weightPercentage defaults to 0.65 if not set in preferences
+    ///
+    /// - Returns: A weighted average of TDD as Decimal, or nil if insufficient data
+    /// - Note: The weight percentage can be configured in preferences. Default is 0.65 (65% recent, 35% historical)
+    private func calculateWeightedAverage() async throws -> Decimal? {
+        // Fetch data from Core Data
+        let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
+        let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval)
+
+        let predicate = NSPredicate(format: "date >= %@", tenDaysAgo as NSDate)
+
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: TDDStored.self,
+            onContext: privateContext,
+            predicate: predicate,
+            key: "date",
+            ascending: false
+        )
+        return await privateContext.perform { () -> Decimal? in
+            guard let results = results as? [TDDStored], !results.isEmpty else { return 0 }
+
+            // Calculate recent (2h) average
+            let recentResults = results.filter { $0.date?.timeIntervalSince(twoHoursAgo) ?? 0 > 0 }
+            let recentTotal = recentResults.compactMap { $0.total?.decimalValue }.reduce(0, +)
+            let recentCount = max(Decimal(recentResults.count), 1)
+            let averageTDDLastTwoHours = recentTotal / recentCount
+
+            // Calculate 10-day average
+            let totalTDD = results.compactMap { $0.total?.decimalValue }.reduce(0, +)
+            let totalCount = max(Decimal(results.count), 1)
+            let averageTDDLastTenDays = totalTDD / totalCount
+
+            // Get weight percentage from preferences (default 0.65 if not set)
+            let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
+            let weightPercentage = userPreferences?.weightPercentage ?? Decimal(0.65) // why is this 1 as default in oref2??
+
+            // Calculate weighted average using the formula:
+            // weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
+            let weightedTDD = weightPercentage * averageTDDLastTwoHours +
+                (1 - weightPercentage) * averageTDDLastTenDays
+
+            return weightedTDD.truncated(toPlaces: 3)
+        }
+    }
+}
+
+/// Extension for rounding Decimal numbers
+extension Decimal {
+    /// Rounds a decimal to specified number of places
+    /// - Parameter places: Number of decimal places
+    /// - Returns: Rounded decimal
+    func rounded(toPlaces places: Int) -> Decimal {
+        var value = self
+        var result = Decimal()
+        NSDecimalRound(&result, &value, places, .plain)
+        return result
+    }
+
+    /// Truncates the `Decimal` to the specified number of decimal places without rounding.
+    ///
+    /// - Parameter places: The number of decimal places to retain.
+    /// - Returns: A `Decimal` truncated to the specified precision.
+    func truncated(toPlaces places: Int) -> Decimal {
+        var original = self
+        var result = Decimal()
+        NSDecimalRound(&result, &original, places, .down)
+        return result
+    }
+}

+ 1 - 0
Trio/Sources/Assemblies/StorageAssembly.swift

@@ -10,6 +10,7 @@ final class StorageAssembly: Assembly {
         container.register(PumpHistoryStorage.self) { r in BasePumpHistoryStorage(resolver: r) }
         container.register(OverrideStorage.self) { r in BaseOverrideStorage(resolver: r) }
         container.register(DeterminationStorage.self) { r in BaseDeterminationStorage(resolver: r) }
+        container.register(TDDStorage.self) { r in BaseTDDStorage(resolver: r) }
         container.register(GlucoseStorage.self) { r in BaseGlucoseStorage(resolver: r) }
         container.register(TempTargetsStorage.self) { r in BaseTempTargetsStorage(resolver: r) }
         container.register(CarbsStorage.self) { r in BaseCarbsStorage(resolver: r) }

+ 1 - 1
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -180513,4 +180513,4 @@
     }
   },
   "version" : "1.0"
-}
+}

+ 1 - 0
Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift

@@ -36,6 +36,7 @@ extension AlgorithmAdvancedSettings {
                 currentBasalSafetyMultiplier = $0 }
             subscribePreferencesSetting(\.useCustomPeakTime, on: $useCustomPeakTime) { useCustomPeakTime = $0 }
             subscribePreferencesSetting(\.insulinPeakTime, on: $insulinPeakTime) { insulinPeakTime = $0 }
+            subscribePreferencesSetting(\.skipNeutralTemps, on: $skipNeutralTemps) { skipNeutralTemps = $0 }
             subscribePreferencesSetting(\.unsuspendIfNoTemp, on: $unsuspendIfNoTemp) { unsuspendIfNoTemp = $0 }
             subscribePreferencesSetting(\.suspendZerosIOB, on: $suspendZerosIOB) { suspendZerosIOB = $0 }
             subscribePreferencesSetting(\.suspendZerosIOB, on: $suspendZerosIOB) { suspendZerosIOB = $0 }

+ 3 - 1
Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift

@@ -366,7 +366,9 @@ extension NightscoutConfig {
         }
 
         func backfillGlucose() async {
-            backfilling = true
+            await MainActor.run {
+                backfilling = true
+            }
 
             let glucose = await nightscoutManager.fetchGlucose(since: Date().addingTimeInterval(-1.days.timeInterval))
 

+ 3 - 4
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -166,11 +166,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         let content = UNMutableNotificationContent()
 
         if snoozeUntilDate > Date() {
-            titles.append(String(localized: "(Snoozed)", comment: "(Snoozed)"))
-        } else {
-            content.sound = .default
-            playSoundIfNeeded()
+            return
         }
+        content.sound = .default
+        playSoundIfNeeded()
 
         titles.append(String(format: String(localized: "Carbs required: %d g", comment: "Carbs required"), carbs))