Ivan Valkou 5 лет назад
Родитель
Сommit
60df983dfd
100 измененных файлов с 10858 добавлено и 0 удалено
  1. 163 0
      FreeAPS.xcodeproj/project.pbxproj
  2. 106 0
      FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPS.xcscheme
  3. 8 0
      FreeAPS.xcodeproj/xcuserdata/i.valkou.xcuserdatad/xcschemes/xcschememanagement.plist
  4. 8 0
      LoopKit/.travis.yml
  5. 72 0
      LoopKit/CODE_OF_CONDUCT.md
  6. 21 0
      LoopKit/Common/LocalizedString.swift
  7. 50 0
      LoopKit/Extensions/Collection.swift
  8. 23 0
      LoopKit/Extensions/Comparable.swift
  9. 40 0
      LoopKit/Extensions/HKUnit.swift
  10. 21 0
      LoopKit/Extensions/IdentifiableClass.swift
  11. 17 0
      LoopKit/Extensions/Math.swift
  12. 17 0
      LoopKit/Extensions/MutableCollection.swift
  13. 56 0
      LoopKit/Extensions/NSData.swift
  14. 33 0
      LoopKit/Extensions/NSDateFormatter.swift
  15. 36 0
      LoopKit/Extensions/NSTimeInterval.swift
  16. 21 0
      LoopKit/Extensions/NibLoadable.swift
  17. 47 0
      LoopKit/Extensions/NumberFormatter.swift
  18. 50 0
      LoopKit/Extensions/OSLog.swift
  19. 16 0
      LoopKit/Extensions/TimeZone.swift
  20. 22 0
      LoopKit/Extensions/UIColor.swift
  21. 13 0
      LoopKit/Extensions/UITableViewCell.swift
  22. 17 0
      LoopKit/Extensions/UUID.swift
  23. 22 0
      LoopKit/LICENSE
  24. 43 0
      LoopKit/LoopKit Example/AppDelegate.swift
  25. 98 0
      LoopKit/LoopKit Example/Assets.xcassets/AppIcon.appiconset/Contents.json
  26. 6 0
      LoopKit/LoopKit Example/Assets.xcassets/Contents.json
  27. 31 0
      LoopKit/LoopKit Example/Base.lproj/LaunchScreen.storyboard
  28. 86 0
      LoopKit/LoopKit Example/Base.lproj/Main.storyboard
  29. 12 0
      LoopKit/LoopKit Example/Extensions/CarbEntryTableViewController.swift
  30. 12 0
      LoopKit/LoopKit Example/Extensions/InsulinDeliveryTableViewController.swift
  31. 205 0
      LoopKit/LoopKit Example/Extensions/NSUserDefaults.swift
  32. 62 0
      LoopKit/LoopKit Example/Info.plist
  33. 8 0
      LoopKit/LoopKit Example/LoopKitExample.entitlements
  34. 121 0
      LoopKit/LoopKit Example/Managers/DeviceDataManager.swift
  35. 427 0
      LoopKit/LoopKit Example/MasterViewController.swift
  36. 40 0
      LoopKit/LoopKit Example/da.lproj/Localizable.strings
  37. 40 0
      LoopKit/LoopKit Example/de.lproj/Localizable.strings
  38. 40 0
      LoopKit/LoopKit Example/en.lproj/Localizable.strings
  39. 40 0
      LoopKit/LoopKit Example/es.lproj/Localizable.strings
  40. 40 0
      LoopKit/LoopKit Example/fi.lproj/Localizable.strings
  41. 40 0
      LoopKit/LoopKit Example/fr.lproj/Localizable.strings
  42. 40 0
      LoopKit/LoopKit Example/it.lproj/Localizable.strings
  43. 40 0
      LoopKit/LoopKit Example/ja.lproj/Localizable.strings
  44. 40 0
      LoopKit/LoopKit Example/nb.lproj/Localizable.strings
  45. 40 0
      LoopKit/LoopKit Example/nl.lproj/Localizable.strings
  46. 40 0
      LoopKit/LoopKit Example/pl.lproj/Localizable.strings
  47. 39 0
      LoopKit/LoopKit Example/pt-BR.lproj/Localizable.strings
  48. 40 0
      LoopKit/LoopKit Example/ro.lproj/Localizable.strings
  49. 40 0
      LoopKit/LoopKit Example/ru.lproj/Localizable.strings
  50. 39 0
      LoopKit/LoopKit Example/sv.lproj/Localizable.strings
  51. 40 0
      LoopKit/LoopKit Example/vi.lproj/Localizable.strings
  52. 40 0
      LoopKit/LoopKit Example/zh-Hans.lproj/Localizable.strings
  53. 3608 0
      LoopKit/LoopKit.xcodeproj/project.pbxproj
  54. 7 0
      LoopKit/LoopKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata
  55. 8 0
      LoopKit/LoopKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  56. 8 0
      LoopKit/LoopKit.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
  57. 87 0
      LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/LoopKit Example.xcscheme
  58. 76 0
      LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared-watchOS.xcscheme
  59. 151 0
      LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared.xcscheme
  60. 41 0
      LoopKit/LoopKit/BasalRateSchedule.swift
  61. 98 0
      LoopKit/LoopKit/Base.lproj/Localizable.strings
  62. 90 0
      LoopKit/LoopKit/CGMManager.swift
  63. 80 0
      LoopKit/LoopKit/CarbKit/AbsorbedCarbValue.swift
  64. 71 0
      LoopKit/LoopKit/CarbKit/CachedCarbObject+CoreDataClass.swift
  65. 30 0
      LoopKit/LoopKit/CarbKit/CachedCarbObject+CoreDataProperties.swift
  66. 14 0
      LoopKit/LoopKit/CarbKit/CarbEntry.swift
  67. 903 0
      LoopKit/LoopKit/CarbKit/CarbMath.swift
  68. 130 0
      LoopKit/LoopKit/CarbKit/CarbStatus.swift
  69. 994 0
      LoopKit/LoopKit/CarbKit/CarbStore.swift
  70. 35 0
      LoopKit/LoopKit/CarbKit/CarbStoreError.swift
  71. 22 0
      LoopKit/LoopKit/CarbKit/CarbValue.swift
  72. 29 0
      LoopKit/LoopKit/CarbKit/DeletedCarbEntry.swift
  73. 26 0
      LoopKit/LoopKit/CarbKit/DeletedCarbObject+CoreDataClass.swift
  74. 23 0
      LoopKit/LoopKit/CarbKit/DeletedCarbObject+CoreDataProperties.swift
  75. 30 0
      LoopKit/LoopKit/CarbKit/HKQuantitySample+CarbKit.swift
  76. 40 0
      LoopKit/LoopKit/CarbKit/NSUserDefaults.swift
  77. 103 0
      LoopKit/LoopKit/CarbKit/NewCarbEntry.swift
  78. 152 0
      LoopKit/LoopKit/CarbKit/StoredCarbEntry.swift
  79. 13 0
      LoopKit/LoopKit/CarbRatioSchedule.swift
  80. 15 0
      LoopKit/LoopKit/CarbSensitivitySchedule.swift
  81. 180 0
      LoopKit/LoopKit/DailyQuantitySchedule+Override.swift
  82. 132 0
      LoopKit/LoopKit/DailyQuantitySchedule.swift
  83. 265 0
      LoopKit/LoopKit/DailyValueSchedule.swift
  84. 52 0
      LoopKit/LoopKit/DeviceManager.swift
  85. 36 0
      LoopKit/LoopKit/DoseProgressReporter.swift
  86. 94 0
      LoopKit/LoopKit/DoseProgressTimerEstimator.swift
  87. 16 0
      LoopKit/LoopKit/EGPSchedule.swift
  88. 28 0
      LoopKit/LoopKit/Extensions/Date.swift
  89. 20 0
      LoopKit/LoopKit/Extensions/Double.swift
  90. 19 0
      LoopKit/LoopKit/Extensions/HKHealthStore.swift
  91. 18 0
      LoopKit/LoopKit/Extensions/HKQuantity.swift
  92. 12 0
      LoopKit/LoopKit/Extensions/HKQuantitySample.swift
  93. 40 0
      LoopKit/LoopKit/Extensions/NSUserActivity+CarbKit.swift
  94. 27 0
      LoopKit/LoopKit/GlucoseChange.swift
  95. 28 0
      LoopKit/LoopKit/GlucoseEffect.swift
  96. 42 0
      LoopKit/LoopKit/GlucoseEffectVelocity.swift
  97. 54 0
      LoopKit/LoopKit/GlucoseKit/CachedGlucoseObject+CoreDataClass.swift
  98. 29 0
      LoopKit/LoopKit/GlucoseKit/CachedGlucoseObject+CoreDataProperties.swift
  99. 214 0
      LoopKit/LoopKit/GlucoseKit/GlucoseMath.swift
  100. 0 0
      LoopKit/LoopKit/GlucoseKit/GlucoseSampleValue.swift

+ 163 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -105,6 +105,78 @@
 		E39E418C56A5A46B61D960EE /* ConfigEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D5B4F8B4194BB7E260EF251 /* ConfigEditorViewModel.swift */; };
 /* End PBXBuildFile section */
 
+/* Begin PBXContainerItemProxy section */
+		3821ECE925DC723100BC42AD /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */;
+			proxyType = 2;
+			remoteGlobalIDString = 430157F71C7EC03B00B64B63;
+			remoteInfo = "LoopKit Example";
+		};
+		3821ECEB25DC723100BC42AD /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */;
+			proxyType = 2;
+			remoteGlobalIDString = 43D8FDCB1C728FDF0073BE78;
+			remoteInfo = LoopKit;
+		};
+		3821ECED25DC723100BC42AD /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */;
+			proxyType = 2;
+			remoteGlobalIDString = A9E675F022713F4700E25293;
+			remoteInfo = "LoopKit-watchOS";
+		};
+		3821ECEF25DC723100BC42AD /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */;
+			proxyType = 2;
+			remoteGlobalIDString = 43D8FDD51C728FDF0073BE78;
+			remoteInfo = LoopKitTests;
+		};
+		3821ECF125DC723100BC42AD /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */;
+			proxyType = 2;
+			remoteGlobalIDString = 43BA7154201E484D0058961E;
+			remoteInfo = LoopKitUI;
+		};
+		3821ECF325DC723100BC42AD /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */;
+			proxyType = 2;
+			remoteGlobalIDString = 89D2047221CC7BD7001238CC;
+			remoteInfo = MockKit;
+		};
+		3821ECF525DC723100BC42AD /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */;
+			proxyType = 2;
+			remoteGlobalIDString = 89D2048F21CC7C12001238CC;
+			remoteInfo = MockKitUI;
+		};
+		3821ECF725DC723100BC42AD /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */;
+			proxyType = 2;
+			remoteGlobalIDString = 892A5D34222F03CB008961AB;
+			remoteInfo = LoopTestingKit;
+		};
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+		3821ECD025DC703C00BC42AD /* Embed Frameworks */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 10;
+			files = (
+			);
+			name = "Embed Frameworks";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXCopyFilesBuildPhase section */
+
 /* Begin PBXFileReference section */
 		111579A6E3AC6BFA79C4DD43 /* NightscoutConfigBuilder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigBuilder.swift; sourceTree = "<group>"; };
 		2F2A13DF0EDEEEDC4106AA2A /* NightscoutConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigDataFlow.swift; sourceTree = "<group>"; };
@@ -178,6 +250,7 @@
 		3811DF0725CAAA4700A708ED /* ServiceContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceContainer.swift; sourceTree = "<group>"; };
 		3811DF0B25CAAABD00A708ED /* APSManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APSManager.swift; sourceTree = "<group>"; };
 		3811DF0F25CAAAE200A708ED /* BaseAPSManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAPSManager.swift; sourceTree = "<group>"; };
+		3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = LoopKit.xcodeproj; path = LoopKit/LoopKit.xcodeproj; sourceTree = "<group>"; };
 		383948D525CD4D8900E91849 /* FileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStorage.swift; sourceTree = "<group>"; };
 		383948D925CD64D500E91849 /* Glucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Glucose.swift; sourceTree = "<group>"; };
 		384E803325C385E60086DB71 /* JavaScriptWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavaScriptWorker.swift; sourceTree = "<group>"; };
@@ -552,9 +625,33 @@
 			path = APS;
 			sourceTree = "<group>";
 		};
+		3821ECCD25DC703C00BC42AD /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+		3821ECDF25DC723100BC42AD /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				3821ECEA25DC723100BC42AD /* LoopKit Example.app */,
+				3821ECEC25DC723100BC42AD /* LoopKit.framework */,
+				3821ECEE25DC723100BC42AD /* LoopKit.framework */,
+				3821ECF025DC723100BC42AD /* LoopKitTests.xctest */,
+				3821ECF225DC723100BC42AD /* LoopKitUI.framework */,
+				3821ECF425DC723100BC42AD /* MockKit.framework */,
+				3821ECF625DC723100BC42AD /* MockKitUI.framework */,
+				3821ECF825DC723100BC42AD /* LoopTestingKit.framework */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
 		388E594F25AD948C0019842D = {
 			isa = PBXGroup;
 			children = (
+				3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */,
+				3821ECCD25DC703C00BC42AD /* Frameworks */,
 				388E595A25AD948C0019842D /* FreeAPS */,
 				388E595925AD948C0019842D /* Products */,
 			);
@@ -660,6 +757,7 @@
 				388E595425AD948C0019842D /* Sources */,
 				388E595525AD948C0019842D /* Frameworks */,
 				388E595625AD948C0019842D /* Resources */,
+				3821ECD025DC703C00BC42AD /* Embed Frameworks */,
 			);
 			buildRules = (
 			);
@@ -703,6 +801,12 @@
 			);
 			productRefGroup = 388E595925AD948C0019842D /* Products */;
 			projectDirPath = "";
+			projectReferences = (
+				{
+					ProductGroup = 3821ECDF25DC723100BC42AD /* Products */;
+					ProjectRef = 3821ECDE25DC723100BC42AD /* LoopKit.xcodeproj */;
+				},
+			);
 			projectRoot = "";
 			targets = (
 				388E595725AD948C0019842D /* FreeAPS */,
@@ -710,6 +814,65 @@
 		};
 /* End PBXProject section */
 
+/* Begin PBXReferenceProxy section */
+		3821ECEA25DC723100BC42AD /* LoopKit Example.app */ = {
+			isa = PBXReferenceProxy;
+			fileType = wrapper.application;
+			path = "LoopKit Example.app";
+			remoteRef = 3821ECE925DC723100BC42AD /* PBXContainerItemProxy */;
+			sourceTree = BUILT_PRODUCTS_DIR;
+		};
+		3821ECEC25DC723100BC42AD /* LoopKit.framework */ = {
+			isa = PBXReferenceProxy;
+			fileType = wrapper.framework;
+			path = LoopKit.framework;
+			remoteRef = 3821ECEB25DC723100BC42AD /* PBXContainerItemProxy */;
+			sourceTree = BUILT_PRODUCTS_DIR;
+		};
+		3821ECEE25DC723100BC42AD /* LoopKit.framework */ = {
+			isa = PBXReferenceProxy;
+			fileType = wrapper.framework;
+			path = LoopKit.framework;
+			remoteRef = 3821ECED25DC723100BC42AD /* PBXContainerItemProxy */;
+			sourceTree = BUILT_PRODUCTS_DIR;
+		};
+		3821ECF025DC723100BC42AD /* LoopKitTests.xctest */ = {
+			isa = PBXReferenceProxy;
+			fileType = wrapper.cfbundle;
+			path = LoopKitTests.xctest;
+			remoteRef = 3821ECEF25DC723100BC42AD /* PBXContainerItemProxy */;
+			sourceTree = BUILT_PRODUCTS_DIR;
+		};
+		3821ECF225DC723100BC42AD /* LoopKitUI.framework */ = {
+			isa = PBXReferenceProxy;
+			fileType = wrapper.framework;
+			path = LoopKitUI.framework;
+			remoteRef = 3821ECF125DC723100BC42AD /* PBXContainerItemProxy */;
+			sourceTree = BUILT_PRODUCTS_DIR;
+		};
+		3821ECF425DC723100BC42AD /* MockKit.framework */ = {
+			isa = PBXReferenceProxy;
+			fileType = wrapper.framework;
+			path = MockKit.framework;
+			remoteRef = 3821ECF325DC723100BC42AD /* PBXContainerItemProxy */;
+			sourceTree = BUILT_PRODUCTS_DIR;
+		};
+		3821ECF625DC723100BC42AD /* MockKitUI.framework */ = {
+			isa = PBXReferenceProxy;
+			fileType = wrapper.framework;
+			path = MockKitUI.framework;
+			remoteRef = 3821ECF525DC723100BC42AD /* PBXContainerItemProxy */;
+			sourceTree = BUILT_PRODUCTS_DIR;
+		};
+		3821ECF825DC723100BC42AD /* LoopTestingKit.framework */ = {
+			isa = PBXReferenceProxy;
+			fileType = wrapper.framework;
+			path = LoopTestingKit.framework;
+			remoteRef = 3821ECF725DC723100BC42AD /* PBXContainerItemProxy */;
+			sourceTree = BUILT_PRODUCTS_DIR;
+		};
+/* End PBXReferenceProxy section */
+
 /* Begin PBXResourcesBuildPhase section */
 		388E595625AD948C0019842D /* Resources */ = {
 			isa = PBXResourcesBuildPhase;

+ 106 - 0
FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPS.xcscheme

@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1240"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "388E595725AD948C0019842D"
+               BuildableName = "FreeAPS.app"
+               BlueprintName = "FreeAPS"
+               ReferencedContainer = "container:FreeAPS.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "43D8FDCA1C728FDF0073BE78"
+               BuildableName = "LoopKit.framework"
+               BlueprintName = "LoopKit"
+               ReferencedContainer = "container:LoopKit/LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "43BA7153201E484D0058961E"
+               BuildableName = "LoopKitUI.framework"
+               BlueprintName = "LoopKitUI"
+               ReferencedContainer = "container:LoopKit/LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "388E595725AD948C0019842D"
+            BuildableName = "FreeAPS.app"
+            BlueprintName = "FreeAPS"
+            ReferencedContainer = "container:FreeAPS.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "388E595725AD948C0019842D"
+            BuildableName = "FreeAPS.app"
+            BlueprintName = "FreeAPS"
+            ReferencedContainer = "container:FreeAPS.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 8 - 0
FreeAPS.xcodeproj/xcuserdata/i.valkou.xcuserdatad/xcschemes/xcschememanagement.plist

@@ -94,5 +94,13 @@
 			<integer>0</integer>
 		</dict>
 	</dict>
+	<key>SuppressBuildableAutocreation</key>
+	<dict>
+		<key>388E595725AD948C0019842D</key>
+		<dict>
+			<key>primary</key>
+			<true/>
+		</dict>
+	</dict>
 </dict>
 </plist>

+ 8 - 0
LoopKit/.travis.yml

@@ -0,0 +1,8 @@
+language: objective-c
+osx_image: xcode11
+xcode_scheme:
+    - LoopKit
+    - LoopKitUI
+script:
+   - set -o pipefail && xcodebuild -project LoopKit.xcodeproj -scheme Shared build -destination 'name=iPhone 8' test | xcpretty
+   - set -o pipefail && xcodebuild -project LoopKit.xcodeproj -scheme "LoopKit Example" build -destination 'name=iPhone 8' CODE_SIGNING_ALLOWED=NO | xcpretty

+ 72 - 0
LoopKit/CODE_OF_CONDUCT.md

@@ -0,0 +1,72 @@
+# Code of Conduct
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, gender identity and expression, level of experience,
+nationality, personal appearance, race, religion, or sexual identity and
+orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+  address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the maintaner [via email](mailto:loudnate@gmail.com). All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at [http://contributor-covenant.org/version/1/4][version]
+
+[homepage]: http://contributor-covenant.org
+[version]: http://contributor-covenant.org/version/1/4/

+ 21 - 0
LoopKit/Common/LocalizedString.swift

@@ -0,0 +1,21 @@
+//
+//  LocalizedString.swift
+//  LoopKit
+//
+//  Created by Retina15 on 8/6/18.
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+private class FrameworkBundle {
+    static let main = Bundle(for: FrameworkBundle.self)
+}
+
+func LocalizedString(_ key: String, tableName: String? = nil, value: String? = nil, comment: String) -> String {
+    if let value = value {
+        return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, value: value, comment: comment)
+    } else {
+        return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, comment: comment)
+    }
+}

+ 50 - 0
LoopKit/Extensions/Collection.swift

@@ -0,0 +1,50 @@
+//
+//  Collection.swift
+//  LoopKit
+//
+//  Created by Michael Pangburn on 2/14/19.
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+/// Returns the cartesian product of a sequence and a collection.
+///
+/// O(1), but O(_n_*_m_) on iteration.
+/// - Note: Don't mind the scary return type; it's just a lazy sequence.
+func product<S: Sequence, C: Collection>(_ s: S, _ c: C) -> LazySequence<FlattenSequence<LazyMapSequence<S, LazyMapSequence<C, (S.Element, C.Element)>>>> {
+    return s.lazy.flatMap { first in
+        c.lazy.map { second in
+            (first, second)
+        }
+    }
+}
+
+extension Collection {
+    /// Returns a sequence containing adjacent pairs of elements in the ordered collection.
+    func adjacentPairs() -> Zip2Sequence<Self, SubSequence> {
+        return zip(self, dropFirst())
+    }
+}
+
+extension RandomAccessCollection {
+    /// Returns all unique pair combinations of elements in the collection.
+    ///
+    /// O(1), but O(*n*²) on iteration.
+    /// - Note: Don't mind the scary return type; it's just a lazy sequence.
+    func allPairs() -> LazyMapSequence<LazyFilterSequence<FlattenSequence<LazyMapSequence<Indices, LazyMapSequence<Indices, (Index, Index)>>>>, (Element, Element)> {
+        return product(indices, indices).filter(<).map {
+            (self[$0], self[$1])
+        }
+    }
+}
+
+extension RangeReplaceableCollection where Index: Hashable {
+    /// Removes the elements at all of the given indices.
+    ///
+    /// O(_n_*_m_)
+    mutating func removeAll<S: Sequence>(at indices: S) where S.Element == Index {
+        let arranged = Set(indices).sorted(by: >)
+        for index in arranged {
+            remove(at: index)
+        }
+    }
+}

+ 23 - 0
LoopKit/Extensions/Comparable.swift

@@ -0,0 +1,23 @@
+//
+//  Comparable.swift
+//  LoopKit
+//
+//  Created by Michael Pangburn on 11/20/18.
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+extension Comparable {
+    func clamped(to range: ClosedRange<Self>) -> Self {
+        if self < range.lowerBound {
+            return range.lowerBound
+        } else if self > range.upperBound {
+            return range.upperBound
+        } else {
+            return self
+        }
+    }
+
+    mutating func clamp(to range: ClosedRange<Self>) {
+        self = clamped(to: range)
+    }
+}

+ 40 - 0
LoopKit/Extensions/HKUnit.swift

@@ -0,0 +1,40 @@
+//
+//  HKUnit.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 1/17/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import HealthKit
+
+
+extension HKUnit {
+    static let milligramsPerDeciliter: HKUnit = {
+        return HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci))
+    }()
+
+    static let millimolesPerLiter: HKUnit = {
+        return HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter())
+    }()
+
+    static let internationalUnitsPerHour: HKUnit = {
+        return HKUnit.internationalUnit().unitDivided(by: .hour())
+    }()
+
+    var foundationUnit: Unit? {
+        if self == HKUnit.milligramsPerDeciliter {
+            return UnitConcentrationMass.milligramsPerDeciliter
+        }
+
+        if self == HKUnit.millimolesPerLiter {
+            return UnitConcentrationMass.millimolesPerLiter(withGramsPerMole: HKUnitMolarMassBloodGlucose)
+        }
+
+        if self == HKUnit.gram() {
+            return UnitMass.grams
+        }
+
+        return nil
+    }
+}

+ 21 - 0
LoopKit/Extensions/IdentifiableClass.swift

@@ -0,0 +1,21 @@
+//
+//  IdentifiableClass.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/9/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+
+
+protocol IdentifiableClass: class {
+    static var className: String { get }
+}
+
+
+extension IdentifiableClass {
+    static var className: String {
+        return NSStringFromClass(self).components(separatedBy: ".").last!
+    }
+}

+ 17 - 0
LoopKit/Extensions/Math.swift

@@ -0,0 +1,17 @@
+//
+//  Math.swift
+//  LoopKitUI
+//
+//  Created by Michael Pangburn on 3/23/19.
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+
+func fractionThrough<T: FloatingPoint>(
+    _ value: T,
+    in range: ClosedRange<T>,
+    using transform: (T) -> T = { $0 }
+) -> T {
+    let transformedLowerBound = transform(range.lowerBound)
+    return (transform(value) - transformedLowerBound) / (transform(range.upperBound) - transformedLowerBound)
+}

+ 17 - 0
LoopKit/Extensions/MutableCollection.swift

@@ -0,0 +1,17 @@
+//
+//  MutableCollection.swift
+//  LoopKit Example
+//
+//  Created by Michael Pangburn on 4/21/19.
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+extension MutableCollection {
+    mutating func mutateEach(_ body: (inout Element) throws -> Void) rethrows {
+        var index = startIndex
+        while index != endIndex {
+            try body(&self[index])
+            formIndex(after: &index)
+        }
+    }
+}

+ 56 - 0
LoopKit/Extensions/NSData.swift

@@ -0,0 +1,56 @@
+//
+//  NSData.swift
+//  LoopKit
+//
+//  Created by Nate Racklyeft on 8/26/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+
+
+// String conversion methods, adapted from https://stackoverflow.com/questions/40276322/hex-binary-string-conversion-in-swift/40278391#40278391
+extension Data {
+    init?(hexadecimalString: String) {
+        self.init(capacity: hexadecimalString.utf16.count / 2)
+
+        // Convert 0 ... 9, a ... f, A ...F to their decimal value,
+        // return nil for all other input characters
+        func decodeNibble(u: UInt16) -> UInt8? {
+            switch u {
+            case 0x30 ... 0x39:  // '0'-'9'
+                return UInt8(u - 0x30)
+            case 0x41 ... 0x46:  // 'A'-'F'
+                return UInt8(u - 0x41 + 10)  // 10 since 'A' is 10, not 0
+            case 0x61 ... 0x66:  // 'a'-'f'
+                return UInt8(u - 0x61 + 10)  // 10 since 'a' is 10, not 0
+            default:
+                return nil
+            }
+        }
+
+        var even = true
+        var byte: UInt8 = 0
+        for c in hexadecimalString.utf16 {
+            guard let val = decodeNibble(u: c) else { return nil }
+            if even {
+                byte = val << 4
+            } else {
+                byte += val
+                self.append(byte)
+            }
+            even = !even
+        }
+        guard even else { return nil }
+    }
+
+    var hexadecimalString: String {
+        return map { String(format: "%02hhx", $0) }.joined()
+    }
+}
+
+extension Data {
+    static func newPumpEventIdentifier() -> Data {
+        return Data(UUID().uuidString.utf8)
+    }
+}

+ 33 - 0
LoopKit/Extensions/NSDateFormatter.swift

@@ -0,0 +1,33 @@
+//
+//  NSDateFormatter.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 11/25/15.
+//  Copyright © 2015 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+
+
+// MARK: - Extensions useful in parsing fixture dates
+extension ISO8601DateFormatter {
+    static func localTimeDate(timeZone: TimeZone = .currentFixed) -> Self {
+        let formatter = self.init()
+
+        formatter.formatOptions = .withInternetDateTime
+        formatter.formatOptions.subtract(.withTimeZone)
+        formatter.timeZone = timeZone
+
+        return formatter
+    }
+}
+
+
+extension DateFormatter {
+    static var descriptionFormatter: DateFormatter {
+        let formatter = self.init()
+        formatter.dateFormat = "yyyy-MM-dd HH:mm:ssZZZZZ"
+
+        return formatter
+    }
+}

+ 36 - 0
LoopKit/Extensions/NSTimeInterval.swift

@@ -0,0 +1,36 @@
+//
+//  NSTimeInterval.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 1/9/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+
+
+extension TimeInterval {
+    static func minutes(_ minutes: Double) -> TimeInterval {
+        return self.init(minutes: minutes)
+    }
+
+    static func hours(_ hours: Double) -> TimeInterval {
+        return self.init(hours: hours)
+    }
+
+    init(minutes: Double) {
+        self.init(minutes * 60)
+    }
+
+    init(hours: Double) {
+        self.init(minutes: hours * 60)
+    }
+
+    var minutes: Double {
+        return self / 60.0
+    }
+
+    var hours: Double {
+        return minutes / 60.0
+    }
+}

+ 21 - 0
LoopKit/Extensions/NibLoadable.swift

@@ -0,0 +1,21 @@
+//
+//  NibLoadable.swift
+//  LoopKit
+//
+//  Created by Nate Racklyeft on 7/2/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import UIKit
+
+
+protocol NibLoadable: IdentifiableClass {
+    static func nib() -> UINib
+}
+
+
+extension NibLoadable {
+    static func nib() -> UINib {
+        return UINib(nibName: className, bundle: Bundle(for: self))
+    }
+}

+ 47 - 0
LoopKit/Extensions/NumberFormatter.swift

@@ -0,0 +1,47 @@
+//
+//  NSNumberFormatter.swift
+//  Loop
+//
+//  Created by Nate Racklyeft on 9/5/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+extension NumberFormatter {
+    func string(from number: Double) -> String? {
+        return string(from: NSNumber(value: number))
+    }
+
+    func string(from number: Double, unit: String, style: Formatter.UnitStyle = .medium) -> String? {
+        guard let stringValue = string(from: number) else {
+            return nil
+        }
+
+        let format: String
+        switch style {
+        case .long, .medium:
+            format = LocalizedString(
+                "quantity-and-unit-space",
+                value: "%1$@ %2$@",
+                comment: "Format string for combining localized numeric value and unit with a space. (1: numeric value)(2: unit)"
+            )
+        case .short:
+            fallthrough
+        @unknown default:
+            format = LocalizedString(
+                "quantity-and-unit-tight",
+                value: "%1$@%2$@",
+                comment: "Format string for combining localized numeric value and unit without spacing. (1: numeric value)(2: unit)"
+            )
+        }
+
+        return String(
+            format: format,
+            stringValue,
+            unit
+        )
+    }
+}

+ 50 - 0
LoopKit/Extensions/OSLog.swift

@@ -0,0 +1,50 @@
+//
+//  OSLog.swift
+//  Loop
+//
+//  Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import os.log
+
+
+extension OSLog {
+    convenience init(category: String) {
+        self.init(subsystem: "com.loopkit.LoopKit", category: category)
+    }
+
+    func debug(_ message: StaticString, _ args: CVarArg...) {
+        log(message, type: .debug, args)
+    }
+
+    func info(_ message: StaticString, _ args: CVarArg...) {
+        log(message, type: .info, args)
+    }
+
+    func `default`(_ message: StaticString, _ args: CVarArg...) {
+        log(message, type: .default, args)
+    }
+
+    func error(_ message: StaticString, _ args: CVarArg...) {
+        log(message, type: .error, args)
+    }
+
+    private func log(_ message: StaticString, type: OSLogType, _ args: [CVarArg]) {
+        switch args.count {
+        case 0:
+            os_log(message, log: self, type: type)
+        case 1:
+            os_log(message, log: self, type: type, args[0])
+        case 2:
+            os_log(message, log: self, type: type, args[0], args[1])
+        case 3:
+            os_log(message, log: self, type: type, args[0], args[1], args[2])
+        case 4:
+            os_log(message, log: self, type: type, args[0], args[1], args[2], args[3])
+        case 5:
+            os_log(message, log: self, type: type, args[0], args[1], args[2], args[3], args[4])
+        default:
+            os_log(message, log: self, type: type, args)
+        }
+    }
+}

+ 16 - 0
LoopKit/Extensions/TimeZone.swift

@@ -0,0 +1,16 @@
+//
+//  TimeZone.swift
+//  LoopKit
+//
+//  Created by Nate Racklyeft on 10/2/16.
+//  Copyright © 2016 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+extension TimeZone {
+    static var currentFixed: TimeZone {
+        return TimeZone(secondsFromGMT: TimeZone.current.secondsFromGMT())!
+    }
+}

+ 22 - 0
LoopKit/Extensions/UIColor.swift

@@ -0,0 +1,22 @@
+//
+//  UIColor.swift
+//  LoopKitUI
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import UIKit
+
+
+extension UIColor {
+    static let delete = UIColor.higRed()
+}
+
+
+// MARK: - HIG colors
+// See: https://developer.apple.com/ios/human-interface-guidelines/visual-design/color/
+extension UIColor {
+    private static func higRed() -> UIColor {
+        return UIColor(red: 1, green: 59 / 255, blue: 48 / 255, alpha: 1)
+    }
+}

+ 13 - 0
LoopKit/Extensions/UITableViewCell.swift

@@ -0,0 +1,13 @@
+//
+//  UITableViewCell.swift
+//  CarbKit
+//
+//  Created by Nathan Racklyeft on 1/15/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import UIKit
+
+
+extension UITableViewCell: IdentifiableClass, NibLoadable {
+}

+ 17 - 0
LoopKit/Extensions/UUID.swift

@@ -0,0 +1,17 @@
+//
+//  UUID.swift
+//  LoopKitTests
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+extension UUID {
+    var data: Data {
+        return withUnsafePointer(to: uuid) {
+            return Data(bytes: $0, count: MemoryLayout.size(ofValue: uuid))
+        }
+    }
+}

+ 22 - 0
LoopKit/LICENSE

@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Nathan Racklyeft
+Copyright (c) 2016 LoopKit Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 43 - 0
LoopKit/LoopKit Example/AppDelegate.swift

@@ -0,0 +1,43 @@
+//
+//  AppDelegate.swift
+//  LoopKit Example
+//
+//  Created by Nathan Racklyeft on 2/24/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import UIKit
+
+@UIApplicationMain
+class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate {
+
+    var window: UIWindow?
+
+    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+        return true
+    }
+
+    func applicationWillResignActive(_ application: UIApplication) {
+        // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
+        // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game.
+    }
+
+    func applicationDidEnterBackground(_ application: UIApplication) {
+        // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
+        // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
+    }
+
+    func applicationWillEnterForeground(_ application: UIApplication) {
+        // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background.
+    }
+
+    func applicationDidBecomeActive(_ application: UIApplication) {
+        // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
+    }
+
+    func applicationWillTerminate(_ application: UIApplication) {
+        // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
+    }
+
+}
+

+ 98 - 0
LoopKit/LoopKit Example/Assets.xcassets/AppIcon.appiconset/Contents.json

@@ -0,0 +1,98 @@
+{
+  "images" : [
+    {
+      "idiom" : "iphone",
+      "size" : "20x20",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "20x20",
+      "scale" : "3x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "29x29",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "29x29",
+      "scale" : "3x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "40x40",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "40x40",
+      "scale" : "3x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "60x60",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "60x60",
+      "scale" : "3x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "20x20",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "20x20",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "29x29",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "29x29",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "40x40",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "40x40",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "76x76",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "76x76",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "83.5x83.5",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "ios-marketing",
+      "size" : "1024x1024",
+      "scale" : "1x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}

+ 6 - 0
LoopKit/LoopKit Example/Assets.xcassets/Contents.json

@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}

+ 31 - 0
LoopKit/LoopKit Example/Base.lproj/LaunchScreen.storyboard

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13771" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
+    <device id="retina4_7" orientation="portrait">
+        <adaptation id="fullscreen"/>
+    </device>
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13772"/>
+        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+    </dependencies>
+    <scenes>
+        <!--View Controller-->
+        <scene sceneID="EHf-IW-A2E">
+            <objects>
+                <viewController id="01J-lp-oVM" sceneMemberID="viewController">
+                    <layoutGuides>
+                        <viewControllerLayoutGuide type="top" id="Llm-lL-Icb"/>
+                        <viewControllerLayoutGuide type="bottom" id="xb3-aO-Qok"/>
+                    </layoutGuides>
+                    <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
+                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="53" y="375"/>
+        </scene>
+    </scenes>
+</document>

+ 86 - 0
LoopKit/LoopKit Example/Base.lproj/Main.storyboard

@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14810.11" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="RMx-3f-FxP">
+    <device id="retina4_7" orientation="portrait" appearance="dark"/>
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14766.13"/>
+        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+    </dependencies>
+    <scenes>
+        <!--UI Tests-->
+        <scene sceneID="pY4-Hu-kfo">
+            <objects>
+                <navigationController title="UI Tests" id="RMx-3f-FxP" sceneMemberID="viewController">
+                    <navigationBar key="navigationBar" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" id="Pmd-2v-anx">
+                        <rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
+                        <autoresizingMask key="autoresizingMask"/>
+                    </navigationBar>
+                    <connections>
+                        <segue destination="7bK-jq-Zjz" kind="relationship" relationship="rootViewController" id="tsl-Nk-0bq"/>
+                    </connections>
+                </navigationController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="8fS-aE-onr" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="-38" y="-630"/>
+        </scene>
+        <!--UI Tests-->
+        <scene sceneID="smW-Zh-WAh">
+            <objects>
+                <tableViewController title="UI Tests" clearsSelectionOnViewWillAppear="NO" id="7bK-jq-Zjz" customClass="MasterViewController" customModule="LoopKit_Example" customModuleProvider="target" sceneMemberID="viewController">
+                    <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="44" sectionHeaderHeight="22" sectionFooterHeight="22" id="r7i-6Z-zg0">
+                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
+                        <prototypes>
+                            <tableViewCell contentMode="scaleToFill" selectionStyle="blue" hidesAccessoryWhenEditing="NO" indentationLevel="1" indentationWidth="0.0" reuseIdentifier="Cell" textLabel="Arm-wq-HPj" style="IBUITableViewCellStyleDefault" id="WCw-Qf-5nD">
+                                <rect key="frame" x="0.0" y="28" width="375" height="44"/>
+                                <autoresizingMask key="autoresizingMask"/>
+                                <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="WCw-Qf-5nD" id="37f-cq-3Eg">
+                                    <rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
+                                    <autoresizingMask key="autoresizingMask"/>
+                                    <subviews>
+                                        <label opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" text="Title" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Arm-wq-HPj">
+                                            <rect key="frame" x="16" y="0.0" width="343" height="44"/>
+                                            <autoresizingMask key="autoresizingMask"/>
+                                            <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
+                                            <nil key="textColor"/>
+                                            <color key="highlightedColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
+                                        </label>
+                                    </subviews>
+                                </tableViewCellContentView>
+                            </tableViewCell>
+                        </prototypes>
+                        <sections/>
+                        <connections>
+                            <outlet property="dataSource" destination="7bK-jq-Zjz" id="Gho-Na-rnu"/>
+                            <outlet property="delegate" destination="7bK-jq-Zjz" id="RA6-mI-bju"/>
+                        </connections>
+                    </tableView>
+                    <navigationItem key="navigationItem" id="Zdf-7t-Un8"/>
+                    <connections>
+                        <segue destination="P3Z-xa-jYH" kind="show" identifier="InsulinDeliveryTableViewController" id="tq6-Gh-gXl"/>
+                        <segue destination="4EV-cN-zR6" kind="show" identifier="CarbEntryTableViewController" id="yH4-bu-swj"/>
+                    </connections>
+                </tableViewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="Rux-fX-hf1" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="709" y="-630"/>
+        </scene>
+        <!--CarbKit-->
+        <scene sceneID="RUH-Hm-Veg">
+            <objects>
+                <viewControllerPlaceholder storyboardName="CarbKit" bundleIdentifier="com.loopkit.LoopKitUI" id="4EV-cN-zR6" sceneMemberID="viewController"/>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="5oW-ge-vQQ" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="1239" y="-888"/>
+        </scene>
+        <!--InsulinKit-->
+        <scene sceneID="gKw-ni-nQG">
+            <objects>
+                <viewControllerPlaceholder storyboardName="InsulinKit" bundleIdentifier="com.loopkit.LoopKitUI" id="P3Z-xa-jYH" sceneMemberID="viewController"/>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="OWP-Ht-6Nv" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="1245.5" y="-801"/>
+        </scene>
+    </scenes>
+</document>

+ 12 - 0
LoopKit/LoopKit Example/Extensions/CarbEntryTableViewController.swift

@@ -0,0 +1,12 @@
+//
+//  CarbEntryTableViewController.swift
+//  LoopKit
+//
+//  Created by Nate Racklyeft on 7/13/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import LoopKitUI
+
+
+extension CarbEntryTableViewController: IdentifiableClass { }

+ 12 - 0
LoopKit/LoopKit Example/Extensions/InsulinDeliveryTableViewController.swift

@@ -0,0 +1,12 @@
+//
+//  InsulinDeliveryTableViewController.swift
+//  LoopKit
+//
+//  Created by Nate Racklyeft on 7/13/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import LoopKitUI
+
+
+extension InsulinDeliveryTableViewController: IdentifiableClass { }

+ 205 - 0
LoopKit/LoopKit Example/Extensions/NSUserDefaults.swift

@@ -0,0 +1,205 @@
+//
+//  NSUserDefaults.swift
+//  LoopKit
+//
+//  Created by Nathan Racklyeft on 3/18/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import LoopKit
+
+
+extension UserDefaults {
+    private enum Key: String {
+        case BasalRateSchedule = "com.LoopKitExample.BasalRateSchedule"
+        case CarbRatioSchedule = "com.LoopKitExample.CarbRatioSchedule"
+        case InsulinActionDuration = "com.LoopKitExample.InsulinActionDuration"
+        case InsulinSensitivitySchedule = "com.LoopKitExample.InsulinSensitivitySchedule"
+        case GlucoseTargetRangeSchedule = "com.LoopKitExample.GlucoseTargetRangeSchedule"
+        case PreMealTargetRange = "com.LoopKitExample.PreMealTargetRange"
+        case LegacyWorkoutTargetRange = "com.LoopKitExample.LegacyWorkoutTargetRange"
+        case MaximumBasalRatePerHour = "com.LoopKitExample.MaximumBasalRatePerHour"
+        case MaximumBolus = "com.LoopKitExample.MaximumBolus"
+        case PumpID = "com.LoopKitExample.PumpID"
+        case PumpTimeZone = "com.LoopKitExample.PumpTimeZone"
+        case TransmitterID = "com.LoopKitExample.TransmitterID"
+        case TransmitterStartTime = "com.LoopKitExample.TransmitterStartTime"
+    }
+
+    var basalRateSchedule: BasalRateSchedule? {
+        get {
+            if let rawValue = dictionary(forKey: Key.BasalRateSchedule.rawValue) {
+                return BasalRateSchedule(rawValue: rawValue)
+            } else {
+                return nil
+            }
+        }
+        set {
+            set(newValue?.rawValue, forKey: Key.BasalRateSchedule.rawValue)
+        }
+    }
+
+    var carbRatioSchedule: CarbRatioSchedule? {
+        get {
+            if let rawValue = dictionary(forKey: Key.CarbRatioSchedule.rawValue) {
+                return CarbRatioSchedule(rawValue: rawValue)
+            } else {
+                return nil
+            }
+        }
+        set {
+            set(newValue?.rawValue, forKey: Key.CarbRatioSchedule.rawValue)
+        }
+    }
+
+    var insulinActionDuration: TimeInterval? {
+        get {
+            let value = double(forKey: Key.InsulinActionDuration.rawValue)
+
+            return value > 0 ? value : TimeInterval(hours: 4)
+        }
+        set {
+            if let insulinActionDuration = newValue {
+                set(insulinActionDuration, forKey: Key.InsulinActionDuration.rawValue)
+            } else {
+                removeObject(forKey: Key.InsulinActionDuration.rawValue)
+            }
+        }
+    }
+
+    var insulinSensitivitySchedule: InsulinSensitivitySchedule? {
+        get {
+            if let rawValue = dictionary(forKey: Key.InsulinSensitivitySchedule.rawValue) {
+                return InsulinSensitivitySchedule(rawValue: rawValue)
+            } else {
+                return nil
+            }
+        }
+        set {
+            set(newValue?.rawValue, forKey: Key.InsulinSensitivitySchedule.rawValue)
+        }
+    }
+
+    var glucoseTargetRangeSchedule: GlucoseRangeSchedule? {
+        get {
+            if let rawValue = dictionary(forKey: Key.GlucoseTargetRangeSchedule.rawValue) {
+                return GlucoseRangeSchedule(rawValue: rawValue)
+            } else {
+                return nil
+            }
+        }
+        set {
+            set(newValue?.rawValue, forKey: Key.GlucoseTargetRangeSchedule.rawValue)
+        }
+    }
+
+    var preMealTargetRange: DoubleRange? {
+        get {
+            if let rawValue = array(forKey: Key.PreMealTargetRange.rawValue) as? DoubleRange.RawValue {
+                return DoubleRange(rawValue: rawValue)
+            } else {
+                return nil
+            }
+        }
+
+        set {
+            set(newValue?.rawValue, forKey: Key.PreMealTargetRange.rawValue)
+        }
+    }
+
+
+    var legacyWorkoutTargetRange: DoubleRange? {
+        get {
+            if let rawValue = array(forKey: Key.LegacyWorkoutTargetRange.rawValue) as? DoubleRange.RawValue {
+                return DoubleRange(rawValue: rawValue)
+            } else {
+                return nil
+            }
+        }
+
+        set {
+            set(newValue?.rawValue, forKey: Key.LegacyWorkoutTargetRange.rawValue)
+        }
+    }
+
+    var maximumBasalRatePerHour: Double? {
+        get {
+            let value = double(forKey: Key.MaximumBasalRatePerHour.rawValue)
+
+            return value > 0 ? value : nil
+        }
+        set {
+            if let maximumBasalRatePerHour = newValue {
+                set(maximumBasalRatePerHour, forKey: Key.MaximumBasalRatePerHour.rawValue)
+            } else {
+                removeObject(forKey: Key.MaximumBasalRatePerHour.rawValue)
+            }
+        }
+    }
+
+    var maximumBolus: Double? {
+        get {
+            let value = double(forKey: Key.MaximumBolus.rawValue)
+
+            return value > 0 ? value : nil
+        }
+        set {
+            if let maximumBolus = newValue {
+                set(maximumBolus, forKey: Key.MaximumBolus.rawValue)
+            } else {
+                removeObject(forKey: Key.MaximumBolus.rawValue)
+            }
+        }
+    }
+
+    var pumpID: String? {
+        get {
+            return string(forKey: Key.PumpID.rawValue) ?? "123456"
+        }
+        set {
+            set(newValue, forKey: Key.PumpID.rawValue)
+        }
+    }
+
+    var pumpTimeZone: TimeZone? {
+        get {
+            if let offset = object(forKey: Key.PumpTimeZone.rawValue) as? NSNumber {
+                return TimeZone(secondsFromGMT: offset.intValue)
+            } else {
+                return nil
+            }
+        } set {
+            if let value = newValue {
+                set(NSNumber(value: value.secondsFromGMT()), forKey: Key.PumpTimeZone.rawValue)
+            } else {
+                removeObject(forKey: Key.PumpTimeZone.rawValue)
+            }
+        }
+    }
+
+    var transmitterStartTime: TimeInterval? {
+        get {
+            let value = double(forKey: Key.TransmitterStartTime.rawValue)
+
+            return value > 0 ? value : nil
+        }
+        set {
+            if let value = newValue {
+                set(value, forKey: Key.TransmitterStartTime.rawValue)
+            } else {
+                removeObject(forKey: Key.TransmitterStartTime.rawValue)
+            }
+        }
+    }
+
+    var transmitterID: String? {
+        get {
+            return string(forKey: Key.TransmitterID.rawValue)
+        }
+        set {
+            set(newValue, forKey: Key.TransmitterID.rawValue)
+        }
+    }
+
+}

+ 62 - 0
LoopKit/LoopKit Example/Info.plist

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>en</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>APPL</string>
+	<key>CFBundleShortVersionString</key>
+	<string>3.0</string>
+	<key>CFBundleSignature</key>
+	<string>????</string>
+	<key>CFBundleVersion</key>
+	<string>2</string>
+	<key>LSRequiresIPhoneOS</key>
+	<true/>
+	<key>NSHealthShareUsageDescription</key>
+	<string>Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation.</string>
+	<key>NSHealthUpdateUsageDescription</key>
+	<string>Carbohydrate meal data entered in the app is stored in the Health database. Glucose data is stored securely in HealthKit.</string>
+	<key>UILaunchStoryboardName</key>
+	<string>LaunchScreen</string>
+	<key>UIMainStoryboardFile</key>
+	<string>Main</string>
+	<key>UIRequiredDeviceCapabilities</key>
+	<array>
+		<string>armv7</string>
+		<string>healthkit</string>
+	</array>
+	<key>UIStatusBarTintParameters</key>
+	<dict>
+		<key>UINavigationBar</key>
+		<dict>
+			<key>Style</key>
+			<string>UIBarStyleDefault</string>
+			<key>Translucent</key>
+			<false/>
+		</dict>
+	</dict>
+	<key>UISupportedInterfaceOrientations</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+	<key>UISupportedInterfaceOrientations~ipad</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationPortraitUpsideDown</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+</dict>
+</plist>

+ 8 - 0
LoopKit/LoopKit Example/LoopKitExample.entitlements

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>com.apple.developer.healthkit</key>
+	<true/>
+</dict>
+</plist>

+ 121 - 0
LoopKit/LoopKit Example/Managers/DeviceDataManager.swift

@@ -0,0 +1,121 @@
+//
+//  DeviceDataManager.swift
+//  LoopKit
+//
+//  Created by Nathan Racklyeft on 3/18/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+import LoopKit
+
+
+class DeviceDataManager : CarbStoreDelegate {
+
+    init() {
+        let healthStore = HKHealthStore()
+        let cacheStore = PersistenceController(directoryURL: FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!)
+
+        carbStore = CarbStore(
+            healthStore: healthStore,
+            cacheStore: cacheStore,
+            carbRatioSchedule: carbRatioSchedule,
+            insulinSensitivitySchedule: insulinSensitivitySchedule
+        )
+        let insulinModel: WalshInsulinModel?
+        if let actionDuration = insulinActionDuration {
+            insulinModel = WalshInsulinModel(actionDuration: actionDuration)
+        } else {
+            insulinModel = nil
+        }
+        doseStore = DoseStore(
+            healthStore: healthStore,
+            cacheStore: cacheStore,
+            insulinModel: insulinModel,
+            basalProfile: basalRateSchedule,
+            insulinSensitivitySchedule: insulinSensitivitySchedule
+        )
+        glucoseStore = GlucoseStore(healthStore: healthStore, cacheStore: cacheStore)
+        carbStore?.delegate = self
+    }
+
+    // Data stores
+
+    let carbStore: CarbStore!
+
+    let doseStore: DoseStore
+
+    let glucoseStore: GlucoseStore!
+
+    // Settings
+
+    var basalRateSchedule = UserDefaults.standard.basalRateSchedule {
+        didSet {
+            UserDefaults.standard.basalRateSchedule = basalRateSchedule
+
+            doseStore.basalProfile = basalRateSchedule
+        }
+    }
+
+    var carbRatioSchedule = UserDefaults.standard.carbRatioSchedule {
+        didSet {
+            UserDefaults.standard.carbRatioSchedule = carbRatioSchedule
+
+            carbStore?.carbRatioSchedule = carbRatioSchedule
+        }
+    }
+
+    var insulinActionDuration = UserDefaults.standard.insulinActionDuration {
+        didSet {
+            UserDefaults.standard.insulinActionDuration = insulinActionDuration
+
+            if let duration = insulinActionDuration {
+                doseStore.insulinModel = WalshInsulinModel(actionDuration: duration)
+            }
+        }
+    }
+
+    var insulinSensitivitySchedule = UserDefaults.standard.insulinSensitivitySchedule {
+        didSet {
+            UserDefaults.standard.insulinSensitivitySchedule = insulinSensitivitySchedule
+
+            carbStore?.insulinSensitivitySchedule = insulinSensitivitySchedule
+            doseStore.insulinSensitivitySchedule = insulinSensitivitySchedule
+        }
+    }
+
+    var glucoseTargetRangeSchedule = UserDefaults.standard.glucoseTargetRangeSchedule {
+        didSet {
+            UserDefaults.standard.glucoseTargetRangeSchedule = glucoseTargetRangeSchedule
+        }
+    }
+
+    public var preMealTargetRange: DoubleRange? = UserDefaults.standard.preMealTargetRange {
+        didSet {
+            UserDefaults.standard.preMealTargetRange = preMealTargetRange
+        }
+    }
+
+    public var legacyWorkoutTargetRange: DoubleRange? = UserDefaults.standard.legacyWorkoutTargetRange {
+        didSet {
+            UserDefaults.standard.legacyWorkoutTargetRange = legacyWorkoutTargetRange
+        }
+    }
+
+    var pumpID = UserDefaults.standard.pumpID {
+        didSet {
+            UserDefaults.standard.pumpID = pumpID
+
+            if pumpID != oldValue {
+                doseStore.resetPumpData()
+            }
+        }
+    }
+
+    // MARK: CarbStoreDelegate
+
+    func carbStore(_ carbStore: CarbStore, didError error: CarbStore.CarbStoreError) {
+        print("carbstore error: \(error)")
+    }
+}

+ 427 - 0
LoopKit/LoopKit Example/MasterViewController.swift

@@ -0,0 +1,427 @@
+//
+//  MasterViewController.swift
+//  LoopKit Example
+//
+//  Created by Nathan Racklyeft on 2/24/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import UIKit
+import LoopKit
+import LoopKitUI
+import HealthKit
+
+
+class MasterViewController: UITableViewController {
+
+    private var dataManager: DeviceDataManager? = DeviceDataManager()
+
+    override func viewDidAppear(_ animated: Bool) {
+        super.viewDidAppear(animated)
+
+        guard let dataManager = dataManager else {
+            return
+        }
+
+        let sampleTypes = Set([
+            dataManager.glucoseStore.sampleType,
+            dataManager.carbStore.sampleType,
+            dataManager.doseStore.sampleType,
+        ].compactMap { $0 })
+
+        if dataManager.glucoseStore.authorizationRequired ||
+            dataManager.carbStore.authorizationRequired ||
+            dataManager.doseStore.authorizationRequired
+        {
+            dataManager.carbStore.healthStore.requestAuthorization(toShare: sampleTypes, read: sampleTypes) { (success, error) in
+                if success {
+                    // Call the individual authorization methods to trigger query creation
+                    dataManager.carbStore.authorize({ _ in })
+                    dataManager.doseStore.insulinDeliveryStore.authorize({ _ in })
+                    dataManager.glucoseStore.authorize({ _ in })
+                }
+            }
+        }
+    }
+
+    // MARK: - Data Source
+
+    private enum Section: Int, CaseIterable {
+        case data
+        case configuration
+    }
+
+    private enum DataRow: Int, CaseIterable {
+        case carbs = 0
+        case reservoir
+        case diagnostic
+        case generate
+        case reset
+    }
+
+    private enum ConfigurationRow: Int, CaseIterable {
+        case basalRate
+        case carbRatio
+        case correctionRange
+        case insulinSensitivity
+        case pumpID
+    }
+
+    // MARK: UITableViewDataSource
+
+    override func numberOfSections(in tableView: UITableView) -> Int {
+        return Section.allCases.count
+    }
+
+    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+        switch Section(rawValue: section)! {
+        case .configuration:
+            return ConfigurationRow.allCases.count
+        case .data:
+            return DataRow.allCases.count
+        }
+    }
+
+    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
+
+        switch Section(rawValue: indexPath.section)! {
+        case .configuration:
+            switch ConfigurationRow(rawValue: indexPath.row)! {
+            case .basalRate:
+                cell.textLabel?.text = LocalizedString("Basal Rates", comment: "The title text for the basal rate schedule")
+            case .carbRatio:
+                cell.textLabel?.text = LocalizedString("Carb Ratios", comment: "The title of the carb ratios schedule screen")
+            case .correctionRange:
+                cell.textLabel?.text = LocalizedString("Correction Range", comment: "The title text for the glucose correction range schedule")
+            case .insulinSensitivity:
+                cell.textLabel?.text = LocalizedString("Insulin Sensitivity", comment: "The title text for the insulin sensitivity schedule")
+            case .pumpID:
+                cell.textLabel?.text = LocalizedString("Pump ID", comment: "The title text for the pump ID")
+            }
+        case .data:
+            switch DataRow(rawValue: indexPath.row)! {
+            case .carbs:
+                cell.textLabel?.text = LocalizedString("Carbs", comment: "The title for the cell navigating to the carbs screen")
+            case .reservoir:
+                cell.textLabel?.text = LocalizedString("Reservoir", comment: "The title for the cell navigating to the reservoir screen")
+            case .diagnostic:
+                cell.textLabel?.text = LocalizedString("Diagnostic", comment: "The title for the cell displaying diagnostic data")
+            case .generate:
+                cell.textLabel?.text = LocalizedString("Generate Data", comment: "The title for the cell displaying data generation")
+            case .reset:
+                cell.textLabel?.text = LocalizedString("Reset", comment: "Title for the cell resetting the data manager")
+            }
+        }
+
+        return cell
+    }
+
+    // MARK: - UITableViewDelegate
+
+    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+        let sender = tableView.cellForRow(at: indexPath)
+
+        switch Section(rawValue: indexPath.section)! {
+        case .configuration:
+            let row = ConfigurationRow(rawValue: indexPath.row)!
+            switch row {
+            case .basalRate:
+
+                // x22 with max basal rate of 5U/hr
+                let pulsesPerUnit = 20
+                let basalRates = (1...100).map { Double($0) / Double(pulsesPerUnit) }
+
+                // full x23 rates
+//                let rateGroup1 = ((1...39).map { Double($0) / Double(40) })
+//                let rateGroup2 = ((20...199).map { Double($0) / Double(20) })
+//                let rateGroup3 = ((100...350).map { Double($0) / Double(10) })
+//                let basalRates = rateGroup1 + rateGroup2 + rateGroup3
+
+                let scheduleVC = BasalScheduleTableViewController(allowedBasalRates: basalRates, maximumScheduleItemCount: 5, minimumTimeInterval: .minutes(30))
+
+                if let profile = dataManager?.basalRateSchedule {
+                    scheduleVC.timeZone = profile.timeZone
+
+
+                    scheduleVC.scheduleItems = profile.items
+                }
+                scheduleVC.delegate = self
+                scheduleVC.title = sender?.textLabel?.text
+                scheduleVC.syncSource = self
+
+                show(scheduleVC, sender: sender)
+            case .carbRatio:
+                let scheduleVC = DailyQuantityScheduleTableViewController()
+
+                scheduleVC.delegate = self
+                scheduleVC.title = NSLocalizedString("Carb Ratios", comment: "The title of the carb ratios schedule screen")
+                scheduleVC.unit = .gram()
+
+                if let schedule = dataManager?.carbRatioSchedule {
+                    scheduleVC.timeZone = schedule.timeZone
+                    scheduleVC.scheduleItems = schedule.items
+                    scheduleVC.unit = schedule.unit
+                }
+
+                show(scheduleVC, sender: sender)
+            case .correctionRange:
+
+                let unit = dataManager?.glucoseTargetRangeSchedule?.unit ?? dataManager?.glucoseStore.preferredUnit ?? HKUnit.milligramsPerDeciliter
+
+                let scheduleVC = GlucoseRangeScheduleTableViewController(allowedValues: unit.allowedCorrectionRangeValues, unit: unit)
+
+                scheduleVC.delegate = self
+                scheduleVC.title = sender?.textLabel?.text
+
+                if let schedule = dataManager?.glucoseTargetRangeSchedule {
+                    var overrides: [TemporaryScheduleOverride.Context: DoubleRange] = [:]
+                    overrides[.preMeal] = dataManager?.preMealTargetRange
+                    overrides[.legacyWorkout] = dataManager?.legacyWorkoutTargetRange
+                    scheduleVC.setSchedule(schedule, withOverrideRanges: overrides)
+                }
+
+                show(scheduleVC, sender: sender)
+            case .insulinSensitivity:
+                let unit = dataManager?.insulinSensitivitySchedule?.unit ?? dataManager?.glucoseStore.preferredUnit ?? HKUnit.milligramsPerDeciliter
+                let scheduleVC = InsulinSensitivityScheduleViewController(allowedValues: unit.allowedSensitivityValues, unit: unit)
+
+                scheduleVC.unit = unit
+                scheduleVC.delegate = self
+                scheduleVC.insulinSensitivityScheduleStorageDelegate = self
+                scheduleVC.schedule = dataManager?.insulinSensitivitySchedule
+                scheduleVC.title = NSLocalizedString("Insulin Sensitivity", comment: "The title of the insulin sensitivity schedule screen")
+                show(scheduleVC, sender: sender)
+
+            case .pumpID:
+                let textFieldVC = TextFieldTableViewController()
+
+//                textFieldVC.delegate = self
+                textFieldVC.title = sender?.textLabel?.text
+                textFieldVC.placeholder = LocalizedString("Enter the 6-digit pump ID", comment: "The placeholder text instructing users how to enter a pump ID")
+                textFieldVC.value = dataManager?.pumpID
+                textFieldVC.keyboardType = .numberPad
+                textFieldVC.contextHelp = LocalizedString("The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).", comment: "Instructions on where to find the pump ID on a Minimed pump")
+
+                show(textFieldVC, sender: sender)
+            }
+        case .data:
+            switch DataRow(rawValue: indexPath.row)! {
+            case .carbs:
+                performSegue(withIdentifier: CarbEntryTableViewController.className, sender: sender)
+            case .reservoir:
+                performSegue(withIdentifier: InsulinDeliveryTableViewController.className, sender: sender)
+            case .diagnostic:
+                let vc = CommandResponseViewController(command: { [weak self] (completionHandler) -> String in
+                    let group = DispatchGroup()
+
+                    guard let dataManager = self?.dataManager else {
+                        completionHandler("")
+                        return "nil"
+                    }
+
+                    var doseStoreResponse = ""
+                    group.enter()
+                    dataManager.doseStore.generateDiagnosticReport { (report) in
+                        doseStoreResponse = report
+                        group.leave()
+                    }
+
+                    var carbStoreResponse = ""
+                    if let carbStore = dataManager.carbStore {
+                        group.enter()
+                        carbStore.generateDiagnosticReport { (report) in
+                            carbStoreResponse = report
+                            group.leave()
+                        }
+                    }
+
+                    var glucoseStoreResponse = ""
+                    group.enter()
+                    dataManager.glucoseStore.generateDiagnosticReport { (report) in
+                        glucoseStoreResponse = report
+                        group.leave()
+                    }
+
+                    group.notify(queue: DispatchQueue.main) {
+                        completionHandler([
+                            doseStoreResponse,
+                            carbStoreResponse,
+                            glucoseStoreResponse
+                        ].joined(separator: "\n\n"))
+                    }
+
+                    return "…"
+                })
+                vc.title = "Diagnostic"
+
+                show(vc, sender: sender)
+            case .generate:
+                let vc = CommandResponseViewController(command: { [weak self] (completionHandler) -> String in
+                    guard let dataManager = self?.dataManager else {
+                        completionHandler("")
+                        return "dataManager is nil"
+                    }
+
+                    let group = DispatchGroup()
+
+                    var unitVolume = 150.0
+
+                    reservoir: for index in sequence(first: TimeInterval(hours: -6), next: { $0 + .minutes(5) }) {
+                        guard index < 0 else {
+                            break reservoir
+                        }
+
+                        unitVolume -= (drand48() * 2.0)
+
+                        group.enter()
+                        dataManager.doseStore.addReservoirValue(unitVolume, at: Date(timeIntervalSinceNow: index)) { (_, _, _, error) in
+                            group.leave()
+                        }
+                    }
+
+                    group.enter()
+                    dataManager.glucoseStore.addGlucose(NewGlucoseSample(date: Date(), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 101), isDisplayOnly: false, syncIdentifier: UUID().uuidString), completion: { (result) in
+                        group.leave()
+                    })
+
+                    group.notify(queue: .main) {
+                        completionHandler("Completed")
+                    }
+
+                    return "Generating…"
+                })
+                vc.title = sender?.textLabel?.text
+
+                show(vc, sender: sender)
+            case .reset:
+                dataManager = nil
+                tableView.reloadData()
+            }
+        }
+    }
+
+    // MARK: - Segues
+
+    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
+        super.prepare(for: segue, sender: sender)
+
+        var targetViewController = segue.destination
+
+        if let navVC = targetViewController as? UINavigationController, let topViewController = navVC.topViewController {
+            targetViewController = topViewController
+        }
+
+        switch targetViewController {
+        case let vc as CarbEntryTableViewController:
+            vc.carbStore = dataManager?.carbStore
+        case let vc as CarbEntryEditViewController:
+            if let carbStore = dataManager?.carbStore {
+                vc.defaultAbsorptionTimes = carbStore.defaultAbsorptionTimes
+                vc.preferredUnit = carbStore.preferredUnit
+            }
+        case let vc as InsulinDeliveryTableViewController:
+            vc.doseStore = dataManager?.doseStore
+        default:
+            break
+        }
+    }
+}
+
+
+extension MasterViewController: DailyValueScheduleTableViewControllerDelegate {
+    func dailyValueScheduleTableViewControllerWillFinishUpdating(_ controller: DailyValueScheduleTableViewController) {
+        if let indexPath = tableView.indexPathForSelectedRow {
+            switch Section(rawValue: indexPath.section)! {
+            case .configuration:
+                switch ConfigurationRow(rawValue: indexPath.row)! {
+                case .basalRate:
+                    if let controller = controller as? BasalScheduleTableViewController {
+                        dataManager?.basalRateSchedule = BasalRateSchedule(dailyItems: controller.scheduleItems, timeZone: controller.timeZone)
+                    }
+                default:
+                    break
+                }
+
+                tableView.reloadRows(at: [indexPath], with: .none)
+            default:
+                break
+            }
+        }
+    }
+}
+
+
+extension MasterViewController: BasalScheduleTableViewControllerSyncSource {
+    func basalScheduleTableViewControllerIsReadOnly(_ viewController: BasalScheduleTableViewController) -> Bool {
+        return false
+    }
+
+    func syncButtonDetailText(for viewController: BasalScheduleTableViewController) -> String? {
+        return nil
+    }
+
+    func syncScheduleValues(for viewController: BasalScheduleTableViewController, completion: @escaping (SyncBasalScheduleResult<Double>) -> Void) {
+        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) {
+            let scheduleItems = viewController.scheduleItems
+            let timezone = self.dataManager?.basalRateSchedule?.timeZone ?? .currentFixed
+            let schedule = BasalRateSchedule(dailyItems: scheduleItems, timeZone: timezone)
+            self.dataManager?.basalRateSchedule = schedule
+            completion(.success(scheduleItems: scheduleItems, timeZone: .currentFixed))
+        }
+    }
+
+    func syncButtonTitle(for viewController: BasalScheduleTableViewController) -> String {
+        return LocalizedString("Sync With Pump", comment: "Title of button to sync basal profile from pump")
+    }
+}
+
+extension MasterViewController: InsulinSensitivityScheduleStorageDelegate {
+    func saveSchedule(_ schedule: InsulinSensitivitySchedule, for viewController: InsulinSensitivityScheduleViewController, completion: @escaping (SaveInsulinSensitivityScheduleResult) -> Void) {
+        self.dataManager?.insulinSensitivitySchedule = schedule
+        completion(.success)
+    }
+}
+
+extension MasterViewController: GlucoseRangeScheduleStorageDelegate {
+    func saveSchedule(for viewController: GlucoseRangeScheduleTableViewController, completion: @escaping (SaveGlucoseRangeScheduleResult) -> Void) {
+        self.dataManager?.glucoseTargetRangeSchedule = viewController.schedule
+        for (context, range) in viewController.overrideRanges {
+            switch context {
+            case .preMeal:
+                self.dataManager?.preMealTargetRange = range
+            case .legacyWorkout:
+                self.dataManager?.legacyWorkoutTargetRange = range
+            default:
+                break
+            }
+        }
+        completion(.success)
+    }
+}
+
+private extension HKUnit {
+    var allowedSensitivityValues: [Double] {
+        if self == HKUnit.milligramsPerDeciliter {
+            return (10...500).map { Double($0) }
+        }
+
+        if self == HKUnit.millimolesPerLiter {
+            return (6...270).map { Double($0) / 10.0 }
+        }
+
+        return []
+    }
+
+    var allowedCorrectionRangeValues: [Double] {
+        if self == HKUnit.milligramsPerDeciliter {
+            return (60...180).map { Double($0) }
+        }
+
+        if self == HKUnit.millimolesPerLiter {
+            return (33...100).map { Double($0) / 10.0 }
+        }
+
+        return []
+    }
+}

+ 40 - 0
LoopKit/LoopKit Example/da.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Basal Rater";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Kulhydrat Ratioer";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Kulhydrater";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Korrektions Interval";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnostisk";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Indtast det 6-cifrede pumpe ID";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Genererer Data";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Insulin Følsomhed";
+
+/* The title text for the pump ID */
+"Pump ID" = "Pumpe ID";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Reservoir";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Nulstil";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Synkroniser med Pumpe";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "Pumpe ID kan findes trykt på bagsiden, eller nær bunden på STATUS/Esc skærmen. Det er den rent numeriske del af serienummeret (vist som SN eller S/N)";
+

+ 40 - 0
LoopKit/LoopKit Example/de.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Basalraten";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Kohlenhydratfaktoren";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Carbs";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Korrekturbereich";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnosedaten";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Geben Sie die 6-stellige Pumpen-ID ein";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Erzeuge Daten";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Insulinempfindlichkeit";
+
+/* The title text for the pump ID */
+"Pump ID" = "Pumpen-ID";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Reservoir";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Reset";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Mit der Pumpe synchronisieren";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "Die Pumpen-ID finden Sie auf der Rückseite oder am unteren Rand des STATUS / Esc-Bildschirms. Dies ist der streng numerische Teil der Seriennummer (gezeigt als SN oder S/N).";
+

+ 40 - 0
LoopKit/LoopKit Example/en.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Basal Rates";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Carb Ratios";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Carbs";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Correction Range";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnostic";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Enter the 6-digit pump ID";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Generate Data";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Insulin Sensitivity";
+
+/* The title text for the pump ID */
+"Pump ID" = "Pump ID";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Reservoir";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Reset";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Sync With Pump";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).";
+

+ 40 - 0
LoopKit/LoopKit Example/es.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Perfil Basal";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Relaciones de hidratos";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Carbohidratos";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Rango Objetivo de Glucosa";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnóstico";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Ingrese ID de 6 dígitios de la microinfusora";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Generar Datos";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Sensibilidad a la insulina";
+
+/* The title text for the pump ID */
+"Pump ID" = "ID de microinfusora";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Reservorio";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Reiniciar";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Sincronizar con la Microinfusora";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "El ID de microinfusora puede encontrarse en la parte trasera o cerca de la parte inferior de la pantalla STATUS/Esc. Es la parte estrictamente numérica del número de serie mostrado como SN o S/N";
+

+ 40 - 0
LoopKit/LoopKit Example/fi.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Basaalitasot";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Hiilihydraattisuhteet";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Hiilihydraatit";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Korjausalue";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnostiikka";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Syötä 6-numeroinen pumpun sarjanumero";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Luo tiedot";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Insuliiniherkkyys";
+
+/* The title text for the pump ID */
+"Pump ID" = "Pumpun sarjanumero";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Säiliö";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Nollaa";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Synkronoi pumpun kanssa";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "Pumpun sarjanumero löytyy pumpun takaosasta tai STATUS/Esc-valikon loppuosasta. Se on pelkästään numeroita sisältävä osa sarjanumerosta (SN tai S/N).";
+

+ 40 - 0
LoopKit/LoopKit Example/fr.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Débits de basale";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Ratios de glucides";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Glucides";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Plage de correction";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnostique";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Entrez l’ID de la pompe, composé de 6 lettres et chiffres";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Générer les données";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Facteur de sensibilité à l’insuline";
+
+/* The title text for the pump ID */
+"Pump ID" = "ID de la pompe";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Réservoir";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Réinitialiser";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Synchroniser avec la pompe";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).";
+

+ 40 - 0
LoopKit/LoopKit Example/it.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Velocità basali";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Proporzioni carboidrati";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Carboidrati";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Intervallo di correzione";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Dati diagnostici";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Inserisci ID a 6 cifre della pompa";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Genera dati";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Sensibilità all’insulina";
+
+/* The title text for the pump ID */
+"Pump ID" = "ID pompa";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Serbatoio";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Ripristina";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Sincronizza con la pompa";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).";
+

+ 40 - 0
LoopKit/LoopKit Example/ja.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "基礎インスリン";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Carb Ratios";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "カーボ";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "ターゲット範囲";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "診断";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "6桁のトランスミッターIDを入力";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "データ生成";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Insulin Sensitivity";
+
+/* The title text for the pump ID */
+"Pump ID" = "ポンプID";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "リザーバ";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "リセット";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "ポンプと同期する";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "ポンプIDは、ポンプの裏面か、STATUS/Esc画面の下方に表示されています。シリアル番号の数字の部分です。(SNまたはS/Nで表示)";
+

+ 40 - 0
LoopKit/LoopKit Example/nb.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Basalsatser";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Karbohydratforhold";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Karbohydrater";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Korreksjonsområde";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnostikk";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Skriv 6-siffret pumpe ID";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Generer data";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Insulinfølsomhet";
+
+/* The title text for the pump ID */
+"Pump ID" = "Pumpe ID";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Reservoar";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Nullstill";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Synkroniser med pumpe";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).";
+

+ 40 - 0
LoopKit/LoopKit Example/nl.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Basaalsnelheden";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Koolhydraten absorptie snelheid";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Koolhydraten";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnose";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Vul de 6 cijferige pomp ID in";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Genereer data";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Correctie bereik";
+
+/* The title text for the glucose target range schedule */
+"Correction Range" = "Gewenst glucose doelbereik";
+
+/* The title text for the pump ID */
+"Pump ID" = "Pomp ID";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Reservoir";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Reset";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Synchroniseer met pomp";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).";
+

+ 40 - 0
LoopKit/LoopKit Example/pl.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Dawki podstawowe";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Współczynniki węglowodanowe";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Węglowodany";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Zakres korekty";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnostyka";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Wprowadź 6-cyfrowy ID pompy";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Generuj dane";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Insulinowrażliwość";
+
+/* The title text for the pump ID */
+"Pump ID" = "Pompą ID";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Zbiornik";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Resetuj";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Synchronizuj z pompą";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).";
+

+ 39 - 0
LoopKit/LoopKit Example/pt-BR.lproj/Localizable.strings

@@ -0,0 +1,39 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Taxas Basais";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Taxas de Carbs";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Carbs";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Faixa de Correção";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnóstico";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Entre com o ID de 6 dígitos da bomba";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Gerar Dados";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Sensibilidade a Insulina";
+
+/* The title text for the pump ID */
+"Pump ID" = "ID da Bomba";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Reservatório";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Restabelecer";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Sincronizar com a Bomba";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "O ID da bomba pode ser encontrado impresso na parte traseira ou na parte inferior da tela STATUS/Esc. É a parte estritamente numérica do número de série (mostrado como SN ou S/N).";

+ 40 - 0
LoopKit/LoopKit Example/ro.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Rate bazale";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Raport carbohidrați";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Carbohidrați";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Interval corecție";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnostic";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Introduceți ID-ul pompei din 6 cifre";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Generează date";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Factor de sensibilitate la insulină";
+
+/* The title text for the pump ID */
+"Pump ID" = "ID pompă";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Rezervor";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Resetează";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Sincronizează cu pompa";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "ID-ul pompei poate fi găsit pe spate sau în partea inferioară a ecranului STATUS/Esc. Este format strict din porțiunea numerică a numărului serial (afișat ca SN sau S/N).";
+

Разница между файлами не показана из-за своего большого размера
+ 40 - 0
LoopKit/LoopKit Example/ru.lproj/Localizable.strings


+ 39 - 0
LoopKit/LoopKit Example/sv.lproj/Localizable.strings

@@ -0,0 +1,39 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Basaldoser";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Insulinkvoter";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Kolhydrater";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Målvärden";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Diagnostisk";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Ange ditt 6-siffriga pump-ID";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Generera data";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Insulinkänslighet";
+
+/* The title text for the pump ID */
+"Pump ID" = "Pump ID";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Reservoar";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Återställ";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Synka med pump";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "Ditt pump-ID står tryckt på baksidan, eller nästan längst ner på status/Esc-menyn. Det är den numeriska delen av serienumret (visad som SN eller S/N). ";

+ 40 - 0
LoopKit/LoopKit Example/vi.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "Basal Rates";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "Carb Ratios";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "Carbs";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "Phạm vi liều Bổ sung";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "Chuẩn đoán";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "Khai báo 6 số ID của bơm";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "Xuất bản dữ liệu";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "Độ nhạy Insulin";
+
+/* The title text for the pump ID */
+"Pump ID" = "Số ID của bơm";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "Ngăn chứa insulin";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "Khôi phục lại";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "Đồng hóa dữ liệu với bơm";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "Số ID của bơm có thể nhìn thấy phía sau của bơm hay gần mục STATUS/Esc. Đây là phần số của số seri (bắt đầu bằng SN hay S/N).";
+

+ 40 - 0
LoopKit/LoopKit Example/zh-Hans.lproj/Localizable.strings

@@ -0,0 +1,40 @@
+/* The title text for the basal rate schedule */
+"Basal Rates" = "基础率";
+
+/* The title of the carb ratios schedule screen */
+"Carb Ratios" = "碳水化合物系数";
+
+/* The title for the cell navigating to the carbs screen */
+"Carbs" = "碳水化合物";
+
+/* The title text for the glucose correction range schedule */
+"Correction Range" = "修正范围";
+
+/* The title for the cell displaying diagnostic data */
+"Diagnostic" = "诊断";
+
+/* The placeholder text instructing users how to enter a pump ID */
+"Enter the 6-digit pump ID" = "输入6位数字泵编号";
+
+/* The title for the cell displaying data generation */
+"Generate Data" = "创建日期";
+
+/* The title of the insulin sensitivity schedule screen
+   The title text for the insulin sensitivity schedule */
+"Insulin Sensitivity" = "胰岛素敏感系数";
+
+/* The title text for the pump ID */
+"Pump ID" = "泵编号";
+
+/* The title for the cell navigating to the reservoir screen */
+"Reservoir" = "储药器";
+
+/* Title for the cell resetting the data manager */
+"Reset" = "重置";
+
+/* Title of button to sync basal profile from pump */
+"Sync With Pump" = "同步到胰岛素";
+
+/* Instructions on where to find the pump ID on a Minimed pump */
+"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).";
+

Разница между файлами не показана из-за своего большого размера
+ 3608 - 0
LoopKit/LoopKit.xcodeproj/project.pbxproj


+ 7 - 0
LoopKit/LoopKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+   version = "1.0">
+   <FileRef
+      location = "self:LoopKit.xcodeproj">
+   </FileRef>
+</Workspace>

+ 8 - 0
LoopKit/LoopKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>IDEDidComputeMac32BitWarning</key>
+	<true/>
+</dict>
+</plist>

+ 8 - 0
LoopKit/LoopKit.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded</key>
+	<false/>
+</dict>
+</plist>

+ 87 - 0
LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/LoopKit Example.xcscheme

@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1240"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "430157F61C7EC03B00B64B63"
+               BuildableName = "LoopKit Example.app"
+               BlueprintName = "LoopKit Example"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "430157F61C7EC03B00B64B63"
+            BuildableName = "LoopKit Example.app"
+            BlueprintName = "LoopKit Example"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <Testables>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "430157F61C7EC03B00B64B63"
+            BuildableName = "LoopKit Example.app"
+            BlueprintName = "LoopKit Example"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "430157F61C7EC03B00B64B63"
+            BuildableName = "LoopKit Example.app"
+            BlueprintName = "LoopKit Example"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 76 - 0
LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared-watchOS.xcscheme

@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1240"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "NO">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "A9E6758022713F4700E25293"
+               BuildableName = "LoopKit.framework"
+               BlueprintName = "LoopKit-watchOS"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "A9E6758022713F4700E25293"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit-watchOS"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "A9E6758022713F4700E25293"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit-watchOS"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 151 - 0
LoopKit/LoopKit.xcodeproj/xcshareddata/xcschemes/Shared.xcscheme

@@ -0,0 +1,151 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1240"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "NO">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "43D8FDCA1C728FDF0073BE78"
+               BuildableName = "LoopKit.framework"
+               BlueprintName = "LoopKit"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "43BA7153201E484D0058961E"
+               BuildableName = "LoopKitUI.framework"
+               BlueprintName = "LoopKitUI"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "892A5D33222F03CB008961AB"
+               BuildableName = "LoopTestingKit.framework"
+               BlueprintName = "LoopTestingKit"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "89D2047121CC7BD7001238CC"
+               BuildableName = "MockKit.framework"
+               BlueprintName = "MockKit"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "89D2048E21CC7C12001238CC"
+               BuildableName = "MockKitUI.framework"
+               BlueprintName = "MockKitUI"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "43D8FDCA1C728FDF0073BE78"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "43D8FDD41C728FDF0073BE78"
+               BuildableName = "LoopKitTests.xctest"
+               BlueprintName = "LoopKitTests"
+               ReferencedContainer = "container:LoopKit.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "43D8FDCA1C728FDF0073BE78"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "43D8FDCA1C728FDF0073BE78"
+            BuildableName = "LoopKit.framework"
+            BlueprintName = "LoopKit"
+            ReferencedContainer = "container:LoopKit.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 41 - 0
LoopKit/LoopKit/BasalRateSchedule.swift

@@ -0,0 +1,41 @@
+//
+//  BasalRateSchedule.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/12/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+
+
+public typealias BasalRateSchedule = DailyValueSchedule<Double>
+
+public struct BasalScheduleValidationResult {
+    let scheduleError: Error?
+    let itemErrors: [(index: Int, error: Error)]
+}
+
+
+public extension DailyValueSchedule where T == Double {
+    /**
+     Calculates the total basal delivery for a day
+
+     - returns: The total basal delivery
+     */
+    func total() -> Double {
+        var total: Double = 0
+
+        for (index, item) in items.enumerated() {
+            var endTime = maxTimeInterval
+
+            if index < items.endIndex - 1 {
+                endTime = items[index + 1].startTime
+            }
+
+            total += (endTime - item.startTime).hours * item.value
+        }
+        
+        return total
+    }
+}

+ 98 - 0
LoopKit/LoopKit/Base.lproj/Localizable.strings

@@ -0,0 +1,98 @@
+/* Describes a certain bolus failure (1: size of the bolus in units) */
+"%1$@ U bolus failed" = "%1$@ U bolus failed";
+
+/* Describes an uncertain bolus failure (1: size of the bolus in units) */
+"%1$@ U bolus may not have succeeded" = "%1$@ U bolus may not have succeeded";
+
+/* The error description describing when Health sharing was denied */
+"Authorization Denied" = "Authorization Denied";
+
+/* Recovery instruction for an uncertain bolus failure */
+"Check your pump before retrying" = "Check your pump before retrying";
+
+/* The description of an error returned when attempting to delete a sample not shared by the current app */
+"com.loudnate.CarbKit.deleteCarbEntryUnownedErrorDescription" = "Authorization Denied";
+
+/* The error recovery suggestion when attempting to delete a sample not shared by the current app */
+"com.loudnate.carbKit.sharingDeniedErrorRecoverySuggestion" = "This sample can be deleted from the Health app";
+
+/* Generic pump error description */
+"Communication Failure" = "Communication Failure";
+
+/* Generic pump error description */
+"Connection Failure" = "Connection Failure";
+
+/* Generic pump error description */
+"Device Refused" = "Device Refused";
+
+/* Recovery suggestion for a no data error */
+"Ensure carb data exists for the specified date" = "Ensure carb data exists for the specified date";
+
+/* Glucose trend down */
+"Falling" = "Falling";
+
+/* Glucose trend down-down */
+"Falling fast" = "Falling fast";
+
+/* Glucose trend down-down-down */
+"Falling very fast" = "Falling very fast";
+
+/* Glucose trend flat */
+"Flat" = "Flat";
+
+/* The short unit display string for grams per U */
+"g/U" = "g/U";
+
+/* Generic pump error description */
+"Invalid Configuration" = "Invalid Configuration";
+
+/* Recovery instruction for a certain bolus failure */
+"It is safe to retry" = "It is safe to retry";
+
+/* The short unit display string for milligrams per deciliter per U */
+"mg/dL/U" = "mg/dL/U";
+
+/* The short unit display string for millimoles per liter */
+"mmol/L" = "mmol/L";
+
+/* The short unit display string for millimoles per liter per U */
+"mmol/L/U" = "mmol/L/U";
+
+/* Sensor state description for the non-valid state */
+"Needs Attention" = "Needs Attention";
+
+/* Describes an error for no data found in a CarbStore request */
+"No values found" = "No values found";
+
+/* Sensor state description for the valid state */
+"OK" = "OK";
+
+/* The error recovery suggestion when Health sharing was denied */
+"Please re-enable sharing in Health" = "Please re-enable sharing in Health";
+
+/* Glucose trend up */
+"Rising" = "Rising";
+
+/* Glucose trend up-up */
+"Rising fast" = "Rising fast";
+
+/* Glucose trend up-up-up */
+"Rising very fast" = "Rising very fast";
+
+/* The short unit display string for international units of insulin */
+"U" = "U";
+
+/* The short unit display string for international units of insulin per hour */
+"U/hr" = "U/hr";
+
+/* The long unit display string for a singular international unit of insulin */
+"Unit" = "Unit";
+
+/* The long unit display string for a singular international unit of insulin per hour */
+"Unit/hour" = "Unit/hour";
+
+/* The long unit display string for international units of insulin */
+"Units" = "Units";
+
+/* The long unit display string for international units of insulin per hour */
+"Units/hour" = "Units/hour";

+ 90 - 0
LoopKit/LoopKit/CGMManager.swift

@@ -0,0 +1,90 @@
+//
+//  CGMManager.swift
+//  Loop
+//
+//  Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import HealthKit
+
+
+/// Describes the result of a CGM manager operation
+///
+/// - noData: No new data was available or retrieved
+/// - newData: New glucose data was received and stored
+/// - error: An error occurred while receiving or store data
+public enum CGMResult {
+    case noData
+    case newData([NewGlucoseSample])
+    case error(Error)
+}
+
+
+public protocol CGMManagerDelegate: class, DeviceManagerDelegate {
+    /// Asks the delegate for a date with which to filter incoming glucose data
+    ///
+    /// - Parameter manager: The manager instance
+    /// - Returns: The date data occuring on or after which should be kept
+    func startDateToFilterNewData(for manager: CGMManager) -> Date?
+
+    /// Informs the delegate that the device has updated with a new result
+    ///
+    /// - Parameters:
+    ///   - manager: The manager instance
+    ///   - result: The result of the update
+    func cgmManager(_ manager: CGMManager, didUpdateWith result: CGMResult) -> Void
+
+    /// Informs the delegate that the manager is deactivating and should be deleted
+    ///
+    /// - Parameter manager: The manager instance
+    func cgmManagerWantsDeletion(_ manager: CGMManager)
+
+    /// Informs the delegate that the manager has updated its state and should be persisted.
+    ///
+    /// - Parameter manager: The manager instance
+    func cgmManagerDidUpdateState(_ manager: CGMManager)
+}
+
+
+public protocol CGMManager: DeviceManager {
+    var cgmManagerDelegate: CGMManagerDelegate? { get set }
+
+    var appURL: URL? { get }
+
+    /// Whether the device is capable of waking the app
+    var providesBLEHeartbeat: Bool { get }
+
+    /// The length of time to keep samples in HealthKit before removal. Return nil to never remove samples.
+    var managedDataInterval: TimeInterval? { get }
+
+    var shouldSyncToRemoteService: Bool { get }
+
+    var sensorState: SensorDisplayable? { get }
+
+    /// The representation of the device for use in HealthKit
+    var device: HKDevice? { get }
+
+    /// Performs a manual fetch of glucose data from the device, if necessary
+    ///
+    /// - Parameters:
+    ///   - completion: A closure called when operation has completed
+    func fetchNewDataIfNeeded(_ completion: @escaping (CGMResult) -> Void) -> Void
+}
+
+
+public extension CGMManager {
+    var appURL: URL? {
+        return nil
+    }
+
+    /// Convenience wrapper for notifying the delegate of deletion on the delegate queue
+    ///
+    /// - Parameters:
+    ///   - completion: A closure called from the delegate queue after the delegate is called
+    func notifyDelegateOfDeletion(completion: @escaping () -> Void) {
+        delegateQueue.async {
+            self.cgmManagerDelegate?.cgmManagerWantsDeletion(self)
+            completion()
+        }
+    }
+}

+ 80 - 0
LoopKit/LoopKit/CarbKit/AbsorbedCarbValue.swift

@@ -0,0 +1,80 @@
+//
+//  AbsorbedCarbValue.swift
+//  LoopKit
+//
+//  Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+/// A quantity of carbs absorbed over a given date interval
+public struct AbsorbedCarbValue: SampleValue {
+    /// The quantity of carbs absorbed
+    public let observed: HKQuantity
+    /// The quantity of carbs absorbed, clamped to the original prediction
+    public let clamped: HKQuantity
+    /// The quantity of carbs entered as eaten
+    public let total: HKQuantity
+    /// The quantity of carbs expected to still absorb
+    public let remaining: HKQuantity
+    /// The dates over which absorption was observed
+    public let observedDate: DateInterval
+
+    /// The predicted time for the remaining carbs to absorb
+    public let estimatedTimeRemaining: TimeInterval
+
+    // Total predicted absorption time for this carb entry
+    public var estimatedDate: DateInterval {
+        return DateInterval(start: observedDate.start, duration: observedDate.duration + estimatedTimeRemaining)
+    }
+    
+    /// The amount of time required to absorb observed carbs
+    public let timeToAbsorbObservedCarbs: TimeInterval
+
+    /// Whether absorption is still in-progress
+    public var isActive: Bool {
+        return estimatedTimeRemaining > 0
+    }
+
+    public var observedProgress: HKQuantity {
+        let gram = HKUnit.gram()
+        let totalGrams = total.doubleValue(for: gram)
+        let percent = HKUnit.percent()
+
+        guard totalGrams > 0 else {
+            return HKQuantity(unit: percent, doubleValue: 0)
+        }
+
+        return HKQuantity(
+            unit: percent,
+            doubleValue: observed.doubleValue(for: gram) / totalGrams
+        )
+    }
+
+    public var clampedProgress: HKQuantity {
+        let gram = HKUnit.gram()
+        let totalGrams = total.doubleValue(for: gram)
+        let percent = HKUnit.percent()
+
+        guard totalGrams > 0 else {
+            return HKQuantity(unit: percent, doubleValue: 0)
+        }
+
+        return HKQuantity(
+            unit: percent,
+            doubleValue: clamped.doubleValue(for: gram) / totalGrams
+        )
+    }
+
+    // MARK: SampleValue
+
+    public var quantity: HKQuantity {
+        return clamped
+    }
+
+    public var startDate: Date {
+        return estimatedDate.start
+    }
+}

+ 71 - 0
LoopKit/LoopKit/CarbKit/CachedCarbObject+CoreDataClass.swift

@@ -0,0 +1,71 @@
+//
+//  CachedCarbObject+CoreDataClass.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+//
+
+import Foundation
+import CoreData
+import HealthKit
+
+
+class CachedCarbObject: NSManagedObject {
+    var absorptionTime: TimeInterval? {
+        get {
+            willAccessValue(forKey: "absorptionTime")
+            defer { didAccessValue(forKey: "absorptionTime") }
+            return primitiveAbsorptionTime?.doubleValue
+        }
+        set {
+            willChangeValue(forKey: "absorptionTime")
+            defer { didChangeValue(forKey: "absorptionTime") }
+            primitiveAbsorptionTime = newValue != nil ? NSNumber(value: newValue!) : nil
+        }
+    }
+
+    var startDate: Date! {
+        get {
+            willAccessValue(forKey: "startDate")
+            defer { didAccessValue(forKey: "startDate") }
+            return primitiveStartDate! as Date
+        }
+        set {
+            willChangeValue(forKey: "startDate")
+            defer { didChangeValue(forKey: "startDate") }
+            primitiveStartDate = newValue as NSDate
+        }
+    }
+
+    var uploadState: UploadState {
+        get {
+            willAccessValue(forKey: "uploadState")
+            defer { didAccessValue(forKey: "uploadState") }
+            return UploadState(rawValue: primitiveUploadState!.intValue)!
+        }
+        set {
+            willChangeValue(forKey: "uploadState")
+            defer { didChangeValue(forKey: "uploadState") }
+            primitiveUploadState = NSNumber(value: newValue.rawValue)
+        }
+    }
+}
+
+
+extension CachedCarbObject {
+    func update(from entry: StoredCarbEntry) {
+        uuid = entry.sampleUUID
+        syncIdentifier = entry.syncIdentifier
+        syncVersion = Int32(clamping: entry.syncVersion)
+        startDate = entry.startDate
+        grams = entry.quantity.doubleValue(for: .gram())
+        foodType = entry.foodType
+        absorptionTime = entry.absorptionTime
+        createdByCurrentApp = entry.createdByCurrentApp
+
+        if let id = entry.externalID {
+            externalID = id
+        }
+    }
+}

+ 30 - 0
LoopKit/LoopKit/CarbKit/CachedCarbObject+CoreDataProperties.swift

@@ -0,0 +1,30 @@
+//
+//  CachedCarbObject+CoreDataProperties.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+//
+
+import Foundation
+import CoreData
+
+
+extension CachedCarbObject {
+
+    @nonobjc public class func fetchRequest() -> NSFetchRequest<CachedCarbObject> {
+        return NSFetchRequest<CachedCarbObject>(entityName: "CachedCarbObject")
+    }
+
+    @NSManaged public var primitiveAbsorptionTime: NSNumber?
+    @NSManaged public var createdByCurrentApp: Bool
+    @NSManaged public var externalID: String?
+    @NSManaged public var foodType: String?
+    @NSManaged public var grams: Double
+    @NSManaged public var primitiveStartDate: NSDate?
+    @NSManaged public var primitiveUploadState: NSNumber?
+    @NSManaged public var uuid: UUID?
+    @NSManaged public var syncIdentifier: String?
+    @NSManaged public var syncVersion: Int32
+
+}

+ 14 - 0
LoopKit/LoopKit/CarbKit/CarbEntry.swift

@@ -0,0 +1,14 @@
+//
+//  CarbEntry.swift
+//  CarbKit
+//
+//  Created by Nathan Racklyeft on 1/3/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+
+
+public protocol CarbEntry: SampleValue {
+    var absorptionTime: TimeInterval? { get }
+}

+ 903 - 0
LoopKit/LoopKit/CarbKit/CarbMath.swift

@@ -0,0 +1,903 @@
+//
+//  CarbMath.swift
+//  CarbKit
+//
+//  Created by Nathan Racklyeft on 1/16/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+struct CarbModelSettings {
+    var absorptionModel: CarbAbsorptionComputable
+    var initialAbsorptionTimeOverrun: Double
+    var adaptiveAbsorptionRateEnabled: Bool
+    var adaptiveRateStandbyIntervalFraction: Double
+    
+    init(absorptionModel: CarbAbsorptionComputable, initialAbsorptionTimeOverrun: Double, adaptiveAbsorptionRateEnabled: Bool, adaptiveRateStandbyIntervalFraction: Double = 0.2) {
+        self.absorptionModel = absorptionModel
+        self.initialAbsorptionTimeOverrun = initialAbsorptionTimeOverrun
+        self.adaptiveAbsorptionRateEnabled = adaptiveAbsorptionRateEnabled
+        self.adaptiveRateStandbyIntervalFraction = adaptiveRateStandbyIntervalFraction
+    }
+}
+
+protocol CarbAbsorptionComputable {
+    /// Returns the percentage of total carbohydrates absorbed as blood glucose at a specified interval after eating.
+    ///
+    /// - Parameters:
+    ///   - percentTime: The percentage of the total absorption time
+    /// - Returns: The percentage of the total carbohydrates that have been absorbed as blood glucose
+    func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double
+    
+    /// Returns the percent of total absorption time for a percentage of total carbohydrates absorbed
+    ///
+    /// The is the inverse of perecentAbsorptionAtPercentTime( :percentTime: )
+    ///
+    /// - Parameters:
+    ///   - percentAbsorption: The percentage of the total carbohydrates that have been absorbed as blood glucose
+    /// - Returns: The percentage of the absorption time needed to absorb the percentage of the total carbohydrates
+    func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double
+
+    /// Returns the total absorption time for a percentage of total carbohydrates absorbed as blood glucose at a specified interval after eating.
+    ///
+    /// - Parameters:
+    ///   - percentAbsorption: The percentage of the total carbohydrates that have been absorbed as blood glucose
+    ///   - time: The interval after the carbohydrates were eaten
+    /// - Returns: The total time of carbohydrates absorption
+    func absorptionTime(forPercentAbsorption percentAbsorption: Double, atTime time: TimeInterval) -> TimeInterval
+
+    /// Returns the number of total carbohydrates absorbed as blood glucose at a specified interval after eating
+    ///
+    /// - Parameters:
+    ///   - total: The total number of carbohydrates eaten
+    ///   - time: The interval after carbohydrates were eaten
+    ///   - absorptionTime: The total time of carbohydrates absorption
+    /// - Returns: The number of total carbohydrates that have been absorbed as blood glucose
+    func absorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double
+
+    /// Returns the number of total carbohydrates not yet absorbed as blood glucose at a specified interval after eating
+    ///
+    /// - Parameters:
+    ///   - total: The total number of carbs eaten
+    ///   - time: The interval after carbohydrates were eaten
+    ///   - absorptionTime: The total time of carb absorption
+    /// - Returns: The number of total carbohydrates that have not yet been absorbed as blood glucose
+    func unabsorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double
+    
+    /// Returns the normalized rate of carbohydrates absorption at a specified percentage of the absorption time
+    ///
+    /// - Parameters:
+    ///   - percentTime: The percentage of absorption time elapsed since the carbohydrates were eaten
+    /// - Returns: The percentage absorption rate at the percentage of absorption time
+    func percentRateAtPercentTime(forPercentTime percentTime: Double) -> Double
+}
+
+
+extension CarbAbsorptionComputable {
+    func absorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double {
+        let percentTime = time / absorptionTime
+        return total * percentAbsorptionAtPercentTime(percentTime)
+    }
+
+    func unabsorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double {
+        let percentTime = time / absorptionTime
+        return total * (1.0 - percentAbsorptionAtPercentTime(percentTime))
+    }
+    
+    func absorptionTime(forPercentAbsorption percentAbsorption: Double, atTime time: TimeInterval) -> TimeInterval {
+        let percentTime = max(percentTimeAtPercentAbsorption(percentAbsorption), .ulpOfOne)
+        return time / percentTime
+    }
+    
+    func timeToAbsorb(forPercentAbsorbed percentAbsorption: Double, absorptionTime: TimeInterval) -> TimeInterval {
+        let percentTime = percentTimeAtPercentAbsorption(percentAbsorption)
+        return percentTime * absorptionTime
+    }
+    
+}
+
+
+// MARK: - Parabolic absorption as described by Scheiner
+// This is the integral approximation of the Scheiner GI curve found in Think Like a Pancreas, Fig 7-8, which first appeared in [GlucoDyn](https://github.com/kenstack/GlucoDyn)
+struct ParabolicAbsorption: CarbAbsorptionComputable {
+    func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double {
+        switch percentTime {
+        case let t where t < 0.0:
+            return 0.0
+        case let t where t <= 0.5:
+            return 2.0 * pow(t, 2)
+        case let t where t < 1.0:
+            return -1.0 + 2.0 * t * (2.0 - t)
+        default:
+            return 1.0
+        }
+    }
+
+    func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double {
+        switch percentAbsorption {
+        case let a where a <= 0:
+            return 0
+        case let a where a <= 0.5:
+            return sqrt(0.5 * a)
+        case let a where a < 1.0:
+            return 1.0 - sqrt(0.5 * (1.0 - a))
+        default:
+            return 1.0
+        }
+    }
+    
+    func percentRateAtPercentTime(forPercentTime percentTime: Double) -> Double {
+        switch percentTime {
+        case let t where t > 0 && t <= 0.5:
+            return 4.0 * t
+        case let t where t > 0.5 && t < 1.0:
+            return 4.0 - 4.0 * t
+        default:
+            return 0.0
+        }
+    }
+}
+
+
+// MARK: - Linear absorption as a factor of reported duration
+struct LinearAbsorption: CarbAbsorptionComputable {
+    func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double {
+        switch percentTime {
+        case let t where t <= 0.0:
+            return 0.0
+        case let t where t < 1.0:
+            return t
+        default:
+            return 1.0
+        }
+    }
+
+    func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double {
+        switch percentAbsorption {
+        case let a where a <= 0.0:
+            return 0.0
+        case let a where a < 1.0:
+            return a
+        default:
+            return 1.0
+        }
+    }
+    
+    func percentRateAtPercentTime(forPercentTime percentTime: Double) -> Double {
+        switch percentTime {
+        case let t where t > 0.0 && t <= 1.0:
+            return 1.0
+        default:
+            return 0.0
+        }
+    }
+}
+
+// MARK: - Piecewise linear absorption as a factor of reported duration
+/// Nonlinear  carb absorption model where absorption rate increases linearly from zero to a maximum value at a fraction of absorption time equal to percentEndOfRise, then remains constant until a fraction of absorption time equal to percentStartOfFall, and then decreases linearly to zero at the end of absorption time
+/// - Parameters:
+///   - percentEndOfRise: the percentage of absorption time when absorption rate reaches maximum, must be strictly between 0 and 1
+///   - percentStartOfFall: the percentage of absorption time when absorption rate starts to decay, must be stritctly between 0 and 1 and  greater than percentEndOfRise
+struct PiecewiseLinearAbsorption: CarbAbsorptionComputable {
+    
+    let percentEndOfRise = 0.15
+    let percentStartOfFall = 0.5
+    var scale: Double {
+        return(2.0 / (1.0 + percentStartOfFall - percentEndOfRise))
+    }
+    
+    func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double {
+        switch percentTime {
+        case let t where t <= 0.0:
+            return 0.0
+        case let t where t < percentEndOfRise:
+            return 0.5 * scale * pow(t, 2.0) / percentEndOfRise
+        case let t where t >= percentEndOfRise && t < percentStartOfFall:
+            return scale * (t - 0.5 * percentEndOfRise)
+        case let t where t >= percentStartOfFall && t < 1.0:
+            return scale * (percentStartOfFall - 0.5 * percentEndOfRise +
+            (t - percentStartOfFall) * (1.0 - 0.5 * (t - percentStartOfFall) / (1.0 - percentStartOfFall)))
+        default:
+            return 1.0
+        }
+    }
+    
+    func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double {
+        switch percentAbsorption {
+        case let a where a <= 0:
+            return 0
+        case let a where a > 0.0 && a < 0.5 * scale * percentEndOfRise:
+            return sqrt(2.0 * percentEndOfRise * a / scale)
+        case let a where a >= 0.5 * scale * percentEndOfRise && a < scale * (percentStartOfFall - 0.5 * percentEndOfRise):
+            return 0.5 * percentEndOfRise + a / scale
+        case let a where a >= scale * (percentStartOfFall - 0.5 * percentEndOfRise) && a < 1.0:
+            return 1.0 - sqrt((1.0 - percentStartOfFall) *
+                (1.0 + percentStartOfFall - percentEndOfRise) * (1.0 - a))
+        default:
+            return 1.0
+        }
+    }
+    
+    func percentRateAtPercentTime(forPercentTime percentTime: Double) -> Double {
+        switch percentTime {
+        case let t where t <= 0:
+            return 0.0
+        case let t where t > 0 && t < percentEndOfRise:
+            return scale * t / percentEndOfRise
+        case let t where t >= percentEndOfRise && t < percentStartOfFall:
+            return scale
+        case let t where t >= percentStartOfFall && t < 1.0:
+            return scale * ((1.0 - t) / (1.0 - percentStartOfFall))
+        case let t where t == 1.0:
+            return 0.0
+        default:
+            return 0.0
+        }
+    }
+}
+
+extension CarbEntry {
+    
+    func carbsOnBoard(at date: Date, defaultAbsorptionTime: TimeInterval, delay: TimeInterval, absorptionModel: CarbAbsorptionComputable) -> Double {
+        let time = date.timeIntervalSince(startDate)
+        let value: Double
+
+        if time >= 0 {
+            value = absorptionModel.unabsorbedCarbs(of: quantity.doubleValue(for: HKUnit.gram()), atTime: time - delay, absorptionTime: absorptionTime ?? defaultAbsorptionTime)
+        } else {
+            value = 0
+        }
+
+        return value
+    }
+
+    // g
+    func absorbedCarbs(
+        at date: Date,
+        absorptionTime: TimeInterval,
+        delay: TimeInterval,
+        absorptionModel: CarbAbsorptionComputable
+    ) -> Double {
+        let time = date.timeIntervalSince(startDate)
+
+        return absorptionModel.absorbedCarbs(
+            of: quantity.doubleValue(for: .gram()),
+            atTime: time - delay,
+            absorptionTime: absorptionTime
+        )
+    }
+
+    // mg/dL / g * g
+    fileprivate func glucoseEffect(
+        at date: Date,
+        carbRatio: HKQuantity,
+        insulinSensitivity: HKQuantity,
+        defaultAbsorptionTime: TimeInterval,
+        delay: TimeInterval,
+        absorptionModel: CarbAbsorptionComputable
+    ) -> Double {
+        return insulinSensitivity.doubleValue(for: HKUnit.milligramsPerDeciliter) / carbRatio.doubleValue(for: .gram()) * absorbedCarbs(at: date, absorptionTime: absorptionTime ?? defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel)
+    }
+
+    fileprivate func estimatedAbsorptionTime(forAbsorbedCarbs carbs: Double, at date: Date, absorptionModel: CarbAbsorptionComputable) -> TimeInterval {
+        let time = date.timeIntervalSince(startDate)
+
+        return max(time, absorptionModel.absorptionTime(forPercentAbsorption: carbs / quantity.doubleValue(for: .gram()), atTime: time))
+    }
+}
+
+extension Collection where Element: CarbEntry {
+    fileprivate func simulationDateRange(
+        from start: Date? = nil,
+        to end: Date? = nil,
+        defaultAbsorptionTime: TimeInterval,
+        delay: TimeInterval,
+        delta: TimeInterval
+    ) -> (start: Date, end: Date)? {
+        guard count > 0 else {
+            return nil
+        }
+
+        if let start = start, let end = end {
+            return (start: start.dateFlooredToTimeInterval(delta), end: end.dateCeiledToTimeInterval(delta))
+        } else {
+            var minDate = first!.startDate
+            var maxDate = minDate
+
+            for sample in self {
+                if sample.startDate < minDate {
+                    minDate = sample.startDate
+                }
+
+                let endDate = sample.endDate.addingTimeInterval(sample.absorptionTime ?? defaultAbsorptionTime).addingTimeInterval(delay)
+                if endDate > maxDate {
+                    maxDate = endDate
+                }
+            }
+
+            return (
+                start: (start ?? minDate).dateFlooredToTimeInterval(delta),
+                end: (end ?? maxDate).dateCeiledToTimeInterval(delta)
+            )
+        }
+    }
+
+    /// Creates groups of entries that have overlapping absorption date intervals
+    ///
+    /// - Parameters:
+    ///   - defaultAbsorptionTime: The default absorption time value, if not set on the entry
+    /// - Returns: An array of arrays representing groups of entries, in chronological order by entry startDate
+    func groupedByOverlappingAbsorptionTimes(
+        defaultAbsorptionTime: TimeInterval
+    ) -> [[Iterator.Element]] {
+        var batches: [[Iterator.Element]] = []
+
+        for entry in sorted(by: { $0.startDate < $1.startDate }) {
+            if let lastEntry = batches.last?.last,
+                lastEntry.startDate.addingTimeInterval(lastEntry.absorptionTime ?? defaultAbsorptionTime) > entry.startDate
+            {
+                batches[batches.count - 1].append(entry)
+            } else {
+                batches.append([entry])
+            }
+        }
+
+        return batches
+    }
+
+    func carbsOnBoard(
+        from start: Date? = nil,
+        to end: Date? = nil,
+        defaultAbsorptionTime: TimeInterval,
+        absorptionModel: CarbAbsorptionComputable,
+        delay: TimeInterval = TimeInterval(minutes: 10),
+        delta: TimeInterval = TimeInterval(minutes: 5)
+    ) -> [CarbValue] {
+        guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else {
+            return []
+        }
+
+        var date = startDate
+        var values = [CarbValue]()
+
+        repeat {
+            let value = reduce(0.0) { (value, entry) -> Double in
+                return value + entry.carbsOnBoard(at: date, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel)
+            }
+
+            values.append(CarbValue(startDate: date, quantity: HKQuantity(unit: HKUnit.gram(), doubleValue: value)))
+            date = date.addingTimeInterval(delta)
+        } while date <= endDate
+
+        return values
+    }
+
+    func glucoseEffects(
+        from start: Date? = nil,
+        to end: Date? = nil,
+        carbRatios: CarbRatioSchedule,
+        insulinSensitivities: InsulinSensitivitySchedule,
+        defaultAbsorptionTime: TimeInterval,
+        absorptionModel: CarbAbsorptionComputable,
+        delay: TimeInterval = TimeInterval(minutes: 10),
+        delta: TimeInterval = TimeInterval(minutes: 5)
+    ) -> [GlucoseEffect] {
+        guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else {
+            return []
+        }
+
+        var date = startDate
+        var values = [GlucoseEffect]()
+        let unit = HKUnit.milligramsPerDeciliter
+
+        repeat {
+            let value = reduce(0.0) { (value, entry) -> Double in
+                return value + entry.glucoseEffect(
+                    at: date,
+                    carbRatio: carbRatios.quantity(at: entry.startDate),
+                    insulinSensitivity: insulinSensitivities.quantity(at: entry.startDate),
+                    defaultAbsorptionTime: defaultAbsorptionTime,
+                    delay: delay,
+                    absorptionModel: absorptionModel
+                )
+            }
+
+            values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: unit, doubleValue: value)))
+            date = date.addingTimeInterval(delta)
+        } while date <= endDate
+
+        return values
+    }
+
+    var totalCarbs: CarbValue? {
+        guard count > 0 else {
+            return nil
+        }
+
+        let unit = HKUnit.gram()
+        var startDate = Date.distantFuture
+        var totalGrams: Double = 0
+
+        for entry in self {
+            totalGrams += entry.quantity.doubleValue(for: unit)
+
+            if entry.startDate < startDate {
+                startDate = entry.startDate
+            }
+        }
+
+        return CarbValue(startDate: startDate, quantity: HKQuantity(unit: unit, doubleValue: totalGrams))
+    }
+}
+
+
+// MARK: - Dyanamic absorption overrides
+extension Collection {
+    func dynamicCarbsOnBoard<T>(
+        from start: Date? = nil,
+        to end: Date? = nil,
+        defaultAbsorptionTime: TimeInterval,
+        absorptionModel: CarbAbsorptionComputable,
+        delay: TimeInterval = TimeInterval(minutes: 10),
+        delta: TimeInterval = TimeInterval(minutes: 5)
+    ) -> [CarbValue] where Element == CarbStatus<T> {
+        guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else {
+            return []
+        }
+
+        var date = startDate
+        var values = [CarbValue]()
+
+        repeat {
+            let value = reduce(0.0) { (value, entry) -> Double in
+                return value + entry.dynamicCarbsOnBoard(
+                    at: date,
+                    defaultAbsorptionTime: defaultAbsorptionTime,
+                    delay: delay,
+                    delta: delta,
+                    absorptionModel: absorptionModel
+                )
+            }
+
+            values.append(CarbValue(startDate: date, quantity: HKQuantity(unit: HKUnit.gram(), doubleValue: value)))
+            date = date.addingTimeInterval(delta)
+        } while date <= endDate
+        
+        return values
+    }
+
+    func dynamicGlucoseEffects<T>(
+        from start: Date? = nil,
+        to end: Date? = nil,
+        carbRatios: CarbRatioSchedule,
+        insulinSensitivities: InsulinSensitivitySchedule,
+        defaultAbsorptionTime: TimeInterval,
+        absorptionModel: CarbAbsorptionComputable,
+        delay: TimeInterval = TimeInterval(minutes: 10),
+        delta: TimeInterval = TimeInterval(minutes: 5)
+    ) -> [GlucoseEffect] where Element == CarbStatus<T> {
+        guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else {
+            return []
+        }
+
+        var date = startDate
+        var values = [GlucoseEffect]()
+        let mgdL = HKUnit.milligramsPerDeciliter
+        let gram = HKUnit.gram()
+
+        repeat {
+            let value = reduce(0.0) { (value, entry) -> Double in
+                let csf = insulinSensitivities.quantity(at: entry.startDate).doubleValue(for: mgdL) / carbRatios.quantity(at: entry.startDate).doubleValue(for: gram)
+
+                return value + csf * entry.dynamicAbsorbedCarbs(
+                    at: date,
+                    absorptionTime: entry.absorptionTime ?? defaultAbsorptionTime,
+                    delay: delay,
+                    delta: delta,
+                    absorptionModel: absorptionModel
+                )
+            }
+
+            values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: mgdL, doubleValue: value)))
+            date = date.addingTimeInterval(delta)
+        } while date <= endDate
+        
+        return values
+    }
+
+    /// The quantity of carbs expected to still absorb at the last date of absorption
+    public func getClampedCarbsOnBoard<T>() -> CarbValue? where Element == CarbStatus<T> {
+        guard let firstAbsorption = first?.absorption else {
+            return nil
+        }
+
+        let gram = HKUnit.gram()
+        var maxObservedEndDate = firstAbsorption.observedDate.end
+        var remainingTotalGrams: Double = 0
+
+        for entry in self {
+            guard let absorption = entry.absorption else {
+                continue
+            }
+
+            maxObservedEndDate = Swift.max(maxObservedEndDate, absorption.observedDate.end)
+            remainingTotalGrams += absorption.remaining.doubleValue(for: gram)
+        }
+
+        return CarbValue(startDate: maxObservedEndDate, quantity: HKQuantity(unit: gram, doubleValue: remainingTotalGrams))
+    }
+}
+
+
+/// Aggregates and computes data about the absorption of a CarbEntry to create a CarbStatus value.
+///
+/// There are three key components managed by this builder:
+///   - The entry data as reported by the user
+///   - The observed data as calculated from glucose changes relative to insulin curves
+///   - The minimum/maximum amounts of absorption used to clamp our observation data within reasonable bounds
+fileprivate class CarbStatusBuilder<T: CarbEntry> {
+
+    // MARK: Model settings
+    
+    private var absorptionModel: CarbAbsorptionComputable
+    
+    private var adaptiveAbsorptionRateEnabled: Bool
+   
+    private var adaptiveRateStandbyIntervalFraction: Double
+    
+    private var adaptiveRateStandbyInterval: TimeInterval {
+        return initialAbsorptionTime * adaptiveRateStandbyIntervalFraction
+    }
+    
+    // MARK: User-entered data
+
+    /// The carb entry input
+    let entry: T
+
+    /// The unit used for carb values
+    let carbUnit: HKUnit
+
+    /// The total grams entered for this entry
+    let entryGrams: Double
+
+    /// The total glucose effect expected for this entry, in glucose units
+    let entryEffect: Double
+
+    /// The carbohydrate-sensitivity factor for this entry, in glucose units per gram
+    let carbohydrateSensitivityFactor: Double
+
+    /// The absorption time for this entry before any absorption is observed
+    let initialAbsorptionTime: TimeInterval
+
+    // MARK: Minimum/maximum bounding factors
+
+    /// The maximum absorption time allowed for this entry, determining the minimum absorption rate
+    let maxAbsorptionTime: TimeInterval
+
+    /// An amount of time to wait after the entry date before minimum absorption is assumed to begin
+    let delay: TimeInterval
+
+    /// The maximum end date allowed for this entry's absorption
+    let maxEndDate: Date
+
+    /// The last date we have effects observed, or "now" in real-time analysis.
+    private let lastEffectDate: Date
+
+    /// The minimum-required carb absorption rate for this entry, in g/s
+    var minAbsorptionRate: Double {
+        return entryGrams / maxAbsorptionTime
+    }
+
+    /// The minimum amount of carbs we assume must have absorbed at the last observation date
+    private var minPredictedGrams: Double {
+        // We incorporate a delay when calculating minimum absorption values
+        let time = lastEffectDate.timeIntervalSince(entry.startDate) - delay
+        return absorptionModel.absorbedCarbs(of: entryGrams, atTime: time, absorptionTime: maxAbsorptionTime)
+    }
+
+    // MARK: Incremental observation
+
+    /// The date at which we observe all the carbs were absorbed. or nil if carb absorption has not finished
+    private var observedCompletionDate: Date?
+
+    /// The total observed effect for each entry, in glucose units
+    private(set) var observedEffect: Double = 0
+
+    /// The timeline of absorption amounts credited to this carb entry, in grams, for computation of historical COB and effect history
+    private(set) var observedTimeline: [CarbValue] = []
+
+    /// The amount of carbs we've observed absorbing
+    private var observedGrams: Double {
+        return observedEffect / carbohydrateSensitivityFactor
+    }
+
+    /// The amount of effect remaining until 100% of entry absorption is observed
+    var remainingEffect: Double {
+        return max(entryEffect - observedEffect, 0)
+    }
+
+    /// The dates over which we observed absorption, from start until 100% or last observed effect.
+    private var observedAbsorptionDates: DateInterval {
+        return DateInterval(start: entry.startDate, end: observedCompletionDate ?? lastEffectDate)
+    }
+
+
+    // MARK: Clamped results
+
+    /// The number of carbs absorbed, suitable for use in calculations.
+    /// This is bounded by minimumPredictedGrams and the entry total.
+    private var clampedGrams: Double {
+        let minPredictedGrams = self.minPredictedGrams
+
+        return min(entryGrams, max(minPredictedGrams, observedGrams))
+    }
+    
+    private var percentAbsorbed: Double {
+        return clampedGrams / entryGrams
+    }
+    
+    /// The amount of time needed to absorb observed grams
+    private var timeToAbsorbObservedCarbs: TimeInterval {
+        let time = lastEffectDate.timeIntervalSince(entry.startDate) - delay
+        guard time > 0 else {
+            return 0.0
+        }
+        var timeToAbsorb: TimeInterval
+        if adaptiveAbsorptionRateEnabled && time > adaptiveRateStandbyInterval {
+            // If adaptive absorption rate is enabled, and if the time since start of absorption is greater than the standby interval, the time to absorb observed carbs equals the obervation time
+            timeToAbsorb = time
+        } else {
+            // If adaptive absorption rate is disabled, or if the time since start of absorption is less than the standby interval, the time to absorb observed carbs is calculated based on the absorption model
+            timeToAbsorb = absorptionModel.timeToAbsorb(forPercentAbsorbed: percentAbsorbed, absorptionTime: initialAbsorptionTime)
+        }
+        return min(timeToAbsorb, maxAbsorptionTime)
+    }
+    
+    /// The amount of time needed for the remaining entry grams to absorb
+    private var estimatedTimeRemaining: TimeInterval {
+        let time = lastEffectDate.timeIntervalSince(entry.startDate) - delay
+        guard time > 0 else {
+            return initialAbsorptionTime
+        }
+        let notToExceedTimeRemaining = max(maxAbsorptionTime - time, 0.0)
+        guard notToExceedTimeRemaining > 0 else {
+            return 0.0
+        }
+        var dynamicTimeRemaining: TimeInterval
+        if adaptiveAbsorptionRateEnabled && time > adaptiveRateStandbyInterval {
+            // If adaptive absorption rate is enabled, and if the time since start of absorption is greater than the standby interval, the remaining time is estimated assuming the observed relative absorption rate persists for the remaining carbs
+            let dynamicAbsorptionTime = absorptionModel.absorptionTime(forPercentAbsorption: percentAbsorbed, atTime: time)
+            dynamicTimeRemaining = dynamicAbsorptionTime - time
+        } else {
+            // If adaptive absorption rate is disabled, or if the time since start of absorption is less than the standby interval, the remaining time is estimated assuming the modeled absorption rate
+            dynamicTimeRemaining = initialAbsorptionTime - timeToAbsorbObservedCarbs
+        }
+        // time remaining must not extend beyond the maximum absorption time
+        let estimatedTimeRemaining = min(dynamicTimeRemaining, notToExceedTimeRemaining)
+        return(estimatedTimeRemaining)
+    }
+
+    /// The timeline of observed absorption, if greater than the minimum required absorption.
+    private var clampedTimeline: [CarbValue]? {
+        return observedGrams >= minPredictedGrams ? observedTimeline : nil
+    }
+
+    /// Configures a new builder
+    ///
+    /// - Parameters:
+    ///   - entry: The carb entry input
+    ///   - carbUnit: The unit used for carb values
+    ///   - carbohydrateSensitivityFactor: The carbohydrate-sensitivity factor for the entry, in glucose units per gram
+    ///   - initialAbsorptionTime: The absorption initially assigned to this entry before any absorption is observed
+    ///   - maxAbsorptionTime: The maximum absorption time allowed for this entry, determining the minimum absorption rate
+    ///   - delay: An amount of time to wait after the entry date before minimum absorption is assumed to begin
+    ///   - lastEffectDate: The last recorded date of effect observation, used to initialize absorption at model defined rate
+    ///   - initialObservedEffect: The initial amount of observed effect, in glucose units. Defaults to 0.
+    init(entry: T, carbUnit: HKUnit, carbohydrateSensitivityFactor: Double, initialAbsorptionTime: TimeInterval, maxAbsorptionTime: TimeInterval, delay: TimeInterval, lastEffectDate: Date?, absorptionModel: CarbAbsorptionComputable, adaptiveAbsorptionRateEnabled: Bool, adaptiveRateStandbyIntervalFraction: Double, initialObservedEffect: Double = 0) {
+        self.entry = entry
+        self.carbUnit = carbUnit
+        self.carbohydrateSensitivityFactor = carbohydrateSensitivityFactor
+        self.initialAbsorptionTime = initialAbsorptionTime
+        self.maxAbsorptionTime = maxAbsorptionTime
+        self.delay = delay
+        self.observedEffect = initialObservedEffect
+        self.absorptionModel = absorptionModel
+        self.adaptiveAbsorptionRateEnabled = adaptiveAbsorptionRateEnabled
+        self.adaptiveRateStandbyIntervalFraction = adaptiveRateStandbyIntervalFraction
+        self.entryGrams = entry.quantity.doubleValue(for: carbUnit)
+        self.entryEffect = entryGrams * carbohydrateSensitivityFactor
+        self.maxEndDate = entry.startDate.addingTimeInterval(maxAbsorptionTime + delay)
+        self.lastEffectDate = min(
+            maxEndDate,
+            Swift.max(lastEffectDate ?? entry.startDate, entry.startDate)
+        )
+
+    }
+
+    /// Increments the builder state with the next glucose effect.
+    /// 
+    /// This function should only be called with values in ascending date order.
+    ///
+    /// - Parameters:
+    ///   - effect: The effect value, in glucose units corresponding to `carbohydrateSensitivityFactor`
+    ///   - start: The start date of the effect
+    ///   - end: The end date of the effect
+    func addNextEffect(_ effect: Double, start: Date, end: Date) {
+        guard start >= entry.startDate else {
+            return
+        }
+
+        observedEffect += effect
+
+        if observedCompletionDate == nil {
+            // Continue recording the timeline until 100% of the carbs have been observed
+            observedTimeline.append(CarbValue(
+                startDate: start,
+                endDate: end,
+                quantity: HKQuantity(
+                    unit: carbUnit,
+                    doubleValue: effect / carbohydrateSensitivityFactor
+                )
+            ))
+
+            // Once 100% of the carbs are observed, track the endDate
+            if observedEffect + Double(Float.ulpOfOne) >= entryEffect {
+                observedCompletionDate = end
+            }
+        }
+    }
+
+    /// The resulting CarbStatus value
+    var result: CarbStatus<T> {
+        let absorption = AbsorbedCarbValue(
+            observed: HKQuantity(unit: carbUnit, doubleValue: observedGrams),
+            clamped: HKQuantity(unit: carbUnit, doubleValue: clampedGrams),
+            total: entry.quantity,
+            remaining: HKQuantity(unit: carbUnit, doubleValue: entryGrams - clampedGrams),
+            observedDate: observedAbsorptionDates,
+            estimatedTimeRemaining: estimatedTimeRemaining,
+            timeToAbsorbObservedCarbs: timeToAbsorbObservedCarbs
+        )
+
+        return CarbStatus(
+            entry: entry,
+            absorption: absorption,
+            observedTimeline: clampedTimeline
+        )
+    }
+    
+    func absorptionRateAtTime(t: TimeInterval) -> Double {
+        let dynamicAbsorptionTime = min(observedAbsorptionDates.duration + estimatedTimeRemaining, maxAbsorptionTime)
+        guard dynamicAbsorptionTime > 0 else {
+            return(0.0)
+        }
+        // time t nomalized to absorption time
+        let percentTime = t / dynamicAbsorptionTime
+        let averageAbsorptionRate = entryGrams / dynamicAbsorptionTime
+        return averageAbsorptionRate * absorptionModel.percentRateAtPercentTime(forPercentTime: percentTime)
+    }
+    
+}
+
+
+// MARK: - Sorted collections of CarbEntries
+extension Collection where Element: CarbEntry {
+    /// Maps a sorted timeline of carb entries to the observed absorbed carbohydrates for each, from a timeline of glucose effect velocities.
+    ///
+    /// This makes some important assumptions:
+    /// - insulin effects, used with glucose to calculate counteraction, are "correct"
+    /// - carbs are absorbed completely in the order they were eaten without mixing or overlapping effects
+    ///
+    /// - Parameters:
+    ///   - effectVelocities: A timeline of glucose effect velocities, ordered by start date
+    ///   - carbRatio: The schedule of carb ratios, in grams per unit
+    ///   - insulinSensitivity: The schedule of insulin sensitivities, in units of insulin per glucose-unit
+    ///   - absorptionTimeOverrun: A multiplier for determining the minimum absorption time from the specified absorption time
+    ///   - defaultAbsorptionTime: The absorption time to use for unspecified carb entries
+    ///   - delay: The time to delay the dose effect
+    /// - Returns: A new array of `CarbStatus` values describing the absorbed carb quantities
+    func map(
+        to effectVelocities: [GlucoseEffectVelocity],
+        carbRatio: CarbRatioSchedule?,
+        insulinSensitivity: InsulinSensitivitySchedule?,
+        absorptionTimeOverrun: Double,
+        defaultAbsorptionTime: TimeInterval,
+        delay: TimeInterval,
+        initialAbsorptionTimeOverrun: Double,
+        absorptionModel: CarbAbsorptionComputable,
+        adaptiveAbsorptionRateEnabled: Bool,
+        adaptiveRateStandbyIntervalFraction: Double
+    ) -> [CarbStatus<Element>] {
+        guard count > 0 else {
+            // TODO: Apply unmatched effects to meal prediction
+            return []
+        }
+
+        guard let carbRatios = carbRatio, let insulinSensitivities = insulinSensitivity else {
+            return map { (entry) in
+                CarbStatus(entry: entry, absorption: nil, observedTimeline: nil)
+            }
+        }
+        
+        // for computation
+        let glucoseUnit = HKUnit.milligramsPerDeciliter
+        let carbUnit = HKUnit.gram()
+
+        let builders: [CarbStatusBuilder<Element>] = map { (entry) in
+            let carbRatio = carbRatios.quantity(at: entry.startDate)
+            let insulinSensitivity = insulinSensitivities.quantity(at: entry.startDate)
+            let initialAbsorptionTimeOverrun = initialAbsorptionTimeOverrun
+
+            return CarbStatusBuilder(
+                entry: entry,
+                carbUnit: carbUnit,
+                carbohydrateSensitivityFactor: insulinSensitivity.doubleValue(for: glucoseUnit) / carbRatio.doubleValue(for: carbUnit), initialAbsorptionTime: (entry.absorptionTime ?? defaultAbsorptionTime) * initialAbsorptionTimeOverrun,
+                maxAbsorptionTime: (entry.absorptionTime ?? defaultAbsorptionTime) * absorptionTimeOverrun,
+                delay: delay,
+                lastEffectDate: effectVelocities.last?.endDate,
+                absorptionModel: absorptionModel,
+                adaptiveAbsorptionRateEnabled: adaptiveAbsorptionRateEnabled,
+                adaptiveRateStandbyIntervalFraction: adaptiveRateStandbyIntervalFraction
+            )
+        }
+
+        for dxEffect in effectVelocities {
+            guard dxEffect.endDate > dxEffect.startDate else {
+                assertionFailure()
+                continue
+            }
+
+            // calculate instantanous absorption rate for all active entries
+            
+            // Apply effect to all active entries
+
+            // Select only the entries whose dates overlap the current date interval.
+            // These are not necessarily contiguous as maxEndDate varies between entries
+            let activeBuilders = builders.filter { (builder) -> Bool in
+                return dxEffect.startDate < builder.maxEndDate && dxEffect.startDate >= builder.entry.startDate
+            }
+
+            // Ignore velocities < 0 when estimating carb absorption.
+            // These are most likely the result of insulin absorption increases such as
+            // during activity
+            var effectValue = Swift.max(0, dxEffect.effect.quantity.doubleValue(for: glucoseUnit))
+
+            // Sum the current absorption rates of each active entry to determine how to split the active effects
+            var totalRate = activeBuilders.reduce(0) { (totalRate, builder) -> Double in
+                let effectTime = dxEffect.startDate.timeIntervalSince(builder.entry.startDate)
+                let absorptionRateAtEffectTime = builder.absorptionRateAtTime(t: effectTime)
+                return totalRate + absorptionRateAtEffectTime
+            }
+
+            for builder in activeBuilders {
+                // Apply a portion of the effect to this entry
+                let effectTime = dxEffect.startDate.timeIntervalSince(builder.entry.startDate)
+                let absorptionRateAtEffectTime = builder.absorptionRateAtTime(t: effectTime)
+                // If total rate is zero, assign zero to partial effect
+                var partialEffectValue: Double = 0.0
+                if totalRate > 0 {
+                    partialEffectValue = Swift.min(builder.remainingEffect, (absorptionRateAtEffectTime / totalRate) * effectValue)
+                    totalRate -= absorptionRateAtEffectTime
+                    effectValue -= partialEffectValue
+                }
+
+                builder.addNextEffect(partialEffectValue, start: dxEffect.startDate, end: dxEffect.endDate)
+
+                // If there's still remainder effects with no additional entries to account them to, count them as overrun on the final entry
+                if effectValue > Double(Float.ulpOfOne) && builder === activeBuilders.last! {
+                    builder.addNextEffect(effectValue, start: dxEffect.startDate, end: dxEffect.endDate)
+                }
+            }
+
+            // We have remaining effect and no activeBuilders (otherwise we would have applied the effect to the last one)
+            if effectValue > Double(Float.ulpOfOne) {
+                // TODO: Track "phantom meals"
+            }
+        }
+
+        return builders.map { $0.result }
+    }
+}

+ 130 - 0
LoopKit/LoopKit/CarbKit/CarbStatus.swift

@@ -0,0 +1,130 @@
+//
+//  CarbStatus.swift
+//  LoopKit
+//
+//  Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+public struct CarbStatus<T: CarbEntry> {
+    /// Details entered by the user
+    public let entry: T
+
+    /// The last-computed absorption of the carbs
+    public let absorption: AbsorbedCarbValue?
+
+    /// The timeline of observed carb absorption. Nil if observed absorption is less than the modeled minimum
+    public let observedTimeline: [CarbValue]?
+}
+
+
+// Masquerade as a carb entry, substituting AbsorbedCarbValue's interpretation of absorption time
+extension CarbStatus: SampleValue {
+    public var quantity: HKQuantity {
+        return entry.quantity
+    }
+
+    public var startDate: Date {
+        return entry.startDate
+    }
+}
+
+
+extension CarbStatus: CarbEntry {
+    public var absorptionTime: TimeInterval? {
+        return absorption?.estimatedDate.duration ?? entry.absorptionTime
+    }
+}
+
+
+extension CarbStatus {
+    
+    func dynamicCarbsOnBoard(at date: Date, defaultAbsorptionTime: TimeInterval, delay: TimeInterval, delta: TimeInterval, absorptionModel: CarbAbsorptionComputable) -> Double {
+        guard date >= startDate - delta,
+            let absorption = absorption
+        else {
+            // We have to have absorption info for dynamic calculation
+            return entry.carbsOnBoard(at: date, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel)
+        }
+
+        let unit = HKUnit.gram()
+
+        guard let observedTimeline = observedTimeline, let observationEnd = observedTimeline.last?.endDate else {
+            // Less than minimum observed or observation not yet started; calc based on modeled absorption rate
+            let total = absorption.total.doubleValue(for: unit)
+            let time = date.timeIntervalSince(startDate) - delay
+            let absorptionTime = absorption.estimatedDate.duration
+            return absorptionModel.unabsorbedCarbs(of: total, atTime: time, absorptionTime: absorptionTime)
+        }
+
+        guard date <= observationEnd else {
+            // Predicted absorption for remaining carbs, post-observation
+            let effectiveTime = date.timeIntervalSince(observationEnd) + absorption.timeToAbsorbObservedCarbs
+            let effectiveAbsorptionTime = absorption.timeToAbsorbObservedCarbs + absorption.estimatedTimeRemaining
+            let total = absorption.total.doubleValue(for: unit)
+            let unabsorbedAtEffectiveTime = absorptionModel.unabsorbedCarbs(of: total, atTime: effectiveTime, absorptionTime: effectiveAbsorptionTime)
+            let unabsorbedCarbs = max(unabsorbedAtEffectiveTime, 0.0)
+            return unabsorbedCarbs
+        }
+
+        // Observed absorption
+        // TODO: This creates an O(n^2) situation for COB timelines
+        let total = entry.quantity.doubleValue(for: unit)
+        return max(observedTimeline.filter({ $0.endDate <= date }).reduce(total) { (total, value) -> Double in
+            return total - value.quantity.doubleValue(for: unit)
+        }, 0)
+    }
+
+    func dynamicAbsorbedCarbs(at date: Date, absorptionTime: TimeInterval, delay: TimeInterval, delta: TimeInterval, absorptionModel: CarbAbsorptionComputable) -> Double {
+        guard date >= startDate,
+            let absorption = absorption
+        else {
+            // We have to have absorption info for dynamic calculation
+            return entry.absorbedCarbs(at: date, absorptionTime: absorptionTime, delay: delay, absorptionModel: absorptionModel)
+        }
+
+        let unit = HKUnit.gram()
+
+        guard let observedTimeline = observedTimeline, let observationEnd = observedTimeline.last?.endDate else {
+            // Less than minimum observed or observation not yet started; calc based on modeled absorption rate
+            let total = absorption.total.doubleValue(for: unit)
+            let time = date.timeIntervalSince(startDate) - delay
+            let absorptionTime = absorption.estimatedDate.duration
+            return absorptionModel.absorbedCarbs(of: total, atTime: time, absorptionTime: absorptionTime)
+        }
+
+        guard date <= observationEnd else {
+            // Predicted absorption for remaining carbs, post-observation
+            let effectiveTime = date.timeIntervalSince(observationEnd) + absorption.timeToAbsorbObservedCarbs
+            let effectiveAbsorptionTime = absorption.timeToAbsorbObservedCarbs + absorption.estimatedTimeRemaining
+            let total = absorption.total.doubleValue(for: unit)
+            let absorbedAtEffectiveTime = absorptionModel.absorbedCarbs(of: total, atTime: effectiveTime, absorptionTime: effectiveAbsorptionTime)
+            let absorbedCarbs = min(absorbedAtEffectiveTime, total)
+            return absorbedCarbs
+        }
+
+        // Observed absorption
+        // TODO: This creates an O(n^2) situation for carb effect timelines
+        var sum: Double = 0
+        var beforeDate = observedTimeline.filter { (value) -> Bool in
+            value.startDate.addingTimeInterval(delta) <= date
+        }
+
+        // Apply only a portion of the value if it extends past the final value
+        if let last = beforeDate.popLast() {
+            let observationInterval = DateInterval(start: last.startDate, end: last.endDate)
+            if  observationInterval.duration > 0,
+                let calculationInterval = DateInterval(start: last.startDate, end: date).intersection(with: observationInterval)
+            {
+                sum += calculationInterval.duration / observationInterval.duration * last.quantity.doubleValue(for: unit)
+            }
+        }
+
+        return min(beforeDate.reduce(sum) { (sum, value) -> Double in
+            return sum + value.quantity.doubleValue(for: unit)
+        }, quantity.doubleValue(for: unit))
+    }
+}

+ 994 - 0
LoopKit/LoopKit/CarbKit/CarbStore.swift

@@ -0,0 +1,994 @@
+//
+//  CarbStore.swift
+//  CarbKit
+//
+//  Created by Nathan Racklyeft on 1/3/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import CoreData
+import HealthKit
+import os.log
+
+
+public enum CarbStoreResult<T> {
+    case success(T)
+    case failure(CarbStore.CarbStoreError)
+}
+
+public enum CarbAbsorptionModel {
+    case linear
+    case nonlinear
+    case adaptiveRateNonlinear
+}
+
+public protocol CarbStoreDelegate: class {
+
+    /// Informs the delegate that an internal error occurred
+    ///
+    /// - parameter carbStore: The carb store
+    /// - parameter error:     The error describing the issue
+    func carbStore(_ carbStore: CarbStore, didError error: CarbStore.CarbStoreError)
+}
+
+public protocol CarbStoreSyncDelegate: class {
+
+    /// Asks the delegate to upload recently-added carb entries not yet marked as uploaded.
+    ///
+    /// The completion handler must be called in all circumstances with each entry passed to the delegate
+    ///
+    /// - parameter carbStore:  The store instance
+    /// - parameter entries:    The carb entries
+    /// - parameter completion: The closure to execute when the upload attempt(s) have completed. The closure takes a single argument of an array of entries. Populate `externalID` and set `isUploaded` for each entry that was uploaded, or pass back the entry unmodified for each entry that failed to upload.
+    func carbStore(_ carbStore: CarbStore, hasEntriesNeedingUpload entries: [StoredCarbEntry], completion: @escaping (_ entries: [StoredCarbEntry]) -> Void)
+
+    /// Asks the delegate to delete carb entries that were previously uploaded.
+    ///
+    /// The completion handler must be called in all circumstances with each entry passed to the delegate
+    ///
+    /// - parameter carbStore:  The store instance
+    /// - parameter entries:    The deleted entries
+    /// - parameter completion: The closure to execute when the deletion attempt(s) have finished. The closure takes a single argument of an array of entries. Set `isUploaded` to true for each entry that was uploaded, or pass back the entry unmodified for each entry that failed to upload.
+    func carbStore(_ carbStore: CarbStore, hasDeletedEntries entries: [DeletedCarbEntry], completion: @escaping (_ entries: [DeletedCarbEntry]) -> Void)
+}
+
+/**
+ Manages storage, retrieval, and calculation of carbohydrate data.
+
+ There are two tiers of storage:
+
+ * Short-term persistant cache, stored in Core Data, used to ensure access if the app is suspended and re-launched while the Health database is protected
+ ```
+ 0       [cacheLength]
+ |––––––––––––|
+ ```
+ * HealthKit data, managed by the current application and persisted indefinitely
+ ```
+ 0
+ |––––––––––––––––––--->
+ ```
+ */
+public final class CarbStore: HealthKitSampleStore {
+    
+    /// Notification posted when carb entries were changed, either via add/replace/delete methods or from HealthKit
+    public static let carbEntriesDidUpdate = NSNotification.Name(rawValue: "com.loudnate.CarbKit.carbEntriesDidUpdate")
+
+    public typealias DefaultAbsorptionTimes = (fast: TimeInterval, medium: TimeInterval, slow: TimeInterval)
+
+    public static let defaultAbsorptionTimes: DefaultAbsorptionTimes = (fast: TimeInterval(hours: 2), medium: TimeInterval(hours: 3), slow: TimeInterval(hours: 4))
+
+    /// The default longest expected absorption time interval for carbohydrates: 8 hours.
+    public static var defaultMaximumAbsorptionTimeInterval: TimeInterval {
+        return defaultAbsorptionTimes.slow * 2
+    }
+
+    public enum CarbStoreError: Error {
+        // The store isn't correctly configured for the requested operation
+        case notConfigured
+        // The health store request returned an error
+        case healthStoreError(Error)
+        // The requested sample can't be modified by this store
+        case unauthorized
+        // No data was found to match the specified request
+        case noData
+    }
+
+    private let carbType = HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.dietaryCarbohydrates)!
+
+    /// The preferred unit. iOS currently only supports grams for dietary carbohydrates.
+    public override var preferredUnit: HKUnit! {
+        return super.preferredUnit
+    }
+
+    /// A history of recently applied schedule overrides.
+    private let overrideHistory: TemporaryScheduleOverrideHistory?
+
+    /// Carbohydrate-to-insulin ratio
+    public var carbRatioSchedule: CarbRatioSchedule? {
+        get {
+            return lockedCarbRatioSchedule.value
+        }
+        set {
+            lockedCarbRatioSchedule.value = newValue
+        }
+    }
+    private let lockedCarbRatioSchedule: Locked<CarbRatioSchedule?>
+
+    /// The carb ratio schedule, applying recent overrides relative to the current moment in time.
+    public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? {
+        if let carbRatioSchedule = carbRatioSchedule {
+            return overrideHistory?.resolvingRecentCarbRatioSchedule(carbRatioSchedule)
+        } else {
+            return nil
+        }
+    }
+
+    /// A trio of default carbohydrate absorption times. Defaults to 2, 3, and 4 hours.
+    public let defaultAbsorptionTimes: DefaultAbsorptionTimes
+
+    /// Insulin-to-glucose sensitivity
+    public var insulinSensitivitySchedule: InsulinSensitivitySchedule? {
+        get {
+            return lockedInsulinSensitivitySchedule.value
+        }
+        set {
+            lockedInsulinSensitivitySchedule.value = newValue
+        }
+    }
+    private let lockedInsulinSensitivitySchedule:  Locked<InsulinSensitivitySchedule?>
+
+    /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time.
+    public var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? {
+        if let insulinSensitivitySchedule = insulinSensitivitySchedule {
+            return overrideHistory?.resolvingRecentInsulinSensitivitySchedule(insulinSensitivitySchedule)
+        } else {
+            return nil
+        }
+    }
+
+    /// The computed carbohydrate sensitivity schedule based on the insulin sensitivity and carb ratio schedules.
+    public var carbSensitivitySchedule: CarbSensitivitySchedule? {
+        guard let insulinSensitivitySchedule = insulinSensitivitySchedule, let carbRatioSchedule = carbRatioSchedule else {
+            return nil
+        }
+        return .carbSensitivitySchedule(insulinSensitivitySchedule: insulinSensitivitySchedule, carbRatioSchedule: carbRatioSchedule)
+    }
+
+    /// The expected delay in the appearance of glucose effects, accounting for both digestion and sensor lag
+    public let delay: TimeInterval
+
+    /// The interval between effect values to use for the calculated timelines.
+    public let delta: TimeInterval
+
+    /// The factor by which the entered absorption time can be extended to accomodate slower-than-expected absorption
+    public let absorptionTimeOverrun: Double
+    
+    /// Carb absorption model
+    public let carbAbsorptionModel: CarbAbsorptionModel
+
+    /// The interval of carb data to keep in cache
+    public let cacheLength: TimeInterval
+
+    public let cacheStore: PersistenceController
+
+    /// The sync version used for new samples written to HealthKit
+    /// Choose a lower or higher sync version if the same sample might be written twice (e.g. from an extension and from an app) for deterministic conflict resolution
+    public let syncVersion: Int
+
+    public weak var delegate: CarbStoreDelegate?
+
+    public weak var syncDelegate: CarbStoreSyncDelegate?
+
+    private let queue = DispatchQueue(label: "com.loudnate.CarbKit.dataAccessQueue", qos: .utility)
+
+    private let log = OSLog(category: "CarbStore")
+    
+    var settings = CarbModelSettings(absorptionModel: LinearAbsorption(), initialAbsorptionTimeOverrun: 1.5, adaptiveAbsorptionRateEnabled: false)
+
+    /**
+     Initializes a new instance of the store.
+
+     - returns: A new instance of the store
+     */
+    public init(
+        healthStore: HKHealthStore,
+        cacheStore: PersistenceController,
+        observationEnabled: Bool = true,
+        cacheLength: TimeInterval = defaultAbsorptionTimes.slow * 2,
+        defaultAbsorptionTimes: DefaultAbsorptionTimes = defaultAbsorptionTimes,
+        carbRatioSchedule: CarbRatioSchedule? = nil,
+        insulinSensitivitySchedule: InsulinSensitivitySchedule? = nil,
+        overrideHistory: TemporaryScheduleOverrideHistory? = nil,
+        syncVersion: Int = 1,
+        absorptionTimeOverrun: Double = 1.5,
+        calculationDelta: TimeInterval = 5 /* minutes */ * 60,
+        effectDelay: TimeInterval = 10 /* minutes */ * 60,
+        carbAbsorptionModel: CarbAbsorptionModel = .nonlinear
+    ) {
+        self.cacheStore = cacheStore
+        self.defaultAbsorptionTimes = defaultAbsorptionTimes
+        self.lockedCarbRatioSchedule = Locked(carbRatioSchedule)
+        self.lockedInsulinSensitivitySchedule = Locked(insulinSensitivitySchedule)
+        self.overrideHistory = overrideHistory
+        self.syncVersion = syncVersion
+        self.absorptionTimeOverrun = absorptionTimeOverrun
+        self.delta = calculationDelta
+        self.delay = effectDelay
+        self.cacheLength = max(cacheLength, defaultAbsorptionTimes.slow * 2)
+        self.carbAbsorptionModel = carbAbsorptionModel
+
+        super.init(healthStore: healthStore, type: carbType, observationStart: Date(timeIntervalSinceNow: -cacheLength), observationEnabled: observationEnabled)
+
+        cacheStore.onReady { (error) in
+            guard error == nil else { return }
+
+            // Migrate modifiedCarbEntries and deletedCarbEntryIDs
+            self.cacheStore.managedObjectContext.perform {
+                for entry in UserDefaults.standard.modifiedCarbEntries ?? [] {
+                    let object = CachedCarbObject(context: self.cacheStore.managedObjectContext)
+                    object.update(from: entry)
+                }
+
+
+                for externalID in UserDefaults.standard.deletedCarbEntryIds ?? [] {
+                    let object = DeletedCarbObject(context: self.cacheStore.managedObjectContext)
+                    object.externalID = externalID
+                }
+
+                self.cacheStore.save()
+            }
+
+            UserDefaults.standard.purgeLegacyCarbEntryKeys()
+            
+            // Carb model settings based on the selected absorption model
+            switch self.carbAbsorptionModel {
+            case .linear:
+                self.settings = CarbModelSettings(absorptionModel: LinearAbsorption(), initialAbsorptionTimeOverrun: absorptionTimeOverrun, adaptiveAbsorptionRateEnabled: false)
+            case .nonlinear:
+                self.settings = CarbModelSettings(absorptionModel: PiecewiseLinearAbsorption(), initialAbsorptionTimeOverrun: absorptionTimeOverrun, adaptiveAbsorptionRateEnabled: false)
+            case .adaptiveRateNonlinear:
+                self.settings = CarbModelSettings(absorptionModel: PiecewiseLinearAbsorption(), initialAbsorptionTimeOverrun: 1.0, adaptiveAbsorptionRateEnabled: true, adaptiveRateStandbyIntervalFraction: 0.2)
+            }
+
+            // TODO: Consider resetting uploadState.uploading
+        }
+    }
+
+    // MARK: - HealthKitSampleStore
+
+    public override func processResults(from query: HKAnchoredObjectQuery, added: [HKSample], deleted: [HKDeletedObject], error: Error?) {
+        if let error = error {
+            self.delegate?.carbStore(self, didError: .healthStoreError(error))
+            return
+        }
+
+        queue.async {
+            var notificationRequired = false
+
+            // Append the new samples
+            if let samples = added as? [HKQuantitySample] {
+                for sample in samples {
+                    if self.addCachedObject(for: sample) {
+                        self.log.debug("Saved sample %@ into cache from HKAnchoredObjectQuery", sample.uuid.uuidString)
+                        notificationRequired = true
+                    }
+                }
+            }
+
+            // Remove deleted samples
+            for sample in deleted {
+                if self.deleteCachedObject(for: sample) {
+                    self.log.debug("Deleted sample %@ from cache from HKAnchoredObjectQuery", sample.uuid.uuidString)
+                    notificationRequired = true
+                }
+            }
+
+            // Notify listeners only if a meaningful change was made
+            if notificationRequired {
+                self.cacheStore.save()
+                self.syncExternalDB()
+
+                NotificationCenter.default.post(name: CarbStore.carbEntriesDidUpdate, object: self, userInfo: [CarbStore.notificationUpdateSourceKey: UpdateSource.queriedByHealthKit.rawValue])
+            }
+        }
+    }
+}
+
+
+// MARK: - Fetching
+extension CarbStore {
+    /// Fetches samples from HealthKit
+    ///
+    /// - Parameters:
+    ///   - start: The earliest date of samples to retrieve
+    ///   - end: The latest date of samples to retrieve, if provided
+    ///   - completion: A closure called once the samples have been retrieved
+    ///   - result: An array of samples, in chronological order by startDate
+    private func getCarbSamples(start: Date, end: Date? = nil, completion: @escaping (_ result: CarbStoreResult<[StoredCarbEntry]>) -> Void) {
+        let predicate = HKQuery.predicateForSamples(withStart: start, end: end)
+        let sortDescriptors = [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)]
+
+        let query = HKSampleQuery(sampleType: carbType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: sortDescriptors) { (query, samples, error) in
+            if let error = error {
+                completion(.failure(.healthStoreError(error)))
+            } else {
+                completion(.success((samples as? [HKQuantitySample] ?? []).map { StoredCarbEntry(sample: $0) }))
+            }
+        }
+
+        healthStore.execute(query)
+    }
+
+    /// Fetches samples from HealthKit, if available, or returns from cache.
+    ///
+    /// - Parameters:
+    ///   - start: The earliest date of samples to retrieve
+    ///   - end: The latest date of samples to retrieve, if provided
+    ///   - completion: A closure called once the samples have been retrieved
+    ///   - samples: An array of samples, in chronological order by startDate
+    public func getCachedCarbSamples(start: Date, end: Date? = nil, completion: @escaping (_ samples: [StoredCarbEntry]) -> Void) {
+        #if os(iOS)
+        // If we're within our cache duration, skip the HealthKit query
+        guard start <= earliestCacheDate else {
+            self.queue.async {
+                completion(self.getCachedCarbEntries().filterDateRange(start, end))
+            }
+            return
+        }
+        #endif
+
+        getCarbSamples(start: start, end: end) { (result) in
+            switch result {
+            case .success(let samples):
+                completion(samples)
+            case .failure:
+                self.queue.async {
+                    completion(self.getCachedCarbEntries().filterDateRange(start, end))
+                }
+            }
+        }
+    }
+
+    /// Retrieves carb entries from HealthKit within the specified date range
+    ///
+    /// - Parameters:
+    ///   - start: The earliest date of values to retrieve
+    ///   - end: The latest date of values to retrieve, if provided
+    ///   - completion: A closure calld once the values have been retrieved
+    ///   - result: An array of carb entries, in chronological order by startDate
+    public func getCarbEntries(start: Date, end: Date? = nil, completion: @escaping (_ result: CarbStoreResult<[StoredCarbEntry]>) -> Void) {
+        getCarbSamples(start: start, end: end) { (result) in
+            switch result {
+            case .success(let samples):
+                completion(.success(samples))
+            case .failure(let error):
+                completion(.failure(error))
+            }
+        }
+    }
+
+    /// Retrieves carb entries from HealthKit within the specified date range and interprets their
+    /// absorption status based on the provided glucose effect
+    ///
+    /// - Parameters:
+    ///   - start: The earliest date of values to retrieve
+    ///   - end: The latest date of values to retrieve, if provided
+    ///   - effectVelocities: A timeline of glucose effect velocities, ordered by start date
+    ///   - completion: A closure calld once the values have been retrieved
+    ///   - result: An array of carb entries, in chronological order by startDate
+    public func getCarbStatus(
+        start: Date,
+        end: Date? = nil,
+        effectVelocities: [GlucoseEffectVelocity]? = nil,
+        completion: @escaping (_ result: CarbStoreResult<[CarbStatus<StoredCarbEntry>]>) -> Void
+    ) {
+        getCarbSamples(start: start, end: end) { (result) in
+            switch result {
+            case .success(let samples):
+                let status = samples.map(
+                    to: effectVelocities ?? [],
+                    carbRatio: self.carbRatioScheduleApplyingOverrideHistory,
+                    insulinSensitivity: self.insulinSensitivityScheduleApplyingOverrideHistory,
+                    absorptionTimeOverrun: self.absorptionTimeOverrun,
+                    defaultAbsorptionTime: self.defaultAbsorptionTimes.medium,
+                    delay: self.delay,
+                    initialAbsorptionTimeOverrun: self.settings.initialAbsorptionTimeOverrun,
+                    absorptionModel: self.settings.absorptionModel,
+                    adaptiveAbsorptionRateEnabled: self.settings.adaptiveAbsorptionRateEnabled,
+                    adaptiveRateStandbyIntervalFraction: self.settings.adaptiveRateStandbyIntervalFraction
+                )
+
+                completion(.success(status))
+            case .failure(let error):
+                completion(.failure(error))
+            }
+        }
+    }
+}
+
+
+// MARK: - Modification
+extension CarbStore {
+    public func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult<StoredCarbEntry>) -> Void) {
+        let sample = entry.createSample(from: nil, syncVersion: syncVersion)
+        let stored = StoredCarbEntry(sample: sample, createdByCurrentApp: true)
+
+        healthStore.save(sample) { (completed, error) -> Void in
+            self.queue.async {
+                if completed {
+                    self.addCachedObject(for: stored)
+                    completion(.success(stored))
+                    NotificationCenter.default.post(name: CarbStore.carbEntriesDidUpdate, object: self, userInfo: [CarbStore.notificationUpdateSourceKey: UpdateSource.changedInApp.rawValue])
+                    self.syncExternalDB()
+                } else if let error = error {
+                    self.log.error("Error saving entry %@: %@", sample.uuid.uuidString, String(describing: error))
+                    completion(.failure(.healthStoreError(error)))
+                } else {
+                    assertionFailure()
+                }
+            }
+        }
+    }
+
+    public func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult<StoredCarbEntry>) -> Void) {
+        guard oldEntry.createdByCurrentApp else {
+            completion(.failure(.unauthorized))
+            return
+        }
+
+        let sample = newEntry.createSample(from: oldEntry, syncVersion: syncVersion)
+        let stored = StoredCarbEntry(sample: sample, createdByCurrentApp: true)
+
+        healthStore.save(sample) { (completed, error) -> Void in
+            self.queue.async {
+                if completed {
+                    self.replaceCachedObject(for: oldEntry, with: stored)
+                    completion(.success(stored))
+                    NotificationCenter.default.post(name: CarbStore.carbEntriesDidUpdate, object: self, userInfo: [CarbStore.notificationUpdateSourceKey: UpdateSource.changedInApp.rawValue])
+                    self.syncExternalDB()
+                } else if let error = error {
+                    self.log.error("Error replacing entry %@: %@", oldEntry.sampleUUID.uuidString, String(describing: error))
+                    completion(.failure(.healthStoreError(error)))
+                } else {
+                    assertionFailure()
+                }
+            }
+        }
+    }
+
+    public func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult<Bool>) -> Void) {
+        guard entry.createdByCurrentApp else {
+            completion(.failure(.unauthorized))
+            return
+        }
+
+        let predicate = HKQuery.predicateForObject(with: entry.sampleUUID)
+        self.healthStore.deleteObjects(of: carbType, predicate: predicate) { (success, count, error) in
+            self.queue.async {
+                if success {
+                    self.deleteCachedObject(for: entry)
+                    completion(.success(true))
+                    NotificationCenter.default.post(name: CarbStore.carbEntriesDidUpdate, object: self, userInfo: [CarbStore.notificationUpdateSourceKey: UpdateSource.changedInApp.rawValue])
+                    self.syncExternalDB()
+                } else if let error = error {
+                    self.log.error("Error deleting entry %@: %@", entry.sampleUUID.uuidString, String(describing: error))
+                    completion(.failure(.healthStoreError(error)))
+                } else {
+                    assertionFailure()
+                }
+            }
+        }
+    }
+}
+
+
+extension NSManagedObjectContext {
+    fileprivate func cachedCarbObjectsWithUUID(_ uuid: UUID, fetchLimit: Int? = nil) -> [CachedCarbObject] {
+        let request: NSFetchRequest<CachedCarbObject> = CachedCarbObject.fetchRequest()
+        if let limit = fetchLimit {
+            request.fetchLimit = limit
+        }
+        request.predicate = NSPredicate(format: "uuid == %@", uuid as NSUUID)
+
+        return (try? fetch(request)) ?? []
+    }
+
+    fileprivate func deleteObjects<T>(matching fetchRequest: NSFetchRequest<T>) throws -> Int where T: NSManagedObject {
+        let objects = try fetch(fetchRequest)
+
+        for object in objects {
+            delete(object)
+        }
+
+        if hasChanges {
+            try save()
+        }
+
+        return objects.count
+    }
+}
+
+
+// MARK: - Cache management
+extension CarbStore {
+    private var earliestCacheDate: Date {
+        return Date(timeIntervalSinceNow: -cacheLength)
+    }
+
+    @discardableResult
+    private func addCachedObject(for sample: HKQuantitySample) -> Bool {
+        return addCachedObject(for: StoredCarbEntry(sample: sample))
+    }
+
+    @discardableResult
+    private func addCachedObject(for entry: StoredCarbEntry) -> Bool {
+        dispatchPrecondition(condition: .onQueue(queue))
+
+        var created = false
+
+        cacheStore.managedObjectContext.performAndWait {
+            guard self.cacheStore.managedObjectContext.cachedCarbObjectsWithUUID(entry.sampleUUID, fetchLimit: 1).count == 0 else {
+                return
+            }
+
+            let object = CachedCarbObject(context: self.cacheStore.managedObjectContext)
+            object.update(from: entry)
+
+            self.cacheStore.save()
+            created = true
+        }
+
+        return created
+    }
+
+    private func replaceCachedObject(for oldEntry: StoredCarbEntry, with newEntry: StoredCarbEntry) {
+        dispatchPrecondition(condition: .onQueue(queue))
+
+        cacheStore.managedObjectContext.performAndWait {
+            for object in self.cacheStore.managedObjectContext.cachedCarbObjectsWithUUID(oldEntry.sampleUUID) {
+                object.update(from: newEntry)
+                object.uploadState = .notUploaded
+            }
+
+            self.cacheStore.save()
+        }
+    }
+
+    @discardableResult
+    private func deleteCachedObject(for sample: HKDeletedObject) -> Bool {
+        return deleteCachedObject(forSampleUUID: sample.uuid)
+    }
+
+    @discardableResult
+    private func deleteCachedObject(for entry: StoredCarbEntry) -> Bool {
+        return deleteCachedObject(forSampleUUID: entry.sampleUUID)
+    }
+
+    @discardableResult
+    private func deleteCachedObject(forSampleUUID uuid: UUID) -> Bool {
+        dispatchPrecondition(condition: .onQueue(queue))
+
+        var deleted = false
+
+        cacheStore.managedObjectContext.performAndWait {
+            for object in self.cacheStore.managedObjectContext.cachedCarbObjectsWithUUID(uuid) {
+                if let externalID = object.externalID {
+                    let deletedObject = DeletedCarbObject(context: self.cacheStore.managedObjectContext)
+                    deletedObject.externalID = externalID
+                }
+
+                self.cacheStore.managedObjectContext.delete(object)
+                deleted = true
+            }
+
+            self.cacheStore.save()
+        }
+
+        return deleted
+    }
+
+    private var cachedDeletedCarbEntries: [DeletedCarbEntry] {
+        dispatchPrecondition(condition: .onQueue(queue))
+        var entries: [DeletedCarbEntry] = []
+        
+        cacheStore.managedObjectContext.performAndWait {
+            let request: NSFetchRequest<DeletedCarbObject> = DeletedCarbObject.fetchRequest()
+
+            guard let objects = try? self.cacheStore.managedObjectContext.fetch(request) else {
+                return
+            }
+            
+            entries = objects.compactMap { DeletedCarbEntry(managedObject: $0) }
+        }
+        
+        return entries
+    }
+
+    private func purgeCachedCarbEntries() {
+        dispatchPrecondition(condition: .onQueue(queue))
+
+        cacheStore.managedObjectContext.performAndWait {
+            let predicate = NSPredicate(format: "startDate < %@", earliestCacheDate as NSDate)
+
+            do {
+                let fetchRequest: NSFetchRequest<CachedCarbObject> = CachedCarbObject.fetchRequest()
+                fetchRequest.predicate = predicate
+                let count = try self.cacheStore.managedObjectContext.deleteObjects(matching: fetchRequest)
+                self.log.info("Deleted %d CachedCarbObjects", count)
+            } catch let error {
+                self.log.error("Unable to purge CachedCarbObjects: %@", String(describing: error))
+            }
+
+            do {
+                let fetchRequest: NSFetchRequest<DeletedCarbObject> = DeletedCarbObject.fetchRequest()
+                fetchRequest.predicate = predicate
+                let count = try self.cacheStore.managedObjectContext.deleteObjects(matching: fetchRequest)
+                self.log.info("Deleted %d DeletedCarbObjects", count)
+            } catch let error {
+                self.log.error("Unable to purge DeletedCarbObjects: %@", String(describing: error))
+            }
+        }
+    }
+
+    private func syncExternalDB() {
+        dispatchPrecondition(condition: .onQueue(queue))
+
+        self.purgeCachedCarbEntries()
+
+        guard let syncDelegate = self.syncDelegate else {
+            return
+        }
+
+        var entriesToUpload: [StoredCarbEntry] = []
+        var entriesToDelete: [DeletedCarbEntry] = []
+
+        cacheStore.managedObjectContext.performAndWait {
+            let notUploaded = NSPredicate(format: "uploadState == %d", UploadState.notUploaded.rawValue)
+
+            let cachedRequest: NSFetchRequest<CachedCarbObject> = CachedCarbObject.fetchRequest()
+            cachedRequest.predicate = notUploaded
+
+            if let objectsToUpload = try? self.cacheStore.managedObjectContext.fetch(cachedRequest) {
+                entriesToUpload = objectsToUpload.map { StoredCarbEntry(managedObject: $0) }
+                objectsToUpload.forEach { $0.uploadState = .uploading }
+            }
+
+            let deletedRequest: NSFetchRequest<DeletedCarbObject> = DeletedCarbObject.fetchRequest()
+            deletedRequest.predicate = notUploaded
+
+            if let objectsToDelete = try? self.cacheStore.managedObjectContext.fetch(deletedRequest) {
+                entriesToDelete = objectsToDelete.compactMap { DeletedCarbEntry(managedObject: $0) }
+                objectsToDelete.forEach { $0.uploadState = .uploading }
+            }
+
+            self.cacheStore.save()
+        }
+
+        if entriesToUpload.count > 0 {
+            syncDelegate.carbStore(self, hasEntriesNeedingUpload: entriesToUpload) { (entries) in
+                self.cacheStore.managedObjectContext.perform {
+                    var hasMissingObjects = false
+
+                    for entry in entries {
+                        let objects = self.cacheStore.managedObjectContext.cachedCarbObjectsWithUUID(entry.sampleUUID)
+                        for object in objects {
+                            object.externalID = entry.externalID
+                            object.uploadState = entry.isUploaded ? .uploaded : .notUploaded
+                        }
+
+                        // If our delegate sent back uploaded entries we no longer know about,
+                        // consider them needing deletion.
+                        if  objects.count == 0,
+                            entry.isUploaded,
+                            entry.startDate > self.earliestCacheDate,
+                            let externalID = entry.externalID
+                        {
+                            self.log.info("Uploaded entry %@ not found in cache", entry.sampleUUID.uuidString)
+                            let deleted = DeletedCarbObject(context: self.cacheStore.managedObjectContext)
+                            deleted.externalID = externalID
+                            hasMissingObjects = true
+                        }
+                    }
+
+                    self.cacheStore.save()
+
+                    if hasMissingObjects {
+                        self.queue.async {
+                            self.syncExternalDB()
+                        }
+                    }
+                }
+            }
+        }
+
+        if entriesToDelete.count > 0 {
+            syncDelegate.carbStore(self, hasDeletedEntries: entriesToDelete) { (entries) in
+                self.cacheStore.managedObjectContext.perform {
+                    for entry in entries {
+                        let request: NSFetchRequest<DeletedCarbObject> = DeletedCarbObject.fetchRequest()
+                        request.predicate = NSPredicate(format: "externalID == %@", entry.externalID)
+
+                        if let objects = try? self.cacheStore.managedObjectContext.fetch(request) {
+                            for object in objects {
+                                if entry.isUploaded {
+                                    self.cacheStore.managedObjectContext.delete(object)
+                                } else {
+                                    object.uploadState = .notUploaded
+                                }
+                            }
+                        }
+                    }
+
+                    self.cacheStore.save()
+                }
+            }
+        }
+    }
+
+    // MARK: - Helpers
+
+    /// Fetches carb entries from the cache that match the given predicate
+    ///
+    /// - Parameter predicate: The predicate to apply to the objects
+    /// - Returns: An array of carb entries, in chronological order by startDate
+    private func getCachedCarbEntries(matching predicate: NSPredicate? = nil) -> [StoredCarbEntry] {
+        dispatchPrecondition(condition: .onQueue(queue))
+        var entries: [StoredCarbEntry] = []
+
+        cacheStore.managedObjectContext.performAndWait {
+            let request: NSFetchRequest<CachedCarbObject> = CachedCarbObject.fetchRequest()
+            request.predicate = predicate
+            request.sortDescriptors = [NSSortDescriptor(key: "startDate", ascending: true)]
+
+            guard let objects = try? self.cacheStore.managedObjectContext.fetch(request) else {
+                return
+            }
+
+            entries = objects.map { StoredCarbEntry(managedObject: $0) }
+        }
+
+        return entries
+    }
+}
+
+
+// MARK: - Math
+extension CarbStore {
+    /// The longest expected absorption time interval for carbohydrates. Defaults to 8 hours.
+    public var maximumAbsorptionTimeInterval: TimeInterval {
+        return defaultAbsorptionTimes.slow * 2
+    }
+
+    /// Retrieves the single carbs on-board value occuring just prior or equal to the specified date
+    ///
+    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
+    ///
+    /// - Parameters:
+    ///   - date: The date of the value to retrieve
+    ///   - effectVelocities: A timeline of glucose effect velocities, ordered by start date
+    ///   - completion: A closure called once the value has been retrieved
+    ///   - result: The carbs on-board value
+    public func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]? = nil, completion: @escaping (_ result: CarbStoreResult<CarbValue>) -> Void) {
+        getCarbsOnBoardValues(start: date.addingTimeInterval(-delta), end: date, effectVelocities: effectVelocities) { (values) in
+            guard let value = values.closestPrior(to: date) else {
+                completion(.failure(.noData))
+                return
+            }
+            completion(.success(value))
+        }
+    }
+
+    /// Retrieves a timeline of unabsorbed carbohydrates
+    ///
+    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
+    ///
+    /// - Parameters:
+    ///   - start: The earliest date of values to retrieve
+    ///   - end: The latest date of values to retrieve, if provided
+    ///   - effectVelocities: A timeline of glucose effect velocities, ordered by start date
+    ///   - completion: A closure called once the values have been retrieved
+    ///   - values: A timeline of carb values, in chronological order
+    public func getCarbsOnBoardValues(start: Date, end: Date? = nil, effectVelocities: [GlucoseEffectVelocity]? = nil, completion: @escaping (_ values: [CarbValue]) -> Void) {
+        // To know COB at the requested start date, we need to fetch samples that might still be absorbing
+        let foodStart = start.addingTimeInterval(-maximumAbsorptionTimeInterval)
+        getCachedCarbSamples(start: foodStart, end: end) { (samples) in
+            let carbsOnBoard: [CarbValue]
+
+            if let velocities = effectVelocities, let carbRatioSchedule = self.carbRatioScheduleApplyingOverrideHistory, let insulinSensitivitySchedule = self.insulinSensitivityScheduleApplyingOverrideHistory {
+                carbsOnBoard = samples.map(
+                    to: velocities,
+                    carbRatio: carbRatioSchedule,
+                    insulinSensitivity: insulinSensitivitySchedule,
+                    absorptionTimeOverrun: self.absorptionTimeOverrun,
+                    defaultAbsorptionTime: self.defaultAbsorptionTimes.medium,
+                    delay: self.delay,
+                    initialAbsorptionTimeOverrun: self.settings.initialAbsorptionTimeOverrun,
+                    absorptionModel: self.settings.absorptionModel,
+                    adaptiveAbsorptionRateEnabled: self.settings.adaptiveAbsorptionRateEnabled,
+                    adaptiveRateStandbyIntervalFraction: self.settings.adaptiveRateStandbyIntervalFraction
+                ).dynamicCarbsOnBoard(
+                    from: start,
+                    to: end,
+                    defaultAbsorptionTime: self.defaultAbsorptionTimes.medium,
+                    absorptionModel: self.settings.absorptionModel,
+                    delay: self.delay,
+                    delta: self.delta
+                )
+            } else {
+                carbsOnBoard = samples.carbsOnBoard(
+                    from: start,
+                    to: end,
+                    defaultAbsorptionTime: self.defaultAbsorptionTimes.medium,
+                    absorptionModel: self.settings.absorptionModel,
+                    delay: self.delay,
+                    delta: self.delta
+                )
+            }
+
+            completion(carbsOnBoard)
+        }
+    }
+
+    /// Retrieves a timeline of effect on blood glucose from carbohydrates
+    ///
+    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
+    ///
+    /// - Parameters:
+    ///   - start: The earliest date of effects to retrieve
+    ///   - end: The latest date of effects to retrieve, if provided
+    ///   - effectVelocities: A timeline of glucose effect velocities, ordered by start date
+    ///   - completion: A closure called once the effects have been retrieved
+    ///   - result: An array of effects, in chronological order
+    public func getGlucoseEffects(start: Date, end: Date? = nil, effectVelocities: [GlucoseEffectVelocity]? = nil, completion: @escaping(_ result: CarbStoreResult<[GlucoseEffect]>) -> Void) {
+        queue.async {
+            guard let carbRatioSchedule = self.carbRatioScheduleApplyingOverrideHistory, let insulinSensitivitySchedule = self.insulinSensitivityScheduleApplyingOverrideHistory else {
+                completion(.failure(.notConfigured))
+                return
+            }
+
+            // To know glucose effects at the requested start date, we need to fetch samples that might still be absorbing
+            let foodStart = start.addingTimeInterval(-self.maximumAbsorptionTimeInterval)
+            let defaultAbsorptionTimes = self.defaultAbsorptionTimes
+            let absorptionTimeOverrun = self.absorptionTimeOverrun
+            let delay = self.delay
+            let delta = self.delta
+            
+            self.getCachedCarbSamples(start: foodStart, end: end) { (samples) in
+                let effects: [GlucoseEffect]
+
+                if let effectVelocities = effectVelocities {
+                    effects = samples.map(
+                        to: effectVelocities,
+                        carbRatio: carbRatioSchedule,
+                        insulinSensitivity: insulinSensitivitySchedule,
+                        absorptionTimeOverrun: absorptionTimeOverrun,
+                        defaultAbsorptionTime: defaultAbsorptionTimes.medium,
+                        delay: delay,
+                        initialAbsorptionTimeOverrun: self.settings.initialAbsorptionTimeOverrun,
+                        absorptionModel: self.settings.absorptionModel,
+                        adaptiveAbsorptionRateEnabled: self.settings.adaptiveAbsorptionRateEnabled,
+                        adaptiveRateStandbyIntervalFraction: self.settings.adaptiveRateStandbyIntervalFraction
+                    ).dynamicGlucoseEffects(
+                        from: start,
+                        to: end,
+                        carbRatios: carbRatioSchedule,
+                        insulinSensitivities: insulinSensitivitySchedule,
+                        defaultAbsorptionTime: defaultAbsorptionTimes.medium,
+                        absorptionModel: self.settings.absorptionModel,
+                        delay: delay,
+                        delta: delta
+                    )
+                } else {
+                    effects = samples.glucoseEffects(
+                        from: start,
+                        to: end,
+                        carbRatios: carbRatioSchedule,
+                        insulinSensitivities: insulinSensitivitySchedule,
+                        defaultAbsorptionTime: defaultAbsorptionTimes.medium,
+                        absorptionModel: self.settings.absorptionModel,
+                        delay: delay,
+                        delta: delta
+                    )
+                }
+
+                completion(.success(effects))
+            }
+        }
+    }
+
+    /// Retrieves the total number of recorded carbohydrates for the specified period.
+    ///
+    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
+    ///
+    /// - Parameters:
+    ///   - start: The earliest date of samples to include.
+    ///   - completion: A closure called once the value has been retrieved.
+    ///   - result: The total carbs recorded and the date of the first sample
+    public func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult<CarbValue>) -> Void) {
+        getCarbSamples(start: start) { (result) in
+            switch result {
+            case .success(let samples):
+                let total = samples.totalCarbs ?? CarbValue(
+                    startDate: start,
+                    quantity: HKQuantity(unit: self.preferredUnit, doubleValue: 0)
+                )
+
+                completion(.success(total))
+            case .failure(let error):
+                completion(.failure(error))
+            }
+        }
+    }
+}
+
+
+extension CarbStore {
+    /// Generates a diagnostic report about the current state
+    ///
+    /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue.
+    ///
+    /// - parameter completionHandler: A closure called once the report has been generated. The closure takes a single argument of the report string.
+    public func generateDiagnosticReport(_ completionHandler: @escaping (_ report: String) -> Void) {
+        queue.async {
+            
+            var carbAbsorptionModel: String
+            switch self.carbAbsorptionModel {
+            case .linear: carbAbsorptionModel = "Linear"
+            case .nonlinear: carbAbsorptionModel = "Nonlinear"
+            case .adaptiveRateNonlinear: carbAbsorptionModel = "Nonlinear with Adaptive Rate for Remaining Carbs"
+            }
+            
+            var report: [String] = [
+                "## CarbStore",
+                "",
+                "* carbRatioSchedule: \(self.carbRatioSchedule?.debugDescription ?? "")",
+                "* carbRatioScheduleApplyingOverrideHistory: \(self.carbRatioScheduleApplyingOverrideHistory?.debugDescription ?? "nil")",
+                "* defaultAbsorptionTimes: \(self.defaultAbsorptionTimes)",
+                "* insulinSensitivitySchedule: \(self.insulinSensitivitySchedule?.debugDescription ?? "")",
+                "* insulinSensitivityScheduleApplyingOverrideHistory: \(self.insulinSensitivityScheduleApplyingOverrideHistory?.debugDescription ?? "nil")",
+                "* overrideHistory: \(self.overrideHistory.map(String.init(describing:)) ?? "nil")",
+                "* carbSensitivitySchedule: \(self.carbSensitivitySchedule?.debugDescription ?? "nil")",
+                "* delay: \(self.delay)",
+                "* delta: \(self.delta)",
+                "* absorptionTimeOverrun: \(self.absorptionTimeOverrun)",
+                "* carbAbsorptionModel: \(carbAbsorptionModel)",
+                "* Carb absorption model settings: \(self.settings)",
+                super.debugDescription,
+                "",
+                "cachedCarbEntries: [",
+                "\tStoredCarbEntry(sampleUUID, syncIdentifier, syncVersion, startDate, quantity, foodType, absorptionTime, createdByCurrentApp, externalID, isUploaded)"
+            ]
+
+            let carbEntries = self.getCachedCarbEntries()
+
+            report.append(carbEntries.map({ (entry) -> String in
+                return [
+                    "\t",
+                    String(describing: entry.sampleUUID),
+                    entry.syncIdentifier ?? "",
+                    String(describing: entry.syncVersion),
+                    String(describing: entry.startDate),
+                    String(describing: entry.quantity),
+                    entry.foodType ?? "",
+                    String(describing: entry.absorptionTime ?? self.defaultAbsorptionTimes.medium),
+                    String(describing: entry.createdByCurrentApp),
+                    entry.externalID ?? "",
+                    String(describing: entry.isUploaded),
+                ].joined(separator: ", ")
+            }).joined(separator: "\n"))
+            report.append("]")
+            report.append("")
+
+            report.append("deletedCarbEntries: [")
+            report.append("\tDeletedCarbEntry(externalID, isUploaded)")
+            for entry in self.cachedDeletedCarbEntries {
+                report.append("\t\(entry.externalID), \(entry.isUploaded)")
+            }
+            report.append("]")
+            report.append("")
+
+            completionHandler(report.joined(separator: "\n"))
+        }
+    }
+}

+ 35 - 0
LoopKit/LoopKit/CarbKit/CarbStoreError.swift

@@ -0,0 +1,35 @@
+//
+//  CarbStoreError.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+
+extension CarbStore.CarbStoreError: LocalizedError {
+    public var errorDescription: String? {
+        switch self {
+        case .unauthorized:
+            return LocalizedString("com.loudnate.CarbKit.deleteCarbEntryUnownedErrorDescription", value: "Authorization Denied", comment: "The description of an error returned when attempting to delete a sample not shared by the current app")
+        case .notConfigured:
+            return nil
+        case .healthStoreError(let error):
+            return error.localizedDescription
+        case .noData:
+            return LocalizedString("No values found", comment: "Describes an error for no data found in a CarbStore request")
+        }
+    }
+
+    public var recoverySuggestion: String? {
+        switch self {
+        case .unauthorized:
+            return LocalizedString("com.loudnate.carbKit.sharingDeniedErrorRecoverySuggestion", value: "This sample can be deleted from the Health app", comment: "The error recovery suggestion when attempting to delete a sample not shared by the current app")
+        case .notConfigured:
+            return nil
+        case .healthStoreError:
+            return nil
+        case .noData:
+            return LocalizedString("Ensure carb data exists for the specified date", comment: "Recovery suggestion for a no data error")
+        }
+    }
+}

+ 22 - 0
LoopKit/LoopKit/CarbKit/CarbValue.swift

@@ -0,0 +1,22 @@
+//
+//  CarbValue.swift
+//  LoopKit
+//
+//  Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+public struct CarbValue: SampleValue {
+    public let startDate: Date
+    public let endDate: Date
+    public var quantity: HKQuantity
+
+    init(startDate: Date, endDate: Date? = nil, quantity: HKQuantity) {
+        self.startDate = startDate
+        self.endDate = endDate ?? startDate
+        self.quantity = quantity
+    }
+}

+ 29 - 0
LoopKit/LoopKit/CarbKit/DeletedCarbEntry.swift

@@ -0,0 +1,29 @@
+//
+//  DeletedCarbEntry.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+public struct DeletedCarbEntry {
+    public let externalID: String
+    public var isUploaded: Bool
+
+    public init(externalID: String, isUploaded: Bool) {
+        self.externalID = externalID
+        self.isUploaded = isUploaded
+    }
+}
+
+
+extension DeletedCarbEntry {
+    init(managedObject: DeletedCarbObject) {
+        self.init(
+            externalID: managedObject.externalID!,
+            isUploaded: managedObject.uploadState == .uploaded
+        )
+    }
+}

+ 26 - 0
LoopKit/LoopKit/CarbKit/DeletedCarbObject+CoreDataClass.swift

@@ -0,0 +1,26 @@
+//
+//  DeletedCarbObject+CoreDataClass.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+//
+
+import Foundation
+import CoreData
+
+
+class DeletedCarbObject: NSManagedObject {
+    var uploadState: UploadState {
+        get {
+            willAccessValue(forKey: "uploadState")
+            defer { didAccessValue(forKey: "uploadState") }
+            return UploadState(rawValue: primitiveUploadState!.intValue)!
+        }
+        set {
+            willChangeValue(forKey: "uploadState")
+            defer { didChangeValue(forKey: "uploadState") }
+            primitiveUploadState = NSNumber(value: newValue.rawValue)
+        }
+    }
+}

+ 23 - 0
LoopKit/LoopKit/CarbKit/DeletedCarbObject+CoreDataProperties.swift

@@ -0,0 +1,23 @@
+//
+//  DeletedCarbObject+CoreDataProperties.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+//
+
+import Foundation
+import CoreData
+
+
+extension DeletedCarbObject {
+
+    @nonobjc public class func fetchRequest() -> NSFetchRequest<DeletedCarbObject> {
+        return NSFetchRequest<DeletedCarbObject>(entityName: "DeletedCarbObject")
+    }
+
+    @NSManaged public var externalID: String?
+    @NSManaged public var primitiveUploadState: NSNumber?
+    @NSManaged public var startDate: Date?
+
+}

+ 30 - 0
LoopKit/LoopKit/CarbKit/HKQuantitySample+CarbKit.swift

@@ -0,0 +1,30 @@
+//
+//  HKQuantitySample.swift
+//  CarbKit
+//
+//  Created by Nathan Racklyeft on 1/10/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import HealthKit
+
+
+let MetadataKeyAbsorptionTimeMinutes = "com.loudnate.CarbKit.HKMetadataKey.AbsorptionTimeMinutes"
+
+extension HKQuantitySample {
+    public var foodType: String? {
+        return metadata?[HKMetadataKeyFoodType] as? String
+    }
+
+    public var absorptionTime: TimeInterval? {
+        return metadata?[MetadataKeyAbsorptionTimeMinutes] as? TimeInterval
+    }
+
+    public var createdByCurrentApp: Bool {
+        return sourceRevision.source == HKSource.default()
+    }
+
+    public var externalID: String? {
+        return metadata?[HKMetadataKeyExternalUUID] as? String
+    }
+}

+ 40 - 0
LoopKit/LoopKit/CarbKit/NSUserDefaults.swift

@@ -0,0 +1,40 @@
+//
+//  NSUserDefaults.swift
+//  LoopKit
+//
+//  Created by Nathan Racklyeft on 2/20/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+
+
+extension UserDefaults {
+    private enum Key: String {
+        case CarbEntryCache = "com.loudnate.CarbKit.CarbEntryCache"
+        case ModifiedCarbEntries = "com.loudnate.CarbKit.ModifiedCarbEntries"
+        case DeletedCarbEntryIds = "com.loudnate.CarbKit.DeletedCarbEntryIds"
+    }
+
+    func purgeLegacyCarbEntryKeys() {
+        removeObject(forKey: Key.CarbEntryCache.rawValue)
+        removeObject(forKey: Key.ModifiedCarbEntries.rawValue)
+        removeObject(forKey: Key.DeletedCarbEntryIds.rawValue)
+    }
+
+    var modifiedCarbEntries: [StoredCarbEntry]? {
+        get {
+            if let rawValue = array(forKey: Key.ModifiedCarbEntries.rawValue) as? [StoredCarbEntry.RawValue] {
+                return rawValue.compactMap { StoredCarbEntry(rawValue: $0) }
+            } else {
+                return nil
+            }
+        }
+    }
+
+    var deletedCarbEntryIds: [String]? {
+        get {
+            return array(forKey: Key.DeletedCarbEntryIds.rawValue) as? [String]
+        }
+    }
+}

+ 103 - 0
LoopKit/LoopKit/CarbKit/NewCarbEntry.swift

@@ -0,0 +1,103 @@
+//
+//  NewCarbEntry.swift
+//  CarbKit
+//
+//  Created by Nathan Racklyeft on 1/15/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+public struct NewCarbEntry: CarbEntry, Equatable, RawRepresentable {
+    public typealias RawValue = [String: Any]
+
+    public let quantity: HKQuantity
+    public let startDate: Date
+    public let foodType: String?
+    public var absorptionTime: TimeInterval?
+    public let createdByCurrentApp = true
+    public let externalID: String?
+    public let syncIdentifier: String?
+    public let isUploaded: Bool
+
+    public init(quantity: HKQuantity, startDate: Date, foodType: String?, absorptionTime: TimeInterval?, isUploaded: Bool = false, externalID: String? = nil, syncIdentifier: String? = nil) {
+        self.quantity = quantity
+        self.startDate = startDate
+        self.foodType = foodType
+        self.absorptionTime = absorptionTime
+        self.isUploaded = isUploaded
+        self.externalID = externalID
+        self.syncIdentifier = syncIdentifier
+    }
+
+    public init?(rawValue: RawValue) {
+        guard
+            let grams = rawValue["grams"] as? Double,
+            let startDate = rawValue["startDate"] as? Date
+        else {
+            return nil
+        }
+
+        let externalID = rawValue["externalID"] as? String
+
+        self.init(
+            quantity: HKQuantity(unit: .gram(), doubleValue: grams),
+            startDate: startDate,
+            foodType: rawValue["foodType"] as? String,
+            absorptionTime: rawValue["absorptionTime"] as? TimeInterval,
+            isUploaded: externalID != nil,
+            externalID: externalID,
+            syncIdentifier: rawValue["syncIdentifier"] as? String
+        )
+    }
+
+    public var rawValue: RawValue {
+        var rawValue: RawValue = [
+            "grams": quantity.doubleValue(for: .gram()),
+            "startDate": startDate
+        ]
+
+        rawValue["foodType"] = foodType
+        rawValue["absorptionTime"] = absorptionTime
+        rawValue["externalID"] = externalID
+        rawValue["syncIdentifier"] = syncIdentifier
+
+        return rawValue
+    }
+}
+
+
+extension NewCarbEntry {
+    func createSample(from oldEntry: StoredCarbEntry? = nil, syncVersion: Int = 1) -> HKQuantitySample {
+        var metadata = [String: Any]()
+
+        if let absorptionTime = absorptionTime {
+            metadata[MetadataKeyAbsorptionTimeMinutes] = absorptionTime
+        }
+
+        if let foodType = foodType {
+            metadata[HKMetadataKeyFoodType] = foodType
+        }
+
+        if let oldEntry = oldEntry, let syncIdentifier = oldEntry.syncIdentifier {
+            metadata[HKMetadataKeySyncVersion] = oldEntry.syncVersion + 1
+            metadata[HKMetadataKeySyncIdentifier] = syncIdentifier
+        } else {
+            // Add a sync identifier to allow for atomic modification if needed
+            metadata[HKMetadataKeySyncVersion] = syncVersion
+            metadata[HKMetadataKeySyncIdentifier] = syncIdentifier ?? UUID().uuidString
+        }
+
+        metadata[HKMetadataKeyExternalUUID] = externalID
+
+        return HKQuantitySample(
+            type: HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)!,
+            quantity: quantity,
+            start: startDate,
+            end: endDate,
+            metadata: metadata
+        )
+    }
+}

+ 152 - 0
LoopKit/LoopKit/CarbKit/StoredCarbEntry.swift

@@ -0,0 +1,152 @@
+//
+//  StoredCarbEntry.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 1/22/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import HealthKit
+import CoreData
+
+private let unit = HKUnit.gram()
+
+
+public struct StoredCarbEntry: CarbEntry {
+
+    public let sampleUUID: UUID
+
+    // MARK: - HealthKit Sync Support
+
+    public let syncIdentifier: String?
+    public let syncVersion: Int
+
+    // MARK: - SampleValue
+
+    public let startDate: Date
+    public let quantity: HKQuantity
+
+    // MARK: - CarbEntry
+
+    public let foodType: String?
+    public let absorptionTime: TimeInterval?
+    public let createdByCurrentApp: Bool
+
+    // MARK: - Sync state
+
+    public var externalID: String?
+    public var isUploaded: Bool
+
+    init(sample: HKQuantitySample, createdByCurrentApp: Bool? = nil) {
+        self.init(
+            sampleUUID: sample.uuid,
+            syncIdentifier: sample.metadata?[HKMetadataKeySyncIdentifier] as? String,
+            syncVersion: sample.metadata?[HKMetadataKeySyncVersion] as? Int ?? 1,
+            startDate: sample.startDate,
+            unitString: unit.unitString,
+            value: sample.quantity.doubleValue(for: unit),
+            foodType: sample.foodType,
+            absorptionTime: sample.absorptionTime,
+            createdByCurrentApp: createdByCurrentApp ?? sample.createdByCurrentApp,
+            externalID: sample.externalID,
+            isUploaded: sample.externalID != nil
+        )
+    }
+
+    public init(
+        sampleUUID: UUID,
+        syncIdentifier: String?,
+        syncVersion: Int,
+        startDate: Date,
+        unitString: String,
+        value: Double,
+        foodType: String?,
+        absorptionTime: TimeInterval?,
+        createdByCurrentApp: Bool,
+        externalID: String?,
+        isUploaded: Bool
+    ) {
+        self.sampleUUID = sampleUUID
+        self.syncIdentifier = syncIdentifier
+        self.syncVersion = syncVersion
+        self.startDate = startDate
+        self.quantity = HKQuantity(unit: HKUnit(from: unitString), doubleValue: value)
+        self.foodType = foodType
+        self.absorptionTime = absorptionTime
+        self.createdByCurrentApp = createdByCurrentApp
+        self.externalID = externalID
+        self.isUploaded = isUploaded
+    }
+}
+
+
+extension StoredCarbEntry: Hashable {
+    public func hash(into hasher: inout Hasher) {
+        hasher.combine(sampleUUID)
+    }
+}
+
+extension StoredCarbEntry: Equatable {
+    public static func ==(lhs: StoredCarbEntry, rhs: StoredCarbEntry) -> Bool {
+        return lhs.sampleUUID == rhs.sampleUUID
+    }
+}
+
+extension StoredCarbEntry: Comparable {
+    public static func <(lhs: StoredCarbEntry, rhs: StoredCarbEntry) -> Bool {
+        return lhs.startDate < rhs.startDate
+    }
+}
+
+// Deprecated, used for migration only
+extension StoredCarbEntry {
+    typealias RawValue = [String: Any]
+
+    init?(rawValue: RawValue) {
+        guard let
+            sampleUUIDString = rawValue["sampleUUID"] as? String,
+            let sampleUUID = UUID(uuidString: sampleUUIDString),
+            let startDate = rawValue["startDate"] as? Date,
+            let unitString = rawValue["unitString"] as? String,
+            let value = rawValue["value"] as? Double,
+            let createdByCurrentApp = rawValue["createdByCurrentApp"] as? Bool else
+        {
+            return nil
+        }
+
+        let externalID = rawValue["externalId"] as? String
+
+        self.init(
+            sampleUUID: sampleUUID,
+            syncIdentifier: nil,
+            syncVersion: 1,
+            startDate: startDate,
+            unitString: unitString,
+            value: value,
+            foodType: rawValue["foodType"] as? String,
+            absorptionTime: rawValue["absorptionTime"] as? TimeInterval,
+            createdByCurrentApp: createdByCurrentApp,
+            externalID: externalID,
+            isUploaded: externalID != nil
+        )
+    }
+}
+
+
+extension StoredCarbEntry {
+    init(managedObject: CachedCarbObject) {
+        self.init(
+            sampleUUID: managedObject.uuid!,
+            syncIdentifier: managedObject.syncIdentifier,
+            syncVersion: Int(managedObject.syncVersion),
+            startDate: managedObject.startDate,
+            unitString: unit.unitString,
+            value: managedObject.grams,
+            foodType: managedObject.foodType,
+            absorptionTime: managedObject.absorptionTime,
+            createdByCurrentApp: managedObject.createdByCurrentApp,
+            externalID: managedObject.externalID,
+            isUploaded: (managedObject.uploadState == .uploaded)
+        )
+    }
+}

+ 13 - 0
LoopKit/LoopKit/CarbRatioSchedule.swift

@@ -0,0 +1,13 @@
+//
+//  CarbRatioSchedule.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/12/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+public typealias CarbRatioSchedule = SingleQuantitySchedule

+ 15 - 0
LoopKit/LoopKit/CarbSensitivitySchedule.swift

@@ -0,0 +1,15 @@
+//
+//  CarbSensitivitySchedule.swift
+//  LoopKit
+//
+//  Created by Michael Pangburn on 3/27/19.
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+public typealias CarbSensitivitySchedule = SingleQuantitySchedule
+
+extension /* CarbSensitivitySchedule */ DailyQuantitySchedule where T == Double {
+    public static func carbSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule, carbRatioSchedule: CarbRatioSchedule) -> CarbSensitivitySchedule {
+        return insulinSensitivitySchedule / carbRatioSchedule
+    }
+}

+ 180 - 0
LoopKit/LoopKit/DailyQuantitySchedule+Override.swift

@@ -0,0 +1,180 @@
+//
+//  DailyQuantitySchedule+Override.swift
+//  LoopKit
+//
+//  Created by Michael Pangburn on 3/26/19.
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import HealthKit
+
+
+extension GlucoseRangeSchedule {
+    public func applyingOverride(_ override: TemporaryScheduleOverride) -> GlucoseRangeSchedule {
+        guard let targetRange = override.settings.targetRange else {
+            return self
+        }
+
+        let doubleRange = targetRange.doubleRange(for: unit)
+        let rangeOverride = GlucoseRangeSchedule.Override(start: override.startDate, end: override.endDate, value: doubleRange)
+        return GlucoseRangeSchedule(rangeSchedule: rangeSchedule, override: rangeOverride)
+    }
+}
+
+extension /* BasalRateSchedule */ DailyValueSchedule where T == Double {
+    func applyingBasalRateMultiplier(
+        from override: TemporaryScheduleOverride,
+        relativeTo date: Date = Date()
+    ) -> BasalRateSchedule {
+        return applyingOverride(override, relativeTo: date, multiplier: \.basalRateMultiplier)
+    }
+}
+
+extension /* InsulinSensitivitySchedule */ DailyQuantitySchedule where T == Double {
+    func applyingSensitivityMultiplier(
+        from override: TemporaryScheduleOverride,
+        relativeTo date: Date = Date()
+    ) -> InsulinSensitivitySchedule {
+        return DailyQuantitySchedule(
+            unit: unit,
+            valueSchedule: valueSchedule.applyingOverride(
+                override,
+                relativeTo: date,
+                multiplier: \.insulinSensitivityMultiplier
+            )
+        )
+    }
+}
+
+extension /* CarbRatioSchedule */ DailyQuantitySchedule where T == Double {
+    func applyingCarbRatioMultiplier(
+        from override: TemporaryScheduleOverride,
+        relativeTo date: Date = Date()
+    ) -> CarbRatioSchedule {
+        return DailyQuantitySchedule(
+            unit: unit,
+            valueSchedule: valueSchedule.applyingOverride(
+                override,
+                relativeTo: date,
+                multiplier: \.carbRatioMultiplier
+            )
+        )
+    }
+}
+
+extension DailyValueSchedule where T == Double {
+    fileprivate func applyingOverride(
+        _ override: TemporaryScheduleOverride,
+        relativeTo date: Date,
+        multiplier multiplierKeyPath: KeyPath<TemporaryScheduleOverrideSettings, Double?>
+    ) -> DailyValueSchedule {
+        guard let multiplier = override.settings[keyPath: multiplierKeyPath] else {
+            return self
+        }
+        return applyingOverride(
+            during: override.activeInterval,
+            relativeTo: date,
+            updatingOverridenValuesWith: { $0 * multiplier }
+        )
+    }
+}
+
+extension DailyValueSchedule {
+    fileprivate func applyingOverride(
+        during activeInterval: DateInterval,
+        relativeTo referenceDate: Date,
+        updatingOverridenValuesWith update: (T) -> T
+    ) -> DailyValueSchedule {
+        guard let activeInterval = clampingToAffectedInterval(activeInterval, relativeTo: referenceDate) else {
+            // Override has no effect relative to the reference date
+            return self
+        }
+
+        let overrideStartOffset = scheduleOffset(for: activeInterval.start)
+        let overrideEndOffset = scheduleOffset(for: activeInterval.end)
+        guard overrideStartOffset != overrideEndOffset else {
+            // Full schedule overridden
+            return DailyValueSchedule(
+                dailyItems: items.map { item in RepeatingScheduleValue(startTime: item.startTime, value: update(item.value)) },
+                timeZone: timeZone
+            )!
+        }
+
+        let overrideCrossesMidnight = overrideStartOffset > overrideEndOffset
+        let scheduleItemsIncludingOverride = scheduleItemsPaddedToClosedInterval
+            .adjacentPairs()
+            .flatMap { item, nextItem -> [RepeatingScheduleValue<T>] in
+                let overriddenItemValue = update(item.value)
+                let overriddenItem = RepeatingScheduleValue(startTime: item.startTime, value: overriddenItemValue)
+                let overrideStart = RepeatingScheduleValue(startTime: overrideStartOffset, value: overriddenItemValue)
+                let overrideEnd = RepeatingScheduleValue(startTime: overrideEndOffset, value: item.value)
+
+                let scheduleItemInterval = item.startTime..<nextItem.startTime
+                let overrideStartsInThisSegment = scheduleItemInterval.contains(overrideStartOffset)
+                let overrideEndsInThisSegment = scheduleItemInterval.contains(overrideEndOffset)
+
+                switch (overrideStartsInThisSegment, overrideEndsInThisSegment) {
+                case (true, true):
+                    if overrideCrossesMidnight {
+                        return item.startTime == overrideEndOffset
+                            ? [overrideEnd, overrideStart]
+                            : [overriddenItem, overrideEnd, overrideStart]
+                    } else {
+                        return item.startTime == overrideStartOffset
+                            ? [overrideStart, overrideEnd]
+                            : [item, overrideStart, overrideEnd]
+                    }
+                case (true, false):
+                    return item.startTime == overrideStartOffset
+                        ? [overrideStart]
+                        : [item, overrideStart]
+                case (false, true):
+                    return item.startTime == overrideEndOffset
+                        ? [overrideEnd]
+                        : [overriddenItem, overrideEnd]
+                case (false, false):
+                    let segmentIsDisjointWithOverride = overrideCrossesMidnight
+                        ? overrideEndOffset...overrideStartOffset ~= item.startTime
+                        : !(overrideStartOffset...overrideEndOffset ~= item.startTime)
+                    return segmentIsDisjointWithOverride
+                        ? [item]
+                        : [overriddenItem]
+                }
+        }
+
+        return DailyValueSchedule(
+            dailyItems: scheduleItemsIncludingOverride,
+            timeZone: timeZone
+        )!
+    }
+
+    /// Clamps the override date interval to the relevant period of effect given a reference date.
+    /// Returns `nil` if an override during the given interval has no effect relative to the reference date.
+    private func clampingToAffectedInterval(_ interval: DateInterval, relativeTo referenceDate: Date) -> DateInterval? {
+        let relevantPeriodStart = referenceDate.addingTimeInterval(-repeatInterval)
+        let relevantPeriodEnd = referenceDate.addingTimeInterval(repeatInterval)
+
+        guard
+            interval.end > relevantPeriodStart,
+            interval.start < relevantPeriodEnd
+        else {
+            return nil
+        }
+
+        let startDate = max(interval.start, relevantPeriodStart)
+        let endDate = min(interval.end, relevantPeriodEnd)
+        let affectedInterval = DateInterval(start: startDate, end: endDate)
+        return affectedInterval
+    }
+
+    /// Pads the schedule with an extra item to form a closed interval.
+    private var scheduleItemsPaddedToClosedInterval: [RepeatingScheduleValue<T>] {
+        guard let lastItem = items.last else {
+            assertionFailure("Schedule can never be empty")
+            return []
+        }
+        let lastItemStartingAtDayEnd = RepeatingScheduleValue(startTime: maxTimeInterval, value: lastItem.value)
+        return items + [lastItemStartingAtDayEnd]
+    }
+}
+

+ 132 - 0
LoopKit/LoopKit/DailyQuantitySchedule.swift

@@ -0,0 +1,132 @@
+//
+//  DailyQuantitySchedule.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/12/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+public struct DailyQuantitySchedule<T: RawRepresentable>: DailySchedule {
+    public typealias RawValue = [String: Any]
+    public let unit: HKUnit
+    var valueSchedule: DailyValueSchedule<T>
+
+    public init?(unit: HKUnit, dailyItems: [RepeatingScheduleValue<T>], timeZone: TimeZone? = nil) {
+        guard let valueSchedule = DailyValueSchedule<T>(dailyItems: dailyItems, timeZone: timeZone) else {
+            return nil
+        }
+
+        self.unit = unit
+        self.valueSchedule = valueSchedule
+    }
+
+    init(unit: HKUnit, valueSchedule: DailyValueSchedule<T>) {
+        self.unit = unit
+        self.valueSchedule = valueSchedule
+    }
+
+    public init?(rawValue: RawValue) {
+        guard let rawUnit = rawValue["unit"] as? String,
+            let valueSchedule = DailyValueSchedule<T>(rawValue: rawValue)
+            else
+        {
+            return nil
+        }
+
+        self.unit = HKUnit(from: rawUnit)
+        self.valueSchedule = valueSchedule
+    }
+
+    public var items: [RepeatingScheduleValue<T>] {
+        return valueSchedule.items
+    }
+
+    public var timeZone: TimeZone {
+        get {
+            return valueSchedule.timeZone
+        }
+        set {
+            valueSchedule.timeZone = newValue
+        }
+    }
+
+    public var rawValue: RawValue {
+        var rawValue = valueSchedule.rawValue
+
+        rawValue["unit"] = unit.unitString
+
+        return rawValue
+    }
+
+    public func between(start startDate: Date, end endDate: Date) -> [AbsoluteScheduleValue<T>] {
+        return valueSchedule.between(start: startDate, end: endDate)
+    }
+
+    public func value(at time: Date) -> T {
+        return valueSchedule.value(at: time)
+    }
+}
+
+
+extension DailyQuantitySchedule: CustomDebugStringConvertible {
+    public var debugDescription: String {
+        return String(reflecting: rawValue)
+    }
+}
+
+
+public typealias SingleQuantitySchedule = DailyQuantitySchedule<Double>
+
+
+public extension DailyQuantitySchedule where T == Double {
+    func quantity(at time: Date) -> HKQuantity {
+        return HKQuantity(unit: unit, doubleValue: valueSchedule.value(at: time))
+    }
+
+    func averageValue() -> Double {
+        var total: Double = 0
+
+        for (index, item) in valueSchedule.items.enumerated() {
+            var endTime = valueSchedule.maxTimeInterval
+
+            if index < valueSchedule.items.endIndex - 1 {
+                endTime = valueSchedule.items[index + 1].startTime
+            }
+
+            total += (endTime - item.startTime) * item.value
+        }
+
+        return total / valueSchedule.repeatInterval
+    }
+
+    func averageQuantity() -> HKQuantity {
+        return HKQuantity(unit: unit, doubleValue: averageValue())
+    }
+}
+
+
+extension DailyQuantitySchedule: Equatable where T: Equatable {
+    public static func == (lhs: DailyQuantitySchedule<T>, rhs: DailyQuantitySchedule<T>) -> Bool {
+        return lhs.valueSchedule == rhs.valueSchedule && lhs.unit.unitString == rhs.unit.unitString
+    }
+}
+
+extension DailyQuantitySchedule where T: Numeric {
+    public static func * (lhs: DailyQuantitySchedule, rhs: DailyQuantitySchedule) -> DailyQuantitySchedule {
+        let unit = lhs.unit.unitMultiplied(by: rhs.unit)
+        let schedule = DailyValueSchedule.zip(lhs.valueSchedule, rhs.valueSchedule).map(*)
+        return DailyQuantitySchedule(unit: unit, valueSchedule: schedule)
+    }
+}
+
+extension DailyQuantitySchedule where T: FloatingPoint {
+    public static func / (lhs: DailyQuantitySchedule, rhs: DailyQuantitySchedule) -> DailyQuantitySchedule {
+        let unit = lhs.unit.unitDivided(by: rhs.unit)
+        let schedule = DailyValueSchedule.zip(lhs.valueSchedule, rhs.valueSchedule).map(/)
+        return DailyQuantitySchedule(unit: unit, valueSchedule: schedule)
+    }
+}

+ 265 - 0
LoopKit/LoopKit/DailyValueSchedule.swift

@@ -0,0 +1,265 @@
+//
+//  QuantitySchedule.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 1/18/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+public struct RepeatingScheduleValue<T> {
+    public let startTime: TimeInterval
+    public let value: T
+
+    public init(startTime: TimeInterval, value: T) {
+        self.startTime = startTime
+        self.value = value
+    }
+
+    public func map<U>(_ transform: (T) -> U) -> RepeatingScheduleValue<U> {
+        return RepeatingScheduleValue<U>(startTime: startTime, value: transform(value))
+    }
+}
+
+extension RepeatingScheduleValue: Equatable where T: Equatable {
+    public static func == (lhs: RepeatingScheduleValue, rhs: RepeatingScheduleValue) -> Bool {
+        return abs(lhs.startTime - rhs.startTime) < .ulpOfOne && lhs.value == rhs.value
+    }
+}
+
+public struct AbsoluteScheduleValue<T>: TimelineValue {
+    public let startDate: Date
+    public let endDate: Date
+    public let value: T
+}
+
+extension AbsoluteScheduleValue: Equatable where T: Equatable {}
+
+extension RepeatingScheduleValue: RawRepresentable where T: RawRepresentable {
+    public typealias RawValue = [String: Any]
+
+    public init?(rawValue: RawValue) {
+        guard let startTime = rawValue["startTime"] as? Double,
+            let rawValue = rawValue["value"] as? T.RawValue,
+            let value = T(rawValue: rawValue) else
+        {
+            return nil
+        }
+
+        self.init(startTime: startTime, value: value)
+    }
+
+    public var rawValue: RawValue {
+        return [
+            "startTime": startTime,
+            "value": value.rawValue
+        ]
+    }
+}
+
+
+public protocol DailySchedule {
+    associatedtype T
+
+    var items: [RepeatingScheduleValue<T>] { get }
+
+    var timeZone: TimeZone { get set }
+
+    func between(start startDate: Date, end endDate: Date) -> [AbsoluteScheduleValue<T>]
+
+    func value(at time: Date) -> T
+}
+
+
+public extension DailySchedule {
+    func value(at time: Date) -> T {
+        return between(start: time, end: time).first!.value
+    }
+}
+
+
+public struct DailyValueSchedule<T>: DailySchedule {
+    let referenceTimeInterval: TimeInterval
+    let repeatInterval = TimeInterval(hours: 24)
+
+    public let items: [RepeatingScheduleValue<T>]
+    public var timeZone: TimeZone
+
+    public init?(dailyItems: [RepeatingScheduleValue<T>], timeZone: TimeZone? = nil) {
+        self.items = dailyItems.sorted { $0.startTime < $1.startTime }
+        self.timeZone = timeZone ?? TimeZone.currentFixed
+
+        guard let firstItem = self.items.first else {
+            return nil
+        }
+
+        referenceTimeInterval = firstItem.startTime
+    }
+
+    var maxTimeInterval: TimeInterval {
+        return referenceTimeInterval + repeatInterval
+    }
+
+    /**
+     Returns the time interval for a given date normalized to the span of the schedule items
+
+     - parameter date: The date to convert
+     */
+    func scheduleOffset(for date: Date) -> TimeInterval {
+        // The time interval since a reference date in the specified time zone
+        let interval = date.timeIntervalSinceReferenceDate + TimeInterval(timeZone.secondsFromGMT(for: date))
+
+        // The offset of the time interval since the last occurence of the reference time + n * repeatIntervals.
+        // If the repeat interval was 1 day, this is the fractional amount of time since the most recent repeat interval starting at the reference time
+        return ((interval - referenceTimeInterval).truncatingRemainder(dividingBy: repeatInterval)) + referenceTimeInterval
+    }
+
+    /**
+     Returns a slice of schedule items that occur between two dates
+
+     - parameter startDate: The start date of the range
+     - parameter endDate:   The end date of the range
+
+     - returns: A slice of `ScheduleItem` values
+     */
+    public func between(start startDate: Date, end endDate: Date) -> [AbsoluteScheduleValue<T>] {
+        guard startDate <= endDate else {
+            return []
+        }
+
+        let startOffset = scheduleOffset(for: startDate)
+        let endOffset = startOffset + endDate.timeIntervalSince(startDate)
+
+        guard endOffset <= maxTimeInterval else {
+            let boundaryDate = startDate.addingTimeInterval(maxTimeInterval - startOffset)
+
+            return between(start: startDate, end: boundaryDate) + between(start: boundaryDate, end: endDate)
+        }
+
+        var startIndex = 0
+        var endIndex = items.count
+
+        for (index, item) in items.enumerated() {
+            if startOffset >= item.startTime {
+                startIndex = index
+            }
+            if endOffset < item.startTime {
+                endIndex = index
+                break
+            }
+        }
+
+        let referenceDate = startDate.addingTimeInterval(-startOffset)
+
+        return (startIndex..<endIndex).map { (index) in
+            let item = items[index]
+            let endTime = index + 1 < items.count ? items[index + 1].startTime : maxTimeInterval
+
+            return AbsoluteScheduleValue(
+                startDate: referenceDate.addingTimeInterval(item.startTime),
+                endDate: referenceDate.addingTimeInterval(endTime),
+                value: item.value
+            )
+        }
+    }
+
+    public func map<U>(_ transform: (T) -> U) -> DailyValueSchedule<U> {
+        return DailyValueSchedule<U>(
+            dailyItems: items.map { $0.map(transform) },
+            timeZone: timeZone
+        )!
+    }
+
+    public static func zip<L, R>(_ lhs: DailyValueSchedule<L>, _ rhs: DailyValueSchedule<R>) -> DailyValueSchedule where T == (L, R) {
+        precondition(lhs.timeZone == rhs.timeZone)
+
+        var (leftCursor, rightCursor) = (lhs.items.startIndex, rhs.items.startIndex)
+        var alignedItems: [RepeatingScheduleValue<(L, R)>] = []
+        repeat {
+            let (leftItem, rightItem) = (lhs.items[leftCursor], rhs.items[rightCursor])
+            let alignedItem = RepeatingScheduleValue(
+                startTime: max(leftItem.startTime, rightItem.startTime),
+                value: (leftItem.value, rightItem.value)
+            )
+            alignedItems.append(alignedItem)
+
+            let nextLeftStartTime = leftCursor == lhs.items.endIndex - 1 ? nil : lhs.items[leftCursor + 1].startTime
+            let nextRightStartTime = rightCursor == rhs.items.endIndex - 1 ? nil : rhs.items[rightCursor + 1].startTime
+            switch (nextLeftStartTime, nextRightStartTime) {
+            case (.some(let leftStart), .some(let rightStart)):
+                if leftStart < rightStart {
+                    leftCursor += 1
+                } else if rightStart < leftStart {
+                    rightCursor += 1
+                } else {
+                    leftCursor += 1
+                    rightCursor += 1
+                }
+            case (.some, .none):
+                leftCursor += 1
+            case (.none, .some):
+                rightCursor += 1
+            case (.none, .none):
+                leftCursor += 1
+                rightCursor += 1
+            }
+        } while leftCursor < lhs.items.endIndex && rightCursor < rhs.items.endIndex
+
+        return DailyValueSchedule(dailyItems: alignedItems, timeZone: lhs.timeZone)!
+    }
+}
+
+
+extension DailyValueSchedule: RawRepresentable, CustomDebugStringConvertible where T: RawRepresentable {
+    public typealias RawValue = [String: Any]
+    public init?(rawValue: RawValue) {
+        guard let rawItems = rawValue["items"] as? [RepeatingScheduleValue<T>.RawValue] else {
+            return nil
+        }
+
+        var timeZone: TimeZone?
+
+        if let offset = rawValue["timeZone"] as? Int {
+            timeZone = TimeZone(secondsFromGMT: offset)
+        }
+
+        let validScheduleItems = rawItems.compactMap(RepeatingScheduleValue<T>.init(rawValue:))
+        guard validScheduleItems.count == rawItems.count else {
+            return nil
+        }
+        self.init(dailyItems: validScheduleItems, timeZone: timeZone)
+    }
+
+    public var rawValue: RawValue {
+        let rawItems = items.map { $0.rawValue }
+
+        return [
+            "timeZone": timeZone.secondsFromGMT(),
+            "items": rawItems
+        ]
+    }
+
+    public var debugDescription: String {
+        return String(reflecting: rawValue)
+    }
+}
+
+
+extension DailyValueSchedule: Equatable where T: Equatable {}
+
+extension RepeatingScheduleValue {
+    public static func == <L: Equatable, R: Equatable> (lhs: RepeatingScheduleValue, rhs: RepeatingScheduleValue) -> Bool where T == (L, R) {
+        return lhs.startTime == rhs.startTime && lhs.value == rhs.value
+    }
+}
+
+extension DailyValueSchedule {
+    public static func == <L: Equatable, R: Equatable> (lhs: DailyValueSchedule, rhs: DailyValueSchedule) -> Bool where T == (L, R) {
+        return lhs.timeZone == rhs.timeZone
+            && lhs.items.count == rhs.items.count
+            && Swift.zip(lhs.items, rhs.items).allSatisfy(==)
+    }
+}

+ 52 - 0
LoopKit/LoopKit/DeviceManager.swift

@@ -0,0 +1,52 @@
+//
+//  DeviceManager.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import UserNotifications
+
+public protocol DeviceManagerDelegate {
+    func scheduleNotification(for manager: DeviceManager,
+                              identifier: String,
+                              content: UNNotificationContent,
+                              trigger: UNNotificationTrigger?)
+
+    func clearNotification(for manager: DeviceManager, identifier: String)
+}
+
+public protocol DeviceManager: class, CustomDebugStringConvertible {
+    typealias RawStateValue = [String: Any]
+
+    /// The identifier of the manager. This should be unique
+    static var managerIdentifier: String { get }
+
+    /// A title describing this type of manager
+    static var localizedTitle: String { get }
+
+    /// A title describing this manager
+    var localizedTitle: String { get }
+
+    /// The queue on which delegate methods are called
+    /// Setting to nil resets to a default provided by the manager
+    var delegateQueue: DispatchQueue! { get set }
+
+    /// Initializes the manager with its previously-saved state
+    ///
+    /// Return nil if the saved state is invalid to prevent restoration
+    ///
+    /// - Parameter rawState: The last state
+    init?(rawState: RawStateValue)
+
+    /// The current, serializable state of the manager
+    var rawState: RawStateValue { get }
+}
+
+
+public extension DeviceManager {
+    var localizedTitle: String {
+        return type(of: self).localizedTitle
+    }
+}

+ 36 - 0
LoopKit/LoopKit/DoseProgressReporter.swift

@@ -0,0 +1,36 @@
+//
+//  DoseProgressReporter.swift
+//  LoopKit
+//
+//  Created by Pete Schwamb on 3/12/19.
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+public struct DoseProgress {
+    public let deliveredUnits: Double
+    public let percentComplete: Double
+
+    public var isComplete: Bool {
+        return percentComplete >= 1.0
+    }
+
+    public init(deliveredUnits: Double, percentComplete: Double) {
+        self.deliveredUnits = deliveredUnits
+        self.percentComplete = percentComplete
+    }
+}
+
+public protocol DoseProgressObserver: class {
+    func doseProgressReporterDidUpdate(_ doseProgressReporter: DoseProgressReporter)
+}
+
+public protocol DoseProgressReporter: class {
+    var progress: DoseProgress { get }
+
+    func addObserver(_ observer: DoseProgressObserver)
+
+    func removeObserver(_ observer: DoseProgressObserver)
+}

+ 94 - 0
LoopKit/LoopKit/DoseProgressTimerEstimator.swift

@@ -0,0 +1,94 @@
+//
+//  DoseProgressTimerEstimator.swift
+//  LoopKit
+//
+//  Created by Pete Schwamb on 3/23/19.
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+open class DoseProgressTimerEstimator: DoseProgressReporter {
+
+    private let lock = UnfairLock()
+
+    private var observers = WeakSet<DoseProgressObserver>()
+
+    var timer: DispatchSourceTimer?
+
+    let reportingQueue: DispatchQueue
+
+    public init(reportingQueue: DispatchQueue) {
+        self.reportingQueue = reportingQueue
+    }
+
+    open var progress: DoseProgress {
+        fatalError("progress must be implemented in subclasse")
+    }
+
+    public func addObserver(_ observer: DoseProgressObserver) {
+        lock.withLock {
+            let firstObserver = observers.isEmpty
+            observers.insert(observer)
+            if firstObserver {
+                start()
+            }
+        }
+    }
+
+    public func removeObserver(_ observer: DoseProgressObserver) {
+        lock.withLock {
+            observers.remove(observer)
+            if observers.isEmpty {
+                stop()
+            }
+        }
+    }
+
+    public func notify() {
+        let observersCopy = lock.withLock { observers }
+
+        for observer in observersCopy {
+            observer.doseProgressReporterDidUpdate(self)
+        }
+
+        if progress.isComplete {
+            lock.withLock { stop() }
+        }
+    }
+
+    private func start() {
+        guard self.timer == nil, !progress.isComplete else {
+            return
+        }
+
+        let (delay, repeating) = timerParameters()
+
+        let timer = DispatchSource.makeTimerSource(queue: reportingQueue)
+        timer.schedule(deadline: .now() + delay, repeating: repeating)
+        timer.setEventHandler(handler: { [weak self] in
+            self?.notify()
+        })
+        self.timer = timer
+        timer.resume()
+    }
+
+    open func timerParameters() -> (delay: TimeInterval, repeating: TimeInterval) {
+        fatalError("timerParameters must be been implemented in subclasse")
+    }
+
+    private func stop() {
+        guard let timer = timer else {
+            return
+        }
+
+        timer.setEventHandler {}
+        timer.cancel()
+        self.timer = nil
+    }
+
+    deinit {
+        lock.withLock { stop() }
+    }
+}

+ 16 - 0
LoopKit/LoopKit/EGPSchedule.swift

@@ -0,0 +1,16 @@
+//
+//  EGPSchedule.swift
+//  LoopKit
+//
+//  Created by Michael Pangburn on 3/27/19.
+//  Copyright © 2019 LoopKit Authors. All rights reserved.
+//
+
+public typealias EGPSchedule = SingleQuantitySchedule
+
+extension /* EGPSchedule */ DailyQuantitySchedule where T == Double {
+    public static func egpSchedule(basalSchedule: BasalRateSchedule, insulinSensitivitySchedule: InsulinSensitivitySchedule) -> EGPSchedule {
+        let basalScheduleWithUnit = DailyQuantitySchedule(unit: .internationalUnitsPerHour, valueSchedule: basalSchedule)
+        return basalScheduleWithUnit * insulinSensitivitySchedule
+    }
+}

+ 28 - 0
LoopKit/LoopKit/Extensions/Date.swift

@@ -0,0 +1,28 @@
+//
+//  NSDate.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 1/17/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+
+
+public extension Date {
+    func dateFlooredToTimeInterval(_ interval: TimeInterval) -> Date {
+        if interval == 0 {
+            return self
+        }
+
+        return Date(timeIntervalSinceReferenceDate: floor(self.timeIntervalSinceReferenceDate / interval) * interval)
+    }
+
+    func dateCeiledToTimeInterval(_ interval: TimeInterval) -> Date {
+        if interval == 0 {
+            return self
+        }
+
+        return Date(timeIntervalSinceReferenceDate: ceil(self.timeIntervalSinceReferenceDate / interval) * interval)
+    }
+}

+ 20 - 0
LoopKit/LoopKit/Extensions/Double.swift

@@ -0,0 +1,20 @@
+//
+//  Double.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 2/12/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+
+extension Double: RawRepresentable {
+    public typealias RawValue = Double
+
+    public init?(rawValue: RawValue) {
+        self = rawValue
+    }
+
+    public var rawValue: RawValue {
+        return self
+    }
+}

+ 19 - 0
LoopKit/LoopKit/Extensions/HKHealthStore.swift

@@ -0,0 +1,19 @@
+//
+//  HKHealthStore.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import HealthKit
+
+
+protocol HKSampleQueryTestable {
+    func executeSampleQuery(
+        for type: HKSampleType,
+        matching predicate: NSPredicate,
+        limit: Int,
+        sortDescriptors: [NSSortDescriptor]?,
+        resultsHandler: @escaping (_ query: HKSampleQuery, _ results: [HKSample]?, _ error: Error?) -> Void
+    )
+}

+ 18 - 0
LoopKit/LoopKit/Extensions/HKQuantity.swift

@@ -0,0 +1,18 @@
+//
+//  HKQuantity.swift
+//  LoopKit
+//
+//  Created by Nathan Racklyeft on 3/10/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+extension HKQuantity: Comparable { }
+
+
+public func <(lhs: HKQuantity, rhs: HKQuantity) -> Bool {
+    return lhs.compare(rhs) == .orderedAscending
+}

+ 12 - 0
LoopKit/LoopKit/Extensions/HKQuantitySample.swift

@@ -0,0 +1,12 @@
+//
+//  HKQuantitySample.swift
+//  LoopKit
+//
+//  Created by Nate Racklyeft on 8/14/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import HealthKit
+
+
+extension HKQuantitySample: SampleValue { }

+ 40 - 0
LoopKit/LoopKit/Extensions/NSUserActivity+CarbKit.swift

@@ -0,0 +1,40 @@
+//
+//  NSUserActivity+CarbKit.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+/// Conveniences for activity handoff and restoration of creating a carb entry
+extension NSUserActivity {
+    public static let newCarbEntryActivityType = "NewCarbEntry"
+
+    public static let newCarbEntryUserInfoKey = "NewCarbEntry"
+
+    public class func forNewCarbEntry() -> NSUserActivity {
+        let activity = NSUserActivity(activityType: newCarbEntryActivityType)
+        activity.requiredUserInfoKeys = []
+        return activity
+    }
+
+    public func update(from entry: NewCarbEntry?) {
+        if let rawValue = entry?.rawValue {
+            addUserInfoEntries(from: [
+                NSUserActivity.newCarbEntryUserInfoKey: rawValue
+            ])
+        } else {
+            userInfo = nil
+        }
+    }
+
+    public var newCarbEntry: NewCarbEntry? {
+        guard let rawValue = userInfo?[NSUserActivity.newCarbEntryUserInfoKey] as? NewCarbEntry.RawValue else {
+            return nil
+        }
+
+        return NewCarbEntry(rawValue: rawValue)
+    }
+}

+ 27 - 0
LoopKit/LoopKit/GlucoseChange.swift

@@ -0,0 +1,27 @@
+//
+//  GlucoseChange.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import HealthKit
+
+
+public struct GlucoseChange: SampleValue, Equatable {
+    public var startDate: Date
+    public var endDate: Date
+    public var quantity: HKQuantity
+}
+
+
+extension GlucoseChange {
+    mutating public func append(_ effect: GlucoseEffect) {
+        startDate = min(effect.startDate, startDate)
+        endDate = max(effect.endDate, endDate)
+        quantity = HKQuantity(
+            unit: .milligramsPerDeciliter,
+            doubleValue: quantity.doubleValue(for: .milligramsPerDeciliter) + effect.quantity.doubleValue(for: .milligramsPerDeciliter)
+        )
+    }
+}

+ 28 - 0
LoopKit/LoopKit/GlucoseEffect.swift

@@ -0,0 +1,28 @@
+//
+//  GlucoseEffect.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 1/24/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+public struct GlucoseEffect: GlucoseValue, Equatable {
+    public let startDate: Date
+    public let quantity: HKQuantity
+
+    public init(startDate: Date, quantity: HKQuantity) {
+        self.startDate = startDate
+        self.quantity = quantity
+    }
+}
+
+
+extension GlucoseEffect: Comparable {
+    public static func <(lhs: GlucoseEffect, rhs: GlucoseEffect) -> Bool {
+        return lhs.startDate < rhs.startDate
+    }
+}

+ 42 - 0
LoopKit/LoopKit/GlucoseEffectVelocity.swift

@@ -0,0 +1,42 @@
+//
+//  GlucoseEffectVelocity.swift
+//  LoopKit
+//
+//  Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+/// The first-derivative of GlucoseEffect, blood glucose over time.
+public struct GlucoseEffectVelocity: SampleValue {
+    public let startDate: Date
+    public let endDate: Date
+    public let quantity: HKQuantity
+
+    public init(startDate: Date, endDate: Date, quantity: HKQuantity) {
+        self.startDate = startDate
+        self.endDate = endDate
+        self.quantity = quantity
+    }
+}
+
+
+extension GlucoseEffectVelocity {
+    static let perSecondUnit = HKUnit.milligramsPerDeciliter.unitDivided(by: .second())
+
+    /// The integration of the velocity span
+    public var effect: GlucoseEffect {
+        let duration = endDate.timeIntervalSince(startDate)
+        let velocityPerSecond = quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit)
+
+        return GlucoseEffect(
+            startDate: endDate,
+            quantity: HKQuantity(
+                unit: .milligramsPerDeciliter,
+                doubleValue: velocityPerSecond * duration
+            )
+        )
+    }
+}

+ 54 - 0
LoopKit/LoopKit/GlucoseKit/CachedGlucoseObject+CoreDataClass.swift

@@ -0,0 +1,54 @@
+//
+//  CachedGlucoseObject+CoreDataClass.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+//
+
+import Foundation
+import CoreData
+import HealthKit
+
+
+class CachedGlucoseObject: NSManagedObject {
+    var startDate: Date! {
+        get {
+            willAccessValue(forKey: "startDate")
+            defer { didAccessValue(forKey: "startDate") }
+            return primitiveStartDate! as Date
+        }
+        set {
+            willChangeValue(forKey: "startDate")
+            defer { didChangeValue(forKey: "startDate") }
+            primitiveStartDate = newValue as NSDate
+        }
+    }
+
+    var uploadState: UploadState {
+        get {
+            willAccessValue(forKey: "uploadState")
+            defer { didAccessValue(forKey: "uploadState") }
+            return UploadState(rawValue: primitiveUploadState!.intValue)!
+        }
+        set {
+            willChangeValue(forKey: "uploadState")
+            defer { didChangeValue(forKey: "uploadState") }
+            primitiveUploadState = NSNumber(value: newValue.rawValue)
+        }
+    }
+}
+
+
+extension CachedGlucoseObject {
+    func update(from sample: StoredGlucoseSample) {
+        uuid = sample.sampleUUID
+        syncIdentifier = sample.syncIdentifier
+        syncVersion = Int32(sample.syncVersion)
+        value = sample.quantity.doubleValue(for: .milligramsPerDeciliter)
+        unitString = HKUnit.milligramsPerDeciliter.unitString
+        startDate = sample.startDate
+        provenanceIdentifier = sample.provenanceIdentifier
+        isDisplayOnly = sample.isDisplayOnly
+    }
+}

+ 29 - 0
LoopKit/LoopKit/GlucoseKit/CachedGlucoseObject+CoreDataProperties.swift

@@ -0,0 +1,29 @@
+//
+//  CachedGlucoseObject+CoreDataProperties.swift
+//  LoopKit
+//
+//  Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+//
+
+import Foundation
+import CoreData
+
+
+extension CachedGlucoseObject {
+
+    @nonobjc public class func fetchRequest() -> NSFetchRequest<CachedGlucoseObject> {
+        return NSFetchRequest<CachedGlucoseObject>(entityName: "CachedGlucoseObject")
+    }
+
+    @NSManaged public var uuid: UUID?
+    @NSManaged public var syncIdentifier: String?
+    @NSManaged public var syncVersion: Int32
+    @NSManaged public var primitiveUploadState: NSNumber?
+    @NSManaged public var value: Double
+    @NSManaged public var unitString: String?
+    @NSManaged public var primitiveStartDate: NSDate?
+    @NSManaged public var provenanceIdentifier: String?
+    @NSManaged public var isDisplayOnly: Bool
+
+}

+ 214 - 0
LoopKit/LoopKit/GlucoseKit/GlucoseMath.swift

@@ -0,0 +1,214 @@
+//
+//  GlucoseMath.swift
+//  Naterade
+//
+//  Created by Nathan Racklyeft on 1/24/16.
+//  Copyright © 2016 Nathan Racklyeft. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+
+fileprivate extension Collection where Element == (x: Double, y: Double) {
+    /**
+     Calculates slope and intercept using linear regression
+     
+     This implementation is not suited for large datasets.
+
+     - parameter points: An array of tuples containing x and y values
+
+     - returns: A tuple of slope and intercept values
+     */
+    func linearRegression() -> (slope: Double, intercept: Double) {
+        var sumX = 0.0
+        var sumY = 0.0
+        var sumXY = 0.0
+        var sumX² = 0.0
+        var sumY² = 0.0
+        let count = Double(self.count)
+
+        for point in self {
+            sumX += point.x
+            sumY += point.y
+            sumXY += (point.x * point.y)
+            sumX² += (point.x * point.x)
+            sumY² += (point.y * point.y)
+        }
+
+        let slope = ((count * sumXY) - (sumX * sumY)) / ((count * sumX²) - (sumX * sumX))
+        let intercept = (sumY * sumX² - (sumX * sumXY)) / (count * sumX² - (sumX * sumX))
+
+        return (slope: slope, intercept: intercept)
+    }
+}
+
+
+extension BidirectionalCollection where Element: GlucoseSampleValue, Index == Int {
+
+    /// Whether the collection contains no calibration entries
+    /// Runtime: O(n)
+    var isCalibrated: Bool {
+        return filter({ $0.isDisplayOnly }).count == 0
+    }
+
+    /// Filters a timeline of glucose samples to only include those after the last calibration.
+    func filterAfterCalibration() -> [Element] {
+        var postCalibration = true
+
+        return reversed().filter({ (sample) in
+            if sample.isDisplayOnly {
+                postCalibration = false
+            }
+
+            return postCalibration
+        }).reversed()
+    }
+
+    /// Whether the collection can be considered continuous
+    ///
+    /// - Parameters:
+    ///   - interval: The interval between readings, on average, used to determine if we have a contiguous set of values
+    /// - Returns: True if the samples are continuous
+    func isContinuous(within interval: TimeInterval = TimeInterval(minutes: 5)) -> Bool {
+        if  let first = first,
+            let last = last,
+            // Ensure that the entries are contiguous
+            abs(first.startDate.timeIntervalSince(last.startDate)) < interval * TimeInterval(count)
+        {
+            return true
+        }
+
+        return false
+    }
+
+    /// Calculates the short-term predicted momentum effect using linear regression
+    ///
+    /// - Parameters:
+    ///   - duration: The duration of the effects
+    ///   - delta: The time differential for the returned values
+    /// - Returns: An array of glucose effects
+    func linearMomentumEffect(
+        duration: TimeInterval = TimeInterval(minutes: 30),
+        delta: TimeInterval = TimeInterval(minutes: 5)
+    ) -> [GlucoseEffect] {
+        guard
+            self.count > 2,  // Linear regression isn't much use without 3 or more entries.
+            isContinuous() && isCalibrated && hasSingleProvenance,
+            let firstSample = self.first,
+            let lastSample = self.last,
+            let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([lastSample], duration: duration, delta: delta)
+        else {
+            return []
+        }
+
+        /// Choose a unit to use during raw value calculation
+        let unit = HKUnit.milligramsPerDeciliter
+
+        let (slope: slope, intercept: _) = self.map { (
+            x: $0.startDate.timeIntervalSince(firstSample.startDate),
+            y: $0.quantity.doubleValue(for: unit)
+        ) }.linearRegression()
+
+        guard slope.isFinite else {
+            return []
+        }
+
+        var date = startDate
+        var values = [GlucoseEffect]()
+
+        repeat {
+            let value = Swift.max(0, date.timeIntervalSince(lastSample.startDate)) * slope
+
+            values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: unit, doubleValue: value)))
+            date = date.addingTimeInterval(delta)
+        } while date <= endDate
+
+        return values
+    }
+}
+
+
+extension Collection where Element: GlucoseSampleValue, Index == Int {
+    /// Whether the collection is all from the same source.
+    /// Runtime: O(n)
+    var hasSingleProvenance: Bool {
+        let firstProvenance = self.first?.provenanceIdentifier
+
+        for sample in self {
+            if sample.provenanceIdentifier != firstProvenance {
+                return false
+            }
+        }
+
+        return true
+    }
+
+    /// Calculates a timeline of effect velocity (glucose/time) observed in glucose readings that counteract the specified effects.
+    ///
+    /// - Parameter effects: Glucose effects to be countered, in chronological order
+    /// - Returns: An array of velocities describing the change in glucose samples compared to the specified effects
+    func counteractionEffects(to effects: [GlucoseEffect]) -> [GlucoseEffectVelocity] {
+        let mgdL = HKUnit.milligramsPerDeciliter
+        let velocityUnit = GlucoseEffectVelocity.perSecondUnit
+        var velocities = [GlucoseEffectVelocity]()
+
+        var effectIndex = 0
+        var startGlucose: Element! = self.first
+
+        for endGlucose in self.dropFirst() {
+            // Find a valid change in glucose, requiring identical provenance and no calibration
+            let glucoseChange = endGlucose.quantity.doubleValue(for: mgdL) - startGlucose.quantity.doubleValue(for: mgdL)
+            let timeInterval = endGlucose.startDate.timeIntervalSince(startGlucose.startDate)
+
+            guard timeInterval > .minutes(4) else {
+                continue
+            }
+
+            defer {
+                startGlucose = endGlucose
+            }
+
+            guard startGlucose.provenanceIdentifier == endGlucose.provenanceIdentifier,
+                !startGlucose.isDisplayOnly, !endGlucose.isDisplayOnly
+            else {
+                continue
+            }
+
+            // Compare that to a change in insulin effects
+            guard effects.count > effectIndex else {
+                break
+            }
+
+            var startEffect: GlucoseEffect?
+            var endEffect: GlucoseEffect?
+
+            for effect in effects[effectIndex..<effects.count] {
+                if startEffect == nil && effect.startDate >= startGlucose.startDate {
+                    startEffect = effect
+                } else if endEffect == nil && effect.startDate >= endGlucose.startDate {
+                    endEffect = effect
+                    break
+                }
+
+                effectIndex += 1
+            }
+
+            guard let startEffectValue = startEffect?.quantity.doubleValue(for: mgdL),
+                let endEffectValue = endEffect?.quantity.doubleValue(for: mgdL)
+            else {
+                break
+            }
+
+            let effectChange = endEffectValue - startEffectValue
+            let discrepancy = glucoseChange - effectChange
+
+            let averageVelocity = HKQuantity(unit: velocityUnit, doubleValue: discrepancy / timeInterval)
+            let effect = GlucoseEffectVelocity(startDate: startGlucose.startDate, endDate: endGlucose.startDate, quantity: averageVelocity)
+
+            velocities.append(effect)
+        }
+
+        return velocities
+    }
+}

+ 0 - 0
LoopKit/LoopKit/GlucoseKit/GlucoseSampleValue.swift


Некоторые файлы не были показаны из-за большого количества измененных файлов