From e8494d329c3ef44ad45f74e82d38b4ca42e862ef Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Thu, 30 Jun 2022 18:24:02 -0700 Subject: [PATCH 01/10] Switch Trailmaking to use a simple clock --- Package.resolved | 8 +- Package.swift | 2 +- .../Trailmaking/TrailmakingStepView.swift | 48 +++------- Sources/BiAffectSDK/Utils/SimpleClock.swift | 87 +++++++++++++++++++ Tests/BiAffectSDKTests/TrailmakingTests.swift | 12 ++- 5 files changed, 111 insertions(+), 46 deletions(-) create mode 100644 Sources/BiAffectSDK/Utils/SimpleClock.swift diff --git a/Package.resolved b/Package.resolved index 35172c9..6d8fa92 100644 --- a/Package.resolved +++ b/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/Sage-Bionetworks/JsonModel-Swift.git", "state": { "branch": null, - "revision": "dcce83598aa1e81478b6bd9adee38c8ed167665b", - "version": "1.4.9" + "revision": "996d4807c42f0660c62b4cec0f95230dd1341639", + "version": "1.5.0" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/Sage-Bionetworks/MobilePassiveData-SDK.git", "state": { "branch": null, - "revision": "9c48602422ff2de9404112b9f2ed98c0a9ca2c44", - "version": "1.2.4" + "revision": "d4aa711788304be57c8e482a89719d734d85435a", + "version": "1.3.0" } }, { diff --git a/Package.swift b/Package.swift index 815a544..c48bc5a 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( from: "0.7.5"), .package(name: "MobilePassiveData", url: "https://github.com/Sage-Bionetworks/MobilePassiveData-SDK.git", - from: "1.2.4"), + from: "1.3.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift b/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift index d459848..5505b4c 100644 --- a/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift +++ b/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift @@ -34,6 +34,7 @@ import SwiftUI import AssessmentModel import AssessmentModelUI import SharedMobileUI +import MobilePassiveData // TODO: syoung 06/24/2022 There is no "taking too long" exit. Should there be? // TODO: syoung 06/24/2022 Is it intensional to not show any response after selecting first button? @@ -97,7 +98,7 @@ struct TrailmakingStepView: View { } } .onChange(of: assessmentState.showingPauseActions) { newValue in - viewModel.paused = newValue + viewModel.clock.isPaused = newValue } .onReceive(timer) { _ in viewModel.onTimerUpdated() @@ -113,29 +114,13 @@ struct TrailmakingStepView: View { @Published var testState: TestState = .idle @Published var currentIndex: Int = 0 - @Published var paused: Bool = false { - didSet { - if paused { - pauseUptime = ProcessInfo.processInfo.systemUptime - } - else if let uptime = pauseUptime, startUptime > 0 { - let timestamp = ProcessInfo.processInfo.systemUptime - pauseInterval += (timestamp - uptime) - pauseUptime = nil - result?.pauseInterval = pauseInterval - } - } - } + var clock: SimpleClock = .init() enum TestState : Int, Comparable { case idle, running, stopping, finished, error } var result: TrailmakingResultObject! - var startUptime: TimeInterval = 0 - var pauseInterval: TimeInterval = 0 - var pauseUptime: TimeInterval? - var stopUptime: TimeInterval? func onAppear(_ nodeState: StepState) { guard let result = nodeState.result as? TrailmakingResultObject @@ -147,6 +132,7 @@ struct TrailmakingStepView: View { self.result = result testState = .running + reset() } @@ -157,12 +143,9 @@ struct TrailmakingStepView: View { result.points = points result.numberOfErrors = nil result.responses = [] - result.pauseInterval = nil // reset the test - startUptime = ProcessInfo.processInfo.systemUptime - pauseInterval = 0 - pauseUptime = nil + clock.reset() runtime = .init() lastIncorrectTap = -1 numberOfErrors = 0 @@ -172,8 +155,7 @@ struct TrailmakingStepView: View { func onTap(at index: Int) { guard testState == .running else { return } - let now = ProcessInfo.processInfo.systemUptime - let timestamp = now - startUptime + let timestamp = clock.runningDuration() let correct = index == currentIndex result.responses?.append(.init(timestamp: timestamp, index: index, incorrect: !correct)) @@ -181,8 +163,8 @@ struct TrailmakingStepView: View { currentIndex += 1 lastIncorrectTap = -1 if currentIndex >= points.count { - result.runtime = timestamp - pauseInterval - stopUptime = now + result.runtime = timestamp + result.pauseInterval = clock.pauseCumulation testState = .stopping } } @@ -195,17 +177,15 @@ struct TrailmakingStepView: View { func onTimerUpdated() { - // Check if stopping and exit early if final result has been shown - if testState == .stopping, - let stopUptime = stopUptime, - ProcessInfo.processInfo.systemUptime - stopUptime >= 2.0 { + // Check if stopping and update state if the final result has been displayed for at least 2 seconds + if testState == .stopping, clock.stoppedDuration() >= 2.0 { testState = .finished } - // Otherwise, do nothing unless running and not paused - guard !paused, testState == .running else { return } - let duration = ProcessInfo.processInfo.systemUptime - startUptime - pauseInterval - runtime.second = Int(duration) + // Exit early unless running and not paused + if !clock.isPaused, testState == .running { + runtime.second = Int(clock.runningDuration()) + } } } } diff --git a/Sources/BiAffectSDK/Utils/SimpleClock.swift b/Sources/BiAffectSDK/Utils/SimpleClock.swift new file mode 100644 index 0000000..36bf8dc --- /dev/null +++ b/Sources/BiAffectSDK/Utils/SimpleClock.swift @@ -0,0 +1,87 @@ +// +// SimpleClock.swift +// +// +// Copyright © 2022 BiAffect. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder(s) nor the names of any contributors +// may be used to endorse or promote products derived from this software without +// specific prior written permission. No license is granted to the trademarks of +// the copyright holders even if such marks are included in this software. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// + +import Foundation +import Combine +import MobilePassiveData + +@MainActor +class SimpleClock : ObservableObject { + + public private(set) var startTime: SystemUptime = ProcessInfo.processInfo.systemUptime + private(set) var stopTime: SystemUptime? = nil + private(set) var pauseCumulation: SecondDuration = 0 + private(set) var pauseStartTime: Double? = nil + + @Published var isPaused: Bool = false { + didSet { + if isPaused { + pause() + } + else { + resume() + } + } + } + + public let onPauseChanged = PassthroughSubject() + + public func reset() { + startTime = ProcessInfo.processInfo.systemUptime + stopTime = nil + pauseStartTime = nil + pauseCumulation = 0 + isPaused = false + } + + public func runningDuration(timestamp: SystemUptime = ProcessInfo.processInfo.systemUptime) -> SecondDuration { + timestamp - startTime - pauseCumulation + } + + public func stoppedDuration(timestamp: SystemUptime = ProcessInfo.processInfo.systemUptime) -> SecondDuration { + stopTime.map { timestamp - $0 } ?? 0 + } + + private func pause() { + guard pauseStartTime == nil else { return } + pauseStartTime = ProcessInfo.processInfo.systemUptime + onPauseChanged.send(true) + } + + private func resume() { + guard let pauseStartTime = pauseStartTime else { return } + pauseCumulation += (ProcessInfo.processInfo.systemUptime - pauseStartTime) + self.pauseStartTime = nil + onPauseChanged.send(false) + } +} diff --git a/Tests/BiAffectSDKTests/TrailmakingTests.swift b/Tests/BiAffectSDKTests/TrailmakingTests.swift index ca814f8..702fda5 100644 --- a/Tests/BiAffectSDKTests/TrailmakingTests.swift +++ b/Tests/BiAffectSDKTests/TrailmakingTests.swift @@ -195,7 +195,7 @@ final class TrailmakingTests: XCTestCase { XCTAssertEqual(.running, viewModel.testState) XCTAssertEqual(viewModel.points, result.points) XCTAssertEqual([], result.responses) - XCTAssertNotEqual(0, viewModel.startUptime) + XCTAssertNotEqual(0, viewModel.clock.startTime) // tap each button for ii in 0.. TimeInterval { let before = ProcessInfo.processInfo.systemUptime - viewModel.paused = true + viewModel.clock.isPaused = true try await Task.sleep(nanoseconds: seconds * 1_000_000_000) - viewModel.paused = false + viewModel.clock.isPaused = false let after = ProcessInfo.processInfo.systemUptime return after - before } From 722bf2169eb51a5e1cc7e02637e51effb91a5bc3 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Thu, 30 Jun 2022 18:24:49 -0700 Subject: [PATCH 02/10] Refactor Go-No-Go to save a file result with raw motion sensor data --- .../GoNoGo/GoNoGoResultObject.swift | 17 +- .../BiAffectSDK/GoNoGo/GoNoGoStepView.swift | 66 +++++-- .../GoNoGo/ShakeMotionSensor.swift | 172 ++++++++++-------- schemas/v1/ShakeSample.json | 102 +++++++++++ 4 files changed, 255 insertions(+), 102 deletions(-) create mode 100644 schemas/v1/ShakeSample.json diff --git a/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift b/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift index 36a8635..e998bbb 100644 --- a/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift +++ b/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift @@ -32,6 +32,7 @@ import Foundation import JsonModel +import MobilePassiveData extension SerializableResultType { static let gonogo: SerializableResultType = "gonogo" @@ -46,7 +47,7 @@ public final class GoNoGoResultObject : MultiplatformResultData, SerializableRes public let identifier: String public var startDateTime: Date public var endDateTime: Date? - public var startUptime: TimeInterval? + public var startUptime: ClockUptime? public var responses: [Response] public var motionError: ErrorResultObject? @@ -69,11 +70,12 @@ public final class GoNoGoResultObject : MultiplatformResultData, SerializableRes public struct Response : Codable, Hashable { private enum CodingKeys : String, OrderedEnumCodingKey { - case timestamp, resetTimestamp, timeToThreshold, go, incorrect, samples + case stepPath, timestamp, resetTimestamp, timeToThreshold, go, incorrect, samples } - public let timestamp: TimeInterval - public let resetTimestamp: TimeInterval - public let timeToThreshold: TimeInterval + public let stepPath: String? + public let timestamp: ClockUptime + public let resetTimestamp: ClockUptime + public let timeToThreshold: SecondDuration public let go: Bool public let incorrect: Bool public let samples: [Sample]? @@ -169,6 +171,9 @@ extension GoNoGoResultObject.Response : DocumentableStruct { throw DocumentableError.invalidCodingKey(codingKey, "\(codingKey) is not recognized for this class") } switch key { + case .stepPath: + return .init(propertyType: .primitive(.string), propertyDescription: + "A marker that matches the 'stepPath' in the 'motion.json' file with raw motion sensor data.") case .timestamp: return .init(propertyType: .primitive(.number), propertyDescription: """ @@ -201,7 +206,7 @@ extension GoNoGoResultObject.Response : DocumentableStruct { } public static func examples() -> [GoNoGoResultObject.Response] { - [.init(timestamp: 0, resetTimestamp: 120492.081, timeToThreshold: 0.1, go: true, incorrect: true, samples: nil)] + [.init(stepPath: "0", timestamp: 0, resetTimestamp: 120492.081, timeToThreshold: 0.1, go: true, incorrect: true, samples: nil)] } } diff --git a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift index b4ef647..43fe387 100644 --- a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift +++ b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift @@ -78,7 +78,7 @@ struct GoNoGoStepView: View { Spacer() } .onAppear { - viewModel.onAppear(nodeState) + viewModel.onAppear(nodeState, assessmentState) } .onDisappear { viewModel.onDisappear() @@ -94,6 +94,9 @@ struct GoNoGoStepView: View { .onChange(of: assessmentState.showingPauseActions) { newValue in viewModel.paused = newValue } + .onReceive(viewModel.shakeSensor.errorNotification) { error in + viewModel.onMotionRecorderError(error) + } } @State var contentHeight: CGFloat = 0 @@ -167,12 +170,11 @@ struct GoNoGoStepView: View { @Published var attemptCount: Int = 1 @Published var maxSuccessCount: Int = 9 @Published var errorCount: Int = 0 - @Published var lastReactionTime: TimeInterval? + @Published var lastReactionTime: SecondDuration? @Published var go: Bool = false @Published var correct: Bool = false @Published var didTimeout: Bool = false @Published var testState: TestState = .idle - @Published var motionDenied: Bool = false @Published var showingDot: Bool = false @Published var showingResponse: Bool = false @@ -182,8 +184,10 @@ struct GoNoGoStepView: View { @Published var paused: Bool = false { didSet { + guard shakeSensor.status == .running else { return } if paused { - shakeSensor.state = .paused + // pause the clock and stop sampling + shakeSensor.pause() } else { reset() @@ -191,6 +195,7 @@ struct GoNoGoStepView: View { } } + var assessmentResult: AssessmentResult! var result: GoNoGoResultObject! var step: GoNoGoStepObject! var successCount: Int = 0 @@ -198,7 +203,7 @@ struct GoNoGoStepView: View { var waitTask: Task? let shakeSensor: ShakeMotionSensor = .init() - func onAppear(_ nodeState: StepState) { + func onAppear(_ nodeState: StepState, _ assessmentState: AssessmentState) { guard let result = nodeState.result as? GoNoGoResultObject, let step = nodeState.step as? GoNoGoStepObject else { @@ -209,30 +214,49 @@ struct GoNoGoStepView: View { guard !isVisible else { return } isVisible = true + self.assessmentResult = assessmentState.assessmentResult self.result = result self.step = step self.instructions = step.detail self.maxSuccessCount = step.numberOfAttempts self.shakeSensor.thresholdAcceleration = step.thresholdAcceleration + result.startUptime = shakeSensor.clock.startUptime + assessmentState.outputDirectory = shakeSensor.outputDirectory - shakeSensor.start() - reset() + Task { + do { + try await shakeSensor.start() + reset() + } catch { + result.motionError = .init(identifier: "motion", error: error) + testState = .error + } + } } func onDisappear() { guard isVisible else { return } isVisible = false waitTask?.cancel() - shakeSensor.stop() + shakeSensor.cancel() } - func onDeviceShaked(_ timestamp: TimeInterval) { - guard isVisible, !showingResponse, !paused else { return } - didFinishAttempt(timestamp) + func onDeviceShaked(_ timestamp: SystemUptime) { + Task { + let uptime = await shakeSensor.clock.relativeUptime(to: timestamp) + guard isVisible, !showingResponse, !paused else { return } + didFinishAttempt(uptime) + } + } + + func onMotionRecorderError(_ error: Error) { + guard isVisible, testState == .running else { return } + result.motionError = .init(identifier: "motion", error: error) + testState = .error } func reset() { - guard isVisible, !paused else { return } + guard isVisible, !paused, shakeSensor.status == .running else { return } waitTask?.cancel() shakeSensor.reset() @@ -243,6 +267,7 @@ struct GoNoGoStepView: View { attemptCount = min(max(1, successCount + 1), maxSuccessCount) let stimulusDelay = calculateStimulusDelay() + waitTask = Task { guard await Task.wait(seconds: stimulusDelay) else { return } showStimulus() @@ -250,7 +275,8 @@ struct GoNoGoStepView: View { } func showStimulus() { - shakeSensor.stimulusUptime = ProcessInfo.processInfo.systemUptime + shakeSensor.stimulusUptime = SystemClock.uptime() + shakeSensor.dotType = go ? .blue : .green showingDot = true waitTask = Task { guard await Task.wait(seconds: step.timeout) else { return } @@ -275,6 +301,7 @@ struct GoNoGoStepView: View { let timeToThreshold = thresholdUptime.map { correct ? $0 - startUptime : 0 } ?? 0 // Update display + shakeSensor.dotType = .result showingDot = correct showingResponse = true if go && correct { @@ -282,12 +309,13 @@ struct GoNoGoStepView: View { } // Add response to result - result.responses.append(.init(timestamp: startUptime, - resetTimestamp: shakeSensor.resetUptime ?? 0, + result.responses.append(.init(stepPath: shakeSensor.currentStepPath, + timestamp: startUptime, + resetTimestamp: shakeSensor.resetUptime, timeToThreshold: timeToThreshold, go: go, incorrect: !correct, - samples: shakeSensor.processSamples(startUptime))) + samples: shakeSensor.processSamples())) // Show response for 2.5 seconds before continuing waitTask = Task { @@ -298,7 +326,11 @@ struct GoNoGoStepView: View { func startNext() { if successCount >= maxSuccessCount { - testState = .finished + Task { + let motionResult = try await shakeSensor.stop() + self.assessmentResult.asyncResults = [motionResult] + testState = .finished + } } else { reset() diff --git a/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift b/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift index df4a70d..970b4fd 100644 --- a/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift +++ b/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift @@ -33,92 +33,81 @@ import SwiftUI import Combine import MobilePassiveData +import MotionSensor +import JsonModel #if canImport(CoreMotion) import CoreMotion #endif -@MainActor -class ShakeMotionSensor : ObservableObject { - @Published var resetUptime: TimeInterval? - @Published var motionError: Error? - @Published var state: State = .idle - - enum State: Int { - case idle, started, listening, paused, stopped +fileprivate let motionRecorderConfig = MotionRecorderConfigurationObject(identifier: "motion", + recorderTypes: [.accelerometer, .gyro, .userAcceleration], + frequency: 100) + +fileprivate func createOutputDirectory() -> URL { + URL(fileURLWithPath: UUID().uuidString, isDirectory: true, relativeTo: FileManager.default.temporaryDirectory) +} + +final class ShakeMotionSensor : MotionRecorder { + + var dotType: DisplayState = .starting { + didSet { + self.moveTo(stepPath: "attempt/\(resetCount)/showing/\(dotType)") + } } var thresholdAcceleration: Double = 0.5 - var thresholdUptime: TimeInterval? - var stimulusUptime: TimeInterval? - var samplesSinceStimulus: Int = 0 - var samples: [GoNoGoResultObject.Sample] = [] + var resetUptime: ClockUptime = SystemClock.uptime() + var stimulusUptime: ClockUptime? - func processSamples(_ stimulusUptime: TimeInterval) -> [GoNoGoResultObject.Sample] { - let ret: [GoNoGoResultObject.Sample] = samples.compactMap { - $0.timestamp >= stimulusUptime ? - .init(timestamp: $0.timestamp - stimulusUptime, vectorMagnitude: $0.vectorMagnitude) : nil - } - samples.removeAll() - return ret - } + private var resetCount: Int = 0 + private var thresholdUptime: SystemUptime? // SystemTime + private var samplesSinceStimulus: Int = 0 + private var samples: [GoNoGoResultObject.Sample] = [] - func reset() { - guard state != .stopped else { return } - - resetUptime = ProcessInfo.processInfo.systemUptime - thresholdUptime = nil - stimulusUptime = nil - samples.removeAll() - samplesSinceStimulus = 0 - - // Wait 0.5 seconds before listening for the participant to shake the device. - Task { - guard await Task.wait(seconds: 0.5) else { return } - state = .listening + enum DisplayState: String, CaseIterable, Comparable, Codable { + case starting, result, none, blue, green + static func < (lhs: ShakeMotionSensor.DisplayState, rhs: ShakeMotionSensor.DisplayState) -> Bool { + allCases.firstIndex(of: lhs)! < allCases.firstIndex(of: rhs)! } } - #if os(iOS) - - let motionManager: CMMotionManager = .init() - - init() { - motionManager.deviceMotionUpdateInterval = 0.01 + init(outputDirectory: URL = createOutputDirectory(), sectionIdentifier: String? = nil) { + super.init(configuration: motionRecorderConfig, + outputDirectory: outputDirectory, + initialStepPath: "starting", + sectionIdentifier: sectionIdentifier) } - deinit { - motionManager.stopDeviceMotionUpdates() + @MainActor func processSamples() -> [GoNoGoResultObject.Sample] { + samples } - func start() { - guard state == .idle else { return } - state = .started - listenForDeviceShake = true - motionManager.startDeviceMotionUpdates(to: .main) { [weak self] (motion, error) in - if let motion = motion { - self?.onMotionReceived(motion) - } - else { - self?.motionError = error - } - } + @MainActor func reset() { + guard status <= .running else { return } + + resetCount += 1 + resetUptime = SystemClock.uptime() + thresholdUptime = nil + stimulusUptime = nil + samples.removeAll() + samplesSinceStimulus = 0 + dotType = .none + resume() } - func onMotionReceived(_ motion: CMDeviceMotion) { - // Turn off listening for device "shake" b/c motion sensors are more precise. - // This allows running the test if using the simulator or if permission to use - // motion sensors has not been given. - listenForDeviceShake = false + @MainActor func onMotionReceived(_ vectorMagnitude: Double, timestamp: SystemUptime) async { + guard status <= .running, dotType >= .none, !clock.isPaused else { return } - // Ignore if not runnning - guard state == .listening else { return } + // Get the relative clock time and exit early if this is old + let uptime = await clock.relativeUptime(to: timestamp) + guard uptime > resetUptime else { return } - // Process the sample - let v = motion.userAcceleration - let vectorMagnitude = sqrt(((v.x * v.x) + (v.y * v.y) + (v.z * v.z))) - let sample: GoNoGoResultObject.Sample = .init(timestamp: motion.timestamp, vectorMagnitude: vectorMagnitude) - samples.append(sample) + // Add the sample if showing the stimulus + if let stimulusUptime = stimulusUptime, uptime > stimulusUptime { + let sample: GoNoGoResultObject.Sample = .init(timestamp: uptime - stimulusUptime, vectorMagnitude: vectorMagnitude) + samples.append(sample) + } let showingStimulus = stimulusUptime != nil let isShaking = vectorMagnitude > thresholdAcceleration @@ -127,7 +116,7 @@ class ShakeMotionSensor : ObservableObject { guard showingStimulus else { if isShaking { // If the user jumps the gun, stop the test right away and exit. - deviceShaked.send(motion.timestamp) + deviceShaked.send(timestamp) } return } @@ -137,39 +126,64 @@ class ShakeMotionSensor : ObservableObject { // Check if we should mark the threshold timestamp. if isShaking, thresholdUptime == nil { - thresholdUptime = motion.timestamp + thresholdUptime = timestamp } // Finally, if there have been 100 samples since showing the stimulus and the // device was shaking during that time, then send the message. if samplesSinceStimulus >= 100, let timestamp = thresholdUptime { - state = .paused deviceShaked.send(timestamp) } } - func stop() { - state = .stopped - listenForDeviceShake = false - motionManager.stopDeviceMotionUpdates() + struct ShakeSample : SampleRecord, Codable, Hashable { + private enum CodingKeys : String, OrderedEnumCodingKey { + case stepPath, uptime, timestamp, sensorType, x, y, z, vectorMagnitude + } + + let stepPath: String + let uptime: ClockUptime + let timestamp: SecondDuration? + let sensorType: MotionRecorderType? + let x: Double? + let y: Double? + let z: Double? + let vectorMagnitude: Double? + + private(set) var timestampDate: Date? = nil + } + + #if os(iOS) + + override func samples(from data: CMDeviceMotion, frame: CMAttitudeReferenceFrame, stepPath: String, uptime: ClockUptime, timestamp: SecondDuration) -> [SampleRecord] { + let v = data.userAcceleration + let vectorMagnitude = sqrt(((v.x * v.x) + (v.y * v.y) + (v.z * v.z))) + Task { + await onMotionReceived(vectorMagnitude, timestamp: data.timestamp) + } + return [ + ShakeSample(stepPath: stepPath, + uptime: uptime, + timestamp: timestamp, + sensorType: .userAcceleration, + x: v.x, + y: v.y, + z: v.z, + vectorMagnitude: vectorMagnitude) + ] } - #else - // If running on a Mac (unit tests) then these methods do nothing. - func start() { } - func stop() { } #endif } -fileprivate var listenForDeviceShake: Bool = false let deviceShaked = PassthroughSubject() -#if os(iOS) +#if os(iOS) && targetEnvironment(simulator) import UIKit extension UIWindow { open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { - if motion == .motionShake, listenForDeviceShake { + if motion == .motionShake { deviceShaked.send(event?.timestamp ?? ProcessInfo.processInfo.systemUptime) } } diff --git a/schemas/v1/ShakeSample.json b/schemas/v1/ShakeSample.json new file mode 100644 index 0000000..36a90e6 --- /dev/null +++ b/schemas/v1/ShakeSample.json @@ -0,0 +1,102 @@ +{ + "$id" : "https://biaffect.github.io/biaffectsdk/schemas/v1/ShakeSample.json", + "$schema" : "http://json-schema.org/draft-07/schema#", + "type" : "array", + "title" : "ShakeSample", + "description" : "An array of motion sensor samples.", + "items" : { + "type" : "object", + "properties" : { + "uptime" : { + "type" : "number", + "description" : "Clock time. This is the machine clock for the device and runs even when the device is asleep (unlike the processor system uptime)." + }, + "timestamp" : { + "type" : "number", + "description" : "A duration in seconds relative to when the recorder was started." + }, + "stepPath" : { + "type" : "string", + "description" : "An identifier marking the response attempt including display state." + }, + "timestampDate" : { + "type" : "string", + "description" : "The date timestamp when step path was changed.", + "format" : "date-time" + }, + "sensorType" : { + "type" : "string", + "description" : "The sensor type for this record sample. If `null` then this sample is a `stepPath` change marker.", + "enum" : [ + "accelerometer", + "gyro", + "userAcceleration" + ] + }, + "x" : { + "type" : "number", + "description" : "The `x` component of the vector measurement for this sensor sample." + }, + "y" : { + "type" : "number", + "description" : "The `y` component of the vector measurement for this sensor sample." + }, + "z" : { + "type" : "number", + "description" : "The `z` component of the vector measurement for this sensor sample." + }, + "vectorMagnitude" : { + "type" : "number", + "description" : "The calculated vector magnitude used to determine whether or not the device was shaken. (`sensorType==userAcceleration`)" + } + }, + "required" : [ + "stepPath", + "uptime", + "timestamp" + ], + "additionalProperties" : false, + "examples" : [ + { + "stepPath" : "starting", + "timestamp" : 0, + "timestampDate" : "2022-06-29T12:41:20.029-07:00", + "uptime" : 1289650.419528791 + }, + { + "stepPath" : "attempt/3/showing/blue", + "timestamp" : 28.606888458365574, + "timestampDate" : "2022-06-29T12:41:48.636-07:00", + "uptime" : 1289679.0264153329 + }, + { + "stepPath" : "attempt/3/showing/blue", + "uptime" : 1289679.0275513744, + "timestamp" : 28.608022583415732, + "sensorType" : "userAcceleration", + "x" : -0.00084100663661956787, + "y" : 0.0043393969535827637, + "z" : 0.010767042636871338, + "vectorMagnitude" : 0.011639023379468177 + }, + { + "uptime" : 1289679.0255373744, + "timestamp" : 28.606008583330549, + "stepPath" : "attempt/3/showing/blue", + "sensorType" : "accelerometer", + "x" : 0.06011962890625, + "y" : -0.43389892578125, + "z" : -0.8861541748046875 + }, + { + "uptime" : 1289679.0335633743, + "timestamp" : 28.614034583326429, + "stepPath" : "attempt/3/showing/blue", + "sensorType" : "gyro", + "x" : -0.011590609326958656, + "y" : 0.00079788308357819915, + "z" : 0.0056229983456432819 + } + ] + } +} From 7ad7599dd841dea2675bd80f396c58ee64b3cac3 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Thu, 30 Jun 2022 18:33:21 -0700 Subject: [PATCH 03/10] Add upper limit for the number of attempts before giving up --- Sources/BiAffectSDK/GoNoGo/GoNoGoStepObject.swift | 7 ++++++- Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepObject.swift b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepObject.swift index a1f7654..0116e87 100644 --- a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepObject.swift +++ b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepObject.swift @@ -49,7 +49,8 @@ struct GoNoGoStepObject : SerializableNode, Step, Codable { _minimumStimulusInterval = "minimumStimulusInterval", _thresholdAcceleration = "thresholdAcceleration", _numberOfAttempts = "numberOfAttempts", - _timeout = "timeout" + _timeout = "timeout", + _maxTotalAttempts = "maxTotalAttempts" } private(set) var serializableType: SerializableNodeType = .gonogo @@ -75,6 +76,10 @@ struct GoNoGoStepObject : SerializableNode, Step, Codable { var numberOfAttempts: Int { _numberOfAttempts ?? 9 } private(set) var _numberOfAttempts: Int? + /// The max number of attempts to try before quitting. + var maxTotalAttempts: Int { _maxTotalAttempts ?? 18 } + private(set) var _maxTotalAttempts: Int? + /// The interval permitted after the stimulus until the test fails, if the threshold is not reached. var timeout: TimeInterval { _timeout ?? 3.0 } private(set) var _timeout: TimeInterval? diff --git a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift index 43fe387..5c525db 100644 --- a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift +++ b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift @@ -56,8 +56,6 @@ extension Font { static let detail: Font = .latoFont(16, relativeTo: .footnote, weight: .regular) } -// TODO: syoung 06/24/2022 There is no "failed too many times" exit. Should there be? - struct GoNoGoStepView: View { @EnvironmentObject var assessmentState: AssessmentState @EnvironmentObject var pagedNavigation: PagedNavigationViewModel @@ -169,6 +167,7 @@ struct GoNoGoStepView: View { @Published var instructions: String = "Hello, World" @Published var attemptCount: Int = 1 @Published var maxSuccessCount: Int = 9 + @Published var maxAttemptCount: Int = 18 @Published var errorCount: Int = 0 @Published var lastReactionTime: SecondDuration? @Published var go: Bool = false @@ -220,6 +219,7 @@ struct GoNoGoStepView: View { self.instructions = step.detail self.maxSuccessCount = step.numberOfAttempts self.shakeSensor.thresholdAcceleration = step.thresholdAcceleration + self.maxAttemptCount = step.maxTotalAttempts result.startUptime = shakeSensor.clock.startUptime assessmentState.outputDirectory = shakeSensor.outputDirectory @@ -325,7 +325,7 @@ struct GoNoGoStepView: View { } func startNext() { - if successCount >= maxSuccessCount { + if successCount >= maxSuccessCount || result.responses.count >= maxAttemptCount { Task { let motionResult = try await shakeSensor.stop() self.assessmentResult.asyncResults = [motionResult] From 581a1119a7e174caabdb2198062d04c93b95c859 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 5 Jul 2022 09:52:50 -0700 Subject: [PATCH 04/10] Change viewbuilder to point at local copies --- .../project.pbxproj | 61 ++++++------------- .../xcshareddata/swiftpm/Package.resolved | 60 +++++++----------- 2 files changed, 42 insertions(+), 79 deletions(-) diff --git a/BiAffectViewBuilder/BiAffectViewBuilder.xcodeproj/project.pbxproj b/BiAffectViewBuilder/BiAffectViewBuilder.xcodeproj/project.pbxproj index 77ab2fa..71fc5c0 100644 --- a/BiAffectViewBuilder/BiAffectViewBuilder.xcodeproj/project.pbxproj +++ b/BiAffectViewBuilder/BiAffectViewBuilder.xcodeproj/project.pbxproj @@ -11,8 +11,6 @@ FF29ACA12862A92E002A42AE /* TrailmakingResultObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF29ACA02862A92E002A42AE /* TrailmakingResultObject.swift */; }; FF29ACA42862BFBA002A42AE /* TrailmakingPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF29ACA32862BFBA002A42AE /* TrailmakingPoint.swift */; }; FFA2306128613D1500120300 /* TrailmakingStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA2306028613D1500120300 /* TrailmakingStepView.swift */; }; - FFBC90FF286A42C700029A59 /* AssessmentModel in Frameworks */ = {isa = PBXBuildFile; productRef = FFBC90FE286A42C700029A59 /* AssessmentModel */; }; - FFBC9101286A42C700029A59 /* AssessmentModelUI in Frameworks */ = {isa = PBXBuildFile; productRef = FFBC9100286A42C700029A59 /* AssessmentModelUI */; }; FFD8C45C285C30B5000FC950 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = FFD8C45E285C30B5000FC950 /* Localizable.strings */; }; FFD8C45F285C30FC000FC950 /* Trail_Making.json in Resources */ = {isa = PBXBuildFile; fileRef = FFD8C461285C30FC000FC950 /* Trail_Making.json */; }; FFD8C462285C3108000FC950 /* Go-No-Go.json in Resources */ = {isa = PBXBuildFile; fileRef = FFD8C464285C3108000FC950 /* Go-No-Go.json */; }; @@ -29,8 +27,10 @@ FFE2A6CC285BA9A8009805C0 /* GoNoGoStepObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE2A6C3285BA9A8009805C0 /* GoNoGoStepObject.swift */; }; FFE2A6CD285BA9A8009805C0 /* BiAffectAssessmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE2A6C5285BA9A8009805C0 /* BiAffectAssessmentView.swift */; }; FFE2A6CE285BA9A8009805C0 /* GoNoGoStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE2A6C6285BA9A8009805C0 /* GoNoGoStepView.swift */; }; - FFE2A6D3285BAA9B009805C0 /* MobilePassiveData in Frameworks */ = {isa = PBXBuildFile; productRef = FFE2A6D2285BAA9B009805C0 /* MobilePassiveData */; }; - FFE2A6D5285BAA9B009805C0 /* MotionSensor in Frameworks */ = {isa = PBXBuildFile; productRef = FFE2A6D4285BAA9B009805C0 /* MotionSensor */; }; + FFF4295C286F667A00361337 /* AssessmentModel in Frameworks */ = {isa = PBXBuildFile; productRef = FFF4295B286F667A00361337 /* AssessmentModel */; }; + FFF4295E286F667A00361337 /* AssessmentModelUI in Frameworks */ = {isa = PBXBuildFile; productRef = FFF4295D286F667A00361337 /* AssessmentModelUI */; }; + FFF42960286F667A00361337 /* MobilePassiveData in Frameworks */ = {isa = PBXBuildFile; productRef = FFF4295F286F667A00361337 /* MobilePassiveData */; }; + FFF42962286F667A00361337 /* MotionSensor in Frameworks */ = {isa = PBXBuildFile; productRef = FFF42961286F667A00361337 /* MotionSensor */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -56,6 +56,8 @@ FFE2A6C3285BA9A8009805C0 /* GoNoGoStepObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoNoGoStepObject.swift; sourceTree = ""; }; FFE2A6C5285BA9A8009805C0 /* BiAffectAssessmentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BiAffectAssessmentView.swift; sourceTree = ""; }; FFE2A6C6285BA9A8009805C0 /* GoNoGoStepView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoNoGoStepView.swift; sourceTree = ""; }; + FFF42959286F660E00361337 /* AssessmentModelKMM */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AssessmentModelKMM; path = ../../AssessmentModelKMM; sourceTree = ""; }; + FFF4295A286F663A00361337 /* MobilePassiveData-SDK */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "MobilePassiveData-SDK"; path = "../../MobilePassiveData-SDK"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -63,10 +65,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - FFE2A6D5285BAA9B009805C0 /* MotionSensor in Frameworks */, - FFBC90FF286A42C700029A59 /* AssessmentModel in Frameworks */, - FFBC9101286A42C700029A59 /* AssessmentModelUI in Frameworks */, - FFE2A6D3285BAA9B009805C0 /* MobilePassiveData in Frameworks */, + FFF42962286F667A00361337 /* MotionSensor in Frameworks */, + FFF4295E286F667A00361337 /* AssessmentModelUI in Frameworks */, + FFF42960286F667A00361337 /* MobilePassiveData in Frameworks */, + FFF4295C286F667A00361337 /* AssessmentModel in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -187,6 +189,8 @@ FFE2A6CF285BA9FE009805C0 /* Packages */ = { isa = PBXGroup; children = ( + FFF42959286F660E00361337 /* AssessmentModelKMM */, + FFF4295A286F663A00361337 /* MobilePassiveData-SDK */, ); name = Packages; sourceTree = ""; @@ -215,10 +219,10 @@ ); name = BiAffectViewBuilder; packageProductDependencies = ( - FFE2A6D2285BAA9B009805C0 /* MobilePassiveData */, - FFE2A6D4285BAA9B009805C0 /* MotionSensor */, - FFBC90FE286A42C700029A59 /* AssessmentModel */, - FFBC9100286A42C700029A59 /* AssessmentModelUI */, + FFF4295B286F667A00361337 /* AssessmentModel */, + FFF4295D286F667A00361337 /* AssessmentModelUI */, + FFF4295F286F667A00361337 /* MobilePassiveData */, + FFF42961286F667A00361337 /* MotionSensor */, ); productName = BiAffectViewBuilder; productReference = FFE2A6A8285BA8AA009805C0 /* BiAffectViewBuilder.app */; @@ -249,8 +253,6 @@ ); mainGroup = FFE2A69F285BA8AA009805C0; packageReferences = ( - FFE2A6D1285BAA9B009805C0 /* XCRemoteSwiftPackageReference "MobilePassiveData-SDK" */, - FFBC90FD286A42C700029A59 /* XCRemoteSwiftPackageReference "AssessmentModelKMM" */, ); productRefGroup = FFE2A6A9285BA8AA009805C0 /* Products */; projectDirPath = ""; @@ -524,44 +526,21 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - FFBC90FD286A42C700029A59 /* XCRemoteSwiftPackageReference "AssessmentModelKMM" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Sage-Bionetworks/AssessmentModelKMM.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.7.5; - }; - }; - FFE2A6D1285BAA9B009805C0 /* XCRemoteSwiftPackageReference "MobilePassiveData-SDK" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Sage-Bionetworks/MobilePassiveData-SDK.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.2.4; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ - FFBC90FE286A42C700029A59 /* AssessmentModel */ = { + FFF4295B286F667A00361337 /* AssessmentModel */ = { isa = XCSwiftPackageProductDependency; - package = FFBC90FD286A42C700029A59 /* XCRemoteSwiftPackageReference "AssessmentModelKMM" */; productName = AssessmentModel; }; - FFBC9100286A42C700029A59 /* AssessmentModelUI */ = { + FFF4295D286F667A00361337 /* AssessmentModelUI */ = { isa = XCSwiftPackageProductDependency; - package = FFBC90FD286A42C700029A59 /* XCRemoteSwiftPackageReference "AssessmentModelKMM" */; productName = AssessmentModelUI; }; - FFE2A6D2285BAA9B009805C0 /* MobilePassiveData */ = { + FFF4295F286F667A00361337 /* MobilePassiveData */ = { isa = XCSwiftPackageProductDependency; - package = FFE2A6D1285BAA9B009805C0 /* XCRemoteSwiftPackageReference "MobilePassiveData-SDK" */; productName = MobilePassiveData; }; - FFE2A6D4285BAA9B009805C0 /* MotionSensor */ = { + FFF42961286F667A00361337 /* MotionSensor */ = { isa = XCSwiftPackageProductDependency; - package = FFE2A6D1285BAA9B009805C0 /* XCRemoteSwiftPackageReference "MobilePassiveData-SDK" */; productName = MotionSensor; }; /* End XCSwiftPackageProductDependency section */ diff --git a/BiAffectViewBuilder/BiAffectViewBuilder.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/BiAffectViewBuilder/BiAffectViewBuilder.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 23285df..495669e 100644 --- a/BiAffectViewBuilder/BiAffectViewBuilder.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/BiAffectViewBuilder/BiAffectViewBuilder.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,41 +1,25 @@ { - "pins" : [ - { - "identity" : "assessmentmodelkmm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Sage-Bionetworks/AssessmentModelKMM.git", - "state" : { - "revision" : "850d98c99152139160287126c20881b214fef2fb", - "version" : "0.7.5" + "object": { + "pins": [ + { + "package": "JsonModel", + "repositoryURL": "https://github.com/Sage-Bionetworks/JsonModel-Swift.git", + "state": { + "branch": null, + "revision": "996d4807c42f0660c62b4cec0f95230dd1341639", + "version": "1.5.0" + } + }, + { + "package": "SharedMobileUI", + "repositoryURL": "https://github.com/Sage-Bionetworks/SharedMobileUI-AppleOS.git", + "state": { + "branch": null, + "revision": "a3c4ff73d2b222d5a246f617d5c2b062eb3abfe4", + "version": "0.17.0" + } } - }, - { - "identity" : "jsonmodel-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Sage-Bionetworks/JsonModel-Swift.git", - "state" : { - "revision" : "ca85a1bb5272f428d050d514d72fdea05c95f6fb", - "version" : "1.4.10" - } - }, - { - "identity" : "mobilepassivedata-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Sage-Bionetworks/MobilePassiveData-SDK.git", - "state" : { - "revision" : "9c48602422ff2de9404112b9f2ed98c0a9ca2c44", - "version" : "1.2.4" - } - }, - { - "identity" : "sharedmobileui-appleos", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Sage-Bionetworks/SharedMobileUI-AppleOS.git", - "state" : { - "revision" : "a3c4ff73d2b222d5a246f617d5c2b062eb3abfe4", - "version" : "0.17.0" - } - } - ], - "version" : 2 + ] + }, + "version": 1 } From 6c7bcb7fdd372531ea1b276e7d74ff5f53ada2ca Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 5 Jul 2022 09:53:28 -0700 Subject: [PATCH 05/10] Motion sensors no longer require premissions if in the foreground --- Sources/BiAffectSDK/BiAffectSDK.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Sources/BiAffectSDK/BiAffectSDK.swift b/Sources/BiAffectSDK/BiAffectSDK.swift index 527003d..782bd24 100644 --- a/Sources/BiAffectSDK/BiAffectSDK.swift +++ b/Sources/BiAffectSDK/BiAffectSDK.swift @@ -37,14 +37,6 @@ import JsonModel let kBaseJsonSchemaURL = URL(string: "https://biaffect.github.io/biaffectsdk/schemas/v1/")! -public final class BiAffectSDK { - public class func setup() { - #if os(iOS) - PermissionAuthorizationHandler.registerAdaptorIfNeeded(MotionSensor.MotionAuthorization.shared) - #endif - } -} - public enum BiAffectIdentifier : String, CaseIterable { case trailmaking = "Trail_Making", goNoGo = "Go-No-Go" From 6109ea431cc17ad4ea09ae3f1dcbfccdf1f0b6f6 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 5 Jul 2022 10:52:04 -0700 Subject: [PATCH 06/10] Refactor to use SimpleClock --- .../BiAffectSDK/GoNoGo/GoNoGoStepView.swift | 21 +++++-------------- .../GoNoGo/ShakeMotionSensor.swift | 2 +- .../Trailmaking/TrailmakingStepView.swift | 2 +- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift index 5c525db..c9dde26 100644 --- a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift +++ b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift @@ -37,15 +37,6 @@ import SharedMobileUI import JsonModel import MobilePassiveData -#if canImport(AudioToolbox) -import AudioToolbox -func vibrateDevice() { - AudioServicesPlayAlertSound(SystemSoundID(kSystemSoundID_Vibrate)) -} -#else -func vibrateDevice() { } -#endif - extension SoundFile { static let success = SoundFile(name: "sms-received5") static let failure = SoundFile(name: "jbl_cancel") @@ -152,7 +143,7 @@ struct GoNoGoStepView: View { soundPlayer.playSound(.failure) } else { - vibrateDevice() + soundPlayer.vibrateDevice() } } } @@ -220,7 +211,7 @@ struct GoNoGoStepView: View { self.maxSuccessCount = step.numberOfAttempts self.shakeSensor.thresholdAcceleration = step.thresholdAcceleration self.maxAttemptCount = step.maxTotalAttempts - result.startUptime = shakeSensor.clock.startUptime + result.startUptime = shakeSensor.clock.startTime assessmentState.outputDirectory = shakeSensor.outputDirectory Task { @@ -242,11 +233,9 @@ struct GoNoGoStepView: View { } func onDeviceShaked(_ timestamp: SystemUptime) { - Task { - let uptime = await shakeSensor.clock.relativeUptime(to: timestamp) - guard isVisible, !showingResponse, !paused else { return } - didFinishAttempt(uptime) - } + let uptime = shakeSensor.clock.relativeUptime(to: timestamp) + guard isVisible, !showingResponse, !paused else { return } + didFinishAttempt(uptime) } func onMotionRecorderError(_ error: Error) { diff --git a/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift b/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift index 970b4fd..f6bcde4 100644 --- a/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift +++ b/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift @@ -100,7 +100,7 @@ final class ShakeMotionSensor : MotionRecorder { guard status <= .running, dotType >= .none, !clock.isPaused else { return } // Get the relative clock time and exit early if this is old - let uptime = await clock.relativeUptime(to: timestamp) + let uptime = clock.relativeUptime(to: timestamp) guard uptime > resetUptime else { return } // Add the sample if showing the stimulus diff --git a/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift b/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift index 5505b4c..23700d7 100644 --- a/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift +++ b/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift @@ -98,7 +98,7 @@ struct TrailmakingStepView: View { } } .onChange(of: assessmentState.showingPauseActions) { newValue in - viewModel.clock.isPaused = newValue + viewModel.clock.pause() } .onReceive(timer) { _ in viewModel.onTimerUpdated() From c2c5096a69a0d2a87b3210bce256cf965c06e2c7 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 5 Jul 2022 11:23:08 -0700 Subject: [PATCH 07/10] Fix the go-no-go clock --- Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift | 10 +++++++--- Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift | 5 ++++- Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift | 8 ++++---- schemas/v1/GoNoGoResultObject.json | 9 +++++++-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift b/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift index e998bbb..b2733b9 100644 --- a/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift +++ b/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift @@ -47,7 +47,7 @@ public final class GoNoGoResultObject : MultiplatformResultData, SerializableRes public let identifier: String public var startDateTime: Date public var endDateTime: Date? - public var startUptime: ClockUptime? + public var startUptime: SystemUptime? public var responses: [Response] public var motionError: ErrorResultObject? @@ -70,12 +70,13 @@ public final class GoNoGoResultObject : MultiplatformResultData, SerializableRes public struct Response : Codable, Hashable { private enum CodingKeys : String, OrderedEnumCodingKey { - case stepPath, timestamp, resetTimestamp, timeToThreshold, go, incorrect, samples + case stepPath, timestamp, resetTimestamp, timeToThreshold, stimulusDelay, go, incorrect, samples } public let stepPath: String? public let timestamp: ClockUptime public let resetTimestamp: ClockUptime public let timeToThreshold: SecondDuration + public let stimulusDelay: SecondDuration public let go: Bool public let incorrect: Bool public let samples: [Sample]? @@ -193,6 +194,9 @@ extension GoNoGoResultObject.Response : DocumentableStruct { Time from when the stimulus occurred to the threshold being reached. For a timeout or false start, this value will be zero. """) + case .stimulusDelay: + return .init(propertyType: .primitive(.number), propertyDescription: + "The delay (in seconds) from reset until the stimulus is shown.") case .go: return .init(propertyType: .primitive(.boolean), propertyDescription: "YES if a go test and NO if a no go test.") @@ -206,7 +210,7 @@ extension GoNoGoResultObject.Response : DocumentableStruct { } public static func examples() -> [GoNoGoResultObject.Response] { - [.init(stepPath: "0", timestamp: 0, resetTimestamp: 120492.081, timeToThreshold: 0.1, go: true, incorrect: true, samples: nil)] + [.init(stepPath: "0", timestamp: 0, resetTimestamp: 120492.081, timeToThreshold: 0.1, stimulusDelay: 7.3, go: true, incorrect: true, samples: nil)] } } diff --git a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift index c9dde26..13ac85b 100644 --- a/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift +++ b/Sources/BiAffectSDK/GoNoGo/GoNoGoStepView.swift @@ -192,6 +192,7 @@ struct GoNoGoStepView: View { var isVisible: Bool = false var waitTask: Task? let shakeSensor: ShakeMotionSensor = .init() + var lastStimulusDelay: SecondDuration = 0 func onAppear(_ nodeState: StepState, _ assessmentState: AssessmentState) { guard let result = nodeState.result as? GoNoGoResultObject, @@ -256,6 +257,7 @@ struct GoNoGoStepView: View { attemptCount = min(max(1, successCount + 1), maxSuccessCount) let stimulusDelay = calculateStimulusDelay() + lastStimulusDelay = stimulusDelay waitTask = Task { guard await Task.wait(seconds: stimulusDelay) else { return } @@ -264,7 +266,7 @@ struct GoNoGoStepView: View { } func showStimulus() { - shakeSensor.stimulusUptime = SystemClock.uptime() + shakeSensor.stimulusUptime = shakeSensor.clock.now() shakeSensor.dotType = go ? .blue : .green showingDot = true waitTask = Task { @@ -302,6 +304,7 @@ struct GoNoGoStepView: View { timestamp: startUptime, resetTimestamp: shakeSensor.resetUptime, timeToThreshold: timeToThreshold, + stimulusDelay: lastStimulusDelay, go: go, incorrect: !correct, samples: shakeSensor.processSamples())) diff --git a/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift b/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift index f6bcde4..1762769 100644 --- a/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift +++ b/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift @@ -57,8 +57,8 @@ final class ShakeMotionSensor : MotionRecorder { } var thresholdAcceleration: Double = 0.5 - var resetUptime: ClockUptime = SystemClock.uptime() - var stimulusUptime: ClockUptime? + var resetUptime: SystemUptime = .greatestFiniteMagnitude + var stimulusUptime: SystemUptime? private var resetCount: Int = 0 private var thresholdUptime: SystemUptime? // SystemTime @@ -87,7 +87,7 @@ final class ShakeMotionSensor : MotionRecorder { guard status <= .running else { return } resetCount += 1 - resetUptime = SystemClock.uptime() + resetUptime = clock.now() thresholdUptime = nil stimulusUptime = nil samples.removeAll() @@ -97,7 +97,7 @@ final class ShakeMotionSensor : MotionRecorder { } @MainActor func onMotionReceived(_ vectorMagnitude: Double, timestamp: SystemUptime) async { - guard status <= .running, dotType >= .none, !clock.isPaused else { return } + guard status <= .running, dotType >= .none, !isPaused else { return } // Get the relative clock time and exit early if this is old let uptime = clock.relativeUptime(to: timestamp) diff --git a/schemas/v1/GoNoGoResultObject.json b/schemas/v1/GoNoGoResultObject.json index 24bc067..b13f90d 100644 --- a/schemas/v1/GoNoGoResultObject.json +++ b/schemas/v1/GoNoGoResultObject.json @@ -23,6 +23,10 @@ "type": "number", "description": "Time from when the stimulus occurred to the threshold being reached.\nFor a timeout or false start, this value will be zero." }, + "stimulusDelay": { + "type": "number", + "description": "The delay (in seconds) from reset until the stimulus is shown." + }, "go": { "type": "boolean", "description": "YES if a go test and NO if a no go test." @@ -49,8 +53,9 @@ "additionalProperties": false, "examples": [{ "timestamp": 0, - "resetTimestamp": 120492.08100000001, - "timeToThreshold": 0.10000000000000001, + "resetTimestamp": 120492.081, + "timeToThreshold": 0.1, + "stimulusDelay": 7.3, "go": true, "incorrect": true }] From 0853c96e6c9b7b06042265f50a92cc39d07e5360 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 5 Jul 2022 11:43:00 -0700 Subject: [PATCH 08/10] Point motion.json file at the local schema which includes vector magnitude --- Sources/BiAffectSDK/BiAffectSDK.swift | 2 +- Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift | 10 ++++++++-- {schemas => json/schemas}/v1/GoNoGoResultObject.json | 0 {schemas => json/schemas}/v1/ShakeSample.json | 0 .../schemas}/v1/TrailmakingResultObject.json | 0 5 files changed, 9 insertions(+), 3 deletions(-) rename {schemas => json/schemas}/v1/GoNoGoResultObject.json (100%) rename {schemas => json/schemas}/v1/ShakeSample.json (100%) rename {schemas => json/schemas}/v1/TrailmakingResultObject.json (100%) diff --git a/Sources/BiAffectSDK/BiAffectSDK.swift b/Sources/BiAffectSDK/BiAffectSDK.swift index 782bd24..ae8459b 100644 --- a/Sources/BiAffectSDK/BiAffectSDK.swift +++ b/Sources/BiAffectSDK/BiAffectSDK.swift @@ -35,7 +35,7 @@ import AssessmentModel import AssessmentModelUI import JsonModel -let kBaseJsonSchemaURL = URL(string: "https://biaffect.github.io/biaffectsdk/schemas/v1/")! +let kBaseJsonSchemaURL = URL(string: "https://biaffect.github.io/biaffectsdk/json/schemas/v1/")! public enum BiAffectIdentifier : String, CaseIterable { case trailmaking = "Trail_Making", goNoGo = "Go-No-Go" diff --git a/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift b/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift index 1762769..4a20640 100644 --- a/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift +++ b/Sources/BiAffectSDK/GoNoGo/ShakeMotionSensor.swift @@ -40,6 +40,10 @@ import JsonModel import CoreMotion #endif +fileprivate let recordSchema = DocumentableRootArray(rootDocumentType: MotionRecord.self, + jsonSchema: .init(string: "ShakeSample.json", relativeTo: kBaseJsonSchemaURL)!, + documentDescription: "A list of motion sensor records.") + fileprivate let motionRecorderConfig = MotionRecorderConfigurationObject(identifier: "motion", recorderTypes: [.accelerometer, .gyro, .userAcceleration], frequency: 100) @@ -136,9 +140,11 @@ final class ShakeMotionSensor : MotionRecorder { } } + override var schemaDoc: DocumentableRootArray? { recordSchema } + struct ShakeSample : SampleRecord, Codable, Hashable { private enum CodingKeys : String, OrderedEnumCodingKey { - case stepPath, uptime, timestamp, sensorType, x, y, z, vectorMagnitude + case stepPath, uptime, timestamp, timestampDate, sensorType, x, y, z, vectorMagnitude } let stepPath: String @@ -152,7 +158,7 @@ final class ShakeMotionSensor : MotionRecorder { private(set) var timestampDate: Date? = nil } - + #if os(iOS) override func samples(from data: CMDeviceMotion, frame: CMAttitudeReferenceFrame, stepPath: String, uptime: ClockUptime, timestamp: SecondDuration) -> [SampleRecord] { diff --git a/schemas/v1/GoNoGoResultObject.json b/json/schemas/v1/GoNoGoResultObject.json similarity index 100% rename from schemas/v1/GoNoGoResultObject.json rename to json/schemas/v1/GoNoGoResultObject.json diff --git a/schemas/v1/ShakeSample.json b/json/schemas/v1/ShakeSample.json similarity index 100% rename from schemas/v1/ShakeSample.json rename to json/schemas/v1/ShakeSample.json diff --git a/schemas/v1/TrailmakingResultObject.json b/json/schemas/v1/TrailmakingResultObject.json similarity index 100% rename from schemas/v1/TrailmakingResultObject.json rename to json/schemas/v1/TrailmakingResultObject.json From e014a51e2c264d78d31fe2a9b38fcbe161d19b49 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 5 Jul 2022 11:56:28 -0700 Subject: [PATCH 09/10] fix trailmaking with the simple clock --- .../BiAffectSDK/Trailmaking/TrailmakingStepView.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift b/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift index 23700d7..6a5fe34 100644 --- a/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift +++ b/Sources/BiAffectSDK/Trailmaking/TrailmakingStepView.swift @@ -98,7 +98,12 @@ struct TrailmakingStepView: View { } } .onChange(of: assessmentState.showingPauseActions) { newValue in - viewModel.clock.pause() + if newValue { + viewModel.clock.pause() + } + else { + viewModel.clock.resume() + } } .onReceive(timer) { _ in viewModel.onTimerUpdated() @@ -132,7 +137,6 @@ struct TrailmakingStepView: View { self.result = result testState = .running - reset() } @@ -166,6 +170,7 @@ struct TrailmakingStepView: View { result.runtime = timestamp result.pauseInterval = clock.pauseCumulation testState = .stopping + clock.stop() } } else { From 7f2d35ee3146880e23f7488ff30891df7619a307 Mon Sep 17 00:00:00 2001 From: Shannon Young Date: Tue, 5 Jul 2022 17:05:07 -0700 Subject: [PATCH 10/10] Update to MobilePassiveData 1.3.1 and use that clock --- Package.resolved | 4 +- Package.swift | 2 +- .../GoNoGo/GoNoGoResultObject.swift | 4 +- Sources/BiAffectSDK/Utils/SimpleClock.swift | 87 ------------------- Tests/BiAffectSDKTests/TrailmakingTests.swift | 4 +- 5 files changed, 7 insertions(+), 94 deletions(-) delete mode 100644 Sources/BiAffectSDK/Utils/SimpleClock.swift diff --git a/Package.resolved b/Package.resolved index 6d8fa92..8a275eb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/Sage-Bionetworks/MobilePassiveData-SDK.git", "state": { "branch": null, - "revision": "d4aa711788304be57c8e482a89719d734d85435a", - "version": "1.3.0" + "revision": "1c271f053bd3be9e2f45ea4c1c3cced3e47304c1", + "version": "1.3.1" } }, { diff --git a/Package.swift b/Package.swift index c48bc5a..41c3eb8 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( from: "0.7.5"), .package(name: "MobilePassiveData", url: "https://github.com/Sage-Bionetworks/MobilePassiveData-SDK.git", - from: "1.3.0"), + from: "1.3.1"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift b/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift index b2733b9..fd1c2ea 100644 --- a/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift +++ b/Sources/BiAffectSDK/GoNoGo/GoNoGoResultObject.swift @@ -73,8 +73,8 @@ public final class GoNoGoResultObject : MultiplatformResultData, SerializableRes case stepPath, timestamp, resetTimestamp, timeToThreshold, stimulusDelay, go, incorrect, samples } public let stepPath: String? - public let timestamp: ClockUptime - public let resetTimestamp: ClockUptime + public let timestamp: SystemUptime + public let resetTimestamp: SystemUptime public let timeToThreshold: SecondDuration public let stimulusDelay: SecondDuration public let go: Bool diff --git a/Sources/BiAffectSDK/Utils/SimpleClock.swift b/Sources/BiAffectSDK/Utils/SimpleClock.swift deleted file mode 100644 index 36bf8dc..0000000 --- a/Sources/BiAffectSDK/Utils/SimpleClock.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// SimpleClock.swift -// -// -// Copyright © 2022 BiAffect. All rights reserved. -// -// Redistribution and use in source and binary forms, with or without modification, -// are permitted provided that the following conditions are met: -// -// 1. Redistributions of source code must retain the above copyright notice, this -// list of conditions and the following disclaimer. -// -// 2. Redistributions in binary form must reproduce the above copyright notice, -// this list of conditions and the following disclaimer in the documentation and/or -// other materials provided with the distribution. -// -// 3. Neither the name of the copyright holder(s) nor the names of any contributors -// may be used to endorse or promote products derived from this software without -// specific prior written permission. No license is granted to the trademarks of -// the copyright holders even if such marks are included in this software. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE -// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -// - -import Foundation -import Combine -import MobilePassiveData - -@MainActor -class SimpleClock : ObservableObject { - - public private(set) var startTime: SystemUptime = ProcessInfo.processInfo.systemUptime - private(set) var stopTime: SystemUptime? = nil - private(set) var pauseCumulation: SecondDuration = 0 - private(set) var pauseStartTime: Double? = nil - - @Published var isPaused: Bool = false { - didSet { - if isPaused { - pause() - } - else { - resume() - } - } - } - - public let onPauseChanged = PassthroughSubject() - - public func reset() { - startTime = ProcessInfo.processInfo.systemUptime - stopTime = nil - pauseStartTime = nil - pauseCumulation = 0 - isPaused = false - } - - public func runningDuration(timestamp: SystemUptime = ProcessInfo.processInfo.systemUptime) -> SecondDuration { - timestamp - startTime - pauseCumulation - } - - public func stoppedDuration(timestamp: SystemUptime = ProcessInfo.processInfo.systemUptime) -> SecondDuration { - stopTime.map { timestamp - $0 } ?? 0 - } - - private func pause() { - guard pauseStartTime == nil else { return } - pauseStartTime = ProcessInfo.processInfo.systemUptime - onPauseChanged.send(true) - } - - private func resume() { - guard let pauseStartTime = pauseStartTime else { return } - pauseCumulation += (ProcessInfo.processInfo.systemUptime - pauseStartTime) - self.pauseStartTime = nil - onPauseChanged.send(false) - } -} diff --git a/Tests/BiAffectSDKTests/TrailmakingTests.swift b/Tests/BiAffectSDKTests/TrailmakingTests.swift index 702fda5..8319a7c 100644 --- a/Tests/BiAffectSDKTests/TrailmakingTests.swift +++ b/Tests/BiAffectSDKTests/TrailmakingTests.swift @@ -270,9 +270,9 @@ final class TrailmakingTests: XCTestCase { @MainActor func pause(viewModel: TrailmakingStepView.ViewModel, seconds: UInt64 = 1) async throws -> TimeInterval { let before = ProcessInfo.processInfo.systemUptime - viewModel.clock.isPaused = true + viewModel.clock.pause() try await Task.sleep(nanoseconds: seconds * 1_000_000_000) - viewModel.clock.isPaused = false + viewModel.clock.resume() let after = ProcessInfo.processInfo.systemUptime return after - before }