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
Original file line number Diff line number Diff line change
Expand Up @@ -506,11 +506,19 @@ public enum AnalyticsEngine {
} else {
let rrSorted = rr.sortedByTsStable()
let enrichedProvided: [SleepSession] = providedSleep.map { s in
// #1801: an HR-only night keeps its NIL restingHR/avgHRV. This is the ONE call site that
// can undo the display-only guarantee — enriching here would hand Charge and the baselines
// exactly the two values that decision withholds, silently and by default.
if s.hrOnly { return s }
guard s.restingHR == nil || s.avgHRV == nil else { return s }
let rhr = s.restingHR ?? SleepStager.sessionRestingHR(start: s.start, end: s.end, hr: hr)
let hrv = s.avgHRV ?? SleepStager.sessionAvgHRV(start: s.start, end: s.end, rr: rrSorted)
// `hrOnly` carried explicitly: unlike Kotlin's `copy`, this rebuilds the struct field by
// field, so a new flag is dropped by DEFAULT unless named here. The guard above means an
// HR-only night never reaches this line today; passing it anyway keeps that a belt rather
// than the only thing standing between the flag and silent loss.
return SleepSession(start: s.start, end: s.end, efficiency: s.efficiency,
stages: s.stages, restingHR: rhr, avgHRV: hrv)
stages: s.stages, restingHR: rhr, avgHRV: hrv, hrOnly: s.hrOnly)
}
let keptDetected = refinedSessions.filter { d in
!enrichedProvided.contains { $0.start < d.end && d.start < $0.end }
Expand Down Expand Up @@ -665,7 +673,18 @@ public enum AnalyticsEngine {
// negligible shift. The Rest/sleep-quality term is main-night; the recovery physiology is
// day-best-resting, night-dominated. Keep these two definitions distinct on purpose.
// Daily resting HR = lowest per-session resting HR across matched sessions.
let restingHRDaily = matched.compactMap { $0.restingHR }.min()
// #1801: the sessions whose PHYSIOLOGY may be folded into the day's aggregates. An HR-only night
// is excluded — it may describe its own duration, stages and Rest, but resting HR, HRV and SDNN
// are what Charge and the baselines are built from, and a baseline is the one thing a false
// positive cannot be unwound from.
//
// Named once rather than filtered at each use, because the nil restingHR/avgHRV an HR-only
// session carries only protects the aggregates that READ those fields. The deep-window HRV pool
// and the SDNN index below re-derive from `rr` over the session's own stages and would have
// folded one in regardless — which is precisely the "one forgotten call site" a scattered filter
// invites.
let physiologySessions = matched.filter { !$0.hrOnly }
let restingHRDaily = physiologySessions.compactMap { $0.restingHR }.min()
// Daily avg HRV = in-bed-weighted mean of per-session avg HRV.
let avgHRVDaily: Double? = {
if deepHrvWindow {
Expand All @@ -675,13 +694,13 @@ public enum AnalyticsEngine {
// = successive diffs). nil when no deep sleep is detected (WHOOP-4.0 staging can be sparse) —
// the caller shows calibrating, never a fabricated number.
let rrSorted = rr.sortedByTsStable()
let deep = matched.flatMap { s in
let deep = physiologySessions.flatMap { s in
SleepStager.sessionHrvWindows(start: s.start, end: s.end, rr: rrSorted, stages: s.stages)
.filter { $0.stage == "deep" }.compactMap { $0.rmssd }
}
return deep.isEmpty ? nil : deep.reduce(0, +) / Double(deep.count)
}
let pairs = matched.compactMap { s -> (Double, Double)? in
let pairs = physiologySessions.compactMap { s -> (Double, Double)? in
s.avgHRV.map { ($0, Double(s.end - s.start)) }
}
guard !pairs.isEmpty else { return nil }
Expand All @@ -698,7 +717,7 @@ public enum AnalyticsEngine {
// against a watch meaningless. The 5-min index is window-comparable to those. nil when no segment has
// enough clean beats (HRVAnalyzer's own gate). Keeps the R-R timestamps (segmentation needs them).
let avgSDNNDaily: Double? = {
let inBed = rr.filter { r in matched.contains { r.ts >= $0.start && r.ts < $0.end } }
let inBed = rr.filter { r in physiologySessions.contains { r.ts >= $0.start && r.ts < $0.end } }
return inBed.isEmpty ? nil : HRVAnalyzer.sdnnIndex(inBed, segmentSec: 300)
}()

Expand Down
98 changes: 96 additions & 2 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,22 @@ public struct SleepSession: Equatable, Sendable {
public let restingHR: Int?
/// Mean RMSSD over 5-min windows across the session (ms), or nil.
public let avgHRV: Double?
/// Staged WITHOUT a motion spine, from heart rate alone (#1801).
///
/// True only for a strap that streams HR but banks no motion, where Stage 0's gravity-stillness
/// spine has nothing to work with. Such a night is weaker by construction, not by tuning: with
/// motion gone a quiet evening at rest can sit in the sleep band. It is allowed to describe itself
/// — duration, stages, Rest — and must NOT reach anything it cannot be unwound from, which is why
/// `restingHR` and `avgHRV` are left nil on one rather than filtered out downstream.
///
/// Kotlin twin: `DetectedSleep.hrOnly` (the model names diverge, `DetectedSleep`/`SleepSession`).
public let hrOnly: Bool

public init(start: Int, end: Int, efficiency: Double, stages: [StageSegment],
restingHR: Int?, avgHRV: Double?) {
restingHR: Int?, avgHRV: Double?, hrOnly: Bool = false) {
self.start = start; self.end = end; self.efficiency = efficiency
self.stages = stages; self.restingHR = restingHR; self.avgHRV = avgHRV
self.hrOnly = hrOnly
}
}

Expand Down Expand Up @@ -472,6 +483,43 @@ public enum SleepStager {
return periods
}

/// Percentile of the window's bpm that anchors the HR-only sleep band.
///
/// NOT `hrBaseline`. That is the window MEDIAN, and it is the right anchor where it is used — as a
/// CONFIRMATION gate on a run gravity stillness already found, where being permissive is deliberate.
/// As a PRIMARY threshold it is disqualified by arithmetic rather than by tuning: a median splits the
/// samples in half by definition, so a band of `median * 1.05` admits strictly more than half of any
/// window whatever the data. Measured on a realistic 24 h (16 h awake 74-96, 8 h night 59-70) it
/// called 14.4 hours sleep against a truth of 8.
///
/// A tenth percentile sits in the night's trough instead, which is what a sleep band should be
/// anchored to, and cannot admit half the window however the day is shaped.
public static let hrOnlyAnchorPercentile: Double = 0.10

/// Multiplier above `hrOnlyAnchorPercentile` that still counts as asleep.
///
/// Numerically equal to `hrSleepBandMult` today, and deliberately a SEPARATE constant: that one is a
/// confirmation gate's tolerance and this one is a detector's, and a future change to either has no
/// business silently moving the other.
///
/// Chosen conservatively from a sweep over anchor x multiplier against windows with known truth,
/// because the two failure directions are not symmetric. Over-detection puts a wrong Rest number on
/// screen; under-detection leaves "No data", which is the state this feature is trying to improve on
/// and therefore a safe place to fail. p10 x 1.05 measured 8.1 h against a truth of 8 on a
/// field-shaped 24 h window, and under-reads a long multi-night window rather than over-reading it
/// (8.7 h of 16 h across two nights in 54 h) — pinned in `SleepStagerHrOnlyAnchorTests`.
public static let hrOnlyBandMult: Double = 1.05

/// The `hrOnlyAnchorPercentile` of `hr` by bpm, or nil when empty. Nearest-rank (no interpolation), so
/// the value is always one the wearer actually recorded and the two platforms cannot disagree on a
/// rounding rule.
static func hrOnlyBaseline(_ hr: [HRSample]) -> Double? {
if hr.isEmpty { return nil }
let sorted = hr.map { Double($0.bpm) }.sorted()
let idx = min(max(Int(Double(sorted.count - 1) * hrOnlyAnchorPercentile), 0), sorted.count - 1)
return sorted[idx]
}

/// Epoch for the HR-only spine, in seconds.
public static let hrOnlyEpochS: Int = 60

Expand Down Expand Up @@ -523,7 +571,7 @@ public enum SleepStager {
// understatement would then be weighed against the caller's minimum-duration gate.
let times = keys.map { $0 * epochS }
let ends = keys.map { lastTs[$0]! }
let flags = keys.map { HRVAnalyzer.median(byEpoch[$0]!) <= baseline * hrSleepBandMult }
let flags = keys.map { HRVAnalyzer.median(byEpoch[$0]!) <= baseline * hrOnlyBandMult }
let maxGapS = maxGapMinutes * 60
var periods: [Period] = []
var runStart = 0
Expand All @@ -544,6 +592,52 @@ public enum SleepStager {
return periods
}

/// Whole sleep SESSIONS from heart rate alone, for a strap that banks no motion (#1801).
///
/// `hrOnlySleepRuns` supplies the spine this normally gets from gravity stillness; `SleepStagerV2`
/// then stages each surviving run from HR and R-R with an EMPTY gravity array. That is not a
/// degenerate call: V2's epoch features read HR and R-R directly and only its motion-quiescence terms
/// go quiet, so it returns a real hypnogram rather than one flat stage. A stageless session would be
/// dropped by `sleepSessionFromProvided` anyway, so a night that fails to stage is correctly omitted
/// here rather than passed on hollow.
///
/// The anchor is `hrOnlyBaseline` — the `hrOnlyAnchorPercentile` of the window — and NOT the
/// `hrBaseline` median the motion path derives. Reusing that median looked like parity and was a bug:
/// as a confirmation gate on an already-detected run it is deliberately permissive, but as a primary
/// threshold it admits over half of any window by definition. See `hrOnlyAnchorPercentile`.
///
/// `restingHR` and `avgHRV` are left NIL deliberately, and that is the whole display-only guarantee.
/// An HR-only night may describe itself — duration, stages, Rest — but the resting HR and HRV it
/// would contribute are exactly what Charge and the baselines fold in, and a baseline is the one
/// thing a false positive cannot be unwound from. Withholding the values is structural; a downstream
/// filter would be one forgotten call site away from failing open.
/// `public` because the app target calls it: `Strand/Data/IntelligenceEngine.swift` is the day scan,
/// and it lives outside this package. The spine and the anchor below it stay `internal` — the tests
/// reach them with `@testable`, and nothing outside should be building its own spine.
public static func hrOnlySessions(hr: [HRSample], rr: [RRInterval], resp: [RespSample],
minMinutes: Int = minSleepMin) -> [SleepSession] {
let hrS = hr.sorted { $0.ts < $1.ts }
guard let baseline = hrOnlyBaseline(hrS) else { return [] }
let rrS = rr.sorted { $0.ts < $1.ts }
var out: [SleepSession] = []
// mergePeriods for the same reason the motion path calls it: a run boundary is a threshold
// crossing, and a sleeping heart rate oscillates across the band all night. Without this the
// spine returns the night's minutes correctly but shredded into sub-mergeMin fragments, every one
// of which then fails the minimum-duration gate below — 8 h of detected sleep yielding zero
// sessions. Absorbing the short runs first is what turns a spine into a night.
for p in mergePeriods(hrOnlySleepRuns(hrS, baseline: baseline)) {
if p.stage != "sleep" { continue }
if (p.end - p.start) < minMinutes * 60 { continue }
let stages = SleepStagerV2.stageSession(start: p.start, end: p.end, grav: [],
hr: hrS, rr: rrS, resp: resp)
if stages.isEmpty { continue }
out.append(SleepSession(start: p.start, end: p.end,
efficiency: efficiency(start: p.start, end: p.end, stages: stages),
stages: stages, restingHR: nil, avgHRV: nil, hrOnly: true))
}
return out
}

/// Absorb runs shorter than mergeMin minutes into their neighbours.
static func mergePeriods(_ periods: [Period], mergeMinutes: Int = mergeMin) -> [Period] {
if periods.isEmpty { return [] }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,14 @@ public enum WakeMotionRefinement {
let newStages = refine(session.stages, grav: grav, steps: steps)
guard newStages != session.stages else { return session }
let newEfficiency = SleepStager.efficiency(start: session.start, end: session.end, stages: newStages)
// `hrOnly` carried explicitly. The Kotlin twin is a `copy`, which preserves a new field for
// free; this rebuilds the struct field by field and drops one BY DEFAULT. Refinement only ever
// sees motion-detected sessions today, so an HR-only night never reaches here — but the two
// platforms would have disagreed silently the moment it did, which is the shape of divergence
// the parity contract exists to catch.
return SleepSession(start: session.start, end: session.end, efficiency: newEfficiency,
stages: newStages, restingHR: session.restingHR, avgHRV: session.avgHRV)
stages: newStages, restingHR: session.restingHR, avgHRV: session.avgHRV,
hrOnly: session.hrOnly)
}

/// Toggle-shaped convenience for the session-level overload (see `apply(_:grav:steps:enabled:)`).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import XCTest
import WhoopProtocol
@testable import StrandAnalytics

/// Twin of the Kotlin `SleepStagerHrOnlyAnchorTest`: what anchors the HR-only band, and why it is not
/// the median (#1801).
final class SleepStagerHrOnlyAnchorTests: XCTestCase {

private func window(_ aC: Int, _ aA: Int, _ aH: Int, _ nC: Int, _ nA: Int, _ nH: Int) -> [HRSample] {
let t0 = 1_788_300_000
var hr: [HRSample] = []
for i in 0..<(aH * 3600) { hr.append(HRSample(ts: t0 + i, bpm: aC + Int(sin(Double(i) / 500.0) * Double(aA)))) }
for j in 0..<(nH * 3600) {
hr.append(HRSample(ts: t0 + aH * 3600 + j, bpm: nC + Int(sin(Double(j) / 900.0) * Double(nA))))
}
return hr
}

private func sleepHours(_ hr: [HRSample], _ baseline: Double) -> Double {
Double(SleepStager.hrOnlySleepRuns(hr, baseline: baseline)
.filter { $0.stage == "sleep" }
.reduce(0) { $0 + ($1.end - $1.start) }) / 3600.0
}

func testAnchorIsTenthPercentileNearestRank() {
let hr = (1...100).map { HRSample(ts: 1_788_300_000 + $0, bpm: $0) }
XCTAssertEqual(SleepStager.hrOnlyAnchorPercentile, 0.10, accuracy: 1e-9)
XCTAssertEqual(SleepStager.hrOnlyBaseline(hr)!, 10.0, accuracy: 1e-9)
}

/// The regression this file exists for: the median anchor admits over half of any window by
/// definition, and measured 14.4 h against a truth of 8 on a field-shaped day.
func testMedianOverDetectsAndPercentileDoesNot() {
let hr = window(74, 11, 16, 64, 5, 8)
let median = sleepHours(hr, SleepStager.hrBaseline(hr)!)
let percentile = sleepHours(hr, SleepStager.hrOnlyBaseline(hr)!)
XCTAssertGreaterThan(median, 13.0)
XCTAssertTrue((7.0...9.5).contains(percentile), "was \(percentile)")
XCTAssertLessThan(percentile, median - 4.0)
}

/// Errs toward UNDER-detection: a missed night leaves "No data", an invented one shows a wrong Rest.
func testLongMultiNightWindowIsUnderReadNotOverRead() {
let h = sleepHours(window(78, 12, 38, 60, 6, 16), SleepStager.hrOnlyBaseline(window(78, 12, 38, 60, 6, 16))!)
XCTAssertLessThanOrEqual(h, 16.0, "was \(h)")
XCTAssertGreaterThanOrEqual(h, 6.0, "was \(h)")
}

func testDetectorBandIsASeparateConstantFromTheConfirmationGate() {
XCTAssertEqual(SleepStager.hrOnlyBandMult, 1.05, accuracy: 1e-9)
XCTAssertEqual(SleepStager.hrSleepBandMult, 1.05, accuracy: 1e-9)
}
}
17 changes: 14 additions & 3 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -940,8 +940,10 @@ final class IntelligenceEngine: ObservableObject {
// since we last scored it THIS session, skipping the 7 stream reads + `analyzeDay`. Gated to a
// registered WHOOP owner (4.0 or 5/MG) via the UN-coalesced `forRegistryDevice` — which
// returns nil for a ring/import/unknown, so the cache never treats one as a WHOOP (a ring's
// `providedSleep` could change a day without an HR move; a WHOOP always streams gravity, so
// its `providedSleep` is empty and the reuse is byte-identical). The per-day key folds the
// `providedSleep` could change a day without an HR move; a WHOOP normally streams gravity, so
// its `providedSleep` is empty and the reuse is byte-identical — and since #1801 a WHOOP with
// NO gravity gets an HR-only `providedSleep` instead, which is derived from the very HR the
// key already fingerprints, so the reuse stays sound by a different route). The per-day key folds the
// night's HR fingerprint (a narrower per-day witness than the whole-pass raw-input gate) and, for a
// 4.0, the window-wide skin anchor (a re-anchor from another night shifts that night's skin
// conversion without moving its HR). A 5/MG banks skin-temp centidegrees directly — no
Expand Down Expand Up @@ -1169,7 +1171,16 @@ final class IntelligenceEngine: ObservableObject {
if owner != Repository.whoopSource, grav.count < 2 {
let persisted = (try? await store.sleepSessions(deviceId: owner, from: from, to: to,
limit: 4000)) ?? []
providedSleep = persisted.compactMap { AnalyticsEngine.sleepSession(fromProvided: $0) }
let stored = persisted.compactMap { AnalyticsEngine.sleepSession(fromProvided: $0) }
// #1801: the comment above assumes a WHOOP always streams a gravity vector. A 5/MG that
// cannot bond does not — it is never clocked, so it banks nothing and persists no
// hypnogram either, leaving neither a spine nor a stored night however much HR it holds.
// Fall back to the HR-only spine ONLY when the device supplied nothing of its own: a
// hypnogram the device actually recorded is always better evidence than one inferred
// from heart rate.
providedSleep = stored.isEmpty
? SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: resp)
: stored
} else {
providedSleep = []
}
Expand Down
Loading
Loading