Skip to content
Open
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
53 changes: 42 additions & 11 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,14 @@ public struct HRZone: Equatable, Sendable {
}
}

/// Five HR zones derived from a max HR, plus the max HR itself and its source.
/// Five HR zones derived from a max HR or personalized BPM boundaries, plus the max HR itself and
/// its source.
public struct HRZoneSet: Equatable, Sendable {
/// The five zones, z1...z5, in ascending order.
public let zones: [HRZone]
/// Max HR (bpm) the zones were built from.
public let maxHR: Double
/// "tanaka" (age formula) or "manual" (caller override).
/// "tanaka" (age formula), "manual" (caller override), or "custom" (personalized boundaries).
public let source: String

public init(zones: [HRZone], maxHR: Double, source: String) {
Expand Down Expand Up @@ -97,6 +98,10 @@ public enum HRZones {
/// %HRmax band edges for zones 1...5: [0.50, 0.60, 0.70, 0.80, 0.90, 1.00].
public static let zoneEdges: [Double] = [0.50, 0.60, 0.70, 0.80, 0.90, 1.00]

/// Sensible editable BPM range for personalized zone starts. The analytics API accepts any
/// positive finite values; the app UIs use this range to keep steppers practical.
public static let customBPMRange: ClosedRange<Int> = 30...250

/// Tanaka (2001) age-predicted max HR: 208 − 0.7 × age (gender-independent).
public static func tanakaMaxHR(age: Double) -> Double {
208.0 - 0.7 * age
Expand All @@ -107,7 +112,9 @@ public enum HRZones {
/// - Parameters:
/// - age: age in years (used only when `maxHROverride` is nil).
/// - maxHROverride: explicit HRmax (bpm); when provided, `source == "manual"`.
public static func zones(age: Double, maxHROverride: Double? = nil) -> HRZoneSet {
public static func zones(age: Double,
maxHROverride: Double? = nil,
customLowerBounds: [Double]? = nil) -> HRZoneSet {
let maxHR: Double
let source: String
if let override = maxHROverride {
Expand All @@ -117,24 +124,48 @@ public enum HRZones {
maxHR = tanakaMaxHR(age: age)
source = "tanaka"
}
return zones(maxHR: maxHR, source: source)
return zones(maxHR: maxHR, source: source, customLowerBounds: customLowerBounds)
}

/// Build the 5-zone set directly from a known max HR.
public static func zones(maxHR: Double, source: String = "manual") -> HRZoneSet {
/// Build the 5-zone set directly from a known max HR, optionally replacing the conventional
/// percentage edges with five personalized inclusive lower bounds in BPM. Invalid custom input
/// falls back to the conventional model, so malformed restored preferences can never create gaps.
public static func zones(maxHR: Double,
source: String = "manual",
customLowerBounds: [Double]? = nil) -> HRZoneSet {
let custom = customLowerBounds.flatMap(validCustomLowerBounds)
var built: [HRZone] = []
for i in 0..<5 {
let loPct = zoneEdges[i]
let hiPct = zoneEdges[i + 1]
let lower = custom?[i] ?? zoneEdges[i] * maxHR
let upper = custom.map { i < 4 ? $0[i + 1] : max(maxHR, $0[i]) }
?? zoneEdges[i + 1] * maxHR
let loPct = maxHR > 0 ? lower / maxHR : 0
let hiPct = maxHR > 0 ? upper / maxHR : 0
built.append(HRZone(
number: i + 1,
lower: loPct * maxHR,
upper: hiPct * maxHR,
lower: lower,
upper: upper,
lowerPct: loPct,
upperPct: hiPct
))
}
return HRZoneSet(zones: built, maxHR: maxHR, source: source)
return HRZoneSet(zones: built, maxHR: maxHR, source: custom == nil ? source : "custom")
}

/// The conventional five inclusive lower bounds, rounded up to whole BPM for an editor. Rounding
/// up preserves the existing integer-sample classification (e.g. a 93.5 edge starts at 94 bpm).
public static func defaultLowerBounds(maxHR: Double) -> [Int] {
Array(zoneEdges.prefix(5)).map { Int(ceil($0 * maxHR)) }
}

/// Return a valid five-boundary custom model, or nil unless values are positive, finite, and
/// strictly increasing. Kept public so persistence layers can reject hand-edited backup values
/// using the exact same invariant as the analytics engine.
public static func validCustomLowerBounds(_ values: [Double]) -> [Double]? {
guard values.count == 5,
values.allSatisfy({ $0.isFinite && $0 > 0 }) else { return nil }
for i in 1..<values.count where values[i] <= values[i - 1] { return nil }
return values
}

/// Compute time-in-zone (seconds) from a time-ordered HR stream.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ final class HRZonesTests: XCTestCase {
XCTAssertEqual(zs.zoneNumber(forBPM: 250), 5) // above max still z5
}

func testCustomBPMBoundariesReplacePercentageEdges() {
let zs = HRZones.zones(maxHR: 200, customLowerBounds: [95, 118, 142, 168, 184])
XCTAssertEqual(zs.source, "custom")
XCTAssertEqual(zs.zones.map(\.lower), [95, 118, 142, 168, 184])
XCTAssertEqual(zs.zoneNumber(forBPM: 117), 1)
XCTAssertEqual(zs.zoneNumber(forBPM: 118), 2)
XCTAssertEqual(zs.zoneNumber(forBPM: 167), 3)
XCTAssertEqual(zs.zoneNumber(forBPM: 168), 4)
XCTAssertEqual(zs.zoneNumber(forBPM: 184), 5)
XCTAssertEqual(zs.zoneNumber(forBPM: 230), 5)
}

func testInvalidCustomBoundariesFallBackToDefaults() {
let zs = HRZones.zones(maxHR: 200, customLowerBounds: [100, 120, 120, 160, 180])
XCTAssertEqual(zs.source, "manual")
XCTAssertEqual(zs.zones.map(\.lower), [100, 120, 140, 160, 180])
}

func testDefaultEditorBoundsPreserveIntegerClassification() {
XCTAssertEqual(HRZones.defaultLowerBounds(maxHR: 187), [94, 113, 131, 150, 169])
}

func testTimeInZoneAccountsForAllTime() {
let zs = HRZones.zones(maxHR: 200) // edges 100/120/140/160/180/200
// 1 Hz samples: 3 in z1 (110), 2 in z3 (150), 1 below (90).
Expand Down
2 changes: 2 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public enum BackupSettings {
"profile.heightCm": .double,
"profile.waistCm": .double,
"profile.hrMax": .int,
"profile.hrZoneThresholds": .string,
"units.system": .string,
"units.temperature": .string,
"effort.scale": .string,
Expand All @@ -62,6 +63,7 @@ public enum BackupSettings {
"profile.heightCm": "profile.heightCm",
"profile.waistCm": "profile.waistCm",
"profile.hrMax": "profile.hrMaxOverride",
"profile.hrZoneThresholds": "profile.hrZoneThresholds",
"units.system": "units.system",
"units.temperature": "units.temperature",
"effort.scale": "effort.scale",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class BackupSettingsTests: XCTestCase {
"profile.heightCm": 168.0,
"profile.waistCm": 71.0,
"profile.hrMax": 191,
"profile.hrZoneThresholds": "95,118,142,168,184",
"units.system": "imperial",
"units.temperature": "celsius",
"effort.scale": "whoop",
Expand All @@ -30,6 +31,7 @@ final class BackupSettingsTests: XCTestCase {
XCTAssertEqual(back["profile.heightCm"] as? Double, 168.0)
XCTAssertEqual(back["profile.waistCm"] as? Double, 71.0)
XCTAssertEqual(back["profile.hrMax"] as? Int, 191)
XCTAssertEqual(back["profile.hrZoneThresholds"] as? String, "95,118,142,168,184")
XCTAssertEqual(back["units.system"] as? String, "imperial")
XCTAssertEqual(back["units.temperature"] as? String, "celsius")
XCTAssertEqual(back["effort.scale"] as? String, "whoop")
Expand Down Expand Up @@ -105,12 +107,14 @@ final class BackupSettingsTests: XCTestCase {
defaults.set(29, forKey: "profile.age")
defaults.set(82.5, forKey: "profile.weightKg")
defaults.set(198, forKey: "profile.hrMaxOverride") // storage key, not the canonical name
defaults.set("95,118,142,168,184", forKey: "profile.hrZoneThresholds")
defaults.set("imperial", forKey: "units.system")

let snap = BackupSettings.snapshot(from: defaults)
XCTAssertEqual(snap["profile.age"] as? Int, 29)
XCTAssertEqual(snap["profile.weightKg"] as? Double, 82.5)
XCTAssertEqual(snap["profile.hrMax"] as? Int, 198, "hrMaxOverride surfaces under the canonical key")
XCTAssertEqual(snap["profile.hrZoneThresholds"] as? String, "95,118,142,168,184")
XCTAssertEqual(snap["units.system"] as? String, "imperial")
XCTAssertNil(snap["profile.heightCm"], "Never-set keys are omitted, not defaulted")
XCTAssertNil(snap["profile.sex"])
Expand All @@ -123,12 +127,14 @@ final class BackupSettingsTests: XCTestCase {
BackupSettings.apply([
"profile.age": 41,
"profile.hrMax": 187,
"profile.hrZoneThresholds": "90,115,140,165,185",
"units.temperature": "fahrenheit",
], to: defaults)

XCTAssertEqual(defaults.object(forKey: "profile.age") as? Int, 41)
XCTAssertEqual(defaults.object(forKey: "profile.hrMaxOverride") as? Int, 187,
"Canonical profile.hrMax lands on the profile.hrMaxOverride storage key")
XCTAssertEqual(defaults.string(forKey: "profile.hrZoneThresholds"), "90,115,140,165,185")
XCTAssertEqual(defaults.string(forKey: "units.temperature"), "fahrenheit")
XCTAssertEqual(defaults.object(forKey: "profile.heightCm") as? Double, 175.0,
"Keys absent from the payload keep the target's value")
Expand Down
6 changes: 2 additions & 4 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1328,10 +1328,8 @@ final class AppModel: ObservableObject {
/// HR-zone haptic coaching: buzz when crossing into the top zone (ease off) or back to recovery.
private func coachZone(_ hr: Int?) {
guard behavior.zoneCoaching, live.bonded, live.worn, let hr, hr >= 30 else { return }
let maxHR = Double(profile.hrMax)
guard maxHR > 0 else { return }
let pct = Double(hr) / maxHR
let zone = pct >= 0.9 ? 5 : pct >= 0.8 ? 4 : pct >= 0.7 ? 3 : pct >= 0.6 ? 2 : 1
guard profile.hrMax > 0 else { return }
let zone = profile.hrZoneSet.zoneNumber(forBPM: Double(hr))
defer { lastCoachZone = zone }
guard lastCoachZone != -1, zone != lastCoachZone else { return }
if zone == 5, lastCoachZone < 5 { buzz(loops: 3) } // entered max , ease off
Expand Down
48 changes: 48 additions & 0 deletions Strand/Data/Profile.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import Combine
import SwiftUI
import StrandAnalytics

/// User profile (age/sex/body metrics/HR-max) persisted in UserDefaults.
/// Powers HR zones, calories and recovery baselines.
Expand All @@ -26,6 +27,13 @@ final class ProfileStore: ObservableObject {
@Published var waistCm: Double { didSet { d.set(waistCm, forKey: K.waist) } }
/// 0 = auto-estimate from age.
@Published var hrMaxOverride: Int { didSet { d.set(hrMaxOverride, forKey: K.hrMax) } }
/// Five personalized inclusive zone starts in BPM; empty = conventional %HRmax zones.
@Published var hrZoneThresholds: [Int] {
didSet {
if hrZoneThresholds.isEmpty { d.removeObject(forKey: K.hrZoneThresholds) }
else { d.set(hrZoneThresholds.map(String.init).joined(separator: ","), forKey: K.hrZoneThresholds) }
}
}
/// Step-calibration divisor (#139/#132): counter ticks per real step for the @57 motion
/// counter. 1.0 = raw pass-through (default — no behavior change). Clamped 0.5–30.0
/// (WHOOP 5/MG motion-counter overcount can reach ~24×, so the ceiling has to be high).
Expand Down Expand Up @@ -69,6 +77,7 @@ final class ProfileStore: ObservableObject {
static let legacyAge = "profile.age"
static let sex = "profile.sex", weight = "profile.weightKg"
static let height = "profile.heightCm", hrMax = "profile.hrMaxOverride"
static let hrZoneThresholds = "profile.hrZoneThresholds"
static let stepScale = "profile.stepTicksPerStep"
static let waist = "profile.waistCm"
static let stepsCoeff = "profile.stepsCalibrationCoefficient"
Expand Down Expand Up @@ -106,6 +115,9 @@ final class ProfileStore: ObservableObject {
heightCm = d.object(forKey: K.height) as? Double ?? 178
waistCm = d.object(forKey: K.waist) as? Double ?? 0
hrMaxOverride = d.object(forKey: K.hrMax) as? Int ?? 0
let storedThresholds = d.string(forKey: K.hrZoneThresholds)?
.split(separator: ",").compactMap { Int($0) } ?? []
hrZoneThresholds = Self.validZoneThresholds(storedThresholds) ? storedThresholds : []
stepTicksPerStep = min(max(d.object(forKey: K.stepScale) as? Double ?? 1.0, 0.5), 30.0)
stepsCalibrationCoefficient = d.object(forKey: K.stepsCoeff) as? Double ?? 0
stepsCalibrationSampleDays = d.object(forKey: K.stepsSampleDays) as? Int ?? 0
Expand Down Expand Up @@ -171,6 +183,42 @@ final class ProfileStore: ObservableObject {
/// Tanaka estimate unless overridden.
var hrMax: Int { hrMaxOverride > 0 ? hrMaxOverride : Int((208 - 0.7 * Double(age)).rounded()) }

/// Personalized zone starts after enforcing the same five-value invariant as `HRZones`.
var customHRZoneLowerBounds: [Double]? {
guard Self.validZoneThresholds(hrZoneThresholds) else { return nil }
return hrZoneThresholds.map(Double.init)
}

/// The single display-zone model used by live HR, workout splits, and haptic coaching.
var hrZoneSet: HRZoneSet {
HRZones.zones(maxHR: Double(hrMax), customLowerBounds: customHRZoneLowerBounds)
}

var hasCustomHRZones: Bool { customHRZoneLowerBounds != nil }

/// Enable by seeding the editor with boundaries that classify integer BPM exactly like today's
/// conventional percentages; disabling removes the override and immediately restores defaults.
func setCustomHRZonesEnabled(_ enabled: Bool) {
hrZoneThresholds = enabled ? HRZones.defaultLowerBounds(maxHR: Double(hrMax)) : []
}

/// Move one boundary while preserving strict ordering. Neighbour-aware clamps make it impossible
/// for the stepper to create a gap, overlap, or invalid persisted state.
func stepHRZoneThreshold(at index: Int, up: Bool) {
guard hrZoneThresholds.indices.contains(index) else { return }
var next = hrZoneThresholds
let floor = index == 0 ? HRZones.customBPMRange.lowerBound : next[index - 1] + 1
let ceiling = index == next.count - 1 ? HRZones.customBPMRange.upperBound : next[index + 1] - 1
next[index] = min(max(next[index] + (up ? 1 : -1), floor), ceiling)
hrZoneThresholds = next
}

nonisolated static func validZoneThresholds(_ values: [Int]) -> Bool {
guard values.count == 5,
values.allSatisfy(HRZones.customBPMRange.contains) else { return false }
return zip(values, values.dropFirst()).allSatisfy(<)
}

/// Whether the cycle-awareness opt-in applies to this profile (#801). Cycle phase is read from the
/// MENSTRUAL skin-temperature shift, so the opt-in (the Health card + the Automations toggle) is only
/// offered to profiles it can apply to and is NOT shown for male profiles. `sex` is the free String
Expand Down
10 changes: 4 additions & 6 deletions Strand/Data/Repository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2508,17 +2508,15 @@ final class Repository: ObservableObject {
return await hrBuckets(from: from, to: to, bucketSeconds: bucket)
}

/// Raw HR samples binned into per-zone MINUTES for a workout window, using the age-derived
/// (Tanaka) %HRmax zones , the same display zone model `WorkoutsView` already uses for imported
/// Raw HR samples binned into per-zone MINUTES for a workout window, using the caller's effective
/// default-or-personalized zones , the same display zone model `WorkoutsView` uses for imported
/// zone percentages, but computed here from the strap's own samples so a session WITHOUT imported
/// `zonesJSON` still gets a real time-in-zone split. Returns nil when the window carries no HR (so
/// the view shows nothing rather than five empty bars). `age <= 0` falls back to a 30 y default ,
/// the zones are approximate either way and clearly labelled as such in the UI.
func workoutZoneMinutes(from: Int, to: Int, age: Int) async -> [Double]? {
/// the view shows nothing rather than five empty bars).
func workoutZoneMinutes(from: Int, to: Int, zoneSet: HRZoneSet) async -> [Double]? {
guard to > from else { return nil }
let samples = await hrSamples(from: from, to: to)
guard !samples.isEmpty else { return nil }
let zoneSet = HRZones.zones(age: age > 0 ? Double(age) : 30)
let tiz = HRZones.timeInZone(samples, zoneSet: zoneSet)
let minutes = tiz.seconds.map { $0 / 60.0 }
return minutes.contains(where: { $0 > 0 }) ? minutes : nil
Expand Down
Loading
Loading