Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Strand/App/ActiveWorkoutPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import WhoopProtocol
/// records a route too, and its fixes are banked by `ActiveRouteStore` rather than by this snapshot,
/// which stays the cheap per-sample write it was built as.) This is the Apple analogue of Android's
/// `ActiveWorkoutStore`/`ActiveWorkoutPersistence`: a tiny `Codable` snapshot (start time, sport, the
/// accumulated HR samples + running stats) is written to `UserDefaults` on start and on every captured
/// sample, and read back on launch so an interrupted session can still be ended and saved.
/// accumulated HR samples + running stats) is checkpointed on start and lifecycle transitions. Captured
/// samples queue scoring and encoding off the main actor before the snapshot is written to `UserDefaults`.
/// It is read back on launch so an interrupted session can still be ended and saved.
///
/// On-device only; mirrors the existing `moments` / `sleepMarks` `UserDefaults` persistence in `AppModel`.
/// The encode/decode is pure (no `UserDefaults` dependency on the codec itself) so the persist/rehydrate
Expand Down Expand Up @@ -69,7 +70,7 @@ enum ActiveWorkoutPersistence {
)
}

/// Persist (overwrite) the snapshot. Cheap; called on start + each captured sample.
/// Persist (overwrite) a lifecycle checkpoint synchronously.
static func store(_ snapshot: Snapshot, into defaults: UserDefaults = .standard) {
guard let data = encode(snapshot) else { return }
defaults.set(data, forKey: defaultsKey)
Expand Down
103 changes: 72 additions & 31 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,25 @@ final class AppModel: ObservableObject {
/// Android's `ActiveWorkout.gpsEnabled`.
private var activeWorkoutIsGps = false

private(set) var workoutSampleRevision: UInt64 = 0
private(set) var workoutSampleTask: Task<Void, Never>?
private var pendingWorkoutSample: WorkoutSampleWork?

struct WorkoutSampleWork {
let revision: UInt64
let snapshot: ActiveWorkoutPersistence.Snapshot
let hrMax: Double
let method: StrainScorer.Method
let sex: String

func process() -> (strain: Double, data: Data?) {
var scored = snapshot
scored.liveStrain = StrainScorer.strain(scored.samples, maxHR: hrMax,
method: method, sex: sex) ?? 0
return (scored.liveStrain, ActiveWorkoutPersistence.encode(scored))
}
}

/// A manual workout in progress. `samples` accumulate from the smoothed live `bpm`; `liveStrain`
/// is recomputed as the window grows so the active card can show strain building in real time.
struct ActiveWorkout: Equatable {
Expand Down Expand Up @@ -155,6 +174,15 @@ final class AppModel: ObservableObject {
/// second folded into `peakHr` (`recordSample`). Nil with no samples.
var savedPeak: Int? { samples.map(\.bpm).max().map { max($0, peakHr) } }

var snapshot: ActiveWorkoutPersistence.Snapshot {
ActiveWorkoutPersistence.Snapshot(
startSec: Int(start.timeIntervalSince1970), sport: sport, samples: samples,
avgHr: avgHr, peakHr: peakHr, liveStrain: liveStrain,
pausedAtSec: pausedAt.map { Int($0.timeIntervalSince1970) },
pausedDurationSec: Int(pausedDuration), sessionID: sessionID,
pausedForPulseLoss: pausedForPulseLoss, completedPauses: completedPauses)
}

/// Delegates to `ActiveWorkoutClock` so this and the two card surfaces cannot drift apart again.
func elapsed(at now: Date = Date()) -> TimeInterval {
ActiveWorkoutClock.activeElapsed(start: start, pausedAt: pausedAt,
Expand Down Expand Up @@ -887,25 +915,12 @@ final class AppModel: ObservableObject {
domain: .dataImport)
}

/// Persist the in-flight manual workout to `UserDefaults` so it survives the app being killed mid-
/// session (#529). Called on start + each captured sample. A no-op when nothing is running. The Apple
/// analogue of Android's `persistNonGpsWorkout`. A distance workout records a route as well, and its
/// fixes are banked separately by `ActiveRouteStore`: keeping them out of here is what lets this stay
/// a small per-sample write instead of rewriting a growing route on every beat.
private func persistActiveWorkout() {
/// Bank the latest raw window at a lifecycle boundary, before the app can be suspended.
func persistActiveWorkout() {
workoutSampleRevision &+= 1
pendingWorkoutSample = nil
guard let w = activeWorkout else { return }
ActiveWorkoutPersistence.store(
ActiveWorkoutPersistence.Snapshot(
startSec: Int(w.start.timeIntervalSince1970),
sport: w.sport,
samples: w.samples,
avgHr: w.avgHr,
peakHr: w.peakHr,
liveStrain: w.liveStrain,
pausedAtSec: w.pausedAt.map { Int($0.timeIntervalSince1970) },
pausedDurationSec: Int(w.pausedDuration),
sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss,
completedPauses: w.completedPauses))
ActiveWorkoutPersistence.store(w.snapshot)
}

/// If a manual workout was in flight when iOS killed the app, rebuild `activeWorkout` from the durable
Expand Down Expand Up @@ -974,6 +989,8 @@ final class AppModel: ObservableObject {

private func clearActiveWorkout() {
guard activeWorkout != nil else { return }
workoutSampleRevision &+= 1
pendingWorkoutSample = nil
activeWorkout = nil
if activeWorkoutIsGps { gpsRecorder.stop() }
activeWorkoutIsGps = false
Expand Down Expand Up @@ -1091,25 +1108,49 @@ final class AppModel: ObservableObject {
await repo.refresh()
}

/// Append the current smoothed `bpm` to the active workout and recompute its running strain. Called
/// from `ingestHR` on every fresh sample; a no-op when no workout is running. Recomputing strain
/// over the growing window each sample is cheap at the ~1 Hz live-HR cadence.
private func captureWorkoutSample() {
/// Keep every accepted sample; score and encode immutable snapshots off the main actor.
func captureWorkoutSample(at date: Date? = nil) {
guard var w = activeWorkout, !w.isPaused, let hr = bpm else { return }
// A second that already has its sample moves only the peak: publish that, and skip the rescore and the
// snapshot (the next second's sample carries the peak into the snapshot).
let peakBefore = w.peakHr
guard w.recordSample(HRSample(ts: Int(Date().timeIntervalSince1970), bpm: hr)) else {
if w.peakHr != peakBefore { activeWorkout = w }
guard w.recordSample(HRSample(ts: Int((date ?? Date()).timeIntervalSince1970), bpm: hr)) else {
if w.peakHr != peakBefore {
activeWorkout = w
queueWorkoutSample(w)
}
return
}
w.peakHr = max(w.peakHr, hr)
w.avgHr = Int((Double(w.samples.map(\.bpm).reduce(0, +)) / Double(w.samples.count)).rounded())
w.liveStrain = StrainScorer.strain(w.samples, maxHR: Double(profile.hrMax),
method: PuffinExperiment.effortMethod, sex: profile.sex) ?? 0
w.avgHr = Int((Double(w.samples.reduce(0) { $0 + $1.bpm }) / Double(w.samples.count)).rounded())
activeWorkout = w
// Re-snapshot the durable session so a kill keeps the latest accumulated HR window (#529).
persistActiveWorkout()
queueWorkoutSample(w)
}

private func queueWorkoutSample(_ workout: ActiveWorkout) {
workoutSampleRevision &+= 1
pendingWorkoutSample = WorkoutSampleWork(revision: workoutSampleRevision,
snapshot: workout.snapshot, hrMax: Double(profile.hrMax),
method: PuffinExperiment.effortMethod, sex: profile.sex)
guard workoutSampleTask == nil else { return }
workoutSampleTask = Task { [weak self] in
while let work = self?.pendingWorkoutSample {
self?.pendingWorkoutSample = nil
let result = await Task.detached(priority: .utility) { work.process() }.value
self?.applyWorkoutSample(work, result: result)
}
self?.workoutSampleTask = nil
}
}

func applyWorkoutSample(_ work: WorkoutSampleWork, result: (strain: Double, data: Data?)) {
guard work.revision == workoutSampleRevision, var workout = activeWorkout,
workout.sessionID == work.snapshot.sessionID, !workout.isPaused else { return }
if workout.liveStrain != result.strain {
workout.liveStrain = result.strain
activeWorkout = workout
}
if let data = result.data {
UserDefaults.standard.set(data, forKey: ActiveWorkoutPersistence.defaultsKey)
}
}

/// Drop the smoothing window and blank the hero number so a resume / re-attach shows ","
Expand Down
1 change: 1 addition & 0 deletions Strand/App/StrandApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ struct StrandApp: App {
// this target is macOS 13.
.onChange(of: scenePhase) { phase in
if phase == .active { model.ble.requestSync(.foreground) }
if phase == .background { model.persistActiveWorkout() }
}
}
.windowStyle(.hiddenTitleBar)
Expand Down
30 changes: 27 additions & 3 deletions StrandTests/OfflineWorkoutSaveTests.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import XCTest
import WhoopStore
import WhoopProtocol
@testable import Strand

@MainActor
final class OfflineWorkoutSaveTests: XCTestCase {
func testNoPulseOrGPSStillSavesAndFailedWriteKeepsTheSessionRetryable() async throws {
try await assertFailedWriteIsRetryable(samples: [])
}

func testFailedWriteRetainsTheLatestSamplesWhileAWorkerIsPending() async throws {
let started = Int(Date().timeIntervalSince1970) - 600
try await assertFailedWriteIsRetryable(samples: (0..<600).map { HRSample(ts: started + $0, bpm: 150) })
}

private func assertFailedWriteIsRetryable(samples: [HRSample]) async throws {
let defaults = UserDefaults.standard
let keys = [ActiveWorkoutPersistence.defaultsKey, RecordedWorkoutHistory.defaultsKey]
let originals = keys.map { ($0, defaults.object(forKey: $0)) }
Expand All @@ -25,12 +35,19 @@ final class OfflineWorkoutSaveTests: XCTestCase {
model.repo.setStoreForTesting(store)
let started = Date().addingTimeInterval(-600)
model.activeWorkout = AppModel.ActiveWorkout(start: started, sport: "Strength")
for sample in samples {
model.bpm = sample.bpm
model.captureWorkoutSample(at: Date(timeIntervalSince1970: Double(sample.ts)))
}
let id = try XCTUnwrap(model.activeWorkout?.sessionID)
try execute("CREATE TRIGGER fail_workout_save BEFORE INSERT ON workout BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path)
do { try await model.finishWorkout(); XCTFail("the write should fail") } catch {}
XCTAssertEqual(model.activeWorkout?.sessionID, id)
XCTAssertTrue(model.activeWorkout?.isPaused == true)
XCTAssertEqual(ActiveWorkoutPersistence.load()?.sessionID, id)
await model.workoutSampleTask?.value
XCTAssertEqual(model.activeWorkout?.samples, samples)
XCTAssertEqual(ActiveWorkoutPersistence.load()?.samples, samples)
try execute("DROP TRIGGER fail_workout_save", path: path)
try await model.finishWorkout()
try await model.finishWorkout()
Expand All @@ -42,9 +59,16 @@ final class OfflineWorkoutSaveTests: XCTestCase {
let row = try XCTUnwrap(rows.first)
XCTAssertEqual(row.sport, "Strength")
XCTAssertEqual(row.durationS ?? 0, 600, accuracy: 2)
XCTAssertNil(row.avgHr)
XCTAssertNil(row.strain)
XCTAssertNil(row.energyKcal)
if samples.isEmpty {
XCTAssertNil(row.avgHr)
XCTAssertNil(row.strain)
XCTAssertNil(row.energyKcal)
} else {
XCTAssertEqual(row.avgHr, 150)
XCTAssertEqual(row.maxHr, 150)
XCTAssertNotNil(row.strain)
XCTAssertNotNil(row.energyKcal)
}
XCTAssertTrue(RecordedWorkoutHistory.load().contains { $0.matches(row, deviceId: model.deviceId) })
}

Expand Down
80 changes: 80 additions & 0 deletions StrandTests/WorkoutSampleBenchmarkTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import XCTest
import StrandAnalytics
import WhoopProtocol
@testable import Strand

@MainActor
final class WorkoutSampleBenchmarkTests: XCTestCase {
func testReleaseBenchmark() async throws {
guard !_isDebugAssertConfiguration() else { throw XCTSkip("Run this benchmark in Release.") }
let defaults = UserDefaults.standard
let original = defaults.object(forKey: ActiveWorkoutPersistence.defaultsKey)
let previousModel = AppModel.shared
defer {
AppModel.shared = previousModel
if let original { defaults.set(original, forKey: ActiveWorkoutPersistence.defaultsKey) }
else { defaults.removeObject(forKey: ActiveWorkoutPersistence.defaultsKey) }
}
let model = AppModel()
model.bpm = 150
for count in [0, 600, 3_600, 7_200] {
let start = 1_700_000_000
let seed = (0..<count).map { HRSample(ts: start + $0, bpm: 110 + $0 % 65) }
var scoreTimes: [Double] = [], encodeTimes: [Double] = [], captureTimes: [Double] = []
var mergeTimes: [Double] = [], mainTimes: [Double] = []
for trial in 0..<106 {
var samples = seed
// Every nonempty scoring call has a new fingerprint, as the live window does.
if count > 0 { samples[count - 1] = HRSample(ts: start + count + trial, bpm: 150) }
var workout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: Double(start)))
workout.samples = samples
workout.sessionID = "synthetic-benchmark"
let (score, scoringMs) = timed {
StrainScorer.strain(samples, maxHR: Double(model.profile.hrMax),
method: PuffinExperiment.effortMethod, sex: model.profile.sex) ?? 0
}
workout.liveStrain = score
let snapshot = ActiveWorkoutPersistence.Snapshot(
startSec: start, sport: workout.sport, samples: samples,
avgHr: workout.avgHr, peakHr: workout.peakHr, liveStrain: score,
pausedDurationSec: 0, sessionID: workout.sessionID,
pausedForPulseLoss: false, completedPauses: [])
let (data, encodingMs) = timed { ActiveWorkoutPersistence.encode(snapshot) }
XCTAssertNotNil(data)
model.activeWorkout = workout
let date = Date(timeIntervalSince1970: Double(start + count + trial + 1))
let (_, captureMs) = timed { model.captureWorkoutSample(at: date) }
let work = AppModel.WorkoutSampleWork(revision: model.workoutSampleRevision,
snapshot: model.activeWorkout!.snapshot, hrMax: Double(model.profile.hrMax),
method: PuffinExperiment.effortMethod, sex: model.profile.sex)
let result = work.process()
let (_, mergeMs) = timed { model.applyWorkoutSample(work, result: result) }
await model.workoutSampleTask?.value
XCTAssertEqual(model.activeWorkout?.samples.count, count + 1)
if trial >= 5 {
scoreTimes.append(scoringMs)
encodeTimes.append(encodingMs)
captureTimes.append(captureMs)
mergeTimes.append(mergeMs)
mainTimes.append(captureMs + mergeMs)
}
}
print("WORKOUT_BENCHMARK samples=\(count) score=\(summary(scoreTimes)) encode=\(summary(encodeTimes)) capture=\(summary(captureTimes)) merge=\(summary(mergeTimes)) main=\(summary(mainTimes))")
if count == 7_200 {
XCTAssertLessThan(mainTimes.sorted()[Int(ceil(Double(mainTimes.count) * 0.95)) - 1], 2,
"Remaining capture + result merge must stay under 2 ms p95.")
}
}
}

private func timed<T>(_ body: () -> T) -> (T, Double) {
let start = DispatchTime.now().uptimeNanoseconds
let value = ObjectiveC.autoreleasepool(invoking: body)
return (value, Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000)
}

private func summary(_ times: [Double]) -> String {
let sorted = times.sorted()
return String(format: "%.4f/%.4f", sorted[sorted.count / 2], sorted[Int(ceil(Double(sorted.count) * 0.95)) - 1])
}
}
Loading