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
15 changes: 15 additions & 0 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,21 @@ public enum HRZones {
return HRZoneSet(zones: built, maxHR: maxHR, source: custom == nil ? source : "custom")
}

/// The five Karvonen heart-rate-RESERVE zones (50/60/70/80/90 % HRR), as bpm bounds: lower =
/// restingHR + pct · (maxHR − restingHR). The model WHOOP's Healthspan zone targets are stated in and
/// the one `StrainScorer` already uses for strain, so a Healthspan zone minute and a strain zone
/// minute are the same minute. Nil when the reserve is not positive.
public static func heartRateReserveZones(restingHR: Double, maxHR: Double) -> HRZoneSet? {
let reserve = maxHR - restingHR
guard restingHR > 0, reserve > 0 else { return nil }
let bounds = zoneEdges.map { restingHR + $0 * reserve }
let built = (0..<5).map { i in
HRZone(number: i + 1, lower: bounds[i], upper: bounds[i + 1],
lowerPct: bounds[i] / maxHR, upperPct: bounds[i + 1] / maxHR)
}
return HRZoneSet(zones: built, maxHR: maxHR, source: "hrr")
}

/// 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] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,6 @@ import Foundation
// the margin's scale: ± 1.96 · sd / 0.5 y. With fewer than three windows the spread is not measurable and
// a prior (`priorWindowSD`) is used, flagged `lowerConfidence`. When the margin covers 1× the honest
// readout is "holding steady", not a figure.
//
// ── THE LEGACY FIT (`compute(samples:)`) ──────────────────────────────────────────────────────────
//
// The ordinary-least-squares drift fit below predates the projection and is kept only until the
// analytics pipeline moves to `project`; nothing new should call it.
public enum PaceOfAgingEngine {

// MARK: - Projection (WHOOP's definition)
Expand Down Expand Up @@ -101,69 +96,17 @@ public enum PaceOfAgingEngine {
lowerConfidence: !measured, comparedKeys: common.sorted())
}

// MARK: - Legacy drift fit (see the header)
// MARK: - Shared constants


/// Days of history rolled into ONE sample of S. A habit, not a night.
public static let recentWindowDays = 30
/// How far back the slope is fitted. Older samples than this are ignored even when present.
public static let trendWindowDays = 90
/// Fewest DAYS that must actually carry data inside one rolling window before it may become a sample.
///
/// A window is meant to be a habit. Near the start of someone's history the same 30-day span holds two
/// or three days, and the S computed from it is a far noisier quantity than the S computed from thirty
/// — same units, same name, different thing. Fitting a trend through both reads the ordinary spread of
/// the thin end as a direction. Callers assembling samples enforce this; `usableSamples` cannot, since
/// a sample arrives already reduced to one number.
/// Fewest scored DAYS a 30-day window must hold before it counts as one month of habits. Near the
/// start of someone's history the same span holds two or three days, and a Body Age computed from
/// them is a far noisier quantity than one from thirty — same units, same name, different thing.
public static let minWindowDays = 15
/// Fewest usable samples before a pace is reported at all.
public static let minSamples = 60
/// WHOOP presents the same idea on a −1× … 3× scale; the clamp keeps a sparse or extreme fit on it.
/// WHOOP presents Pace of Aging on a −1× … 3× scale; the clamp keeps an extreme month on it.
public static let minPace = -1.0, maxPace = 3.0
/// Days per year used to annualise a per-day slope.
static let daysPerYear = 365.25
/// Two-sided 95% normal critical value — the bar a slope clears before it is called a direction.
/// Two-sided 95 % normal critical value.
static let steadyZ = 1.96

/// One day's rolling-30-day summed log-hazard, as `VitalityEngine.Result.lnHazardSum` reports it.
public struct Sample: Equatable, Sendable {
/// Days since any fixed epoch — only differences between samples matter.
public let dayIndex: Int
public let lnHazardSum: Double
/// The factors behind this sample, sorted and joined (e.g. "hrv,rhr,sleep,steps"). Samples whose
/// signature differs from the newest one are not comparable and are not fitted. See the header.
public let factorSignature: String

public init(dayIndex: Int, lnHazardSum: Double, factorSignature: String) {
self.dayIndex = dayIndex; self.lnHazardSum = lnHazardSum
self.factorSignature = factorSignature
}
}

public struct Result: Equatable, Sendable {
/// Body Age years gained per calendar year, clamped to [minPace, maxPace]. 1 = holding steady.
public let pace: Double
/// Drift of the summed log-hazard, per year — the raw fitted slope before rescaling.
public let slopeLnPerYear: Double
/// Standard error of that slope, same units.
public let standardErrorLnPerYear: Double
/// ± band on `pace` at 95%, in the same × units, so a UI can show the number with its uncertainty.
public let paceMargin: Double
/// True when the slope is not distinguishable from zero at 95% — show "holding steady", not a figure.
public let isSteady: Bool
public let samplesUsed: Int
/// True when fewer than `trendWindowDays` samples were available, so the fit rests on a short span.
public let lowerConfidence: Bool

public init(pace: Double, slopeLnPerYear: Double, standardErrorLnPerYear: Double,
paceMargin: Double, isSteady: Bool, samplesUsed: Int, lowerConfidence: Bool) {
self.pace = pace; self.slopeLnPerYear = slopeLnPerYear
self.standardErrorLnPerYear = standardErrorLnPerYear; self.paceMargin = paceMargin
self.isSteady = isSteady; self.samplesUsed = samplesUsed
self.lowerConfidence = lowerConfidence
}
}

/// Day index for a "yyyy-MM-dd" key: whole days since 1970-01-01, counted on a fixed UTC calendar.
///
/// UTC deliberately, and it is not a bug that the day keys themselves are LOCAL days. The index is
Expand All @@ -181,67 +124,4 @@ public enum PaceOfAgingEngine {
guard let date = cal.date(from: comps) else { return nil }
return Int((date.timeIntervalSince1970 / 86_400).rounded())
}

/// The samples a fit would actually use: the newest `trendWindowDays` days, restricted to the newest
/// contiguous run whose factor signature matches the newest sample's. Exposed so the readiness
/// countdown and the fit can never disagree about what counts.
public static func usableSamples(_ samples: [Sample]) -> [Sample] {
let sorted = samples.sorted { $0.dayIndex < $1.dayIndex }
guard let newest = sorted.last else { return [] }
var run: [Sample] = []
for s in sorted.reversed() {
guard s.factorSignature == newest.factorSignature else { break }
guard newest.dayIndex - s.dayIndex < trendWindowDays else { break }
run.append(s)
}
return run.reversed()
}

/// Samples still needed before a pace can be reported at all — the countdown a not-ready card shows.
/// Assumes continued daily wear; a gap simply doesn't advance the count.
public static func daysUntilReady(_ samples: [Sample]) -> Int {
max(0, minSamples - usableSamples(samples).count)
}

/// Fit the pace of aging. Nil until `minSamples` comparable samples exist, or when every sample falls
/// on one day (no time base to fit a slope against).
public static func compute(samples: [Sample]) -> Result? {
let used = usableSamples(samples)
guard used.count >= minSamples else { return nil }

let n = Double(used.count)
let xs = used.map { Double($0.dayIndex) }
let ys = used.map { $0.lnHazardSum }
let meanX = xs.reduce(0, +) / n
let meanY = ys.reduce(0, +) / n
var sxx = 0.0, sxy = 0.0
for i in 0..<used.count {
let dx = xs[i] - meanX
sxx += dx * dx
sxy += dx * (ys[i] - meanY)
}
guard sxx > 0 else { return nil } // every sample on one day

let slopePerDay = sxy / sxx
let intercept = meanY - slopePerDay * meanX
// Residual standard error of the slope: sqrt( SSE/(n−2) / Sxx ).
var sse = 0.0
for i in 0..<used.count {
let fitted = intercept + slopePerDay * xs[i]
sse += (ys[i] - fitted) * (ys[i] - fitted)
}
let slopeSEPerDay = ((sse / (n - 2)) / sxx).squareRoot()

let slopePerYear = slopePerDay * daysPerYear
let sePerYear = slopeSEPerDay * daysPerYear
let rawPace = 1 + slopePerYear / VitalityEngine.lnHazardPerYear
let margin = steadyZ * sePerYear / VitalityEngine.lnHazardPerYear
return Result(pace: min(maxPace, max(minPace, rawPace)),
slopeLnPerYear: slopePerYear,
standardErrorLnPerYear: sePerYear,
paceMargin: margin,
isSteady: abs(slopePerYear) < steadyZ * sePerYear,
samplesUsed: used.count,
lowerConfidence: used.count < trendWindowDays)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ public enum SleepRegularity {
}
}

/// A session from a stored sleep block: its onset/end and the `"wake"` segments of a computed
/// `[{start,end,stage}]` staging array. Any other staging shape (an imported minute dictionary, which
/// carries no clock times) or unparseable JSON yields a session with no wake stretches — the span is
/// still the best timing evidence there is. Nil for an empty or inverted span.
public static func session(startTs: Int, endTs: Int, stagesJSON: String?) -> Session? {
guard endTs > startTs else { return nil }
var wake: [Span] = []
if let data = stagesJSON?.data(using: .utf8),
let segments = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] {
for seg in segments {
let stage = (seg["stage"] as? String)?.lowercased()
guard stage == "wake" || stage == "awake",
let s = (seg["start"] as? NSNumber)?.doubleValue,
let e = (seg["end"] as? NSNumber)?.doubleValue, e > s else { continue }
wake.append(Span(start: s, end: e))
}
}
return Session(start: Double(startTs), end: Double(endTs), wake: wake)
}

/// Local sleep day `d` runs from noon of local day `d` (days since 1970-01-01) to the next noon.
static func dayStart(_ day: Int, tzOffsetSec: Int) -> Double {
Double(day * 86_400 + 43_200 - tzOffsetSec)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,20 @@ final class HRZonesTests: XCTestCase {
XCTAssertEqual(tiz.seconds(inZone: 1), tiz.total, accuracy: 1e-9) // all of it is zone 1
}
}

extension HRZonesTests {

/// Heart-rate-reserve zones: lower bound = rest + pct · (max − rest), the Karvonen model the Healthspan
/// zone targets are stated in.
func testHeartRateReserveZones() {
let z = HRZones.heartRateReserveZones(restingHR: 60, maxHR: 190)!
XCTAssertEqual(z.zones.map(\.lower), [125, 138, 151, 164, 177])
XCTAssertEqual(z.zones.last!.upper, 190)
XCTAssertEqual(z.zoneNumber(forBPM: 124), 0)
XCTAssertEqual(z.zoneNumber(forBPM: 151), 3)
XCTAssertEqual(z.zoneNumber(forBPM: 164), 4)
XCTAssertEqual(z.source, "hrr")
XCTAssertNil(HRZones.heartRateReserveZones(restingHR: 190, maxHR: 190))
XCTAssertNil(HRZones.heartRateReserveZones(restingHR: 0, maxHR: 190))
}
}
Original file line number Diff line number Diff line change
@@ -1,125 +1,10 @@
import XCTest
@testable import StrandAnalytics

final class PaceOfAgingEngineTests: XCTestCase {

private let signature = "hrv,rhr,sleep,steps"

/// A run of samples whose summed log-hazard drifts by `gompertzYearsPerYear` Gompertz-years each
/// calendar year — i.e. the drift that should read as a pace of `1 + gompertzYearsPerYear`.
private func drift(_ gompertzYearsPerYear: Double, days: Int = 90,
signature: String? = nil) -> [PaceOfAgingEngine.Sample] {
(0..<days).map {
PaceOfAgingEngine.Sample(
dayIndex: $0,
lnHazardSum: 0.20 + VitalityEngine.lnHazardPerYear
* gompertzYearsPerYear * Double($0) / PaceOfAgingEngine.daysPerYear,
factorSignature: signature ?? self.signature)
}
}

/// Behaviour that holds steady ages at exactly 1× — the property the whole construct rests on. It is
/// not a calibrated constant: it falls out of `BodyAge = age + S/k` the moment dS/dt is zero.
func testSteadyBehaviourAgesAtExactlyOne() {
let r = PaceOfAgingEngine.compute(samples: drift(0))!
XCTAssertEqual(r.pace, 1.0, accuracy: 1e-9)
XCTAssertEqual(r.slopeLnPerYear, 0, accuracy: 1e-12)
XCTAssertTrue(r.isSteady)
XCTAssertEqual(r.samplesUsed, 90)
XCTAssertFalse(r.lowerConfidence)
}

/// A hazard drifting up by one Gompertz year per calendar year is two years of Body Age per year.
func testWorseningAndImprovingScaleThroughGompertz() {
XCTAssertEqual(PaceOfAgingEngine.compute(samples: drift(1))!.pace, 2.0, accuracy: 1e-9)
XCTAssertEqual(PaceOfAgingEngine.compute(samples: drift(-0.5))!.pace, 0.5, accuracy: 1e-9)
XCTAssertEqual(PaceOfAgingEngine.compute(samples: drift(0.25))!.pace, 1.25, accuracy: 1e-9)
}

/// The scale is bounded at both ends, so a wild fit still renders on the dial.
func testPaceClampsToTheScale() {
XCTAssertEqual(PaceOfAgingEngine.compute(samples: drift(-20))!.pace, PaceOfAgingEngine.minPace)
XCTAssertEqual(PaceOfAgingEngine.compute(samples: drift(20))!.pace, PaceOfAgingEngine.maxPace)
}

/// A slope fitted to noise always has SOME sign. Here the true slope is zero and the residuals are
/// large, so the verdict must be "holding steady" — otherwise a dial swings on nothing.
func testNoiseReadsAsSteadyNotAsADirection() {
let noisy = (0..<90).map {
PaceOfAgingEngine.Sample(dayIndex: $0,
lnHazardSum: 0.20 + ($0 % 2 == 0 ? 0.05 : -0.05),
factorSignature: signature)
}
let r = PaceOfAgingEngine.compute(samples: noisy)!
XCTAssertTrue(r.isSteady, "a zig-zag around a flat mean is not a direction")
XCTAssertGreaterThan(r.paceMargin, abs(r.pace - 1),
"the band must cover 1× whenever the verdict is steady")
}

/// A real trend is NOT called steady — the steadiness guard must not swallow genuine signal.
func testARealTrendIsNotCalledSteady() {
XCTAssertFalse(PaceOfAgingEngine.compute(samples: drift(1))!.isSteady)
XCTAssertFalse(PaceOfAgingEngine.compute(samples: drift(-0.5))!.isSteady)
}

/// Below the sample floor there is no number at all, and the countdown says how far off it is.
func testGatedBelowTheSampleFloor() {
XCTAssertNil(PaceOfAgingEngine.compute(samples: drift(0, days: 59)))
XCTAssertEqual(PaceOfAgingEngine.daysUntilReady(drift(0, days: 59)), 1)
XCTAssertEqual(PaceOfAgingEngine.daysUntilReady(drift(0, days: 40)), 20)
XCTAssertEqual(PaceOfAgingEngine.daysUntilReady(drift(0, days: 90)), 0)
XCTAssertNotNil(PaceOfAgingEngine.compute(samples: drift(0, days: 60)))
XCTAssertTrue(PaceOfAgingEngine.compute(samples: drift(0, days: 60))!.lowerConfidence,
"a 60-day span is a shorter fit than the 90-day window and should say so")
}

/// Connecting a new data source changes S for a reason that has nothing to do with how someone lived.
/// Only the newest run sharing the newest factor signature is comparable, so the jump is never fitted
/// — the user waits for the run to refill instead of being told they aged.
func testAChangedFactorSetIsNotReadAsAging() {
// 70 days on three factors, then 20 on four, with a large jump in S at the boundary.
let mixed = (0..<90).map { i in
PaceOfAgingEngine.Sample(dayIndex: i,
lnHazardSum: i < 70 ? 0.20 : 0.90,
factorSignature: i < 70 ? "hrv,rhr,sleep" : signature)
}
XCTAssertEqual(PaceOfAgingEngine.usableSamples(mixed).count, 20)
XCTAssertNil(PaceOfAgingEngine.compute(samples: mixed))
XCTAssertEqual(PaceOfAgingEngine.daysUntilReady(mixed), 40)
}

/// Samples older than the trend window are dropped even when the signature matches, and the run is
/// broken by the window edge rather than quietly fitting a year of history.
func testOnlyTheTrendWindowIsFitted() {
var old = drift(0, days: 60)
old += [PaceOfAgingEngine.Sample(dayIndex: 400, lnHazardSum: 0.20, factorSignature: signature)]
// The newest sample is day 400; everything else is >90 days older, so nothing else is comparable.
XCTAssertEqual(PaceOfAgingEngine.usableSamples(old).count, 1)
XCTAssertNil(PaceOfAgingEngine.compute(samples: old))
}

/// Unordered input must not change the fit — the engine sorts before it windows.
func testOrderIndependent() {
let forward = drift(0.4)
let shuffled = Array(forward.reversed())
XCTAssertEqual(PaceOfAgingEngine.compute(samples: forward)!.pace,
PaceOfAgingEngine.compute(samples: shuffled)!.pace, accuracy: 1e-12)
}

/// No time base → no slope, rather than a divide-by-zero dressed as a number.
func testAllSamplesOnOneDayYieldsNil() {
let stacked = (0..<70).map { _ in
PaceOfAgingEngine.Sample(dayIndex: 10, lnHazardSum: 0.2, factorSignature: signature)
}
XCTAssertNil(PaceOfAgingEngine.compute(samples: stacked))
}

func testEmptyInput() {
XCTAssertNil(PaceOfAgingEngine.compute(samples: []))
XCTAssertTrue(PaceOfAgingEngine.usableSamples([]).isEmpty)
XCTAssertEqual(PaceOfAgingEngine.daysUntilReady([]), PaceOfAgingEngine.minSamples)
}
}
/// Pace of Aging as WHOOP defines it: the last 30 days held for six months. Exact values on the edge
/// cases are pinned by `HealthspanOracleTests`; these name the properties. (The v1 least-squares drift
/// fit and its tests were retired with it.)
final class PaceOfAgingEngineTests: XCTestCase {}

extension PaceOfAgingEngineTests {

Expand All @@ -139,16 +24,6 @@ extension PaceOfAgingEngineTests {
}
}

extension PaceOfAgingEngineTests {

/// The window floor is a real constraint, not decoration: a 30-day window is meant to be a habit, and
/// half of it is the least that can pass for one.
func testWindowFloorIsHalfTheWindow() {
XCTAssertEqual(PaceOfAgingEngine.minWindowDays, PaceOfAgingEngine.recentWindowDays / 2)
XCTAssertLessThan(PaceOfAgingEngine.minWindowDays, PaceOfAgingEngine.recentWindowDays)
}
}

// MARK: - Projection (WHOOP's definition)

extension PaceOfAgingEngineTests {
Expand Down
Loading