diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 588a25d37e..e4107eea73 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -127,6 +127,31 @@ final class AppModel: ObservableObject { var isPaused: Bool { pausedAt != nil } + /// Adds a heart-rate sample unless this second already has one, and says whether it did. + /// + /// `captureWorkoutSample` runs from two `@Published` sinks (`heartRate` and `rr`), so a strap sends + /// it one call per R-R packet plus another whenever the rate itself moves, which during exercise is + /// most seconds: two samples with one `ts`. Effort credits each sample with the gap to the next and a + /// zero gap with a full second (`StrainScorer.sampleDurationsMinutes`), so every repeat counted as + /// another second of effort, live and in the saved workout. The stream is one reading a second. + /// + /// A refused reading still reaches `peakHr`: the sinks fire because the rate moved, so a repeat is often a + /// different bpm for that second, and a within-second high is part of the workout's peak. Only the last + /// sample is compared, so this drops a repeat of the current second, not an out-of-order arrival; the live + /// stream is monotonic. + mutating func recordSample(_ sample: HRSample) -> Bool { + if let last = samples.last, last.ts == sample.ts { + peakHr = max(peakHr, sample.bpm) + return false + } + samples.append(sample) + return true + } + + /// The maximum heart rate the workout is saved with: the highest sample, or a higher reading a repeated + /// second folded into `peakHr` (`recordSample`). Nil with no samples. + var savedPeak: Int? { samples.map(\.bpm).max().map { max($0, peakHr) } } + /// 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, @@ -985,7 +1010,7 @@ final class AppModel: ObservableObject { } let avg = samples.isEmpty ? nil : Int((Double(samples.map(\.bpm).reduce(0, +)) / Double(samples.count)).rounded()) - let peak = samples.map(\.bpm).max() + let peak = w.savedPeak // #983: score the SAVED workout with the wearer's measured resting HR, not the hardcoded // default of 60. %HRR is (bpm - resting) / (max - resting), so the default moves every zone // boundary — at 136 bpm with maxHR 190 it is the difference between zone 1 and zone 2. Today's @@ -1045,7 +1070,13 @@ final class AppModel: ObservableObject { /// over the growing window each sample is cheap at the ~1 Hz live-HR cadence. private func captureWorkoutSample() { guard var w = activeWorkout, !w.isPaused, let hr = bpm else { return } - w.samples.append(HRSample(ts: Int(Date().timeIntervalSince1970), bpm: hr)) + // 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 } + 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), diff --git a/StrandTests/WorkoutSampleOncePerSecondTests.swift b/StrandTests/WorkoutSampleOncePerSecondTests.swift new file mode 100644 index 0000000000..92be51c84e --- /dev/null +++ b/StrandTests/WorkoutSampleOncePerSecondTests.swift @@ -0,0 +1,55 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +@testable import Strand + +/// A manual workout records one heart-rate sample a second. The live heart rate reaches it once per R-R packet and +/// again whenever the rate moves, and Effort credits a repeated second as another second of effort. +final class WorkoutSampleOncePerSecondTests: XCTestCase { + + func testASecondThatAlreadyHasASampleTakesNoOther() { + var workout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: 1_000)) + XCTAssertTrue(workout.recordSample(HRSample(ts: 1_000, bpm: 120))) + XCTAssertFalse(workout.recordSample(HRSample(ts: 1_000, bpm: 121))) + XCTAssertTrue(workout.recordSample(HRSample(ts: 1_001, bpm: 122))) + XCTAssertEqual(workout.samples.map(\.ts), [1_000, 1_001]) + XCTAssertEqual(workout.samples.map(\.bpm), [120, 122]) + } + + /// The rate moving is often why a second arrives twice, so the refused reading can be that second's high: it is + /// not recorded, but it is the workout's peak, live and saved. + func testARepeatedSecondStillReachesThePeak() { + var workout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: 1_000)) + XCTAssertNil(workout.savedPeak) + _ = workout.recordSample(HRSample(ts: 1_000, bpm: 120)) + workout.peakHr = 120 // what captureWorkoutSample does after an accepted sample + XCTAssertFalse(workout.recordSample(HRSample(ts: 1_000, bpm: 131))) + XCTAssertEqual(workout.samples.map(\.bpm), [120]) + XCTAssertEqual(workout.peakHr, 131) + XCTAssertEqual(workout.savedPeak, 131) + XCTAssertFalse(workout.recordSample(HRSample(ts: 1_000, bpm: 125))) // a lower repeat moves nothing + XCTAssertEqual(workout.peakHr, 131) + } + + /// Twenty minutes climbing from 100 to 160 bpm, each second arriving twice (the R-R packet, then the rate + /// moving) as it does during exercise. Recorded through `recordSample`, the workout's Effort is the Effort of + /// the plain once-a-second stream; the repeats, kept, scored higher. + func testRepeatedSecondsNoLongerAddEffort() { + let seconds = 20 * 60 + let clean = (0.. Double { + StrainScorer.strain(samples, maxHR: 190, restingHR: 60) ?? 0 + } + XCTAssertEqual(workout.samples.map(\.ts), clean.map(\.ts)) + XCTAssertEqual(effort(workout.samples), effort(clean)) + XCTAssertGreaterThan(effort(everyArrival), effort(clean)) + } +} diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index d66d6d0f4e..ef3792174f 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1732,7 +1732,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val pausedMs = w.pausedDurationMs + (w.pausedAtMs?.let { endMs - it } ?: 0L) val activeDurationMs = (endMs - w.startMs - pausedMs).coerceAtLeast(0L) val avg = if (samples.isNotEmpty()) samples.sumOf { it.bpm } / samples.size else null - val peak = if (samples.isNotEmpty()) samples.maxOf { it.bpm } else null + // `w.peakHr` can exceed every sample: a repeated second's higher reading is folded into it, not recorded. + val peak = if (samples.isNotEmpty()) maxOf(samples.maxOf { it.bpm }, w.peakHr) else null // #983: score the SAVED workout with the wearer's measured resting HR, not the hardcoded // default of 60. %HRR is (bpm - resting) / (max - resting), so the default moves every zone // boundary — at 136 bpm with maxHR 190 it is the difference between zone 1 and zone 2. Today's @@ -1801,12 +1802,24 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { @Suppress("UNNECESSARY_SAFE_CALL") val w = _activeWorkout?.value ?: return if (w.pausedAtMs != null) return - val s = w.samples + HrSample(deviceId = deviceId, ts = System.currentTimeMillis() / 1000, bpm = bpm) + // One sample a second. This runs on every LiveState emission that carries a heart rate — any field + // changing, not only the rate — so a second often arrives more than once with one `ts`, and Effort + // credits a zero gap with a full second (`StrainScorer.sampleDurationsMinutes`): each repeat counted + // as another second of effort, live and in the saved workout. A refused reading still reaches the peak: the + // rate moving is often why this ran, so it can be a within-second high; only the peak is published for it, + // with no rescore and no snapshot. Twin of iOS `ActiveWorkout.recordSample`. + val ts = System.currentTimeMillis() / 1000 + if (w.samples.lastOrNull()?.ts == ts) { + if (bpm > w.peakHr) _activeWorkout.value = w.copy(peakHr = bpm) + return + } + val s = w.samples + HrSample(deviceId = deviceId, ts = ts, bpm = bpm) val strain = StrainScorer.strain( s, maxHR = profileStore.hrMax.toDouble(), method = NoopPrefs.effortMethod(appContext), sex = profileStore.sex) ?: 0.0 val updated = w.copy( - samples = s, avgHr = s.sumOf { it.bpm } / s.size, peakHr = s.maxOf { it.bpm }, liveStrain = strain, + // Grown by comparison, not recomputed from `s`, so a peak folded in from a repeated second is kept. + samples = s, avgHr = s.sumOf { it.bpm } / s.size, peakHr = maxOf(w.peakHr, bpm), liveStrain = strain, ) _activeWorkout.value = updated // Re-snapshot the durable non-GPS session so a process kill keeps the latest accumulated HR (#529).