Skip to content

Commit

Permalink
Testing scenarios (#17)
Browse files Browse the repository at this point in the history
* Initial scenarios impl

* Fix step forward without recommended temp

* Active scenario stepping gestures

* Testing scenario docs

* Fix typo

* Fix typo, again

math is hard

* Save -> Load

* Naming updates
  • Loading branch information
mpangburn authored and ps2 committed Jun 8, 2019
1 parent b0efe6e commit 8c1dfdb
Show file tree
Hide file tree
Showing 16 changed files with 957 additions and 33 deletions.
Binary file added Documentation/Testing/Images/mock_managers.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Documentation/Testing/Images/rewind.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Documentation/Testing/Images/scenarios_menu.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Documentation/Testing/Images/scenarios_url.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 67 additions & 0 deletions Documentation/Testing/Scenarios.md
@@ -0,0 +1,67 @@
# Guide: Testing Scenarios

## Purpose

This document describes how to load data-based scenarios, including glucose values, dose history, and carb entries, into Loop on demand.

## File Format

A scenario consists of a single JSON file containing glucose, basal, bolus, and carb entry histories. Each history corresponds to a property of the scenario JSON object—a list of individual entries. Each entry has one or more properties describing its value (e.g. `unitsPerHourValue` and `duration`) and a _relative_ date offset, in seconds (e.g. 0 means 'right now' and -300 means '5 minutes ago').

For example, a carb entry history might look like this:

```json
"carbEntries": [
{
"gramValue": 30,
"dateOffset": -300,
"absorptionTime": 10800
},
{
"gramValue": 15,
"dateOffset": 900,
"absorptionTime": 7200,
"enteredAtOffset": -900
}
]
```

Carb entries have two date offsets: `dateOffset`, which describes the date at which carbs were consumed, and `enteredAtOffset`, which describes the date at which the carb entry was created. The second carb entry in the example above was entered 30 minutes early.

## Generating Scenarios

A Python script with classes corresponding to the entry types is available at `/Scripts/make_scenario.py`. Running it will generate a sample script, which will allow you to inspect the file format in more detail.

## Loading Scenarios

Launch Loop in the Xcode simulator.

Before loading scenarios, mock pump and CGM managers must be enabled in Loop. From the status screen, tap the settings icon in the bottom-right corner; then, tap on each of the pump and CGM rows and select the Simulator option from the presented action sheets:

![](Images/mock_managers.png)

Next, type 'scenario' in the search bar in the bottom-right corner of the Xcode console with the Loop app running:

![](Images/scenarios_url.png)

The first line will include `[TestingScenariosManager]` and a path to the simulator-specific directory in which to place scenario JSON files.

With one or more scenarios placed in the listed directory, the debug menu can be activated by "shaking" the iPhone: in the simulator, press ^⌘Z. The scenario selection screen will appear:

![](Images/scenarios_menu.png)

Tap on a scenario to select it, then press 'Load' in the top-right corner to load it into Loop.

With the app running, additional scenarios can be added to the scenarios directory; the changes will be detected, and the scenario list reloaded.

## Time Travel

Because all historic date offsets are relative, scenarios can be stepped through one or more loop iterations at a time, so long as the scenario contains sufficient past or future data.

Swiping right or left on a scenario cell reveals the 'rewind' or 'advance' button, respectively:

![](Images/rewind.png)

Tap on the button, and you will be prompted for a number of loop iterations to progress backward or forward in time. Note that advancing forward will run the full algorithm for each step and in turn apply the suggested basal at each decision point.

For convenience, an active scenario can be stepped through without leaving the status screen. Swipe right or left on the toolbar at the bottom of the screen to move one loop iteration into the past or future, respectively.
22 changes: 19 additions & 3 deletions Loop.xcodeproj/project.pbxproj
Expand Up @@ -346,6 +346,10 @@
898ECA63218ABD21001E9D35 /* ComplicationChartManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA62218ABD21001E9D35 /* ComplicationChartManager.swift */; };
898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA64218ABD9A001E9D35 /* CGRect.swift */; };
898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */; };
89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */; };
89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */; };
89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */; };
89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */; };
C10428971D17BAD400DD539A /* NightscoutUploadKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */; };
C10B28461EA9BA5E006EA1FC /* far_future_high_bg_forecast.json in Resources */ = {isa = PBXBuildFile; fileRef = C10B28451EA9BA5E006EA1FC /* far_future_high_bg_forecast.json */; };
C11C87DE1E21EAAD00BB71D3 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; };
Expand Down Expand Up @@ -976,7 +980,11 @@
898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WatchApp Extension-Bridging-Header.h"; sourceTree = "<group>"; };
898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "CLKTextProvider+Compound.m"; sourceTree = "<group>"; };
898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CLKTextProvider+Compound.h"; sourceTree = "<group>"; };
C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = NightscoutUploadKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosManager.swift; sourceTree = "<group>"; };
89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryObserver.swift; sourceTree = "<group>"; };
89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosTableViewController.swift; sourceTree = "<group>"; };
89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTestingScenariosManager.swift; sourceTree = "<group>"; };
C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NightscoutUploadKit.framework; path = Carthage/Build/iOS/NightscoutUploadKit.framework; sourceTree = SOURCE_ROOT; };
C10B28451EA9BA5E006EA1FC /* far_future_high_bg_forecast.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = far_future_high_bg_forecast.json; sourceTree = "<group>"; };
C12F21A61DFA79CB00748193 /* recommend_temp_basal_very_low_end_in_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_very_low_end_in_range.json; sourceTree = "<group>"; };
C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MealBolusNightscoutTreatment.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1433,19 +1441,20 @@
4389916A1E91B689000EEF90 /* ChartSettings+Loop.swift */,
4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */,
43CE7CDD1CA8B63E003CC1B0 /* Data.swift */,
892A5D58222F0A27008961AB /* Debug.swift */,
89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */,
43D9003221EB258C00AF44BF /* InsulinModelSettings+Loop.swift */,
C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */,
438172D81F4E9E37003C3328 /* NewPumpEvent.swift */,
43CEE6E51E56AFD400CB9116 /* NightscoutUploader.swift */,
43DACFFF20A2736F000F8529 /* PersistedPumpEvent.swift */,
892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */,
C1FB428B217806A300FAB378 /* StateColorPalette.swift */,
43F41C361D3BF32400C11ED6 /* UIAlertController.swift */,
43BFF0BB1E45C80600FF19A9 /* UIColor+Loop.swift */,
437CEEE31CDE5C0A003C8C80 /* UIImage.swift */,
434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */,
430B29922041F5B200BA9F93 /* UserDefaults+Loop.swift */,
892A5D58222F0A27008961AB /* Debug.swift */,
892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand All @@ -1468,6 +1477,7 @@
43F5C2DA1B92A5E1003EB13D /* SettingsTableViewController.swift */,
43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */,
4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */,
89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */,
);
path = "View Controllers";
sourceTree = "<group>";
Expand Down Expand Up @@ -1500,12 +1510,14 @@
4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */,
43F78D251C8FC000002152D1 /* DoseMath.swift */,
43E2D8C71D208D5B004DA55F /* KeychainManager+Loop.swift */,
89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */,
43A567681C94880B00334FAC /* LoopDataManager.swift */,
C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */,
43C094491CACCC73001F6403 /* NotificationManager.swift */,
432E73CA1D24B3D6009AD15D /* RemoteDataManager.swift */,
43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */,
4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */,
89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */,
4328E0341CFC0AE100E199AA /* WatchDataManager.swift */,
);
path = Managers;
Expand Down Expand Up @@ -2389,6 +2401,7 @@
4341F4EB1EDB92AC001C936B /* LogglyService.swift in Sources */,
43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */,
C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */,
89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */,
439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */,
4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */,
43F5C2DB1B92A5E1003EB13D /* SettingsTableViewController.swift in Sources */,
Expand All @@ -2407,6 +2420,7 @@
4346D1E71C77F5FE00ABAFE3 /* ChartTableViewCell.swift in Sources */,
437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */,
43DBF0591C93F73800B3C386 /* CarbEntryTableViewController.swift in Sources */,
89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */,
43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */,
43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */,
43BFF0BC1E45C80600FF19A9 /* UIColor+Loop.swift in Sources */,
Expand Down Expand Up @@ -2448,11 +2462,13 @@
4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */,
4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */,
43C3B6EC20B650A80026CAFA /* SettingsImageTableViewCell.swift in Sources */,
89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */,
4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */,
436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */,
4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */,
435CB6231F37967800C320C7 /* InsulinModelSettingsViewController.swift in Sources */,
4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */,
89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */,
43F78D261C8FC000002152D1 /* DoseMath.swift in Sources */,
438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */,
892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */,
Expand Down
12 changes: 9 additions & 3 deletions Loop/Extensions/Debug.swift
Expand Up @@ -6,10 +6,16 @@
// Copyright © 2019 LoopKit Authors. All rights reserved.
//

func assertingDebugOnly(file: StaticString = #file, line: UInt = #line, _ doIt: () -> Void) {
var debugEnabled: Bool {
#if DEBUG || IOS_SIMULATOR
doIt()
return true
#else
fatalError("\(file):\(line) should never be invoked in release builds", file: file, line: line)
return false
#endif
}

func assertDebugOnly(file: StaticString = #file, line: UInt = #line) {
guard debugEnabled else {
fatalError("\(file):\(line) should never be invoked in release builds", file: file, line: line)
}
}
40 changes: 40 additions & 0 deletions Loop/Extensions/DirectoryObserver.swift
@@ -0,0 +1,40 @@
//
// DirectoryObserver.swift
// Loop
//
// Created by Michael Pangburn on 4/20/19.
// Copyright © 2019 LoopKit Authors. All rights reserved.
//

import Foundation


protocol DirectoryObserver {}
typealias DirectoryObservationToken = AnyObject

extension DirectoryObserver {
func observeDirectory(at url: URL, updatingWith notifyOfUpdates: @escaping () -> Void) -> DirectoryObservationToken? {
return DirectoryObservation(url: url, updatingWith: notifyOfUpdates)
}
}

private final class DirectoryObservation {
private let fileDescriptor: CInt
private let source: DispatchSourceFileSystemObject

fileprivate init?(url: URL, updatingWith notifyOfUpdates: @escaping () -> Void) {
fileDescriptor = open(url.path, O_EVTONLY)
guard fileDescriptor != -1 else {
assertionFailure("Unable to open url: \(url)")
return nil
}
source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .all)
source.setEventHandler(handler: notifyOfUpdates)
source.activate()
}

deinit {
source.cancel()
close(fileDescriptor)
}
}
88 changes: 88 additions & 0 deletions Loop/Extensions/UIAlertController.swift
Expand Up @@ -130,3 +130,91 @@ extension UIAlertController {
addAction(UIAlertAction(title: cancel, style: .cancel, handler: handler))
}
}


// Adapted from https://oleb.net/2018/uialertcontroller-textfield/
extension UIAlertController {
public enum TextInputResult {
/// The user tapped Cancel.
case cancel
/// The user tapped the OK button. The payload is the text they entered in the text field.
case ok(String)
}

/// Creates a fully configured alert controller with one text field for text input, a Cancel and
/// and an OK button.
///
/// - Parameters:
/// - title: The title of the alert view.
/// - message: The message of the alert view.
/// - cancelButtonTitle: The title of the Cancel button.
/// - okButtonTitle: The title of the OK button.
/// - isValid: The OK button will be disabled as long as the entered text doesn't pass
/// the validation. By default, all entered text is considered valid.
/// - textFieldConfiguration: Use this to configure the text field (e.g. set placeholder text).
/// - onCompletion: Called when the user closes the alert view. The argument tells you whether
/// the user tapped the Close or the OK button (in which case this delivers the entered text).
public convenience init(title: String, message: String? = nil,
cancelButtonTitle: String, okButtonTitle: String,
validate isValid: @escaping (String) -> Bool = { _ in true },
textFieldConfiguration: ((UITextField) -> Void)? = nil,
onCompletion: @escaping (TextInputResult) -> Void) {
self.init(title: title, message: message, preferredStyle: .alert)

/// Observes a UITextField for various events and reports them via callbacks.
/// Sets itself as the text field's delegate and target-action target.
class TextFieldObserver: NSObject, UITextFieldDelegate {
let textFieldValueChanged: (UITextField) -> Void
let textFieldShouldReturn: (UITextField) -> Bool

init(textField: UITextField, valueChanged: @escaping (UITextField) -> Void, shouldReturn: @escaping (UITextField) -> Bool) {
self.textFieldValueChanged = valueChanged
self.textFieldShouldReturn = shouldReturn
super.init()
textField.delegate = self
textField.addTarget(self, action: #selector(TextFieldObserver.textFieldValueChanged(sender:)), for: .editingChanged)
}

@objc func textFieldValueChanged(sender: UITextField) {
textFieldValueChanged(sender)
}

// MARK: UITextFieldDelegate
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
return textFieldShouldReturn(textField)
}
}

var textFieldObserver: TextFieldObserver?

// Every `UIAlertAction` handler must eventually call this
func finish(result: TextInputResult) {
// Capture the observer to keep it alive while the alert is on screen
textFieldObserver = nil
onCompletion(result)
}

let cancelAction = UIAlertAction(title: cancelButtonTitle, style: .cancel, handler: { _ in
finish(result: .cancel)
})
let okAction = UIAlertAction(title: okButtonTitle, style: .default, handler: { [unowned self] _ in
finish(result: .ok(self.textFields?.first?.text ?? ""))
})
addAction(cancelAction)
addAction(okAction)
preferredAction = okAction

addTextField(configurationHandler: { textField in
textFieldConfiguration?(textField)
textFieldObserver = TextFieldObserver(textField: textField,
valueChanged: { textField in
okAction.isEnabled = isValid(textField.text ?? "")
},
shouldReturn: { textField in
isValid(textField.text ?? "")
})
})
// Start with a disabled OK button if necessary
okAction.isEnabled = isValid(textFields?.first?.text ?? "")
}
}

0 comments on commit 8c1dfdb

Please sign in to comment.