From 6f2798ba0937a2b44243ba22eb6bc89ef6abad9d Mon Sep 17 00:00:00 2001 From: eduardopietre Date: Fri, 1 Jul 2022 14:00:36 -0300 Subject: [PATCH] Implemented Insulin on Board. Insulin on Board implementation, all done locally. OpenAPS docs were used as reference for this implementation: https://openaps.readthedocs.io/en/latest/docs/While%20You%20Wait%20For%20Gear/understanding-insulin-on-board-calculations.html This commit features: - A settings section for insulin on board with 4 settings: enable display, show on chart, duration of insulin activity (OpenAPS dia, but in minutes) and insulin peak time (OpenAPS peak). - InsulinOnBoardCalculator, class responsible for calculating the IOB. - IOB plotted at main chart, as a blue line and same scale of insulin bolus. - Convenience ChartPoint init for a date and insulin amount, for IOB. Things to notice: - At RootViewController there are two TODOs, where the IOB display label should be set. The code to calculate the IOB there is already present. - Caching is not used, but the implementation was done considering that cache may be needed and implemented in the future. --- xdrip.xcodeproj/project.pbxproj | 8 + xdrip/Extensions/ChartPoint.swift | 15 + xdrip/Extensions/UserDefaults.swift | 74 +++- .../Managers/Charts/GlucoseChartManager.swift | 67 +++- xdrip/Texts/TextsSettingsView.swift | 29 ++ .../Treatments/InsulinOnBoardCalculator.swift | 348 ++++++++++++++++++ .../RootViewController.swift | 28 +- .../SettingsViewController.swift | 5 + .../SettingsViewInsulinOnBoardModel.swift | 137 +++++++ 9 files changed, 707 insertions(+), 4 deletions(-) create mode 100644 xdrip/Treatments/InsulinOnBoardCalculator.swift create mode 100644 xdrip/View Controllers/SettingsNavigationController/SettingsViewController/SettingsViewModels/SettingsViewInsulinOnBoardModel.swift diff --git a/xdrip.xcodeproj/project.pbxproj b/xdrip.xcodeproj/project.pbxproj index 1fe49893e..5382d34e9 100644 --- a/xdrip.xcodeproj/project.pbxproj +++ b/xdrip.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ CE1B2FE125D0264B00F642F5 /* Main.strings in Resources */ = {isa = PBXBuildFile; fileRef = CE1B2FD425D0264900F642F5 /* Main.strings */; }; D400F8032778BD8000B57648 /* TextsTreatmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D400F8022778BD8000B57648 /* TextsTreatmentsView.swift */; }; D4028CC02774A50600341476 /* TreatmentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4028CBF2774A50600341476 /* TreatmentsViewController.swift */; }; + D4082870286B9EA60004FD0B /* SettingsViewInsulinOnBoardModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D408286F286B9EA60004FD0B /* SettingsViewInsulinOnBoardModel.swift */; }; + D4082878286CAD020004FD0B /* InsulinOnBoardCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4082877286CAD020004FD0B /* InsulinOnBoardCalculator.swift */; }; D40C3DA4277542C400111B73 /* TreatmentEntry+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = D40C3DA3277542C400111B73 /* TreatmentEntry+CoreDataClass.swift */; }; D40C3DA62775438F00111B73 /* TreatmentEntry+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = D40C3DA52775438F00111B73 /* TreatmentEntry+CoreDataProperties.swift */; }; D417E51C282EC8DB008DC467 /* ProgressBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D417E51B282EC8DB008DC467 /* ProgressBarViewController.swift */; }; @@ -729,6 +731,8 @@ CE1B2FE425D026B400F642F5 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Common.strings; sourceTree = ""; }; D400F8022778BD8000B57648 /* TextsTreatmentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextsTreatmentsView.swift; sourceTree = ""; }; D4028CBF2774A50600341476 /* TreatmentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreatmentsViewController.swift; sourceTree = ""; }; + D408286F286B9EA60004FD0B /* SettingsViewInsulinOnBoardModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewInsulinOnBoardModel.swift; sourceTree = ""; }; + D4082877286CAD020004FD0B /* InsulinOnBoardCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinOnBoardCalculator.swift; sourceTree = ""; }; D40C3DA3277542C400111B73 /* TreatmentEntry+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TreatmentEntry+CoreDataClass.swift"; sourceTree = ""; }; D40C3DA52775438F00111B73 /* TreatmentEntry+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TreatmentEntry+CoreDataProperties.swift"; sourceTree = ""; }; D417E51B282EC8DB008DC467 /* ProgressBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarViewController.swift; sourceTree = ""; }; @@ -1650,6 +1654,7 @@ D4E499AA277B43E3000F8CBA /* TreatmentCollection.swift */, D4E499AC277B4CE7000F8CBA /* DateOnly.swift */, D48E8F77278E49B300CCEE08 /* TreatmentNSResponse.swift */, + D4082877286CAD020004FD0B /* InsulinOnBoardCalculator.swift */, ); path = Treatments; sourceTree = ""; @@ -2511,6 +2516,7 @@ 47AB72F227105EF4005E7CAB /* SettingsViewHelpSettingModel.swift */, 47150A3F27F6211C00DB2994 /* SettingsViewTreatmentsSettingsViewModel.swift */, D41F32912827240E00861B3D /* SettingsViewHousekeeperSettingsViewModel.swift */, + D408286F286B9EA60004FD0B /* SettingsViewInsulinOnBoardModel.swift */, ); path = SettingsViewModels; sourceTree = ""; @@ -3541,6 +3547,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D4082878286CAD020004FD0B /* InsulinOnBoardCalculator.swift in Sources */, F8BDD450221CAA64006EAB84 /* TextsCommon.swift in Sources */, F81D6D4E22BFC762005EFAE2 /* TextsDexcomShareTestResult.swift in Sources */, F8A5EEC2257D18DC0085E660 /* LibreNFCDelegate.swift in Sources */, @@ -3556,6 +3563,7 @@ F8F9721923A5915900C3F17D /* CGMGNSEntryTransmitter.swift in Sources */, F8B3A84A227F090E004BA588 /* SettingsViewGeneralSettingsViewModel.swift in Sources */, F83098FE23AD3F84005741DF /* UITabBarController.swift in Sources */, + D4082870286B9EA60004FD0B /* SettingsViewInsulinOnBoardModel.swift in Sources */, F816E11A243923B2009EE65B /* Droplet+CoreDataClass.swift in Sources */, F80D916D24F82A17006840B5 /* CGMLibre2TransmitterDelegate.swift in Sources */, F8B955EB2591355200C06016 /* CGMLibre2Transmitter+TestData.swift in Sources */, diff --git a/xdrip/Extensions/ChartPoint.swift b/xdrip/Extensions/ChartPoint.swift index 17f9ccf41..76dd02191 100644 --- a/xdrip/Extensions/ChartPoint.swift +++ b/xdrip/Extensions/ChartPoint.swift @@ -41,6 +41,21 @@ extension ChartPoint { y: ChartAxisValueDouble(bgCheck.value.mgdlToMmol(mgdl: unitIsMgDl).bgValueRounded(mgdl: unitIsMgDl)) ) + } + + /// Convenience init from a Date, a insulin Double and a DateFormatter + /// used for IOB. + convenience init(date: Date, insulin: Double, formatter: DateFormatter) { + + /// Calculates a scaled value for display. + let isMgDl = UserDefaults.standard.bloodGlucoseUnitIsMgDl + let scaledValue = ConstantsGlucoseChart.absoluteMinimumChartValueInMgdl.mgdlToMmol(mgdl: isMgDl) + ConstantsGlucoseChart.bolusTreatmentChartPointYAxisOffsetInMgDl.mgdlToMmol(mgdl: isMgDl) + (insulin * ConstantsGlucoseChart.bolusTreatmentChartPointYAxisScaleFactor.mgdlToMmol(mgdl: isMgDl)) + + self.init( + x: ChartAxisValueDate(date: date, formatter: formatter), + y: ChartAxisValueDouble(scaledValue) + ) + } /// the chartpoints defined for certain treatment entries (such as carbs) are positioned relative to other elements and need to be re-scaled to fit the y-axis values of the glucose chart points (and therefore avoid needing a secondary axis) diff --git a/xdrip/Extensions/UserDefaults.swift b/xdrip/Extensions/UserDefaults.swift index 0ddb7af7b..e152b0307 100644 --- a/xdrip/Extensions/UserDefaults.swift +++ b/xdrip/Extensions/UserDefaults.swift @@ -89,6 +89,20 @@ extension UserDefaults { /// should the BG Checks be listed in the treatment list/table? case showBgCheckTreatmentsInList = "showBgCheckTreatmentsInList" + + // Insulin On Board settings + + /// Should the display label be enabled? + case insulinOnBoardEnabledDisplay = "insulinOnBoardEnabledDisplay" + + /// Draw on chart? + case insulinOnBoardShowOnChart = "insulinOnBoardShowOnChart" + + /// Insulin Activity Duration in minutes + case insulinOnBoardInsulinActivityDuration = "insulinOnBoardInsulinActivityDuration" + + /// Insulin Peak Time in minutes + case insulinOnBoardInsulinPeakTime = "insulinOnBoardInsulinPeakTime" // Statistics settings @@ -905,7 +919,65 @@ extension UserDefaults { set(!newValue, forKey: Key.showBgCheckTreatmentsInList.rawValue) } } - + + + // MARK: Insulin On Board Settings + + + /// Should the IOB display label be enabled? + @objc dynamic var insulinOnBoardEnabledDisplay: Bool { + // default value for bool in userdefaults is false + get { + return bool(forKey: Key.insulinOnBoardEnabledDisplay.rawValue) + } + set { + set(newValue, forKey: Key.insulinOnBoardEnabledDisplay.rawValue) + } + } + + /// Draw on chart? + @objc dynamic var insulinOnBoardShowOnChart: Bool { + // default value for bool in userdefaults is false + get { + return bool(forKey: Key.insulinOnBoardShowOnChart.rawValue) + } + set { + set(newValue, forKey: Key.insulinOnBoardShowOnChart.rawValue) + } + } + + /// IOB insulin activity duration in minutes. + @objc dynamic var insulinOnBoardInsulinActivityDuration: Int { + get { + let returnValue = integer(forKey: Key.insulinOnBoardInsulinActivityDuration.rawValue) + /// if not set yet, or set to 0, return 300 + if returnValue == 0 { + return 300 + } + return returnValue + } + set { + set(newValue, forKey: Key.insulinOnBoardInsulinActivityDuration.rawValue) + } + } + + /// IOB insulin peak time in minutes. + @objc dynamic var insulinOnBoardInsulinPeakTime: Int { + get { + let returnValue = integer(forKey: Key.insulinOnBoardInsulinPeakTime.rawValue) + /// if not set yet, or set to 0, defaults to 75 + if returnValue == 0 { + return 75 + } + return returnValue + } + set { + // Constrains the newValue to be < insulinOnBoardInsulinActivityDuration. + let value = min(newValue, self.insulinOnBoardInsulinActivityDuration - 1) + set(value, forKey: Key.insulinOnBoardInsulinPeakTime.rawValue) + } + } + // MARK: Statistics Settings diff --git a/xdrip/Managers/Charts/GlucoseChartManager.swift b/xdrip/Managers/Charts/GlucoseChartManager.swift index 77d5f8235..781da8a48 100644 --- a/xdrip/Managers/Charts/GlucoseChartManager.swift +++ b/xdrip/Managers/Charts/GlucoseChartManager.swift @@ -70,6 +70,9 @@ public class GlucoseChartManager { /// ChartPoints to be shown on chart, processed only in main thread - not Urgent Range private var notUrgentRangeGlucoseChartPoints = [ChartPoint]() + /// Points used to draw the Insulin On Board line. + private var insulinOnBoardChartPoints = [ChartPoint]() + /// for logging private var oslog = OSLog(subsystem: ConstantsLog.subSystem, category: ConstantsLog.categoryGlucoseChartManager) @@ -114,6 +117,9 @@ public class GlucoseChartManager { /// initialise treatmentEntryAccessor private var treatmentEntryAccessor: TreatmentEntryAccessor? + + /// initialize insulinOnBoardCalculator + private var insulinOnBoardCalculator: InsulinOnBoardCalculator? /// a coreDataManager private var coreDataManager: CoreDataManager @@ -321,6 +327,13 @@ public class GlucoseChartManager { self.treatmentChartPoints.bgChecks = treatmentChartPoints.bgChecks } + + /// IOB chart points. We do not need a property just to pass the new value, + /// a local variable is enough. + var newInsulinOnBoardChartPoints: [ChartPoint] = [] + if UserDefaults.standard.insulinOnBoardShowOnChart { + newInsulinOnBoardChartPoints = self.getInsulinOnBoardChartPoints(startDate: startDateToUse, endDate: endDate, treatmentEntryAccessor: self.data().treatmentEntryAccessor, on: self.coreDataManager.mainManagedObjectContext) + } DispatchQueue.main.async { @@ -348,6 +361,9 @@ public class GlucoseChartManager { // assign the BG check treatment chart points self.bgCheckTreatmentChartPoints = self.treatmentChartPoints.bgChecks + + /// Update insulinOnBoardChartPoints to the new points. + self.insulinOnBoardChartPoints = newInsulinOnBoardChartPoints // update the chart outlet chartOutlet.reloadChart() @@ -872,6 +888,12 @@ public class GlucoseChartManager { ] layers.append(contentsOf: layersGlucoseCircles) + + if UserDefaults.standard.insulinOnBoardShowOnChart { + let lineModel = ChartLineModel(chartPoints: self.insulinOnBoardChartPoints, lineColor: UIColor.systemBlue, lineWidth: 2, lineJoin: .bevel, lineCap: .round, animDuration: 0, animDelay: 0, dashPattern: nil) + let insulinOnBoardLayer: ChartLayer = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel]) + layers.append(insulinOnBoardLayer) + } if UserDefaults.standard.showTreatmentsOnChart { @@ -880,6 +902,7 @@ public class GlucoseChartManager { bgCheckCirclesOuterLayer, bgCheckCirclesInnerLayer, // treatment label layers + // Labels must be the last thing, in order to appear in front of everything else . smallCarbsLabelsLayer, mediumCarbsLabelsLayer, largeCarbsLabelsLayer, @@ -1046,6 +1069,37 @@ public class GlucoseChartManager { return (calibrationChartPoints) } + + + /// GetInsulinOnBoardChartPoints - Receives a start and end date and returns the ChartPoints of IOB between these dates. + /// These dates will typically be the start and end dates of the chart x-axis. + /// + /// - parameters: + /// - startDate : start date to calculate the IOB + /// - endDate : end date to calculate the IOB + /// - treatmentEntryAccessor : treatment entry accessor object + /// - managedObjectContext : the ManagedObjectContext to use + /// - returns: a list of ChartPoint, each a point of the IOB line. + private func getInsulinOnBoardChartPoints(startDate: Date, endDate: Date, treatmentEntryAccessor: TreatmentEntryAccessor, on managedObjectContext: NSManagedObjectContext) -> [ChartPoint] { + + /// Since calculating the IOB for each second would be impracticable, + /// we must select some dates for it. + /// To prevent the line from being inconsistent when the user moves the graph, + /// dates should be selected at regular intervals and from regular points. + + /// + let calculator = data().insulinOnBoardCalculator + let (xAxisDates, yAxisIOB) = calculator.insulinYetToBeConsumed(startDate: startDate, endDate: endDate, steps: 40, surroundTreatments: true) + + /// Now that we have the x axis and the y values, create the ChartPoints from it + /// using zip and them mapping to ChartPoint is the easier way to do it. + let formatter = data().chartPointDateFormatter + let insulinOnBoardChartPoints: [ChartPoint] = zip(xAxisDates, yAxisIOB).map { + return ChartPoint(date: $0, insulin: $1, formatter: formatter) + } + + return insulinOnBoardChartPoints + } /// Receives a start and end date and returns the treatment entries from coredata between these dates. These dates will typically be the start and end dates of the chart x-axis. These individual treatment entries are returned as a tuple with multiple chartPoint arrays as defined in TreatmentChartPointsTypes. @@ -1388,6 +1442,8 @@ public class GlucoseChartManager { smallCarbsTreatmentChartPoints = [ChartPoint]() mediumCarbsTreatmentChartPoints = [ChartPoint]() largeCarbsTreatmentChartPoints = [ChartPoint]() + + insulinOnBoardChartPoints = [ChartPoint]() bgCheckTreatmentChartPoints = [ChartPoint]() @@ -1408,6 +1464,8 @@ public class GlucoseChartManager { calibrationsAccessor = nil treatmentEntryAccessor = nil + + insulinOnBoardCalculator = nil urgentRangeGlucoseChartPoints = [] @@ -1428,7 +1486,7 @@ public class GlucoseChartManager { } /// function which gives is variables that are set back to nil when nillifyData is called - private func data() -> (chartSettings: ChartSettings, chartPointDateFormatter: DateFormatter, operationQueue: OperationQueue, chartLabelSettings: ChartLabelSettings, chartLabelSettingsObjectives: ChartLabelSettings, chartLabelSettingsObjectivesSecondary: ChartLabelSettings, chartLabelSettingsTarget: ChartLabelSettings, chartLabelSettingsDimmed: ChartLabelSettings, chartLabelSettingsHidden: ChartLabelSettings, chartGuideLinesLayerSettings: ChartGuideLinesLayerSettings, axisLabelTimeFormatter: DateFormatter, bgReadingsAccessor: BgReadingsAccessor, calibrationsAccessor: CalibrationsAccessor, treatmentEntryAccessor: TreatmentEntryAccessor) { + private func data() -> (chartSettings: ChartSettings, chartPointDateFormatter: DateFormatter, operationQueue: OperationQueue, chartLabelSettings: ChartLabelSettings, chartLabelSettingsObjectives: ChartLabelSettings, chartLabelSettingsObjectivesSecondary: ChartLabelSettings, chartLabelSettingsTarget: ChartLabelSettings, chartLabelSettingsDimmed: ChartLabelSettings, chartLabelSettingsHidden: ChartLabelSettings, chartGuideLinesLayerSettings: ChartGuideLinesLayerSettings, axisLabelTimeFormatter: DateFormatter, bgReadingsAccessor: BgReadingsAccessor, calibrationsAccessor: CalibrationsAccessor, treatmentEntryAccessor: TreatmentEntryAccessor, insulinOnBoardCalculator: InsulinOnBoardCalculator) { // setup chartSettings if chartSettings == nil { @@ -1541,8 +1599,13 @@ public class GlucoseChartManager { if treatmentEntryAccessor == nil { treatmentEntryAccessor = TreatmentEntryAccessor(coreDataManager: coreDataManager) } + + // initialize insulinOnBoardCalculator + if insulinOnBoardCalculator == nil { + insulinOnBoardCalculator = InsulinOnBoardCalculator(coreDataManager: coreDataManager) + } - return (chartSettings!, chartPointDateFormatter!, operationQueue!, chartLabelSettings!, chartLabelSettingsObjectives!, chartLabelSettingsObjectivesSecondary!, chartLabelSettingsTarget!, chartLabelSettingsDimmed!, chartLabelSettingsHidden!, chartGuideLinesLayerSettings!, axisLabelTimeFormatter!, bgReadingsAccessor!, calibrationsAccessor!, treatmentEntryAccessor!) + return (chartSettings!, chartPointDateFormatter!, operationQueue!, chartLabelSettings!, chartLabelSettingsObjectives!, chartLabelSettingsObjectivesSecondary!, chartLabelSettingsTarget!, chartLabelSettingsDimmed!, chartLabelSettingsHidden!, chartGuideLinesLayerSettings!, axisLabelTimeFormatter!, bgReadingsAccessor!, calibrationsAccessor!, treatmentEntryAccessor!, insulinOnBoardCalculator!) } diff --git a/xdrip/Texts/TextsSettingsView.swift b/xdrip/Texts/TextsSettingsView.swift index 31bd54377..eb259c97d 100644 --- a/xdrip/Texts/TextsSettingsView.swift +++ b/xdrip/Texts/TextsSettingsView.swift @@ -548,6 +548,35 @@ class Texts_SettingsView { static let settingsviews_housekeeperRetentionPeriodMessage = { return NSLocalizedString("settingsviews_housekeeperRetentionPeriodMessage", tableName: filename, bundle: Bundle.main, value: "For how many days should data be stored? (Min 90, Max 365)\n\n(Recommended: 90 days)", comment: "When clicking the retention setting, a pop up asks for how many days should data be stored") }() + + // MARK: - Section Insulin On Board + static let sectionTitleInsulinOnBoard: String = { + return NSLocalizedString("settingsviews_sectionTitleInsulinOnBoard", tableName: filename, bundle: Bundle.main, value: "Insulin On Board", comment: "Insulin on board section title.") + }() + + static let sectionTitleInsulinOnBoardToggleDisplay: String = { + return NSLocalizedString("settingsviews_sectionTitleInsulinOnBoardToggleDisplay", tableName: filename, bundle: Bundle.main, value: "Enable Display", comment: "Toggle the IOB label display at root view.") + }() + + static let sectionTitleInsulinOnBoardShowIOBOnChart: String = { + return NSLocalizedString("settingsviews_sectionTitleInsulinOnBoardShowIOBOnChart", tableName: filename, bundle: Bundle.main, value: "Show On Chart", comment: "Display IOB on the chart.") + }() + + static let sectionTitleInsulinOnBoardInsulinActivityDuration: String = { + return NSLocalizedString("settingsviews_sectionTitleInsulinOnBoardInsulinActivityDuration", tableName: filename, bundle: Bundle.main, value: "Duration of Insulin Activity", comment: "IOB 'dia' time in minutes.") + }() + + static let sectionTitleInsulinOnBoardInsulinActivityDurationMessage: String = { + return NSLocalizedString("settingsviews_sectionTitleInsulinOnBoardInsulinActivityDurationMessage", tableName: filename, bundle: Bundle.main, value: "Duration of Insulin Activity in minutes", comment: "IOB 'dia' time in minutes. Description message.") + }() + + static let sectionTitleInsulinOnBoardInsulinPeakTime: String = { + return NSLocalizedString("settingsviews_sectionTitleInsulinOnBoardInsulinPeakTime", tableName: filename, bundle: Bundle.main, value: "Insulin Peak Time", comment: "Insulin peak time in minutes.") + }() + + static let sectionTitleInsulinOnBoardInsulinPeakTimeMessage: String = { + return NSLocalizedString("settingsviews_sectionTitleInsulinOnBoardInsulinPeakTime", tableName: filename, bundle: Bundle.main, value: "Insulin Peak Time in minutes. Setting to 0 will calculate a default value based on Duration of Insulin Activity.", comment: "Insulin peak time in minutes. Description message.") + }() } diff --git a/xdrip/Treatments/InsulinOnBoardCalculator.swift b/xdrip/Treatments/InsulinOnBoardCalculator.swift new file mode 100644 index 000000000..97aeab495 --- /dev/null +++ b/xdrip/Treatments/InsulinOnBoardCalculator.swift @@ -0,0 +1,348 @@ +// +// InsulinOnBoardCalculator.swift +// xdrip +// +// Created by Eduardo Pietre on 29/06/22. +// Copyright © 2022 Johan Degraeve. All rights reserved. +// + +import Foundation +import CoreData +import SwiftCharts + + + +/// +/// InsulinYetToBeConsumed is - and should be - a PURE FUNCTION. +/// A function is pure if given the same arguments it always returns the same output +/// AND it does not have any side effects - like accessing or modifying a property or a global variable. +/// +/// Why is this calculation implemented as a PURE FUNCTION? +/// This function is called numerous time, and being a pure function allows for, IF NEEDED, safely cache the results of it. +/// +/// This function is heavly based on the following sources and matches the same formula: +/// https://openaps.readthedocs.io/en/latest/docs/While%20You%20Wait%20For%20Gear/understanding-insulin-on-board-calculations.html +/// https://github.com/openaps/oref0/blob/master/lib/iob/calculate.js +/// https://github.com/LoopKit/Loop/issues/388#issuecomment-317938473 +/// +fileprivate func InsulinYetToBeConsumed(insulin: Double, minutesAgo: Double, activityDuration: Double, peakTime: Double) -> Double { + /// If minutesAgo if >= than activityDuration, the IOB will always be 0. + if minutesAgo >= activityDuration { + return 0 + } + + /// Assign variables to smaller variables names + /// This improves readability in the next section and matches the formula sources. + let peak = peakTime + let end = activityDuration + + /// Math (very close to magic) happens here. + /// Performs an exponential interpolation. + /// Variable names are the same as used in the formula sources. + let tau = peak * (1 - (peak / end)) / (1 - (2 * peak / end)) + let a = 2 * tau / end + let S = 1 / (1 - a + ((1 + a) * exp(-end / tau))) + + let remaining = insulin * (1 - S * (1 - a) * ((pow(minutesAgo, 2) / (tau * end * (1 - a)) - minutesAgo / tau - 1) * exp(-minutesAgo / tau) + 1)) + return remaining +} + + +/// +/// InsulinOnBoardCalculator is the +/// class interface responsible for calculating IOBs. +/// For example, given a date (or many), it is able to +/// load all insulin treatments that impact the IOB at that moment +/// and calculating it. +/// +/// 'override func observeValue' requires us to inherit from NSObject. +/// +public class InsulinOnBoardCalculator: NSObject { + + /// reference to coreDataManager + private let coreDataManager: CoreDataManager + + /// reference to treatmentEntryAccessor + private let treatmentEntryAccessor: TreatmentEntryAccessor + + /// reference to coreDataManager object context + private let objectContext: NSManagedObjectContext + + /// Get activityDuration and peakTime from UserDefaults and convert them to Double + private var activityDuration: Double = Double(UserDefaults.standard.insulinOnBoardInsulinActivityDuration) + private var peakTime: Double = Double(UserDefaults.standard.insulinOnBoardInsulinPeakTime) + + + // MARK: - initializer + + /// initializer + /// - parameters: + /// - coreDataManager : needed to get the treatments + init(coreDataManager: CoreDataManager) { + self.coreDataManager = coreDataManager + self.objectContext = coreDataManager.mainManagedObjectContext + self.treatmentEntryAccessor = TreatmentEntryAccessor(coreDataManager: coreDataManager) + + super.init() + + /// Add observers for ActivityDuration and PeakTime. + UserDefaults.standard.addObserver(self, forKeyPath: UserDefaults.Key.insulinOnBoardInsulinActivityDuration.rawValue, options: .new, context: nil) + UserDefaults.standard.addObserver(self, forKeyPath: UserDefaults.Key.insulinOnBoardInsulinPeakTime.rawValue, options: .new, context: nil) + } + + + /// deinitializer, used to free the UserDefaults observers. + /// If these observers are not removed, them being called after + /// the destruction of self will result in an EXC_BAD_ACCESS exception. + deinit { + UserDefaults.standard.removeObserver(self, forKeyPath: UserDefaults.Key.insulinOnBoardInsulinActivityDuration.rawValue) + UserDefaults.standard.removeObserver(self, forKeyPath: UserDefaults.Key.insulinOnBoardInsulinPeakTime.rawValue) + } + + + // MARK: - public functions + + + /// InsulinYetToBeConsumedAt - given a date, returns a double that represents + /// how many insulins units are yet to be consumed (IOB). + /// All insulin treatments in the last insulinOnBoardInsulinActivityDuration + /// are taken into account for it. + /// + /// - parameters: + /// - date : the moment of time to calculate the insulin yet to be consumed. + /// - returns: a double representing in units the insulin yet to be consumed. + /// + public func insulinYetToBeConsumedAt(date: Date) -> Double { + /// We must take into account all insulin treatments in the last insulinOnBoardInsulinActivityDuration period. + let startDate: Date = date - (activityDuration * 60) + + /// Variable to receive the IOB value. + var yetToBeConsumed: Double = 0.0 + objectContext.performAndWait { + /// get treaments between the two timestamps from coredata + /// filter so no deleted treatments are included and only of type .Insulin. + let treatmentEntries = treatmentEntryAccessor.getTreatments(fromDate: startDate, toDate: date, on: objectContext).filter({ + !$0.treatmentdeleted && $0.treatmentType == .Insulin + }) + + /// Use multipleInsulinYetToBeConsumed to calculate, but with only one date. + let insulinsYetToBeConsumed: [Double] = self.multipleInsulinYetToBeConsumed(treatmentEntries, atDates: [date]) + + /// Check if .first is not nil and sets yetToBeConsumed to it. + if let insulin = insulinsYetToBeConsumed.first { + yetToBeConsumed = insulin + } + } + + return yetToBeConsumed + } + + + /// + /// InsulinYetToBeConsumed - given two dates, number of steps and if should surround treatments, returns: + /// - a list of N dates between those two dates. + /// - a list of the IOB at each of those returned dates. + /// The first IOB double corresponds to the first date, and so on. + /// + /// - parameters: + /// - startDate : the start date (not guaranteed to be included). + /// - endDate : the end date (not guaranteed to be included). Must be after startDate. + /// - steps: the min amount of dates and IOB wanted. + /// - surroundTreatments: a bool, if true will also calculate and include the date and IOB right before and after each insulin treatment. This results in a more polished line when plotting. However, if the number of treatments at the interval is way to big, may cause lag. If false, the .count of the output is guaranteed to be equal to steps. + /// - returns: a pair: a list of the dates and a list of the iob at each date, the first IOB double corresponds to the first date, and so on. + /// + public func insulinYetToBeConsumed(startDate: Date, endDate: Date, steps: Int, surroundTreatments: Bool) -> (dates: [Date], iob: [Double]) { + + /// Safe guard to ensure endDate is after startDate. + guard endDate > startDate else { + return ([], []) + } + + /// Use roundModulus as 5 * 60 to aproximate to multiples of 5 minutes. + /// Having the dates selected at regular intervals and from regular points + /// prevents the result having small inconsistent flutuations when startDate + /// changes by a small value. + var dates: [Date] = self.determinedEvenlySpacedDates(startDate: startDate, endDate: endDate, steps: steps, roundModulus: 5 * 60) + + /// Define a variable to receive the result of multipleInsulinYetToBeConsumed + var yetToBeConsumed: [Double] = [] + objectContext.performAndWait { + /// fromDate is calculated based on startDate and activityDuration + /// will be used to load the treatments that impact the IOB. + let fromDate: Date = startDate - (activityDuration * 60) + + /// get treaments between the two timestamps from coredata + /// filter so no deleted treatments are included and only of type .Insulin. + let treatments = treatmentEntryAccessor.getTreatments(fromDate: fromDate, toDate: endDate, on: objectContext).filter({ + !$0.treatmentdeleted && $0.treatmentType == .Insulin + }) + + /// Even though we now have equally spaced dates, for a better "looking" + /// if surroundTreatments is true also add a date 10 seconds before + /// each insulin treatment and another 10 seconds after the treatment. + /// This ensures that the line slope right where it intersepts the insulin + /// treatment is not influenced by the offset to the closest x date. + if surroundTreatments { + for treatment in treatments { + let date = treatment.date + /// 10 seconds before and after + dates.append(date - 10.0) + dates.append(date + 10.0) + } + /// remember to sort dates again + dates.sort() // In place + } + + /// Calls multipleInsulinYetToBeConsumed to calculate the IOB. + yetToBeConsumed = self.multipleInsulinYetToBeConsumed(treatments, atDates: dates) + } + + return (dates, yetToBeConsumed) + } + + + /// + /// InsulinYetToBeConsumed - given a treatment and a date, returns a double that represents how many insulins units of this treatment are yet to be consumed (IOB). + /// + /// - parameters: + /// - treatment : the insulin treatment to calculate the IOB of. + /// - date : the moment of time to calculate the insulin yet to be consumed. + /// - returns: a double representing in units the insulin yet to be consumed. + /// + public func insulinYetToBeConsumed(_ treatment: TreatmentEntry, atDate: Date) -> Double { + + /// If the treatmentType is not .Insulin, the IOB is 0. + guard treatment.treatmentType == .Insulin else { + return 0 + } + + /// atDate must not be before treatment.date, or the IOB will also be always 0. + guard atDate >= treatment.date else { + return 0 + } + + /// Calculate how many minutes have elapsed from the treatment date to atDate. + let minutesAgo: Double = (atDate.timeIntervalSince1970 - treatment.date.timeIntervalSince1970) / 60 + + /// InsulinYetToBeConsumed will do the remaining of the calculation. + let insulin = treatment.value + return InsulinYetToBeConsumed(insulin: insulin, minutesAgo: minutesAgo, activityDuration: activityDuration, peakTime: peakTime) + } + + + /// + /// MultipleInsulinYetToBeConsumed - given a list of treatments and a list of dates, returns a list of doubles that represents how many insulins units of these treatments are yet to be consumed at each date (IOB). + /// The first double corresponds to the first date, and so on. + /// + /// - parameters: + /// - treatments : list of treatments to be taken into account. + /// - atDates : list of points in time (Dates) to calculate the IOB. + /// - returns: a list of doubles representing in units the insulin yet to be consumed at each date. + /// + public func multipleInsulinYetToBeConsumed(_ treatments: [TreatmentEntry], atDates: [Date]) -> [Double] { + + /// If treatments is empty, IOB will be 0 for all dates. + guard !treatments.isEmpty else { + return [Double](repeating: 0.0, count: atDates.count) + } + + /// First, we must calculate for each treatment the IOB at each date. + /// Use a list to keep track of it. + /// Each element in this list is in itself a list of doubles, + /// each double element representing the IOB at one point in time. + /// + /// calculatedRemainings[N][I] represents the IOB of the treatment at index N and at date atDates[I]. + var calculatedRemainings: [[Double]] = [] + for treatment in treatments { + /// Calculate the IOB at each date for this treatment. + var newPartials: [Double] = [] + for date in atDates { + let yetToBeConsumed = self.insulinYetToBeConsumed(treatment, atDate: date) + newPartials.append(yetToBeConsumed) + } + /// Append the list of doubles to calculatedRemainings + calculatedRemainings.append(newPartials) + } + + /// Now that we have the calculatedRemainings, we must sum the IOB of all treatments at each date. + /// For this, iterate over the indices of the first sublist (since all sublists have the same length and indices) and sum the remainings of all treatments at each index. + var mergedRemainings: [Double] = [] + if let first = calculatedRemainings.first { + for i in first.indices { + var total: Double = 0.0 + for calculatedRemaining in calculatedRemainings { + total += calculatedRemaining[i] + } + mergedRemainings.append(total) + } + } + + /// mergedRemainings now has the result we want. + return mergedRemainings + } + + + // MARK: - private functions + + + /// + /// DeterminedEvenlySpacedDates - returns N evenly spaced dates between start date and end date. + /// The timeIntervalSince1970 representation of these dates are guaranteed to be a multiple of roundModulus. + /// + /// For example, if roundModulus = 5 * 60 (5 min) + /// and starDate = ...10:44:31 + /// the roundDate to start calculating will be 10:40:00 + /// 10:40:00 will not be the first returned value, but the start point to calculate the evenly spaced dates. + /// + /// - parameters: + /// - startDate : the start point for the sequence (not necessary included). + /// - endDate : the end point for the sequence (not necessary included). + /// - steps : amount of evenly spaced dates to calculate. + /// - roundModulus : used to round the startDate to the previous second multiple of roundModulus. E.g.: (10 * 60) will round to the previous multiple of 10 minutes. + /// - returns: a list of dates evenly spaced and determined from a reproducible roundDate as start point. + /// + private func determinedEvenlySpacedDates(startDate: Date, endDate: Date, steps: Int, roundModulus: Int) -> [Date] { + + var dates: [Date] = [] + + /// Round the startDate by subtracting the modulus of it by roundModulus. + let roundedDate = startDate - Double(Int(startDate.timeIntervalSince1970) % roundModulus) + + /// Calculate the interval size by diving the time diff in seconds by the N of steps. + let intervalSize = (endDate.timeIntervalSince1970 - roundedDate.timeIntervalSince1970) / Double(steps) + + /// Calculate the dates from roundedDate and append to xAxisDates + /// +1 so the line goes up to the graph border, instead of stopping before endDate. + for i in 0.. String? { + return Texts_SettingsView.sectionTitleInsulinOnBoard; + } + + func settingsRowText(index: Int) -> String { + guard let setting = Setting(rawValue: index) else { fatalError("Unexpected Section") } + + switch setting { + + case .toggleDisplay: + return Texts_SettingsView.sectionTitleInsulinOnBoardToggleDisplay + case .showIOBOnChart: + return Texts_SettingsView.sectionTitleInsulinOnBoardShowIOBOnChart + case .insulinActivityDuration: + return Texts_SettingsView.sectionTitleInsulinOnBoardInsulinActivityDuration + case .insulinPeakTime: + return Texts_SettingsView.sectionTitleInsulinOnBoardInsulinPeakTime + + } + } + + func accessoryType(index: Int) -> UITableViewCell.AccessoryType { + guard let setting = Setting(rawValue: index) else { fatalError("Unexpected Section") } + + switch setting { + case .toggleDisplay, .showIOBOnChart: + return UITableViewCell.AccessoryType.none + case .insulinActivityDuration, .insulinPeakTime: + return UITableViewCell.AccessoryType.disclosureIndicator + } + } + + func detailedText(index: Int) -> String? { + guard let setting = Setting(rawValue: index) else { fatalError("Unexpected Section") } + + switch setting { + case .toggleDisplay, .showIOBOnChart: + return nil + case .insulinActivityDuration: + return UserDefaults.standard.insulinOnBoardInsulinActivityDuration.description + case .insulinPeakTime: + return UserDefaults.standard.insulinOnBoardInsulinPeakTime.description + } + } + + func uiView(index: Int) -> UIView? { + guard let setting = Setting(rawValue: index) else { fatalError("Unexpected Section") } + + switch setting { + case .toggleDisplay: + return UISwitch(isOn: UserDefaults.standard.insulinOnBoardEnabledDisplay, action: {(isOn:Bool) in UserDefaults.standard.insulinOnBoardEnabledDisplay = isOn}) + case .showIOBOnChart: + return UISwitch(isOn: UserDefaults.standard.insulinOnBoardShowOnChart, action: {(isOn:Bool) in UserDefaults.standard.insulinOnBoardShowOnChart = isOn}) + case .insulinActivityDuration: + return nil + case .insulinPeakTime: + return nil + } + } + + func numberOfRows() -> Int { + return Setting.allCases.count + } + + func onRowSelect(index: Int) -> SettingsSelectedRowAction { + guard let setting = Setting(rawValue: index) else { fatalError("Unexpected Section") } + + switch setting { + case .toggleDisplay: + return SettingsSelectedRowAction.callFunction(function: { + UserDefaults.standard.insulinOnBoardEnabledDisplay = !UserDefaults.standard.insulinOnBoardEnabledDisplay + }) + case .showIOBOnChart: + return SettingsSelectedRowAction.callFunction(function: { + UserDefaults.standard.insulinOnBoardShowOnChart = !UserDefaults.standard.insulinOnBoardShowOnChart + }) + case .insulinActivityDuration: + return SettingsSelectedRowAction.askText(title: Texts_SettingsView.sectionTitleInsulinOnBoardInsulinActivityDuration, message: Texts_SettingsView.sectionTitleInsulinOnBoardInsulinActivityDurationMessage, keyboardType: .numberPad, text: UserDefaults.standard.insulinOnBoardInsulinActivityDuration.description, placeHolder: "180", actionTitle: nil, cancelTitle: nil, actionHandler: {(threshold:String) in if let threshold = Int(threshold) {UserDefaults.standard.insulinOnBoardInsulinActivityDuration = Int(threshold)}}, cancelHandler: nil, inputValidator: nil) + case .insulinPeakTime: + return SettingsSelectedRowAction.askText(title: Texts_SettingsView.sectionTitleInsulinOnBoardInsulinPeakTime, message: Texts_SettingsView.sectionTitleInsulinOnBoardInsulinPeakTimeMessage, keyboardType: .numberPad, text: UserDefaults.standard.insulinOnBoardInsulinPeakTime.description, placeHolder: "0", actionTitle: nil, cancelTitle: nil, actionHandler: {(threshold:String) in if let threshold = Int(threshold) {UserDefaults.standard.insulinOnBoardInsulinPeakTime = Int(threshold)}}, cancelHandler: nil, inputValidator: nil) + } + } + + func isEnabled(index: Int) -> Bool { + return true + } + + func completeSettingsViewRefreshNeeded(index: Int) -> Bool { + return false + } + + func storeMessageHandler(messageHandler: @escaping ((String, String) -> Void)) { + // this ViewModel does need to send back messages to the viewcontroller asynchronously + } + + func storeUIViewController(uIViewController: UIViewController) { + } + + func storeRowReloadClosure(rowReloadClosure: @escaping ((Int) -> Void)) { + } + +} +