Skip to content
Merged
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
35 changes: 33 additions & 2 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
55 changes: 55 additions & 0 deletions StrandTests/WorkoutSampleOncePerSecondTests.swift
Original file line number Diff line number Diff line change
@@ -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..<seconds).map { HRSample(ts: 5_000 + $0, bpm: 100 + $0 * 60 / seconds) }
var workout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: 5_000))
var everyArrival: [HRSample] = []
for sample in clean {
for _ in 0..<2 {
_ = workout.recordSample(sample)
everyArrival.append(sample)
}
}
func effort(_ samples: [HRSample]) -> 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))
}
}
19 changes: 16 additions & 3 deletions android/app/src/main/java/com/noop/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
Loading