diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index d9f3fce995..f2c0ba2ef6 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -364,18 +364,17 @@ public enum AnalyticsEngine { // pure-function callers/tests free of it; IntelligenceEngine threads the // night window's persisted band state. (#531 / H8 consume) bandSleepState: [(ts: Int, state: Int)] = [], - // Which sleep-staging recipe runs (V2 vs V1). When true, detected nights - // are staged by `SleepStagerV2` instead of V1. This PARAMETER defaults - // false to keep pure-function callers/tests byte-identical — it is NOT - // the product default. IntelligenceEngine threads - // `PuffinExperiment.experimentalSleepV2Enabled`, which is default ON - // (#277/#351), so the shipped app stages with V2. (7.0.0) - useSleepStagerV2: Bool = false, + // Which sleep-staging recipe stages detected nights. This PARAMETER + // defaults to `.v1` to keep pure-function callers/tests byte-identical — + // it is NOT the product default. IntelligenceEngine threads + // `PuffinExperiment.sleepStager`, which is `.v3` unless the user picked + // another recipe, so the shipped app stages with V3. + stager: SleepStagerVersion = .v1, // Opt-in motion-aware wake refinement (#364 "Proposal 2" follow-up; density // gate precedent #345). When true, `WakeMotionRefinement` re-derives each // detected session's stages, reclassifying a hot-but-still WAKE segment to // `light` when it shows no locomotion and a stable posture outside isolated - // burst minutes; it only ever runs AFTER V1/V2 staging and self-gates on the + // burst minutes; it only ever runs AFTER staging and self-gates on the // observed gravity + step density, so it is a no-op on a sparse (e.g. WHOOP // 4.0) night regardless of this flag. Default false keeps every pure-function // caller/test byte-identical; IntelligenceEngine threads @@ -445,9 +444,9 @@ public enum AnalyticsEngine { let detectedSessions = SleepStager.detectSleep(hr: hr, rr: rr, resp: resp, gravity: gravity, tzOffsetSeconds: tzOffsetSeconds, wristOff: wristOff, bandSleepState: bandSleepState, - useSleepStagerV2: useSleepStagerV2, + stager: stager, traceSink: traceSink) - // Motion-aware wake refinement (#364 follow-up) runs AFTER V1/V2 staging, over every detected + // Motion-aware wake refinement (#364 follow-up) runs AFTER staging, over every detected // session (naps included — the same eligibility gates apply). `steps` is the SAME calendar-day/ // night-window stream the caller passed for the rest of this analysis; the pass self-gates on its // observed density, so an empty/sparse `steps` (e.g. a WHOOP 4.0, which never emits StepSample at @@ -594,11 +593,14 @@ public enum AnalyticsEngine { } // #319: the motion-coverage + staging context behind the Rest number, so a high score on a poor // night can be explained from an export (WHOOP 4.0 banks motion coarsely → sparse=true → most - // epochs default to sleep → over-counted duration → high Rest). `stager` says whether V1/V2 ran. + // epochs default to sleep → over-counted duration → high Rest). `stager` says which recipe ran: + // an HR-only night was staged by `hrOnlyRecipe` (V2 where the choice is V1, which cannot stage + // a night without motion), which IntelligenceEngine hands `hrOnlySessions` from the same choice. + let stagedBy = mainGroup.contains { $0.hrOnly } ? stager.hrOnlyRecipe : stager traceSink(AnalyticsEngine.sleepMotionLine( day: day, grav: gravity.count, hr: hr.count, sparse: gravitySparse, - useSleepStagerV2: useSleepStagerV2, family: skinTempFamily)) + stager: stagedBy, family: skinTempFamily)) // CAPTURE-C (#799): append the sleep PROVENANCE so an imported row winning the merge is visible // (not silently swapped for the measured night). hoursAsleep = the scored night's tst in minutes; // sourceRowId = the main-night's start ts for the measured path (stable per night), else the diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RestSubScoreTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RestSubScoreTrace.swift index a2c6f0b276..d658b00ccf 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/RestSubScoreTrace.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RestSubScoreTrace.swift @@ -64,9 +64,9 @@ extension AnalyticsEngine { /// most epochs default to sleep → over-counted duration → high Rest); `stager` says which engine ran; /// `family` the day's owner. PURE; byte-identical to Android `AnalyticsEngine.sleepMotionLine`. public static func sleepMotionLine(day: String, grav: Int, hr: Int, sparse: Bool, - useSleepStagerV2: Bool, family: DeviceFamily) -> String { + stager: SleepStagerVersion, family: DeviceFamily) -> String { "sleep-motion day=\(day) grav=\(grav) hr=\(hr) sparse=\(sparse) " - + "stager=\(useSleepStagerV2 ? "V2" : "V1") family=\(family.rawValue)" + + "stager=\(stager.label) family=\(family.rawValue)" } /// How long AFTER the detected onset to sample HR for the #271 onset trace (seconds). The first diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index da267ed005..d21c637fbc 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -633,13 +633,21 @@ public enum SleepStager { /// 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` + /// `hrOnlySleepRuns` supplies the spine this normally gets from gravity stillness; `stager.hrOnlyRecipe` /// 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 + /// degenerate call for V2 or V3. V2's epoch features read HR and R-R directly and only its + /// motion-quiescence terms go quiet. V3 reads the absent motion channel as missing, so it moves no logit. + /// Either 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. /// + /// `stager` is the user's choice, as for every other night. With motion stripped from PSG nights, V3 + /// scored four-class kappa 0.477 / 0.446 / 0.275 on Wearanize+ / sleep-accel / DREAMT inside the band + /// window, against V2's 0.303 / 0.363 / 0.224. Only DREAMT is out of V3's sample, and its first REM + /// lands 40-74 min late there against V2's -28 to +19. The choice used to be ignored here: these nights + /// were always V2, under a trace that named the chosen recipe. V1 stages no night without motion, so it + /// maps to V2 (`hrOnlyRecipe`). The default keeps pure-function callers on V2. + /// /// 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 @@ -657,6 +665,7 @@ public enum SleepStager { /// 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], + stager: SleepStagerVersion = .v2, minMinutes: Int = minSleepMin, traceSink: ((String) -> Void)? = nil) -> [SleepSession] { let hrS = hr.sorted { $0.ts < $1.ts } @@ -684,8 +693,8 @@ public enum SleepStager { if p.stage != "sleep" { continue } longestSleepS = max(longestSleepS, p.end - p.start) 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) + let stages = stager.hrOnlyRecipe.stageSession(start: p.start, end: p.end, grav: [], + hr: hrS, rr: rrS, resp: resp) staged += 1 if stages.isEmpty { continue } // #1884: MEASURED, not nilled. The bounds here are inferred from heart rate, which is why @@ -1261,9 +1270,16 @@ public enum SleepStager { static func applyBandStateLatencyTrim(_ stages: [StageSegment], start: Int, end: Int, bandSleepState: [(ts: Int, state: Int)], enabled: Bool = bandStateLatencyTrimEnabled) -> [StageSegment] { - guard !stages.isEmpty, - let window = bandSleepWindow(start: start, end: end, bandSleepState: bandSleepState, - enabled: enabled) else { return stages } + applyBandStateLatencyTrim(stages, start: start, end: end, + window: bandSleepWindow(start: start, end: end, bandSleepState: bandSleepState, + enabled: enabled)) + } + + /// The trim against a window the caller already resolved with `bandSleepWindow`, so a caller that also + /// hands that window to the recipe scans the band once. nil is the no-op case. + static func applyBandStateLatencyTrim(_ stages: [StageSegment], start: Int, end: Int, + window: (from: Int, to: Int)?) -> [StageSegment] { + guard !stages.isEmpty, let window else { return stages } let n = Int(ceil(Double(end - start) / epochS)) var labels = epochLabels(stages, start: start, epochs: n) var changed = false @@ -1315,6 +1331,31 @@ public enum SleepStager { return nil } + /// Stage `[start, end]` the way a detected night is staged: the chosen recipe, inside the band's sleep + /// window for V2 and V3, then the band latency trim and the band wake-veto. `detectSleep` stages every + /// accepted night through here, and so does the app's re-stage of an edited night, a manually added nap + /// and the post-sync self-heal, so a night re-staged over its own bounds reproduces its detected + /// hypnogram. + /// + /// The re-stage used to call the recipe alone, with no window and no trim. Moving "Got up" by five + /// minutes then staged a 5/MG night's lying-awake lead-in as sleep: on the owner's six banked nights V2's + /// first sleep moved up to 2 h 04 min earlier and V3's about 20 min, both before the band's own onset. + /// + /// The window is resolved once, handed to the recipe and reused by the trim. V1 has no window input and + /// relies on the trim alone. `trimmed` is the hypnogram before the wake-veto, which the #1210 shadow + /// trace in `detectSleep` reads. + public static func stageWindow(start: Int, end: Int, grav: [GravitySample], hr: [HRSample], + rr: [RRInterval], resp: [RespSample], + bandSleepState: [(ts: Int, state: Int)], + stager: SleepStagerVersion) -> (stages: [StageSegment], trimmed: [StageSegment]) { + let window = bandSleepWindow(start: start, end: end, bandSleepState: bandSleepState) + let raw = stager.stageSession(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp, + sleepWindow: stager == .v1 ? nil : window) + let trimmed = applyBandStateLatencyTrim(raw, start: start, end: end, window: window) + let stages = applyBandStateWakeVeto(trimmed, start: start, end: end, bandSleepState: bandSleepState) + return (stages, trimmed) + } + /// Off-wrist HR-gap spans (#500). The contiguous HR-coverage gaps of at least `offWristHRGapMin` /// minutes WITHIN [p.start, p.end], as concrete `[start, end)` sub-intervals — a strong wrist-OFF /// proxy. Worn, the strap streams ~1 Hz HR (or PPG-derived HR on a 5/MG), so a real night yields no @@ -1402,16 +1443,15 @@ public enum SleepStager { /// re-onset (#531): a daytime block the strap itself scored predominantly "asleep" is KEPT even on a /// borderline HR dip. Default empty keeps pure-function callers/tests free of it; IntelligenceEngine /// passes the night window's persisted band state. It can only RESCUE a real-sleep block, never fabricate. - /// `useSleepStagerV2` (7.0.0; default ON since #277/#351): which recipe stages an accepted night — the cardiorespiratory - /// `SleepStagerV2.stageSession` when true, V1's `stageSession` when false. DETECTION is unchanged - /// (same accepted windows); only the per-epoch hypnogram differs. + /// `stager`: which recipe stages an accepted night (`SleepStagerVersion`). DETECTION is unchanged (same + /// accepted windows); only the per-epoch hypnogram differs. /// /// THE TWO DEFAULTS ARE NOT THE SAME, and reading only the signature gets this backwards. This - /// PARAMETER defaults false so pure-function callers and the frozen-golden tests stay byte-identical. - /// The SHIPPED app never takes that default: the live call site threads - /// `PuffinExperiment.experimentalSleepV2Enabled`, which is **default ON** (V2 was promoted over V1 in - /// #277 and extended to every strap family in #351), so a normal user's nights are staged by **V2**. - /// `= false` here describes the library's contract with its callers, not the product's behaviour. + /// PARAMETER defaults to `.v1` so pure-function callers and the frozen-golden tests stay byte-identical. + /// The SHIPPED app never takes that default: the live call site threads `PuffinExperiment.sleepStager`, + /// which is **`.v3`** unless the user picked another recipe in Settings, so a normal user's nights are + /// staged by **V3**. `.v1` here describes the library's contract with its callers, not the product's + /// behaviour. /// `sleepHRBaseline` (motion-corroborated wake, directive b): the wearer's PERSONALISED overnight HR band /// (`adaptiveOvernightHRBaseline`), used by `confirmSleepWithHR` in place of the day-median so a supplement / /// fitness era self-calibrates the sleep band. Default nil keeps the day-median (byte-identical to before); @@ -1423,7 +1463,7 @@ public enum SleepStager { tzOffsetSeconds: Int = 0, wristOff: [(start: Int, end: Int)] = [], bandSleepState: [(ts: Int, state: Int)] = [], - useSleepStagerV2: Bool = false, + stager: SleepStagerVersion = .v1, sleepHRBaseline: Double? = nil, traceSink: ((String) -> Void)? = nil) -> [SleepSession] { // Sleep & Rest test mode only: when a trace is requested we MUST run the live ladder, not a @@ -1434,7 +1474,7 @@ public enum SleepStager { if let traceSink { return detectSleepUncached(hr: hr, rr: rr, resp: resp, gravity: gravity, tzOffsetSeconds: tzOffsetSeconds, wristOff: wristOff, - bandSleepState: bandSleepState, useSleepStagerV2: useSleepStagerV2, + bandSleepState: bandSleepState, stager: stager, sleepHRBaseline: sleepHRBaseline, traceSink: traceSink) } // v7.0.2 perf (#707): the single heaviest analytics call — it sorts the dense full-day gravity @@ -1443,8 +1483,8 @@ public enum SleepStager { // a re-run with the SAME raw (an idempotent re-pass, or a later sync that didn't touch this day's // streams) re-does all of it for an identical `[SleepSession]`. Memoize on a FULL key: every input // that steers detection or staging — the four streams, the tz offset (daytime-guard + onset band), - // the off-wrist intervals (#500 backstop), the persisted band state (#531 H8), and the V2 toggle (an - // edit to any re-keys to a fresh compute). Result-only + bounded; the raw arrays are never retained. + // the off-wrist intervals (#500 backstop), the persisted band state (#531 H8), and the stager choice + // (an edit to any re-keys to a fresh compute). Result-only + bounded; the raw arrays are never retained. // Match Android's raw-axis semantics: mix x/y/z IEEE-754 bits in order, never their lossy sum. let key = DetectKey( grav: StreamFingerprint.of(gravity, ts: { $0.ts }, quant: { @@ -1456,12 +1496,12 @@ public enum SleepStager { tz: tzOffsetSeconds, wristOff: StreamFingerprint.of(wristOff, ts: { $0.start }, quant: { $0.end }), band: StreamFingerprint.of(bandSleepState, ts: { $0.ts }, quant: { $0.state }), - v2: useSleepStagerV2, + stager: stager, sleepHRBaseline: sleepHRBaseline) return detectSleepCache.value(key) { detectSleepUncached(hr: hr, rr: rr, resp: resp, gravity: gravity, tzOffsetSeconds: tzOffsetSeconds, wristOff: wristOff, - bandSleepState: bandSleepState, useSleepStagerV2: useSleepStagerV2, + bandSleepState: bandSleepState, stager: stager, sleepHRBaseline: sleepHRBaseline, traceSink: nil) } } @@ -1471,7 +1511,7 @@ public enum SleepStager { let rr: StreamFingerprint; let resp: StreamFingerprint let tz: Int let wristOff: StreamFingerprint; let band: StreamFingerprint - let v2: Bool + let stager: SleepStagerVersion let sleepHRBaseline: Double? } /// ≈ the number of distinct days in a scoring window; FIFO-evicted, holds only small session arrays. @@ -1485,7 +1525,7 @@ public enum SleepStager { tzOffsetSeconds: Int, wristOff: [(start: Int, end: Int)], bandSleepState: [(ts: Int, state: Int)], - useSleepStagerV2: Bool, + stager: SleepStagerVersion, sleepHRBaseline: Double? = nil, traceSink: ((String) -> Void)? = nil) -> [SleepSession] { let grav = gravity.sorted { $0.ts < $1.ts } @@ -1677,26 +1717,18 @@ public enum SleepStager { detail: "daytime=true restingHR=\(resting ?? -1) baseline=\(baseline.map { Int($0) } ?? -1) nightTail=false")) continue } - // V2 stages with the band's sleep window as a constraint, so the latency trim below finds wake - // already in place and the recipe's onset-relative terms start from the band's onset. V1 has no - // such input and relies on the trim alone. - let rawStages = useSleepStagerV2 - ? SleepStagerV2.stageSession(start: p.start, end: p.end, grav: grav, - hr: hrS, rr: rrS, resp: respS, - sleepWindow: bandSleepWindow(start: p.start, end: p.end, - bandSleepState: bandSleepState)) - : stageSession(start: p.start, end: p.end, grav: grav, - hr: hrS, rr: rrS, resp: respS) - // Band sleep_state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN band - // (`bandSleepState`) scored "asleep". No-op when the band is absent (WHOOP 4.0) or the flag is - // off; stager-agnostic (corrects whichever hypnogram V1/V2 produced). Efficiency below is then - // computed on the corrected stages, so a night NOOP over-called wake on reports true efficiency. - // Band latency trim: relabel as wake the lying-still-awake lead-in and tail outside the band's - // own persistent "asleep" span. No-op without a band stream (WHOOP 4.0). - let trimmedStages = applyBandStateLatencyTrim(rawStages, start: p.start, end: p.end, - bandSleepState: bandSleepState) - let stages = applyBandStateWakeVeto(trimmedStages, start: p.start, end: p.end, - bandSleepState: bandSleepState) + // `stageWindow`, the one staging funnel the app's re-stage shares. V2 and V3 stage inside the + // band's sleep window, so V2's onset guards and V3's elapsed time start at the band's onset (V2's + // cycle-prior clock still spans the session; see `SleepStagerV2.cyclePrior`). Band latency trim: + // relabel as wake the lying-still-awake lead-in and tail outside the band's own persistent + // "asleep" span. Band sleep_state WAKE-veto: recover INTERIOR false-wake epochs the strap's OWN + // band scored "asleep". Both are no-ops without a band stream (WHOOP 4.0) and stager-agnostic. + // Efficiency below is computed on the final stages, so a night NOOP over-called wake on reports + // true efficiency. + let staged = stageWindow(start: p.start, end: p.end, grav: grav, hr: hrS, rr: rrS, resp: respS, + bandSleepState: bandSleepState, stager: stager) + let trimmedStages = staged.trimmed + let stages = staged.stages let eff = efficiency(start: p.start, end: p.end, stages: stages) let avgHrv = sessionAvgHRV(start: p.start, end: p.end, rr: rrS) sessions.append(SleepSession(start: p.start, end: p.end, efficiency: eff, diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift index 5c3196a710..dc46c71f96 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV2.swift @@ -63,37 +63,12 @@ public enum SleepStagerV2 { // slice each stream to the read window BEFORE fingerprinting and BEFORE the uncached recipe. This is // output-identical for sorted callers (and stable for same-second rows), drops only rows `features()` // could never touch, and tightens the fingerprint to the rows that matter. PAD_LO/PAD_HI are the SAME - // values the Android twin (R1) clips with. - let stableOrder: (Int, (Int) -> Int) -> [Int]? = { count, tsAt in - if count < 2 { return nil } - var hasInversion = false - for i in 1.. tsAt(i) { - hasInversion = true - break - } - if !hasInversion { return nil } - return (0..(_ samples: [T], lo: Int, hi: Int, ts: (T) -> Int) -> [T] { - if samples.isEmpty { return samples } - // Already inside the window in full → avoid the copy (the common single-night case). - if ts(samples[0]) >= lo && ts(samples[samples.count - 1]) < hi { return samples } - // lowerBound: first index with ts >= lo. - var l = 0, h = samples.count - while l < h { let m = (l + h) / 2; if ts(samples[m]) < lo { l = m + 1 } else { h = m } } - let start = l - // upperBound: first index with ts >= hi (exclusive upper, matching the half-open read windows). - l = start; h = samples.count - while l < h { let m = (l + h) / 2; if ts(samples[m]) < hi { l = m + 1 } else { h = m } } - if start == 0 && l == samples.count { return samples } - return Array(samples[start..(capacity: 24) + /// ≈ a couple of weeks of distinct nights (incl. re-staged edits); FIFO-evicted, result-only. Keyed by + /// the locked window, the fingerprints of the clipped streams the recipe reads and the sleep window. + private static let stageCache = AnalyticsMemoCache(capacity: 24) /// The unchanged staging recipe. Split out verbatim from `stageSession` so the public entry can memoize /// in front of it; behaviour is byte-identical (a cache miss runs exactly this). @@ -145,23 +95,13 @@ public enum SleepStagerV2 { // The public veneer has already established stable timestamp order before clipping. let feats = features(start: start, end: end, grav: grav, hr: hr, rr: rr) if feats.isEmpty { return [StageSegment(start: start, end: end, stage: "light")] } - let labels = stageEpochs(feats, sleepWindow: sleepWindow) - - // Tile [start, end] with one segment per staged epoch. The first segment back-fills [start, firstEpoch) - // and the last extends to `end`; an interior coverage gap is carried by the preceding label. "awake" - // is renamed to the canonical "wake" used by V1 / StageSegment. - var segments: [StageSegment] = [] - for (i, f) in feats.enumerated() { - let stage = labels[i] == "awake" ? "wake" : labels[i] - let segStart = i == 0 ? start : f.start - let segEnd = i == feats.count - 1 ? end : feats[i + 1].start - if let last = segments.last, last.stage == stage { - segments[segments.count - 1].end = segEnd - } else { - segments.append(StageSegment(start: segStart, end: segEnd, stage: stage)) - } - } - return segments + let labels = stageEpochs(feats, sleepWindow: sleepWindow, end: end) + + // Tile [start, end] with one segment per staged epoch (`StagerInput.tile`: the first segment back-fills + // [start, firstEpoch), the last extends to `end`, an interior coverage gap is carried by the preceding + // label). "awake" is renamed to the canonical "wake" used by V1 / StageSegment. + return StagerInput.tile(epochStarts: feats.map { $0.start }, + labels: labels.map { $0 == "awake" ? "wake" : $0 }, start: start, end: end) } // MARK: - Recipe constants (all fixed a-priori — NOT fit to labels) @@ -517,6 +457,13 @@ public enum SleepStagerV2 { /// it MORE variable, not less (CV 0.617 as a fraction vs 0.537 in minutes). The `c < 0.12` step this /// replaced therefore scaled a fixed physiological interval by session length: across one WHOOP 5 user's /// own recorded nights it ranged 7.4–84.5 min, an 11× spread, for the same wearer and the same physiology. + /// + /// `c` STAYS A FRACTION OF THE SESSION when a band sleep window is given, although the onset guards move + /// to the window. With a long lying-awake lead-in, part of the early deep prior is spent before sleep + /// begins: on the owner's six banked 5/MG nights `c` at the band's onset was 0.03–0.19, a deep prior of + /// 0.79–1.13 instead of 1.20. Measuring `c` over the window instead was tried and scored no better + /// against PSG in the window: four-class kappa 0.363 → 0.360 on Wearanize+, 0.434 → 0.427 on + /// sleep-accel and 0.259 → 0.261 on DREAMT, with first REM later on all three (+5, +2 and +23 min). static func cyclePrior(_ c: Double, _ minutesSinceOnset: Double) -> [String: Double] { ["deep": 1.2 * max(0.0, 1.0 - c / 0.55), "rem": 1.0 * c - remLatencyGuard(minutesSinceOnset), @@ -545,12 +492,18 @@ public enum SleepStagerV2 { /// enough that no emission or transition can outweigh it, and finite so the lattice never sums infinities. static let forcedWakePenalty = 1.0e6 - /// Whether the 30 s epoch starting at `epochStart` lies inside `window`. The window sits on the session's - /// own grid (`SleepStager.bandSleepWindow`) and recipe epochs sit on the wall-clock grid, so each epoch is - /// judged by the one session-grid instant it contains. That is the instant the latency trim reads the - /// epoch's label at, so the trim finds nothing left to relabel. - static func epochInSleepWindow(_ epochStart: Int, _ window: (from: Int, to: Int)) -> Bool { - let t = epochStart + ((window.from - epochStart) % 30 + 30) % 30 + /// Whether the 30 s epoch starting at `epochStart` lies inside `window`, for a session ending at `end`. The + /// window sits on the session's own grid (`SleepStager.bandSleepWindow`) and recipe epochs sit on the + /// wall-clock grid, so each epoch is judged by the one session-grid instant it contains. That is the + /// instant the latency trim reads the epoch's label at, so the trim finds nothing left to relabel. + /// + /// The session's last recipe epoch is cut short at `end` and can hold no session-grid instant before it. + /// It then lies inside the session's last grid epoch and is judged by that epoch's instant. Judged by the + /// instant past `end`, it fell outside a window that runs to the end, so a night the band slept through + /// closed on a sliver of wake (up to 29 s) that no trim removed. + static func epochInSleepWindow(_ epochStart: Int, _ window: (from: Int, to: Int), end: Int) -> Bool { + var t = epochStart + ((window.from - epochStart) % 30 + 30) % 30 + if t >= end { t -= 30 } return t >= window.from && t < window.to } @@ -608,7 +561,7 @@ public enum SleepStagerV2 { /// staged there, often deep, which is the transition the awake row forbids. Only the labels are /// constrained: every emission, z-score and percentile is computed exactly as before, over the whole /// session. - static func stageEpochs(_ feats: [Epoch], sleepWindow: (from: Int, to: Int)? = nil) -> [String] { + static func stageEpochs(_ feats: [Epoch], sleepWindow: (from: Int, to: Int)? = nil, end: Int = .max) -> [String] { if feats.isEmpty { return [] } // Per-night z-score over the present values (population std; 0 std → 1 so a flat channel is neutral). @@ -657,7 +610,7 @@ public enum SleepStagerV2 { for s in stageNames { em[s]! += pr[s]! } if f.jerkMax > f.jerkScale * jerkFloorGateMult { em["awake"]! += motionGateBoost } if let rg = f.respReg { em["rem"]! -= respWeight * max(0.0, zrg(rg)) } - if let w = sleepWindow, !epochInSleepWindow(f.start, w) { + if let w = sleepWindow, !epochInSleepWindow(f.start, w, end: end) { for s in stageNames where s != "awake" { em[s]! -= forcedWakePenalty } } seq.append(em) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV3.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV3.swift new file mode 100644 index 0000000000..e2074e5bd5 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV3.swift @@ -0,0 +1,631 @@ +import Foundation +import WhoopProtocol + +// SleepStagerV3.swift — the DEFAULT sleep-staging recipe: a stager fitted to human-scored polysomnography. +// +// V2 (`SleepStagerV2`) sets every coefficient by hand from sleep physiology. Every published wrist stager +// that reaches a four-class kappa of 0.5 does the opposite: it fits its per-epoch emissions to PSG labels +// (Fitbit, Oura, Philips, Fonseca et al. 2023, Walch et al. 2019). V3 follows that design, with open data only: +// +// 1. per-epoch features that are all relative to the night itself, so a different strap or fit changes +// nothing but scale: motion as jerk over the night's own quiet floor and posture change over the night's +// median change; heart rate as within-night z-scores, percentile ranks and multi-scale variability; +// time since and until movement; elapsed time; and, when the strap delivered beat intervals, 5-minute +// heart-rate variability (RMSSD, SDNN, pNN50, LF and HF power, their ratio, the HF peak as a breathing +// rate, its peakedness, DFA alpha-1) scaled to the night's 5th-95th percentile range; +// 2. two multinomial logistic-regression heads (`SleepStagerV3Model.swift`, generated) that turn them into +// stage posteriors: the base head reads motion and heart rate, the HRV head the beat features as well. +// When at least half the staged epochs have beat intervals, both run and their log-posteriors are +// averaged with equal weight; otherwise the base head stages the night alone; +// 3. a Viterbi pass over those posteriors with the stage-transition matrix and start distribution counted +// from the training hypnograms. +// +// Training: Wearanize+ (88 nights, Empatica E4 wrist + PSG, two human scorers) and PhysioNet sleep-accel +// (31 nights, Apple Watch + PSG). The tool that fits it, its cross-validated agreement per cohort and an +// independent-cohort test are in `Tools/SleepTrain`; `Tools/SleepPSG --section v3` replays this file over +// the same nights. `Tools/SleepTrain/sleeptrain/features.py` is the definition this port is pinned to, +// through the oracle fixture `SleepStagerV3OracleTests` loads. +// +// HONEST HEDGING: agreement with PSG is measured on other people's nights and other wrist devices, not on a +// WHOOP strap, for which no public PSG recording exists. The stages remain approximations and are not medical +// advice. V2 and V1 stay selectable. + +public enum SleepStagerV3 { + + /// Stage `[start, end]`. DROP-IN for `SleepStagerV2.stageSession`: same signature, same `[StageSegment]` + /// tiling. `resp` is accepted for parity and not read. + /// + /// `sleepWindow` (the band's `SleepStager.bandSleepWindow`) limits staging to the epochs it contains: + /// features, normalisation and decoding all run over those epochs only, and every other epoch is wake. + /// That is the view the model was trained on for band nights. Without a window the whole session is staged. + public static func stageSession(start: Int, end: Int, grav: [GravitySample], + hr: [HRSample], rr: [RRInterval], resp: [RespSample], + sleepWindow: (from: Int, to: Int)? = nil) -> [StageSegment] { + let epochs = epochStarts(start: start, end: end) + guard let first = epochs.first, let last = epochs.last else { + return [StageSegment(start: start, end: end, stage: "light")] + } + // Only [first epoch, last epoch + 30) is ever read, so slice every stream to it before keying the + // memo: the callers pass multi-day streams to every per-night call. + let lo = first, hi = last + epochS + let gravW = StagerInput.clip(StagerInput.sortedByTs(grav, { $0.ts }), lo: lo, hi: hi, ts: { $0.ts }) + let hrW = StagerInput.clip(StagerInput.sortedByTs(hr, { $0.ts }), lo: lo, hi: hi, ts: { $0.ts }) + let rrW = StagerInput.clip(StagerInput.sortedByTs(rr, { $0.ts }), lo: lo, hi: hi, ts: { $0.ts }) + let key = StagerInput.Key(start: start, end: end, grav: gravW, hr: hrW, rr: rrW, sleepWindow: sleepWindow) + return stageCache.value(key) { + let labels = stageEpochs(epochs: epochs, grav: gravW, hr: hrW, rr: rrW, sleepWindow: sleepWindow, + end: end) + return StagerInput.tile(epochStarts: epochs, labels: labels, start: start, end: end) + } + } + + private static let stageCache = AnalyticsMemoCache(capacity: 24) + + /// Stage names in model order. + static let stages = ["wake", "light", "deep", "rem"] + static let epochS = 30 + + /// Wall-clock 30 s epoch starts covering `[start, end)`, the grid V2 uses. + static func epochStarts(start: Int, end: Int) -> [Int] { + var out: [Int] = [] + var e = ((start + 29) / 30) * 30 + while e < end { out.append(e); e += epochS } + return out + } + + /// One label per epoch of the grid: the staged span decoded, everything else wake. `end` is the session's. + static func stageEpochs(epochs: [Int], grav: [GravitySample], hr: [HRSample], rr: [RRInterval], + sleepWindow: (from: Int, to: Int)?, end: Int) -> [String] { + var labels = [String](repeating: "wake", count: epochs.count) + let inSpan: [Int] + if let w = sleepWindow { + inSpan = epochs.indices.filter { SleepStagerV2.epochInSleepWindow(epochs[$0], w, end: end) } + } else { + inSpan = Array(epochs.indices) + } + guard let a = inSpan.first, let b = inSpan.last else { return labels } + let f = features(grav: grav, hr: hr, rr: rr, spanStart: epochs[a], n: b - a + 1) + for (k, s) in stage(f).enumerated() { labels[a + k] = stages[s] } + return labels + } + + // MARK: - Model + + /// One logistic-regression head plus its decoder statistics, as `train.py` writes them. + struct Head { + let features: [String] + let mean: [Double] + let scale: [Double] + /// One row per stage (wake, light, deep, rem), one weight per feature. + let coef: [[Double]] + let intercept: [Double] + let prior: [Double] + let transition: [[Double]] + + /// Per-epoch stage posteriors (softmax of the standardised features' logits). + func posteriors(_ f: Features) -> [[Double]] { + let n = f.n + let cols = features.indices.map { j in + imputed(f.columns[features[j]] ?? [Double](repeating: .nan, count: n), absent: mean[j]) + } + var out: [[Double]] = [] + out.reserveCapacity(n) + for i in 0.. [Int] { + decode(logPosteriors: post.map { p in p.map { Foundation.log(max($0, probFloor)) } }) + } + + func decode(logPosteriors lp: [[Double]]) -> [Int] { + let logPrior = prior.map { Foundation.log($0) } + let logT = transition.map { $0.map { Foundation.log($0) } } + let em = lp.map { l in (0..<4).map { l[$0] - priorPower * logPrior[$0] } } + return viterbi(em, logT: logT, logStart: logPrior) + } + } + + static let probFloor = 1e-6 + static let priorPower = 0.5 + /// Fraction of the span's epochs that need `hrvMinBeats` beats in their window for the HRV head. + static let hrvCoverage = 0.5 + /// Weight of the HRV head's log-posterior when both heads run; the base head gets the rest. Equal + /// weights, not tuned. Against the HRV head alone it moved cross-validated kappa on Wearanize+ 0.554 → + /// 0.547 and on DREAMT 0.320 → 0.313, and cut the REM over-count 2.1 → 0.1 and 7.9 → 5.9 percentage + /// points: out of its training cohort the HRV head alone calls REM high. + static let hrvBlend = 0.5 + + static func usesHRV(_ f: Features) -> Bool { + guard f.n > 0 else { return false } + let covered = f.beats.reduce(0) { $0 + ($1 >= hrvMinBeats ? 1 : 0) } + return Double(covered) / Double(f.n) >= hrvCoverage + } + + /// Stage indices for every epoch of the span. + static func stage(_ f: Features) -> [Int] { + guard usesHRV(f) else { return baseHead.decode(baseHead.posteriors(f)) } + let ph = hrvHead.posteriors(f), pb = baseHead.posteriors(f) + let lp = zip(ph, pb).map { a, b in + (0..<4).map { hrvBlend * Foundation.log(max(a[$0], probFloor)) + + (1 - hrvBlend) * Foundation.log(max(b[$0], probFloor)) } + } + return hrvHead.decode(logPosteriors: lp) + } + + /// Most likely state path; a tie keeps the lower state index, in each step and in the final pick. + static func viterbi(_ em: [[Double]], logT: [[Double]], logStart: [Double]) -> [Int] { + guard let first = em.first else { return [] } + let k = first.count + var v = (0.. bestV { best = p; bestV = v[p] + logT[p][s] } + nv[s] = bestV + em[t][s]; bp[s] = best + } + v = nv; back.append(bp) + } + var last = 0 + for s in 1.. v[last] { last = s } + var path = [Int](repeating: 0, count: em.count) + path[em.count - 1] = last + var t = em.count - 1 + while t > 0 { last = back[t][last]; t -= 1; path[t] = last } + return path + } + + /// Missing values take the column's median over the night, so a gap reads as typical for the night rather + /// than as an extreme. A column missing for the whole night takes `absent`, the head's training mean for + /// it, so it standardises to 0 and moves no logit. + /// + /// A whole-night gap used to take 0, which is neutral only for the z-scored columns. For a percentile + /// rank (typical value 0.5) or a ratio it is an extreme. A span with gravity but no heart rate yet read as + /// the lowest, flattest heart rate of the night: hr_pct and the three hrsd ranks at z = -1.74, hr_over_low + /// at -1.03. On the owner's six banked 5/MG nights with the heart rate removed that staged 32-48 % deep, + /// against 8-18 % with it present. No training night holds a whole-night gap in a column its head reads, + /// so the fitted model is unchanged, and so are the PSG figures. + static func imputed(_ x: [Double], absent: Double) -> [Double] { + let m = median(x) + let fill = m.isNaN ? absent : m + return x.map { $0.isNaN ? fill : $0 } + } + + // MARK: - Features (a line-for-line port of Tools/SleepTrain/sleeptrain/features.py) + + static let moveMult = 38.0 + static let moveCountInit = 60.0 + static let moveCountCap = 120.0 + static let ctx = [2, 5, 10, 20] + static let hrsd: [(half: Int, name: String)] = [(150, "5"), (330, "11"), (600, "20")] + static let hrsdMin = 10 + static let hrCtx = [5, 10, 30] + static let hrTrend = 10 + static let minutesCap = 720.0 + static let timeDecays = [10, 30, 90] + static let hrvHalf = 150.0 + static let hrvMinBeats = 20 + static let specMinBeats = 30 + static let specMinSpan = 120.0 + static let dfaMinBeats = 64 + static let hrvCtx = 10 + static let resampleHz = 4.0 + static let nperseg = 256 + static let lfBand = (0.04, 0.15) + static let hfBand = (0.15, 0.40) + static let hrvNames = ["meanNN", "sdnn", "lnrmssd", "pnn50", "lnlf", "lnhf", "lnlfhf", "resp_bpm", + "hf_peaked", "dfa_a1", "beats_per_min"] + + /// Every feature of the span's `n` epochs by name (NaN = no measurement), and the beat count in each + /// epoch's 5-minute window. + struct Features { + let n: Int + var columns: [String: [Double]] + let beats: [Int] + } + + static func features(grav: [GravitySample], hr: [HRSample], rr: [RRInterval], spanStart: Int, n: Int) -> Features { + let S = epochS * n + var cols: [String: [Double]] = [:] + + // Per-second grid (mean of the samples a second carries). + var gx = [Double](repeating: 0, count: S), gy = gx, gz = gx + var gc = [Int](repeating: 0, count: S) + for s in grav { + let i = s.ts - spanStart + guard i >= 0 && i < S else { continue } + gx[i] += s.x; gy[i] += s.y; gz[i] += s.z; gc[i] += 1 + } + for i in 0.. 0 { let c = Double(gc[i]); gx[i] /= c; gy[i] /= c; gz[i] /= c } else { gx[i] = .nan; gy[i] = .nan; gz[i] = .nan } + } + var h = [Double](repeating: 0, count: S) + var hc = [Int](repeating: 0, count: S) + for s in hr { + let i = s.ts - spanStart + guard i >= 0 && i < S, s.bpm > 0 else { continue } + h[i] += Double(s.bpm); hc[i] += 1 + } + for i in 0.. 0 ? h[i] / Double(hc[i]) : .nan } + + // Motion. + var jerk = [Double](repeating: .nan, count: S) + if S > 1 { + for i in 1.. 0) { floor = 1e-6 } + var actMean = [Double](repeating: 0, count: n), actMax = actMean, moveFrac = actMean + for e in 0.. moveMult { moves += 1 } + } + actMean[e] = sum / Double(epochS); actMax[e] = mx; moveFrac[e] = moves / Double(epochS) + } + cols["act_mean"] = actMean; cols["act_max"] = actMax; cols["move_frac"] = moveFrac + var since = [Double](repeating: 0, count: n), until = since + var c = moveCountInit + for i in 0.. 0 ? 0 : min(c + 1, moveCountCap); since[i] = c } + c = moveCountInit + for i in stride(from: n - 1, through: 0, by: -1) { c = moveFrac[i] > 0 ? 0 : min(c + 1, moveCountCap); until[i] = c } + cols["since_move"] = since.map { Foundation.log1p($0) } + cols["until_move"] = until.map { Foundation.log1p($0) } + for w in ctx { cols["act_ctx\(w)"] = centredMean(actMean, w) } + // No jerk anywhere in the span (no gravity, or none in two consecutive seconds) is no motion + // measurement: its columns are missing, and so read as typical, not as the stillest night the model + // ever saw (zero activity and the time since movement at its cap). That is how a night with no motion + // at all reaches V3, through `SleepStager.hrOnlySessions`. + if !jerk.contains(where: { !$0.isNaN }) { + let motion = ["act_mean", "act_max", "move_frac", "since_move", "until_move"] + ctx.map { "act_ctx\($0)" } + for name in motion { cols[name] = [Double](repeating: .nan, count: n) } + } + + // Posture: z-angle change between 5 s block means, over the night's median change. + let blocks = (0..<(S / 5)).map { j -> Double in + var vals: [Double] = [] + for i in (5 * j)..<(5 * j + 5) { + vals.append(Foundation.atan2(gz[i], (gx[i] * gx[i] + gy[i] * gy[i]).squareRoot()) * (180.0 / Double.pi)) + } + return nanMean(vals) + } + var dblk = [Double](repeating: 0, count: blocks.count) + var postureMeasured = false + if blocks.count > 1 { + for j in 1.. 0 ? dmed : 1e-6)) } + cols["dangn"] = dangn + for w in ctx { cols["dangn_ctx\(w)"] = centredMean(dangn, w) } + // Likewise no two consecutive blocks with gravity is no posture measurement. + if !postureMeasured { + for name in ["dangn"] + ctx.map({ "dangn_ctx\($0)" }) { cols[name] = [Double](repeating: .nan, count: n) } + } + + // Heart rate. + let ehr = (0.. hrsdMin { sd[e] = nanStd(win) } + } + cols["hrsd\(name)_pct"] = pctRank(sd); cols["hrsd\(name)_z"] = zscore(sd) + } + for w in hrCtx { cols["hr_ctx\(w)_z"] = zscore(centredMean(ehr, w)) } + var trend = [Double](repeating: 0, count: n) + if n > 1 { + for e in 1.. 1e-6 ? hsd : 1e-6) } + + // Time of night. + cols["clock"] = (0.. ([Double], [Double]) { + let hi = spanStart + epochS * n + let kept = StagerInput.sortedByTs(rr, { $0.ts }).filter { $0.ts >= spanStart && $0.ts < hi } + var t = [Double](), v = [Double]() + t.reserveCapacity(kept.count); v.reserveCapacity(kept.count) + var i = 0 + while i < kept.count { + var j = i + while j + 1 < kept.count && kept[j + 1].ts == kept[i].ts { j += 1 } + let c = Double(j - i + 1) + for k in i...j { + t.append(Double(kept[i].ts - spanStart) + Double(k - i) / c) + v.append(Double(kept[k].rrMs)) + } + i = j + 1 + } + return (t, v) + } + + /// Drop impossible intervals, then any more than 20 % from the median of its 11-beat neighbourhood. + static func cleanBeats(_ beats: ([Double], [Double])) -> ([Double], [Double]) { + var t: [Double] = [], v: [Double] = [] + for (a, b) in zip(beats.0, beats.1) where b >= 300 && b <= 2000 { t.append(a); v.append(b) } + if v.count < 5 { return (t, v) } + var ot: [Double] = [], ov: [Double] = [] + for i in v.indices { + let m = median(Array(v[max(0, i - 5).. ([[Double]], [Int]) { + var out = [[Double]](repeating: [Double](repeating: .nan, count: hrvNames.count), count: n) + var count = [Int](repeating: 0, count: n) + for e in 0.. 50 { big += 1 } + } + let nd = Double(x.count - 1) + let rmssd = (d2 / nd).squareRoot() + let (lf, hf, peak, peaked) = spectral(tt, x) + out[e] = [x.reduce(0, +) / Double(x.count), nanStd(x), Foundation.log(rmssd + 1e-6), big / nd, + Foundation.log(lf + 1e-6), Foundation.log(hf + 1e-6), + hf > 0 ? Foundation.log(lf / hf + 1e-9) : .nan, + peak * 60.0, peaked, + x.count >= dfaMinBeats ? dfaAlpha1(x) : .nan, + Double(x.count) / (2 * hrvHalf) * 60.0] + } + return (out, count) + } + + /// (LF power, HF power, HF peak frequency, HF peakedness) of the 4 Hz resampled, linearly detrended + /// tachogram, from a Welch PSD: periodic Hann windows of `nperseg` samples, 50 % overlap, each segment + /// mean-removed, one-sided density, averaged — scipy.signal.welch's defaults. Only the bins the two + /// bands read are transformed. + static func spectral(_ t: [Double], _ rr: [Double]) -> (Double, Double, Double, Double) { + let nan = Double.nan + guard rr.count >= specMinBeats, let t0 = t.first, let tN = t.last, tN - t0 >= specMinSpan else { + return (nan, nan, nan, nan) + } + let m = Int(Foundation.ceil((tN - t0) * resampleHz)) + var y = [Double](repeating: 0, count: m), x = y + var j = 0 + for i in 0.. 0 ? sxy / sxx : 0 + for i in 0.. Double { + var s = 0.0, prev: Int? + for i in 0..= lo && binHz(bins[i]) < hi { + if let q = prev { s += (binHz(bins[i]) - binHz(bins[q])) * (p[i] + p[q]) / 2 } + prev = i + } + return s + } + let lf = band(lfBand.0, lfBand.1), hf = band(hfBand.0, hfBand.1) + var peak: Int?, total = 0.0 + for i in 0..= hfBand.0 && binHz(bins[i]) < hfBand.1 { + total += p[i] + if peak == nil || p[i] > p[peak!] { peak = i } + } + return (lf, hf, binHz(bins[peak!]), total > 0 ? p[peak!] / total : nan) + } + + static func binHz(_ b: Int) -> Double { Double(b) * resampleHz / Double(nperseg) } + + /// The periodic Hann window and its sum of squares (summed in index order). + static let hann: [Double] = (0..= lfBand.0 && binHz($0) < hfBand.1 } + static let dftCos: [[Double]] = dftBins.map { b in + (0.. Double { + let mean = rr.reduce(0, +) / Double(rr.count) + var x = [Double](repeating: 0, count: rr.count), acc = 0.0 + for i in rr.indices { acc += rr[i] - mean; x[i] = acc } + var logN: [Double] = [], logF: [Double] = [] + for n in 4...16 { + let k = x.count / n + if k < 2 { return .nan } + let im = Double(n - 1) / 2 + var sii = 0.0 + for i in 0.. Double { + var s = 0.0, c = 0 + for v in x where !v.isNaN { s += v; c += 1 } + return c > 0 ? s / Double(c) : .nan + } + + /// Population standard deviation over the present values. + static func nanStd(_ x: [Double]) -> Double { + let m = nanMean(x) + if m.isNaN { return .nan } + var s = 0.0, c = 0 + for v in x where !v.isNaN { s += (v - m) * (v - m); c += 1 } + return (s / Double(c)).squareRoot() + } + + static func median(_ x: [Double]) -> Double { + let s = x.filter { !$0.isNaN }.sorted() + if s.isEmpty { return .nan } + let h = s.count / 2 + return s.count % 2 == 1 ? s[h] : 0.5 * (s[h - 1] + s[h]) + } + + /// Linear-interpolation percentile over the present values. + static func percentile(_ x: [Double], _ q: Double) -> Double { + let s = x.filter { !$0.isNaN }.sorted() + if s.isEmpty { return .nan } + let pos = q / 100 * Double(s.count - 1) + let lo = Int(Foundation.floor(pos)), hi = min(lo + 1, s.count - 1) + return s[lo] + (pos - Double(lo)) * (s[hi] - s[lo]) + } + + static func zscore(_ x: [Double]) -> [Double] { + let m = nanMean(x), sd = nanStd(x) + if m.isNaN { return x.map { _ in .nan } } + let d = sd > 0 ? sd : 1 + return x.map { ($0 - m) / d } + } + + /// Percentile rank: the average 1-based rank of a value's ties over the count of present values. + static func pctRank(_ x: [Double]) -> [Double] { + let s = x.filter { !$0.isNaN }.sorted() + if s.isEmpty { return x } + let n = Double(s.count) + return x.map { v in + if v.isNaN { return .nan } + let below = lowerBound(s, v), through = upperBound(s, v) + return (Double(below) + Double(through - below + 1) / 2) / n + } + } + + /// Mean of the present values in [i - w, i + w], clipped to the span; NaN when none is present. + static func centredMean(_ x: [Double], _ w: Int) -> [Double] { + let n = x.count + var cs = [Double](repeating: 0, count: n + 1), cc = [Int](repeating: 0, count: n + 1) + for i in 0.. 0 ? (cs[hi] - cs[lo]) / Double(c) : .nan + } + } + + /// Scale to the night's 5th-95th percentile range. + static func robust(_ x: [Double]) -> [Double] { + let lo = percentile(x, 5), hi = percentile(x, 95) + if lo.isNaN { return x.map { _ in .nan } } + let d = hi > lo ? hi - lo : 1 + return x.map { ($0 - lo) / d } + } + + static func lowerBound(_ s: [Double], _ v: Double) -> Int { + var lo = 0, hi = s.count + while lo < hi { let m = (lo + hi) / 2; if s[m] < v { lo = m + 1 } else { hi = m } } + return lo + } + + static func upperBound(_ s: [Double], _ v: Double) -> Int { + var lo = 0, hi = s.count + while lo < hi { let m = (lo + hi) / 2; if s[m] <= v { lo = m + 1 } else { hi = m } } + return lo + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV3Model.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV3Model.swift new file mode 100644 index 0000000000..d475f4d51f --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV3Model.swift @@ -0,0 +1,229 @@ +// GENERATED by Tools/SleepTrain/train.py. Do not edit by hand: rerun the tool (see its README). +// +// The two SleepStagerV3 heads (per-feature standardisation, multinomial logistic-regression weights for +// wake / light / deep / rem in that order, class prior and the counted stage-transition matrix). +// Trained on open polysomnography only: +// Wearanize+ OA, Radboud University, CC BY 4.0, DOI 10.34973/xrmf-5726 (88 nights, Empatica E4) +// PhysioNet sleep-accel v1.0.0, Walch et al. SLEEP 2019, ODC-By 1.0 (31 nights, Apple Watch) + +extension SleepStagerV3 { + /// Motion, heart rate and beat-interval features. Trained on Wearanize+. + static let hrvHead = Head( + features: [ + "act_mean", "act_max", "move_frac", "since_move", "until_move", "act_ctx2", "act_ctx5", + "act_ctx10", "act_ctx20", "dangn", "dangn_ctx2", "dangn_ctx5", "dangn_ctx10", "dangn_ctx20", + "hr_z", "hr_pct", "hrsd5_pct", "hrsd5_z", "hrsd11_pct", "hrsd11_z", "hrsd20_pct", "hrsd20_z", + "hr_ctx5_z", "hr_ctx10_z", "hr_ctx30_z", "hr_trend", "hr_over_low", "clock", "minutes", "time_e10", + "time_e30", "time_e90", "hrv_meanNN_r", "hrv_meanNN_ctx", "hrv_sdnn_r", "hrv_sdnn_ctx", + "hrv_lnrmssd_r", "hrv_lnrmssd_ctx", "hrv_pnn50_r", "hrv_pnn50_ctx", "hrv_lnlf_r", "hrv_lnlf_ctx", + "hrv_lnhf_r", "hrv_lnhf_ctx", "hrv_lnlfhf_r", "hrv_lnlfhf_ctx", "hrv_resp_bpm_r", + "hrv_resp_bpm_ctx", "hrv_hf_peaked_r", "hrv_hf_peaked_ctx", "hrv_dfa_a1_r", "hrv_dfa_a1_ctx", + "hrv_beats_per_min_r", "hrv_beats_per_min_ctx", + ], + mean: [ + 0.8621899019075925, 1.7940008896110553, 0.023028710605344106, 2.4875854143903506, + 2.4970027954399234, 0.8623426684167985, 0.8623357606661844, 0.8617684316207651, 0.8605320140983569, + 1.1431085898829128, 1.143430256134653, 1.1431678748097485, 1.1417477204671411, 1.1389502321190523, + -0.000347304467688868, 0.5005460142539249, 0.5004812273487077, -0.0008327895658732179, + 0.5004194473306997, -0.0011738247989453515, 0.5003636217180508, -0.0014432895769285275, + -3.6005541942449045e-05, 1.2811148124638137e-05, 0.00036325992379792384, -6.71845169483638e-05, + 1.0243404318701046, 0.4991402515413316, 238.17121936878723, 0.02210244990001618, 0.06521607940194, + 0.1927257900068314, 0.5239512725203431, 0.5222356213237895, 0.39891934268661916, + 0.4063965978536058, 0.5148779093435533, 0.5205220857718369, 0.5062562062938546, 0.5109504487931944, + 0.5440563705837577, 0.5336659550457484, 0.5713234377437079, 0.5558290999509768, 0.5021381071332088, + 0.5083307103817134, 0.5724611384974555, 0.5657839677573447, 0.42451296780057873, + 0.42368676638458014, 0.47200580580514967, 0.4759266472053853, 0.5833525776165872, + 0.5716003198314983, + ], + scale: [ + 0.5998849663781155, 1.375189322919068, 0.09459431514030396, 1.301234862501773, 1.305413696434928, + 0.46899460058263076, 0.4122429464048579, 0.36488898047917584, 0.3145598461736013, 1.37452380438298, + 0.9749455052880361, 0.8214756056561836, 0.7148842326843968, 0.6138869056002495, 0.9982884736976412, + 0.28859029143130616, 0.28863242481725865, 0.9977025299473733, 0.2885905782120641, + 0.9975319138859539, 0.2885063412788578, 0.9975055863043274, 0.9997688015487569, 1.000278107853302, + 1.001051661555508, 0.9975301125311101, 1.044002563809615, 0.2889128647473429, 143.23455728802088, + 0.10411018076491736, 0.16919606032252849, 0.2457304646025061, 0.3221436251635782, + 0.3076591761487735, 0.32435842694155637, 0.2726698591247466, 0.3129731294695001, + 0.2835654084984225, 0.32288912628547867, 0.29404434938695195, 0.30882238660380135, + 0.26053603627376, 0.3205056435004371, 0.28643773170371917, 0.3020877194919723, 0.24658371555270814, + 0.3409945096013103, 0.25165513928398753, 0.3081537967704737, 0.24236385039013353, + 0.3001749121190624, 0.2526169994617051, 0.33324049530436695, 0.3105828988692951, + ], + coef: [ + [ + -0.19194933753091983, -0.2538319621122693, 0.2682352786991653, -0.7460908508036491, + 0.09280256235265515, 0.35397244326513977, 0.07427438683202284, -0.006679805219254605, + -0.2457346991117761, -0.02076482875651472, -0.0847584009842222, 0.028039312441119867, + 0.08448774387122006, 1.0843209883076395, -0.5910650142147676, 0.23493639128414556, + 0.07899822325365802, -0.07532875846900752, -0.04280650571555985, 0.10958976563690802, + 0.19333095320181692, -0.13726975671222427, 0.16621842346703872, -0.2964439263750079, + 0.2106678636831739, -0.11426215264672417, 0.6747762632244746, 0.16493909318209693, + 0.10076727505262034, 0.9598446259897985, 0.4472649917424865, -0.10082562876020139, + -0.24564128665196966, 0.2262892250441702, -0.039341880629386004, 0.3842157815554515, + -0.03389975464354366, -0.4145431238523732, -0.1148796849002832, 0.29277069737257516, + 0.180793297933746, -0.2135146584420023, -0.09610667596739014, 0.14679088328275539, + -0.16793187755079478, 0.11557790058506666, 0.02097687833071339, -0.0017450481587130589, + 0.040902492490179326, 0.038890206328755936, -0.10496492132184718, -0.40712711807974417, + -0.12773841904205258, 0.039886438448374605, + ], + [ + -0.08522616801785182, 0.11527009432916568, 0.05453325658868221, -0.20424056809655688, + 0.3137262502108433, -0.008771623742746662, -0.006013530882134352, -0.06321106228376532, + 0.08306465340505481, -0.1597408278765444, -0.015188844539106698, 0.06167013566385734, + 0.14447513803824916, -0.2298956061233925, -0.0025657102391554746, -0.2251731229443702, + -0.1847141023628344, 0.12475908080022294, 0.012998345170815126, 0.04111147877500057, + 0.03532288562847845, -0.06371926153985222, -0.025093660850533182, -0.045683001239543185, + 0.12197265147778694, -0.05833974284827472, 0.030907438451628962, -0.11271251401048395, + 0.1080002435044317, 0.761392360776429, 0.10515905690245811, 0.059815142757282495, + 0.17120072240704776, -0.017219752840087126, 0.015815746002579355, 0.004500527422738139, + 0.10971618029312584, 0.04317082715741158, -0.03406115612917416, -0.08280118658363458, + -0.0927592408190744, 0.19139068276483287, 0.10346924687292534, -0.1635376038398726, + 0.10847880843755127, 0.023658752205246353, -0.03808232814616092, 0.11521513936670165, + 0.034546329724479366, 0.40001472420686063, 0.027233212264443397, -0.018128883939010392, + 0.03068642855995458, -0.0068005945422311646, + ], + [ + -0.33764950648586134, 0.24027539766749403, 0.33076987274427616, 1.0647513993941016, + -0.37382243504558244, -0.05725551637448628, 0.0107327131913468, -0.09421356888657248, + 0.3898940855375117, 0.18181495357491406, -9.438340300519529e-05, 0.07832623279079148, + 0.23569337890355802, -1.0845892883993944, 0.32803745856858296, 2.4386138091284603e-05, + -0.6094787377999513, 0.2345960298287322, -0.08090326117481768, 0.13210347772427458, + -0.11222436217683152, 0.09945163012108477, -0.1151278438174485, 0.030150417012749623, + -0.12333892255868828, 0.14811645624751552, -0.3728604211204779, -0.1929637264177109, + -0.24031415163597644, -1.4089174055202136, 0.47671774575913567, 0.6207666375853622, + 0.07702647746322146, -0.14208430858533344, 0.2759762413502978, -0.39584397242861097, + -0.09828205571532471, 0.20684943557675894, 0.03600338945896466, 0.04249187655189836, + -0.17376355225593107, 0.08340200865158309, 0.2311866710591947, -0.34103312384535495, + 0.2152581650072922, -0.22214981922068233, 0.035673505370386556, 0.29705782111317885, + -0.005825961868012531, 0.5451797552606432, -0.04919242303755108, -0.13123030900034946, + 0.004182139597132367, -0.12865585869092863, + ], + [ + 0.6148250120346089, -0.1017135298843646, -0.653538408031848, -0.11441998049366238, + -0.03270637751778482, -0.28794530314788286, -0.07899356914125893, 0.1641044363896316, + -0.2272240398308487, -0.0013092969418299486, 0.1000416289263842, -0.1680356808958158, + -0.46465626081308176, 0.23016390621508126, 0.2655932658852709, -0.009787654477824054, + 0.71519461690904, -0.2840263521599503, 0.11071142171943554, -0.2828047221361908, + -0.11642947665352052, 0.10153738813096831, -0.025996918799063014, 0.31197651060177883, + -0.20930159260227868, 0.024485439247421095, -0.33282328055562965, 0.1407371472461376, + 0.031546633078944855, -0.31231958124603537, -1.029141794404065, -0.579756151582346, + -0.00258591321830858, -0.06698516361878484, -0.2524501067235507, 0.0071276634504982015, + 0.022465630065718056, 0.16452286111825917, 0.11293745157048928, -0.2524613873408788, + 0.08572949514129141, -0.061278032974403894, -0.23854924196474686, 0.35777984440249505, + -0.1558050958940631, 0.08291316643038124, -0.018568055554960668, -0.41052791232111396, + -0.0696228603466541, -0.9840846857962454, 0.1269241320949678, 0.5564863110190922, + 0.09286985088486477, 0.09557001478481487, + ] + ], + intercept: [ + -1.239083118765814, 2.220620383102066, -0.2708657421472619, -0.7106715221875605, + ], + prior: [ + 0.08353493841602715, 0.5350792843887232, 0.19047852369957066, 0.19090725349567902, + ], + transition: [ + [ + 0.8743722304283604, 0.12282127031019202, 0.00022156573116691286, 0.00258493353028065, + ], + [ + 0.014569946049963919, 0.9451565238307962, 0.030434234791473374, 0.009839295327766515, + ], + [ + 0.0036329732510288065, 0.0815650720164609, 0.9144483024691358, 0.0003536522633744856, + ], + [ + 0.008056750337035373, 0.02038261539449188, 3.209860692045965e-05, 0.9715285356615523, + ] + ]) + + /// Motion and heart rate only. Trained on Wearanize+ and sleep-accel. + static let baseHead = Head( + features: [ + "act_mean", "act_max", "move_frac", "since_move", "until_move", "act_ctx2", "act_ctx5", + "act_ctx10", "act_ctx20", "dangn", "dangn_ctx2", "dangn_ctx5", "dangn_ctx10", "dangn_ctx20", + "hr_z", "hr_pct", "hrsd5_pct", "hrsd5_z", "hrsd11_pct", "hrsd11_z", "hrsd20_pct", "hrsd20_z", + "hr_ctx5_z", "hr_ctx10_z", "hr_ctx30_z", "hr_trend", "hr_over_low", "clock", "minutes", "time_e10", + "time_e30", "time_e90", + ], + mean: [ + 0.8507739331129439, 1.7526429636546057, 0.02127968191002201, 2.5168149465998377, 2.529165659204926, + 0.8508307379748203, 0.8507992910708213, 0.850292938962549, 0.8492239351108759, 1.1304368952786887, + 1.1305682237078594, 1.1303070590235889, 1.1290253374656505, 1.1265205048971307, + -0.0013833328372034016, 0.5005467637198011, 0.5005206673789985, -0.002727975163231043, + 0.5004779873791418, -0.0019435954093314342, 0.5004367400246075, -0.001991166839353445, + -3.458079200503346e-05, 4.000438074248178e-05, 0.00031477807685821975, 2.4816762880168007e-05, + 1.0771553571162003, 0.49934568859763084, 233.98867870317164, 0.022614688126277305, + 0.06672385848019598, 0.19673783844702514, + ], + scale: [ + 0.5875699638931989, 1.371339161164791, 0.09052413050858245, 1.30227319444367, 1.3088515016725255, + 0.4629182084483637, 0.4105499076757204, 0.3669828929301195, 0.32098817112966105, + 1.3895343432348173, 1.0074745623665138, 0.8667645326091218, 0.7712941061731748, 0.6823070063674663, + 0.9952053474073118, 0.2875554912309123, 0.28754430653195, 0.9947386581551414, 0.2875400994014306, + 0.9944870432405541, 0.28750024890555265, 0.9945998365166571, 0.999593978038787, 1.00017586524401, + 1.000811861823294, 0.9974209098690884, 1.04361175192138, 0.28893586603796134, 141.27417622714498, + 0.10525481441561067, 0.17085110666255132, 0.2470947032255387, + ], + coef: [ + [ + -0.4655058158742739, -0.025953840791762522, 0.5058459213742267, -0.7870193447848539, + 0.02233631740315676, 0.36785456300977837, 0.11115832601573251, -0.14033023099738068, + 0.28428987824940793, -0.2424372361957676, -0.11988079919781246, -0.0199768156460954, + 0.148663486363893, 0.43222675210009315, -0.4485223679402739, 0.19112189200254368, + 0.12502984118370775, -0.06649425378885133, 0.058390314265457946, 0.07503665313671906, + 0.13605318198472774, 0.004136998633584295, 0.09806983027188997, -0.2805758883550827, + 0.10478456521961539, -0.1066563991117735, 0.5705194379464997, 0.36339921658759095, + 0.010944864722561275, 0.7419475927684396, 0.3203673151736074, 0.09657619546174721, + ], + [ + 0.005805281262730468, -0.01764828523873568, -0.014390012244294374, -0.21500086118489325, + 0.3015476208893319, -0.015136245971535256, -0.039598285681476296, -0.001326214662427082, + 0.02616364858583974, -0.050725651618585894, -0.0027691852664592795, 0.09247286009252147, + 0.11540308580520163, -0.17623744394481433, 0.1746219176799554, -0.2938031674358222, + -0.23477169108462734, 0.20171614961506443, -0.014970137297261219, 0.0846680669680427, + 0.030244722545850908, -0.053174779693607364, -0.15879054161027942, -0.13860246164080742, + 0.09718409128923944, -0.06473592748880702, -0.05925432791458657, -0.13813734894238228, + 0.1081213373205922, 0.44549440101158316, 0.07189546486822773, -0.016013290107308994, + ], + [ + -0.30174900822464396, 0.16535295986134882, 0.3249965222867706, 1.1064229765511582, + -0.32070563432296745, -0.04006840953985889, -0.023364158530449108, 0.17664129322074187, + 0.040101810508289655, 0.25238261857904065, 0.01235307470898726, 0.30009741971482323, + -0.1259666207363376, -0.7570336317710297, 0.4180316183529883, -0.11971848233106028, + -0.8692459902538574, 0.4125980603191878, -0.24358505452498888, 0.10233387025199857, + -0.18619173134261435, 0.026866876725257043, -0.08849397577016198, 0.1436433300245399, + -0.10151871042459291, 0.1518288598561486, -0.422061429731879, -0.3195937699728488, + -0.35619744275639575, -2.0683624573301373, 0.8265468568289233, 0.19252910483329527, + ], + [ + 0.7614495428362392, -0.12175083383093155, -0.8164524314171377, -0.10440277058161133, + -0.0031783039695314403, -0.31264990749833405, -0.04819588180367801, -0.034984847560963865, + -0.35055533734353345, 0.040780269235249716, 0.11029690975503774, -0.3725934641610377, + -0.13809995143302967, 0.5010443236153542, -0.14413116809277096, 0.22239975776410625, + 0.9789878401550438, -0.5478199561455825, 0.20016487755695575, -0.2620385903569299, + 0.019893826811886915, 0.02217090433510945, 0.1492146871088276, 0.2755350199718655, + -0.10044994608443189, 0.019563466744491775, -0.08920368029995067, 0.094331902327559, + 0.2371312407131284, 0.8809204635499497, -1.2188096368708081, -0.27309201018767826, + ] + ], + intercept: [ + -1.321559346534829, 1.8794887572867152, -0.5937238916579409, 0.03579448090603902, + ], + prior: [ + 0.0811768958422155, 0.5415778449218678, 0.17826292472241273, 0.198982334513504, + ], + transition: [ + [ + 0.8474712643678161, 0.14626436781609195, 0.00022988505747126436, 0.00603448275862069, + ], + [ + 0.016595733748940286, 0.9464106801853105, 0.025784186954622913, 0.011209399111126336, + ], + [ + 0.005250708325751865, 0.07286007642120039, 0.9216032855917444, 0.0002859296613033194, + ], + [ + 0.009442320246199757, 0.022731511703814232, 2.3314370978271005e-05, 0.9678028536790078, + ] + ]) +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerVersion.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerVersion.swift new file mode 100644 index 0000000000..fee8b38067 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerVersion.swift @@ -0,0 +1,38 @@ +import Foundation +import WhoopProtocol + +/// Which recipe stages an accepted night. Session DETECTION is the same for all three; only the per-epoch +/// hypnogram inside a detected night differs. The raw value is what the app persists for the user's choice. +public enum SleepStagerVersion: String, CaseIterable, Sendable { + /// The original percentile-band stager, `SleepStager.stageSession`. + case v1 + /// The hand-set cardiorespiratory recipe, `SleepStagerV2`. + case v2 + /// The stager fitted to polysomnography, `SleepStagerV3`. The app's default. + case v3 + + /// "V1" / "V2" / "V3", for traces and diagnostics. + public var label: String { rawValue.uppercased() } + + /// The recipe that stages a night with no motion at all, through `SleepStager.hrOnlySessions`. V2 and V3 + /// stage it from heart rate and beat intervals. V1 cannot: its epoch grid is built from gravity, and + /// without any it returns one flat "light" block, so a V1 choice stages such a night with V2. + public var hrOnlyRecipe: SleepStagerVersion { self == .v1 ? .v2 : self } + + /// Stage `[start, end]` with this recipe. `sleepWindow` is the band's `SleepStager.bandSleepWindow`; V2 and + /// V3 stage inside it, and V1, which has no such input, ignores it (the band latency trim applies it + /// afterwards for every recipe). + public func stageSession(start: Int, end: Int, grav: [GravitySample], hr: [HRSample], rr: [RRInterval], + resp: [RespSample], sleepWindow: (from: Int, to: Int)? = nil) -> [StageSegment] { + switch self { + case .v1: + return SleepStager.stageSession(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp) + case .v2: + return SleepStagerV2.stageSession(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp, + sleepWindow: sleepWindow) + case .v3: + return SleepStagerV3.stageSession(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp, + sleepWindow: sleepWindow) + } + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StagerInput.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StagerInput.swift new file mode 100644 index 0000000000..fb493e0380 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StagerInput.swift @@ -0,0 +1,72 @@ +import Foundation +import WhoopProtocol + +/// The stream plumbing `SleepStagerV2` and `SleepStagerV3` share in front of their recipes: stable timestamp +/// order, a half-open clip to the rows a recipe reads, the memo key over those rows, and the tiling of a +/// per-epoch labelling into segments. One copy, so a fix to the clip bounds or the key reaches both stagers. +enum StagerInput { + + /// `xs` in stable timestamp order: same-second rows keep their input order, which decides how same-second + /// beats pair up downstream. Returned as is, with no copy, when already in order (the common case). + static func sortedByTs(_ xs: [T], _ ts: (T) -> Int) -> [T] { + var sorted = true + for i in xs.indices.dropFirst() where ts(xs[i - 1]) > ts(xs[i]) { sorted = false; break } + if sorted { return xs } + return xs.enumerated().sorted { a, b in + let ta = ts(a.element), tb = ts(b.element) + return ta == tb ? a.offset < b.offset : ta < tb + }.map { $0.element } + } + + /// The rows of a ts-sorted stream inside `[lo, hi)`: a lower/upper-bound pair, then one copy of the kept + /// rows, or none when nothing falls outside. The callers pass multi-day streams to every per-night call. + static func clip(_ xs: [T], lo: Int, hi: Int, ts: (T) -> Int) -> [T] { + if xs.isEmpty { return xs } + if ts(xs[0]) >= lo && ts(xs[xs.count - 1]) < hi { return xs } + var a = 0, b = xs.count + while a < b { let m = (a + b) / 2; if ts(xs[m]) < lo { a = m + 1 } else { b = m } } + let start = a + b = xs.count + while a < b { let m = (a + b) / 2; if ts(xs[m]) < hi { a = m + 1 } else { b = m } } + return Array(xs[start.. [StageSegment] { + var segments: [StageSegment] = [] + for (i, e) in epochStarts.enumerated() { + let segStart = i == 0 ? start : e + let segEnd = i == epochStarts.count - 1 ? end : epochStarts[i + 1] + if let last = segments.last, last.stage == labels[i] { + segments[segments.count - 1].end = segEnd + } else { + segments.append(StageSegment(start: segStart, end: segEnd, stage: labels[i])) + } + } + return segments + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift index c630e3a5ae..0911736efb 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift @@ -67,4 +67,19 @@ final class AnalyticsEngineHrOnlyDayTests: XCTestCase { // that remains TRUE of this day — the change made the vitals available, not the staging better. XCTAssertEqual(res.daily.sleepHrOnly, true, "the day must still be marked HR-only") } + + /// The Rest trace's `stager=` names the recipe that staged the scored night. For an HR-only night that is + /// `hrOnlyRecipe`: the choice itself, except V1, which cannot stage without motion and hands it to V2. + func testTheTraceNamesTheRecipeThatStagedAnHrOnlyNight() { + let (hr, rr) = streams() + for (stager, named) in [(SleepStagerVersion.v3, "V3"), (.v2, "V2"), (.v1, "V2")] { + let provided = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: [], stager: stager) + var trace: [String] = [] + _ = AnalyticsEngine.analyzeDay(day: day, hr: hr, rr: rr, profile: profile, stager: stager, + providedSleep: provided, traceSink: { trace.append($0) }) + let line = trace.first { $0.hasPrefix("sleep-motion") } + XCTAssertNotNil(line, stager.label) + XCTAssertTrue(line?.contains(" stager=\(named) ") ?? false, "\(stager.label): \(line ?? "no line")") + } + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineProvidedSleepTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineProvidedSleepTests.swift index 99f5da237f..f55557010c 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineProvidedSleepTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineProvidedSleepTests.swift @@ -114,7 +114,7 @@ final class AnalyticsEngineProvidedSleepTests: XCTestCase { sleepConsistency: nil, habitualMidsleepSec: nil, bandSleepState: [], - useSleepStagerV2: false, + stager: .v1, useMotionAwareWake: false, providedSleep: provided, sleepProvenance: .measured, diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift index 335577cb8a..70db522d1a 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift @@ -62,13 +62,13 @@ final class Live5NoGravityRestChainTests: XCTestCase { /// default stager. The pair here is not that assertion again: it is the statement that the answer is /// the SAME on both stagers, which is the fact the chain turns on and the one whose absence let this /// file spend two years implying V2 might rescue the night. The original spelling relied on - /// `useSleepStagerV2`'s default, so it silently stopped describing shipped behaviour when that + /// the stager parameter's default, so it silently stopped describing shipped behaviour when that /// default moved — which is how the implication survived. func testV1WithNoGravityDetectsNoSleep() { let start = nightStart() let dur = 8 * 60 * 60 let hr = hrStream(start: start, durationS: dur, bpm: 50) - let sessions = SleepStager.detectSleep(hr: hr, gravity: [], useSleepStagerV2: false) + let sessions = SleepStager.detectSleep(hr: hr, gravity: [], stager: .v1) XCTAssertTrue(sessions.isEmpty, "V1's spine is motion stillness: no gravity, no session") } @@ -90,9 +90,11 @@ final class Live5NoGravityRestChainTests: XCTestCase { func testV2WithNoGravityAlsoDetectsNoSleep() { let start = nightStart() let hr = hrStream(start: start, durationS: 8 * 60 * 60, bpm: 50) - let sessions = SleepStager.detectSleep(hr: hr, gravity: [], useSleepStagerV2: true) - XCTAssertTrue(sessions.isEmpty, - "detectSleep needs motion on either stager; the HR-only rescue is the engine's fallback") + for stager in [SleepStagerVersion.v2, .v3] { + let sessions = SleepStager.detectSleep(hr: hr, gravity: [], stager: stager) + XCTAssertTrue(sessions.isEmpty, + "detectSleep needs motion on every stager; the HR-only rescue is the engine's fallback") + } } // MARK: - Stage 2: no sleep ⇒ no Rest composite ⇒ no sleep_performance point diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RestSubScoreTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RestSubScoreTraceTests.swift index 97ffd926b5..3292d6c4dd 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RestSubScoreTraceTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RestSubScoreTraceTests.swift @@ -56,12 +56,16 @@ final class RestSubScoreTraceTests: XCTestCase { func testSleepMotionLine() { XCTAssertEqual( AnalyticsEngine.sleepMotionLine(day: "2026-07-12", grav: 118, hr: 590, sparse: true, - useSleepStagerV2: false, family: .whoop5), + stager: .v1, family: .whoop5), "sleep-motion day=2026-07-12 grav=118 hr=590 sparse=true stager=V1 family=whoop5") XCTAssertEqual( AnalyticsEngine.sleepMotionLine(day: "2026-07-12", grav: 800, hr: 590, sparse: false, - useSleepStagerV2: true, family: .whoop5), + stager: .v2, family: .whoop5), "sleep-motion day=2026-07-12 grav=800 hr=590 sparse=false stager=V2 family=whoop5") + XCTAssertEqual( + AnalyticsEngine.sleepMotionLine(day: "2026-07-12", grav: 800, hr: 590, sparse: false, + stager: .v3, family: .whoop5), + "sleep-motion day=2026-07-12 grav=800 hr=590 sparse=false stager=V3 family=whoop5") } // MARK: - #271 onset trace (over-early WHOOP 4.0 bedtime) diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift index df2182062e..e6dcc0aea4 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift @@ -78,4 +78,34 @@ final class SleepStagerHrOnlySessionsTests: XCTestCase { XCTAssertNotNil(s.restingHR, "restingHR needs only HR") XCTAssertNil(s.avgHRV, "no R-R means no RMSSD to report") } + + /// The staging choice reaches a night with no motion, as it reaches every other night. V2 and V3 stage it + /// themselves; V1 cannot stage without gravity (one flat "light" block), so it maps to V2. The spine, + /// and so the bounds, are the same whichever recipe stages them. + func testTheStagingChoiceStagesTheNight() throws { + let (hr, rr) = window() + let bounds = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: [], stager: .v2).map { [$0.start, $0.end] } + for (stager, recipe) in [(SleepStagerVersion.v1, SleepStagerVersion.v2), (.v2, .v2), (.v3, .v3)] { + let sessions = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: [], stager: stager) + XCTAssertEqual(sessions.map { [$0.start, $0.end] }, bounds, stager.label) + let s = try XCTUnwrap(sessions.first) + XCTAssertEqual(s.stages, recipe.stageSession(start: s.start, end: s.end, grav: [], hr: hr, rr: rr, resp: []), + "\(stager.label) stages with \(recipe.label)") + } + XCTAssertEqual(SleepStagerVersion.v1.hrOnlyRecipe, .v2) + XCTAssertEqual(SleepStagerVersion.v3.hrOnlyRecipe, .v3) + } + + /// V3 reads a night with no gravity as missing motion, not as the stillest night it ever saw. Read as + /// still, every epoch's activity is 0 and the time since movement sits at its cap, which on PSG nights + /// stripped of motion over-called deep by 19-30 percentage points. + func testV3ReadsNoGravityAsMissingMotion() { + let (hr, rr) = window(aH: 0, nH: 2) + let f = SleepStagerV3.features(grav: [], hr: hr, rr: rr, spanStart: 1_788_300_000, n: 240) + for name in ["act_mean", "act_max", "move_frac", "since_move", "until_move", "act_ctx2", "act_ctx20", + "dangn", "dangn_ctx5"] { + XCTAssertTrue(f.columns[name]?.allSatisfy { $0.isNaN } ?? false, "\(name) must be missing") + } + XCTAssertFalse(f.columns["hr_z"]?.allSatisfy { $0.isNaN } ?? true, "heart rate is still measured") + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerStageWindowTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerStageWindowTests.swift new file mode 100644 index 0000000000..92ad612b93 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerStageWindowTests.swift @@ -0,0 +1,79 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// `SleepStager.stageWindow` is the one staging funnel. `detectSleep` stages every accepted night through +/// it, and the app re-stages an edited night, a manually added nap and the post-sync self-heal window +/// through it as well. The re-stage used to call the recipe alone, without the band's sleep window or the +/// latency trim, so an edit that kept a night's bounds, or moved "Got up" by a few minutes, staged the +/// lying-awake lead-in as sleep again and moved the Asleep time back to the in-bed start. +final class SleepStagerStageWindowTests: XCTestCase { + + /// A 3 h still overnight window at 01:00 UTC with sleep-band HR and a regular R-R stream, which + /// `detectSleep` accepts whole (the night `SleepStagerV2Tests.testDetectSleepThreadsV2FlagIntoNormalNight` + /// uses). + private let start = 1_749_517_200 + private let dur = 3 * 60 * 60 + private var grav: [GravitySample] { (0.. SleepSession? { + let sessions = SleepStager.detectSleep(hr: hr, rr: rr, gravity: grav, bandSleepState: band, stager: stager) + XCTAssertEqual(sessions.count, 1, "\(stager.label): the still night must be detected") + return sessions.first + } + + private func firstSleep(_ stages: [StageSegment]) -> Int? { + stages.first { !SleepStageVocabulary.isWake($0.stage) }?.start + } + + func testARestageOverTheDetectedBoundsReproducesTheDetectedNight() { + for stager in SleepStagerVersion.allCases { + guard let night = detected(stager) else { continue } + let restaged = SleepStager.stageWindow(start: night.start, end: night.end, grav: grav, hr: hr, rr: rr, + resp: [], bandSleepState: band, stager: stager) + XCTAssertEqual(restaged.stages, night.stages, "\(stager.label): a re-stage must match the detected night") + XCTAssertGreaterThanOrEqual(firstSleep(night.stages) ?? .max, bandOnset, + "\(stager.label): nothing before the band's window is sleep") + } + } + + /// What the re-stage used to do: the recipe alone sleeps through the band's lying-awake hour. + func testTheRecipeAloneStagesTheLeadInAsSleep() { + for stager in SleepStagerVersion.allCases { + guard let night = detected(stager) else { continue } + let alone = stager.stageSession(start: night.start, end: night.end, grav: grav, hr: hr, rr: rr, resp: []) + XCTAssertLessThan(firstSleep(alone) ?? .max, bandOnset, + "\(stager.label): this fixture must exercise the lead-in the funnel removes") + } + } + + func testMovingGotUpKeepsTheBandsOnset() { + for stager in SleepStagerVersion.allCases { + guard let night = detected(stager) else { continue } + let edited = SleepStager.stageWindow(start: night.start, end: night.end - 300, grav: grav, hr: hr, + rr: rr, resp: [], bandSleepState: band, stager: stager).stages + XCTAssertEqual(firstSleep(edited), firstSleep(night.stages), + "\(stager.label): an earlier Got up must not move Asleep") + } + } + + /// Without a band (WHOOP 4.0, or a window the strap never banked) the funnel is the recipe alone. + func testWithoutABandTheFunnelIsTheRecipe() { + for stager in SleepStagerVersion.allCases { + let staged = SleepStager.stageWindow(start: start, end: start + dur, grav: grav, hr: hr, rr: rr, resp: [], + bandSleepState: [], stager: stager) + let alone = stager.stageSession(start: start, end: start + dur, grav: grav, hr: hr, rr: rr, resp: []) + XCTAssertEqual(staged.stages, alone, stager.label) + XCTAssertEqual(staged.trimmed, alone, stager.label) + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2SleepWindowTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2SleepWindowTests.swift index ea2579513d..c9142339dc 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2SleepWindowTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerV2SleepWindowTests.swift @@ -59,11 +59,38 @@ final class SleepStagerV2SleepWindowTests: XCTestCase { func testEachEpochIsJudgedByTheSessionGridInstantItContains() { // Session grid starts at 1000 (10 s past a wall-clock boundary); window = grid epochs 5 ..< 20. - let w = (from: 1_150, to: 1_600) - XCTAssertFalse(SleepStagerV2.epochInSleepWindow(1_110, w), "[1110, 1140) holds 1120, before the window") - XCTAssertTrue(SleepStagerV2.epochInSleepWindow(1_140, w), "[1140, 1170) holds 1150, the window's first") - XCTAssertTrue(SleepStagerV2.epochInSleepWindow(1_560, w), "[1560, 1590) holds 1570, the window's last") - XCTAssertFalse(SleepStagerV2.epochInSleepWindow(1_590, w), "[1590, 1620) holds 1600, past the end") + let w = (from: 1_150, to: 1_600), end = 2_200 + XCTAssertFalse(SleepStagerV2.epochInSleepWindow(1_110, w, end: end), "[1110, 1140) holds 1120, before the window") + XCTAssertTrue(SleepStagerV2.epochInSleepWindow(1_140, w, end: end), "[1140, 1170) holds 1150, the window's first") + XCTAssertTrue(SleepStagerV2.epochInSleepWindow(1_560, w, end: end), "[1560, 1590) holds 1570, the window's last") + XCTAssertFalse(SleepStagerV2.epochInSleepWindow(1_590, w, end: end), "[1590, 1620) holds 1600, past the end") + } + + /// A session that starts off the wall-clock grid ends on a recipe epoch cut short at `end`. When that + /// epoch holds no session-grid instant before `end`, it lies inside the session's last grid epoch and is + /// judged by it. Judged by the instant past `end`, a band that slept through the end still closed the + /// night on a few seconds of wake. + func testThePartialLastEpochIsJudgedByTheSessionsLastGridEpoch() { + // Session grid 15 s past the wall clock; 120 grid epochs, the last one [end - 20, end). + let start = 1_699_999_995, end = start + 3_590 + let lastRecipeEpoch = end - 5 // [end - 5, end): the next grid instant is end + 10 + let band = (0..<(end - start)).map { (ts: start + $0, state: 2) } + guard let w = SleepStager.bandSleepWindow(start: start, end: end, bandSleepState: band, enabled: true) else { + return XCTFail("the band sleeps throughout") + } + XCTAssertEqual(w.to, end) + XCTAssertTrue(SleepStagerV2.epochInSleepWindow(lastRecipeEpoch, w, end: end)) + XCTAssertFalse(SleepStagerV2.epochInSleepWindow(lastRecipeEpoch, (from: start, to: end - 20), end: end), + "a window that closes before the last grid epoch still excludes it") + + let grav = (start.. [String: Any] { + let relative = "Packages/StrandAnalytics/Tests/StrandAnalyticsTests/oracles/sleep_stager_v3.json" + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<8 { + let candidate = directory.appendingPathComponent(relative) + if FileManager.default.fileExists(atPath: candidate.path) { + let object = try JSONSerialization.jsonObject(with: Data(contentsOf: candidate)) + let root = try XCTUnwrap(object as? [String: Any]) + XCTAssertEqual(root["schemaVersion"] as? Int, 1) + return root + } + directory = directory.deletingLastPathComponent() + } + XCTFail("committed oracle \(relative) not found above \(#filePath)") + throw CocoaError(.fileNoSuchFile) + } + + /// SplitMix64, as `make_oracle.py` implements it. + private struct SplitMix64 { + var s: UInt64 + mutating func next() -> UInt64 { + s = s &+ 0x9E37_79B9_7F4A_7C15 + var z = s + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + mutating func below(_ n: Int) -> Int { Int(next() % UInt64(n)) } + } + + /// `make_oracle.synth`, call for call: the order of every draw matters. + private func synth(seed: Int, start: Int, epochs: Int, rr mode: String, hrGap: [Int]?) + -> ([GravitySample], [HRSample], [RRInterval]) { + var rng = SplitMix64(s: UInt64(seed)) + var stages: [Int] = [] + while stages.count < epochs { + if stages.count < 20 { stages.append(0); continue } + stages += [Int](repeating: 1, count: 10 + rng.below(20)) + stages += [Int](repeating: 2, count: 5 + rng.below(25)) + stages += [Int](repeating: 1, count: 5 + rng.below(15)) + stages += [Int](repeating: 3, count: 5 + rng.below(25)) + if rng.below(3) == 0 { stages += [Int](repeating: 0, count: 1 + rng.below(6)) } + } + stages = Array(stages.prefix(epochs)) + let baseHR = [72, 60, 54, 63], rsa = [30, 20, 35, 8] + var posture = [700, 300, 600] + var grav: [GravitySample] = [], hr: [HRSample] = [] + for s in 0..<(30 * epochs) { + let st = stages[s / 30] + if st == 0 && rng.below(40) == 0 { + posture = [rng.below(2048) - 1024, rng.below(2048) - 1024, rng.below(2048) - 1024] + } + let amp = st == 0 && rng.below(4) == 0 ? 300 : (st == 3 ? 6 : 3) + if rng.below(97) != 0 { + let v = posture.map { Double($0 + rng.below(2 * amp + 1) - amp) / 1024.0 } + grav.append(GravitySample(ts: start + s, x: v[0], y: v[1], z: v[2])) + } + let bpm = baseHR[st] + rng.below(7) - 3 + (s / 600) % 4 + let inGap = hrGap.map { $0[0] <= s && s < $0[1] } ?? false + if !inGap && rng.below(53) != 0 { hr.append(HRSample(ts: start + s, bpm: bpm)) } + } + var rr: [RRInterval] = [] + if mode != "none" { + var t = start * 1000 + 400 + let endMs = (start + 30 * epochs) * 1000 + while t < endMs { + let s = t / 1000 - start + let st = stages[min(s / 30, epochs - 1)] + let tri = abs((t / 250) % 16 - 8) + var v = 60000 / baseHR[st] + rsa[st] * tri / 8 - rsa[st] / 2 + rng.below(21) - 10 + if rng.below(211) == 0 { v *= 2 } else if rng.below(307) == 0 { v = 250 } + t += v + if mode == "sparse" && (s / 60) % 3 != 0 { continue } + rr.append(RRInterval(ts: t / 1000, rrMs: v)) + } + } + return (grav, hr, rr) + } + + private func close(_ a: Double, _ b: Double?, _ what: String) { + guard let b = b else { XCTAssertTrue(a.isNaN, "\(what): expected missing, got \(a)"); return } + XCTAssertFalse(a.isNaN, "\(what): expected \(b), got NaN") + XCTAssertLessThanOrEqual(abs(a - b), 1e-9 * max(1, abs(b)), "\(what): \(a) vs \(b)") + } + + func testPortMatchesTheReference() throws { + let cases = try XCTUnwrap(try loadOracle()["cases"] as? [[String: Any]]) + XCTAssertEqual(cases.count, 4) + for c in cases { + let name = try XCTUnwrap(c["name"] as? String) + let start = try XCTUnwrap(c["start"] as? Int), epochs = try XCTUnwrap(c["epochs"] as? Int) + let (synthGrav, hr, rr) = synth(seed: try XCTUnwrap(c["seed"] as? Int), start: start, epochs: epochs, + rr: try XCTUnwrap(c["rr"] as? String), hrGap: c["hrGap"] as? [Int]) + // An HR-only night: the generator draws its gravity (so every later draw matches) and drops it. + let grav = try XCTUnwrap(c["gravity"] as? Bool) ? synthGrav : [] + let samples = try XCTUnwrap(c["samples"] as? [String: Int]) + XCTAssertEqual([grav.count, hr.count, rr.count], [samples["grav"], samples["hr"], samples["rr"]], + "\(name): synthetic inputs differ from the generator's") + let window = (c["window"] as? [Int]).map { (from: $0[0], to: $0[1]) } + let end = start + 30 * epochs + + let spanStart = try XCTUnwrap(c["spanStart"] as? Int), n = try XCTUnwrap(c["n"] as? Int) + let f = SleepStagerV3.features(grav: grav, hr: hr, rr: rr, spanStart: spanStart, n: n) + XCTAssertEqual(f.beats, try XCTUnwrap(c["beats"] as? [Int]), "\(name): beat counts") + XCTAssertEqual(SleepStagerV3.usesHRV(f) ? "hrv" : "base", c["head"] as? String, "\(name): head") + let stride = try XCTUnwrap(c["stride"] as? Int) + let picks = Array(Swift.stride(from: 0, to: n, by: stride)) + let expected = try XCTUnwrap(c["features"] as? [String: [Any]]) + for (feature, values) in expected { + let col = try XCTUnwrap(f.columns[feature], "\(name): missing feature \(feature)") + XCTAssertEqual(values.count, picks.count) + for (k, i) in picks.enumerated() { + close(col[i], (values[k] as? NSNumber)?.doubleValue, "\(name) \(feature)[\(i)]") + } + } + for (key, head) in [("hrvPosteriors", SleepStagerV3.hrvHead), ("basePosteriors", SleepStagerV3.baseHead)] { + let post = head.posteriors(f) + let expectedPost = try XCTUnwrap(c[key] as? [[Any]]) + for (k, i) in picks.enumerated() { + for s in 0..<4 { + close(post[i][s], (expectedPost[k][s] as? NSNumber)?.doubleValue, "\(name) \(key)[\(i)][\(s)]") + } + } + } + + let segments = SleepStagerV3.stageSession(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: [], + sleepWindow: window) + let labels = SleepStagerV3.epochStarts(start: start, end: end).map { e in + segments.first { $0.start <= e && e < $0.end }?.stage ?? "?" + } + XCTAssertEqual(labels.map { String($0.prefix(1)) }.joined(), c["labels"] as? String, "\(name): labels") + let expectedSegments = try XCTUnwrap(c["segments"] as? [[Any]]) + XCTAssertEqual(segments.count, expectedSegments.count, "\(name): segment count") + for (seg, e) in zip(segments, expectedSegments) { + XCTAssertEqual(seg, StageSegment(start: e[0] as! Int, end: e[1] as! Int, stage: e[2] as! String)) + } + } + } + + /// A column missing for the whole night takes the head's training mean, so it standardises to 0 and moves + /// no logit; a partly missing column takes the night's median. A whole-night gap used to take 0, which for + /// a percentile rank reads as the night's extreme: no heart rate at all read as the lowest, flattest heart + /// rate of the night. + func testAColumnMissingAllNightMovesNoLogit() { + XCTAssertEqual(SleepStagerV3.imputed([.nan, .nan], absent: 0.5), [0.5, 0.5]) + XCTAssertEqual(SleepStagerV3.imputed([.nan, 1, 3, 2], absent: 0.5), [2, 1, 3, 2]) + let head = SleepStagerV3.baseHead, n = 3 + var columns: [String: [Double]] = [:] + for (j, name) in head.features.enumerated() { columns[name] = [Double](repeating: head.mean[j], count: n) } + let atMean = head.posteriors(SleepStagerV3.Features(n: n, columns: columns, beats: [0, 0, 0])) + for name in ["hr_pct", "hrsd5_pct", "hr_over_low"] { columns[name] = [.nan, .nan, .nan] } + XCTAssertEqual(head.posteriors(SleepStagerV3.Features(n: n, columns: columns, beats: [0, 0, 0])), atMean) + } + + /// The Welch band powers equal a direct evaluation of the density on a signal with known content: a pure + /// 0.25 Hz tone puts the HF peak on that bin, and its LF power is negligible next to HF. + func testSpectralPeakTracksAToneAndMovesWithIt() { + for (hz, bin) in [(0.25, 16), (0.203125, 13), (0.34375, 22)] { + var t: [Double] = [], v: [Double] = [] + var time = 0.0 + while time < 290 { + let rr = 1000 + 40 * sin(2 * Double.pi * hz * time) + t.append(time); v.append(rr) + time += rr / 1000 + } + let (lf, hf, peak, peaked) = SleepStagerV3.spectral(t, v) + XCTAssertEqual(peak, Double(bin) / 64, accuracy: 1e-12, "tone \(hz) Hz") + XCTAssertLessThan(lf, hf / 50) + XCTAssertGreaterThan(peaked, 0.3) + } + } + + /// Outside the band window every epoch is wake, and a window that holds no epoch leaves the night awake + /// instead of staging it anyway. + func testEpochsOutsideTheWindowAreWake() { + let start = 1_700_000_010, end = start + 3 * 3600 + let grav = (0..<(end - start)).map { GravitySample(ts: start + $0, x: 0.7, y: 0.3, z: 0.6) } + let hr = (0..<(end - start)).map { HRSample(ts: start + $0, bpm: 58 + $0 % 5) } + let w = (from: start + 3600, to: start + 7200) + let epochs = SleepStagerV3.epochStarts(start: start, end: end) + let labels = SleepStagerV3.stageEpochs(epochs: epochs, grav: grav, hr: hr, rr: [], sleepWindow: w, end: end) + for (e, l) in zip(epochs, labels) where !SleepStagerV2.epochInSleepWindow(e, w, end: end) { + XCTAssertEqual(l, "wake") + } + XCTAssertTrue(labels.contains { $0 != "wake" }) + let empty = SleepStagerV3.stageEpochs(epochs: epochs, grav: grav, hr: hr, rr: [], + sleepWindow: (from: end + 60, to: end + 600), end: end) + XCTAssertEqual(Set(empty), ["wake"]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/oracles/sleep_stager_v3.json b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/oracles/sleep_stager_v3.json new file mode 100644 index 0000000000..9d021884e3 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/oracles/sleep_stager_v3.json @@ -0,0 +1,8504 @@ +{ + "schemaVersion": 1, + "note": "Generated by Tools/SleepTrain/make_oracle.py from sleeptrain/features.py and model.py over SplitMix64 synthetic nights, with the committed SleepStagerV3Model.swift. Do not edit.", + "cases": [ + { + "name": "band night with beats", + "seed": 1, + "start": 1700000017, + "epochs": 600, + "rr": "full", + "hrGap": null, + "window": [ + 1700001224, + 1700017117 + ], + "gravity": true, + "samples": { + "grav": 17808, + "hr": 17670, + "rr": 17908 + }, + "spanStart": 1700001210, + "n": 530, + "head": "hrv", + "stride": 20, + "features": { + "act_mean": [ + 0.6207840151003758, + 0.5400287688689835, + 1.6736055662244964, + 0.5559502603101713, + 1.7319919248341842, + 0.6170612627003611, + 0.5563733831828432, + 0.8661130947076029, + 0.5775327067492538, + 0.6280271452844953, + 0.5903859280407154, + 0.9674904545959875, + 0.5950369101343892, + 0.5629228052882742, + 0.7733357773949933, + 0.7177350823445631, + 0.6314405596950944, + 0.6100033316714407, + 0.9487002993051061, + 0.5881911352073341, + 0.5796426511015534, + 1.0374848186725663, + 0.64471357189823, + 0.6475376011472461, + 0.546973933833494, + 0.6727537577867896, + 0.6268873752559929 + ], + "act_max": [ + 0.984180005392285, + 0.933824116699362, + 5.687809477943752, + 0.8756976039835094, + 4.593016317570895, + 0.9482307085514864, + 0.8396632316180376, + 1.2773021185814188, + 0.8581243734645478, + 1.0009671555786888, + 0.971091485078303, + 1.3765694018097776, + 0.9085183451308549, + 0.9032455204648637, + 1.177008029542116, + 1.043709830134217, + 1.0009671555786888, + 0.933824116699362, + 1.409683786840622, + 0.971091485078303, + 0.933824116699362, + 1.374926872913254, + 0.8869636729032554, + 0.984180005392285, + 0.7925132376418627, + 0.9798665104772026, + 0.933824116699362 + ], + "move_frac": [ + 0.0, + 0.0, + 0.23333333333333334, + 0.0, + 0.23333333333333334, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "since_move": [ + 4.127134385045092, + 4.406719247264253, + 0.0, + 2.9444389791664403, + 0.0, + 2.772588722239781, + 3.58351893845611, + 4.02535169073515, + 1.3862943611198906, + 3.1780538303479458, + 3.784189633918261, + 4.1588830833596715, + 1.6094379124341003, + 3.2188758248682006, + 3.8066624897703196, + 4.174387269895637, + 4.442651256490317, + 4.653960350157523, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741 + ], + "until_move": [ + 3.6888794541139363, + 2.995732273553991, + 0.0, + 3.044522437723423, + 0.0, + 3.9512437185814275, + 3.4657359027997265, + 2.4849066497880004, + 4.30406509320417, + 3.9889840465642745, + 3.5263605246161616, + 2.6390573296152584, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.709530201312334, + 4.51085950651685, + 4.2626798770413155 + ], + "act_ctx2": [ + 0.6298814875315325, + 0.5745047918107407, + 1.650096078456523, + 0.5903087810002745, + 1.473913506139472, + 0.6310272660545252, + 0.6344481544350487, + 0.9149823268219052, + 0.630274428702208, + 0.6278259984918236, + 0.6180371611158477, + 0.9680988668491978, + 0.6045363517426665, + 0.6110213198591111, + 0.8522778107868418, + 0.6259889497518429, + 0.6233292324964452, + 0.6267171232213116, + 0.9144428387807466, + 0.6285495044895584, + 0.6289878377089849, + 0.9852760347709136, + 0.631347568943454, + 0.6150172763458273, + 0.6008778529832284, + 0.6470032055286993, + 0.615728169420629 + ], + "act_ctx5": [ + 0.6192613569725488, + 0.6383711863232979, + 1.14721530708432, + 0.5845371615816213, + 1.446019213512149, + 0.62702778423571, + 0.6082528223650056, + 0.9339624945430477, + 0.7980200862254556, + 0.6204194349429987, + 0.6067987039429128, + 0.9531047871545375, + 0.7683103574548795, + 0.610169824350622, + 0.8870207355495268, + 0.6177541543216979, + 0.6238491794337929, + 0.6419176296872474, + 0.9229097207414931, + 0.6072898295523508, + 0.6233987495070995, + 0.9263309385525118, + 0.6180917562531466, + 0.6253033890635554, + 0.6782337705486053, + 0.6273144668972773, + 0.6847648915751744 + ], + "act_ctx10": [ + 0.6146899437547851, + 0.7098938180748965, + 0.9668746607309783, + 0.5879619476275595, + 1.0488664090898234, + 0.6247605519349122, + 0.6114253284575332, + 0.8945773501890846, + 0.9498056356086699, + 0.6193625174169354, + 0.6350739883525334, + 0.9363612762074476, + 0.8466725698626598, + 0.6244140302846931, + 0.8382220110294183, + 0.6107397481849871, + 0.6151956687716917, + 0.7070029368099007, + 0.8851034182974196, + 0.6043838656222127, + 0.6264591177946954, + 0.7959904211302842, + 0.6202101924834335, + 0.6863655312584239, + 0.7245859195953672, + 0.613078738840811, + 0.7204726514267918 + ], + "act_ctx20": [ + 0.6069274363228659, + 0.7883401723696886, + 0.8335456211266745, + 0.7334905572600979, + 0.8266627175069987, + 0.8116689858284177, + 0.6862445695503754, + 0.9177920973640187, + 0.8634319934623222, + 0.6153314487278911, + 0.7069370806607741, + 0.8932590509330874, + 0.815434749039128, + 0.680421893044189, + 0.7245548041694923, + 0.6746630418856533, + 0.618422470557599, + 0.7366268865096803, + 0.7912522978981008, + 0.6723014798006139, + 0.6606379834717891, + 0.7128551457363165, + 0.6758794508176384, + 0.7035626040985749, + 0.6938765926581432, + 0.6099261806497613, + 0.6907445154903352 + ], + "dangn": [ + 0.3895266267203534, + 0.5584924807908028, + 5.130482752653173, + 1.0424271939670504, + 3.3689170162419404, + 0.5460895500595823, + 0.7609900177734837, + 1.1168079696145294, + 0.5990237580810052, + 0.4698458067570033, + 0.7325105427992074, + 1.1058847272478203, + 0.6422335471929084, + 0.7599622771676553, + 1.0979327047905785, + 0.4532809184321923, + 0.5207882062250107, + 0.46340540093293525, + 0.960974127428018, + 0.25123312519970553, + 0.7003415861621983, + 1.106127255970578, + 0.31412568357277165, + 0.6615915260606946, + 0.5428416462388318, + 0.6689397983337589, + 0.7198341708959519 + ], + "dangn_ctx2": [ + 0.40206466720506934, + 0.6132985256537665, + 3.410449085730401, + 0.963750834738805, + 2.732697698158151, + 0.6820695845430208, + 0.7949366246087834, + 1.0984591555949976, + 0.7429513474921123, + 0.7156555384166154, + 0.5965763710320402, + 1.115767281150363, + 0.6326043374427854, + 0.662445094781458, + 0.9815612828376515, + 0.5647230374616129, + 0.5853358054071691, + 0.5049428799799898, + 0.7903571179502933, + 0.5233010490194829, + 0.5306368306985746, + 0.8003578333787346, + 0.49978411040516446, + 0.49619397689190237, + 0.6417985327002157, + 0.5047657808188319, + 0.629026550922822 + ], + "dangn_ctx5": [ + 0.5140221371413988, + 0.6904786977464444, + 2.0817268413006325, + 0.9489440439103247, + 2.733255806795219, + 0.7968369721913459, + 0.7710408776985234, + 1.085555746501378, + 1.4655794536537237, + 0.6415040982006185, + 0.7340459053953442, + 1.097112785828177, + 1.2397213275771823, + 0.5688695837205583, + 0.8862356331614478, + 0.6150282438728916, + 0.5986835293092967, + 0.5973160508768842, + 0.8685094032148765, + 0.5720099959273584, + 0.5359563827949242, + 0.8377859853956693, + 0.5773448710129453, + 0.5128167798996773, + 0.695128048479324, + 0.5871338868581378, + 0.6298015230774158 + ], + "dangn_ctx10": [ + 0.5677564744260595, + 0.770542253676465, + 1.523441713683671, + 0.925614147659464, + 1.8454810342998424, + 0.7412176774305983, + 0.7220991374435228, + 1.0298436551302277, + 1.8589307170970149, + 0.5945302140598342, + 0.7513022894466876, + 1.1525004496073112, + 1.2758950924388335, + 0.5419220288345384, + 0.7799351630878656, + 0.5860069504254338, + 0.5588338159674322, + 0.6850191759084753, + 0.8058962287604459, + 0.5900586166930241, + 0.5326909875955153, + 0.6741912832176646, + 0.5707052175777324, + 0.5807924592427948, + 0.6886880311116075, + 0.5832069047417738, + 0.6289300094779122 + ], + "dangn_ctx20": [ + 0.6213062825858312, + 0.9452828499824408, + 1.2090987046434674, + 1.2490409677735035, + 1.3568974789086934, + 1.217495581952246, + 0.8021201513332674, + 1.4604696265614157, + 1.3550898665242008, + 0.661676339664743, + 0.8381664445695558, + 1.2511338151188625, + 1.0628627506848785, + 0.6240136875193216, + 0.6655612985635804, + 0.6532530010926729, + 0.5731225022875328, + 0.6759046671613856, + 0.720179078805952, + 0.6365447033388788, + 0.5860117859356292, + 0.6131394179565635, + 0.6084731816396465, + 0.616676317822213, + 0.6686456840892144, + 0.5871055655891046, + 0.594159674232723 + ], + "hr_z": [ + -1.2088623120582938, + 0.42706387819338787, + 2.983428872767928, + -1.4719258200384644, + 2.5412100446978125, + -0.9869024772000257, + -1.4727762408616765, + 0.6049840632781894, + 0.22154551258387956, + 0.5421541629347127, + -1.414380677667802, + 0.7805554670417404, + 0.202552780865484, + -1.0645742457200336, + 0.7638305241852428, + 0.03246861622313264, + -1.103754347932288, + 0.5257126936859513, + 0.5785602734141106, + 0.11467596246693633, + -1.3877341585405008, + 1.0104525629166539, + 0.048910085471894085, + -0.1701137727348102, + 0.2708699203301621, + -1.174217787569835, + -0.2396660472046299 + ], + "hr_pct": [ + 0.14150943396226415, + 0.6613207547169812, + 0.9839622641509433, + 0.06320754716981132, + 0.9716981132075472, + 0.22358490566037736, + 0.06037735849056604, + 0.7773584905660378, + 0.5377358490566038, + 0.7254716981132076, + 0.07924528301886792, + 0.8358490566037736, + 0.5283018867924528, + 0.1820754716981132, + 0.8283018867924529, + 0.4679245283018868, + 0.17169811320754716, + 0.7160377358490566, + 0.7518867924528302, + 0.5, + 0.09056603773584905, + 0.8849056603773585, + 0.47264150943396227, + 0.3867924528301887, + 0.5632075471698114, + 0.15471698113207547, + 0.3490566037735849 + ], + "hrsd5_pct": [ + 0.17547169811320754, + 0.9113207547169812, + 0.9679245283018868, + 0.7037735849056603, + 0.9641509433962264, + 0.7245283018867924, + 0.5547169811320755, + 0.18679245283018867, + 0.9207547169811321, + 0.7396226415094339, + 0.660377358490566, + 0.25471698113207547, + 0.8301886792452831, + 0.7849056603773585, + 0.5981132075471698, + 0.24716981132075472, + 0.11132075471698114, + 0.37735849056603776, + 0.6113207547169811, + 0.35094339622641507, + 0.7886792452830189, + 0.4207547169811321, + 0.9283018867924528, + 0.24339622641509434, + 0.3867924528301887, + 0.28679245283018867, + 0.5471698113207547 + ], + "hrsd5_z": [ + -0.7803256959706497, + 1.4935874043519828, + 2.7250065741788365, + 0.3395628090611808, + 2.694382141044142, + 0.41776783201410855, + -0.30416799884526696, + -0.7771336056769862, + 1.674040025101405, + 0.4973071872671092, + 0.09726359652440673, + -0.7498149583713061, + 0.7824243240446145, + 0.657543429671068, + -0.26732314965455506, + -0.7505431006765436, + -0.8059028440468262, + -0.6374690576411708, + -0.24237967799608537, + -0.6818645500890317, + 0.6658347440286408, + -0.5271129099416803, + 1.8490536205778922, + -0.7539642742407331, + -0.6209807076207755, + -0.7347122620807336, + -0.31090974305396324 + ], + "hrsd11_pct": [ + 0.007547169811320755, + 0.9132075471698113, + 0.8962264150943396, + 0.5264150943396226, + 0.9735849056603774, + 0.44150943396226416, + 0.3264150943396226, + 0.23018867924528302, + 0.9415094339622642, + 0.4509433962264151, + 0.4075471698113208, + 0.02830188679245283, + 0.9, + 0.7226415094339622, + 0.32264150943396225, + 0.020754716981132074, + 0.5018867924528302, + 0.20566037735849058, + 0.32075471698113206, + 0.03962264150943396, + 0.7, + 0.22075471698113208, + 0.8754716981132076, + 0.6113207547169811, + 0.46226415094339623, + 0.03773584905660377, + 0.47735849056603774 + ], + "hrsd11_z": [ + -1.4513951352642642, + 1.468395742861928, + 1.3775413037403519, + -0.04447809952715429, + 2.2201917269403175, + -0.24674150103672376, + -0.5611758077787544, + -0.8849765568555124, + 1.7503895107063354, + -0.22131532552949065, + -0.30733816781544293, + -1.4103190687881197, + 1.4147296312845699, + 0.41313872053083023, + -0.5990593118514798, + -1.4316755432093413, + -0.08703491784507507, + -0.9426174696867922, + -0.60276208465907, + -1.3896726164174855, + 0.31917569326588086, + -0.8973348478429326, + 1.2605207443187252, + 0.1362951301961572, + -0.1958720786504417, + -1.396745695522713, + -0.1628348638223453 + ], + "hrsd20_pct": [ + 0.0018867924528301887, + 0.9075471698113208, + 0.8150943396226416, + 0.7471698113207547, + 0.9716981132075472, + 0.9283018867924528, + 0.38301886792452833, + 0.9018867924528302, + 0.5943396226415094, + 0.12075471698113208, + 0.3471698113207547, + 0.6396226415094339, + 0.7886792452830189, + 0.6415094339622641, + 0.1471698113207547, + 0.1641509433962264, + 0.4056603773584906, + 0.5415094339622641, + 0.06981132075471698, + 0.12641509433962264, + 0.4188679245283019, + 0.06037735849056604, + 0.7603773584905661, + 0.6075471698113207, + 0.41132075471698115, + 0.2849056603773585, + 0.3018867924528302 + ], + "hrsd20_z": [ + -1.841160890640271, + 1.6617668353733244, + 0.9419087049946295, + 0.6722191065971055, + 2.12546324051482, + 1.7810277786614144, + -0.6310529966827096, + 1.6529249267502724, + 0.11774609199364862, + -1.0163353645696662, + -0.6934585996285978, + 0.19687420460545624, + 0.863697817082247, + 0.19723494650263138, + -0.9768272659832472, + -0.9532029666548173, + -0.5386801800497851, + -0.009190237942656106, + -1.1189755458458168, + -0.9995184802693426, + -0.4815267952060214, + -1.1532990835563657, + 0.7728228250038275, + 0.12796882362406975, + -0.511805888762085, + -0.7979810861432072, + -0.7797617070746564 + ], + "hr_ctx5_z": [ + -1.4155304032723206, + -0.05259664268102582, + 1.5079243448586348, + -1.5533654121751543, + 2.2066138260742503, + -0.6236560744867654, + -1.5703934465712635, + 0.8489687263129848, + 0.8839355268543087, + -0.2609731363346957, + -1.1301906358247666, + 0.8013051536573733, + 0.5955461327035799, + -0.8241344554666746, + 1.0926241047264842, + -0.12355278138535843, + -1.560683106536582, + 0.5551888307655723, + 1.0449277194775732, + -0.09553301443225856, + -1.1167637227510354, + 1.2799315108582443, + -0.6072169653719519, + -0.16804228254048376, + 0.36831455086181925, + -1.2641228906052595, + 0.3537982596992664 + ], + "hr_ctx10_z": [ + -1.7150442378671185, + 0.0025488642204695077, + 1.2988801169810413, + -1.4687388984462213, + 1.4533707944636374, + -0.5857367771650195, + -1.4022417839504893, + 0.8694747801622507, + 1.5045049015480245, + -0.3993055556830266, + -1.0085349897226117, + 0.9257329447521111, + 0.8057818823213723, + -0.7274215115571017, + 1.0599417029202698, + -0.1516008713526094, + -1.3783167353616554, + 0.9232941102868152, + 1.133892186380648, + -0.13373166595379252, + -0.7640603939304249, + 1.1573521058860374, + -0.775570391160982, + -0.30336538832529325, + 0.2785908314829703, + -1.4956508547847773, + 0.047195482215408924 + ], + "hr_ctx30_z": [ + -1.7388568620986578, + 0.4720860523077787, + -0.29598255420529035, + 1.359109729871766, + -0.6799750703967438, + -0.5667351231130374, + -1.1433325510754924, + 0.9562154198046322, + 1.9853917107922165, + 0.11614832097330986, + -0.5155461880551178, + 0.6371643104166427, + 1.0736311031342662, + 1.2289159727442465, + 0.13291702605852362, + -0.5002362383705578, + -0.6935908244166935, + 0.5791242260517921, + 1.9424850771001054, + 0.2331049769746085, + 0.21473089403505682, + -0.505504904359056, + 0.13668683061477574, + -0.8122546658898085, + -1.6109136677155758, + -1.1465078797903172, + -2.288594454333389 + ], + "hr_trend": [ + -0.033325584052932976, + 2.18900718955045, + -1.0897670295524131, + -1.0102849313778912, + 1.4769940234808696, + -1.234249535312914, + -0.24719779975817407, + 0.3816161340867275, + -1.7285879472786467, + -1.1755353689236598, + 0.2930689056200487, + 0.25118565084062267, + -1.0825613083000394, + 1.3468038752885572, + -0.7599125811771151, + 0.3299614189671654, + 0.11287174479406915, + 0.5250593531281331, + -0.9316345031542781, + 0.21627644298243906, + 0.7791822773915539, + 0.46458802234160673, + -2.1395411620581553, + 0.9381801543287185, + -0.6200635620392407, + 0.23931276237751106, + 0.5363074666816448 + ], + "hr_over_low": [ + 0.31423049417674526, + 1.9501566844284268, + 4.506521679002967, + 0.05116698619657453, + 4.064302850932852, + 0.5361903290350133, + 0.050316565373362585, + 2.1280768695132286, + 1.7446383188189185, + 2.0652469691697517, + 0.10871212856723694, + 2.3036482732767793, + 1.725645587100523, + 0.4585185605150055, + 2.2869233304202816, + 1.5555614224581717, + 0.41933845830275096, + 2.04880549992099, + 2.1016530796491497, + 1.6377687687019753, + 0.13535864769453826, + 2.533545369151693, + 1.572002891706933, + 1.3529790335002287, + 1.7939627265652012, + 0.34887501866520393, + 1.2834267590304091 + ], + "clock": [ + 0.0, + 0.03780718336483932, + 0.07561436672967864, + 0.11342155009451796, + 0.15122873345935728, + 0.1890359168241966, + 0.22684310018903592, + 0.2646502835538752, + 0.30245746691871456, + 0.34026465028355385, + 0.3780718336483932, + 0.4158790170132325, + 0.45368620037807184, + 0.4914933837429111, + 0.5293005671077504, + 0.5671077504725898, + 0.6049149338374291, + 0.6427221172022685, + 0.6805293005671077, + 0.718336483931947, + 0.7561436672967864, + 0.7939508506616257, + 0.831758034026465, + 0.8695652173913043, + 0.9073724007561437, + 0.945179584120983, + 0.9829867674858223 + ], + "minutes": [ + 0.0, + 10.0, + 20.0, + 30.0, + 40.0, + 50.0, + 60.0, + 70.0, + 80.0, + 90.0, + 100.0, + 110.0, + 120.0, + 130.0, + 140.0, + 150.0, + 160.0, + 170.0, + 180.0, + 190.0, + 200.0, + 210.0, + 220.0, + 230.0, + 240.0, + 250.0, + 260.0 + ], + "time_e10": [ + 1.0, + 0.36787944117144233, + 0.1353352832366127, + 0.049787068367863944, + 0.01831563888873418, + 0.006737946999085467, + 0.0024787521766663585, + 0.0009118819655545162, + 0.00033546262790251185, + 0.00012340980408667956, + 4.5399929762484854e-05, + 1.670170079024566e-05, + 6.14421235332821e-06, + 2.2603294069810542e-06, + 8.315287191035679e-07, + 3.059023205018258e-07, + 1.1253517471925912e-07, + 4.139937718785167e-08, + 1.522997974471263e-08, + 5.602796437537268e-09, + 2.061153622438558e-09, + 7.582560427911907e-10, + 2.7894680928689246e-10, + 1.026187963170189e-10, + 3.775134544279098e-11, + 1.3887943864964021e-11, + 5.109089028063325e-12 + ], + "time_e30": [ + 1.0, + 0.7165313105737893, + 0.513417119032592, + 0.36787944117144233, + 0.26359713811572677, + 0.18887560283756183, + 0.1353352832366127, + 0.09697196786440505, + 0.06948345122280154, + 0.049787068367863944, + 0.035673993347252395, + 0.025561533206507402, + 0.01831563888873418, + 0.013123728736940968, + 0.009403562551495206, + 0.006737946999085467, + 0.004827949993831441, + 0.0034593773364647584, + 0.0024787521766663585, + 0.0017761035457343791, + 0.0012726338013398079, + 0.0009118819655545162, + 0.0006533919798673806, + 0.00046817581165277687, + 0.00033546262790251185, + 0.00024036947641951407, + 0.00017223225596081014 + ], + "time_e90": [ + 1.0, + 0.8948393168143698, + 0.8007374029168081, + 0.7165313105737893, + 0.6411803884299546, + 0.5737534207374327, + 0.513417119032592, + 0.45942582403592663, + 0.41111229050718745, + 0.36787944117144233, + 0.32919298780790557, + 0.29457482831010734, + 0.26359713811572677, + 0.2358770829857, + 0.2110720877910902, + 0.18887560283756183, + 0.1690133154060661, + 0.15123975969049577, + 0.1353352832366127, + 0.12110333239232973, + 0.10836802322189586, + 0.09697196786440505, + 0.08677432947392923, + 0.0776490817034759, + 0.06948345122280154, + 0.06217652402211632, + 0.05563799827784281 + ], + "hrv_meanNN_r": [ + 1.002556103317932, + 0.5726012284129894, + -0.003341317275808163, + 0.8830137974682698, + -0.2030992416605374, + 0.7603804017810255, + 0.9980704684583049, + 0.13792095436900995, + 0.21344077157270722, + 0.6340701293009112, + 0.8568835543809641, + 0.14046630339993113, + 0.31894440002870406, + 0.8562771084594971, + 0.13724081099556482, + 0.3953516679841446, + 0.9996938964983677, + 0.3884747892575144, + 0.1425262693447991, + 0.39925629786190864, + 0.8564711000170153, + 0.16087756039882253, + 0.6371383484699192, + 0.3966014065482062, + 0.3474581848390038, + 0.9997183524452211, + 0.3390679072908229 + ], + "hrv_meanNN_ctx": [ + 0.9985961851492514, + 0.5728728558370392, + 0.13884172419438234, + 0.735435082338499, + 0.13662342775121838, + 0.7303260073622246, + 0.8497604894641898, + 0.1344586452306299, + 0.12511218057726733, + 0.6697035905239226, + 0.7466275225631442, + 0.13896499435129445, + 0.28317798623974155, + 0.7360334848280349, + 0.20670676437611796, + 0.40813122853448036, + 0.8278667078898252, + 0.3365072944223146, + 0.1773502972024381, + 0.401897896039501, + 0.6732225576467344, + 0.264738527605374, + 0.6575113683173096, + 0.442168859620305, + 0.39055486754210866, + 0.9817239486930278, + 0.4637157426972351 + ], + "hrv_sdnn_r": [ + 0.07542226950048671, + 0.8903490211909382, + 1.0559227345430306, + 0.6464248749053665, + 0.9111983310611067, + 0.8077161939988527, + 0.08178211646293612, + 0.00034515165142370976, + 1.0190557902654822, + 0.8093500631638003, + 0.7008668583983639, + -0.005700184697518977, + 0.6971815979851296, + 0.6989606558860684, + 0.002940853006226422, + 0.024614735615450183, + 0.07668608996998569, + 0.0830034823036958, + 0.0003951242804031957, + 0.019881634282625155, + 0.7025272891727581, + 0.12438574428041162, + 0.8134695418367183, + 0.04819083845252033, + 0.22572599436765556, + 0.0806830975258104, + 0.24665217312342141 + ], + "hrv_sdnn_ctx": [ + 0.07328123415255278, + 0.40943555977663565, + 0.5717226359434491, + 0.5099843427299174, + 0.9097942694181053, + 0.3460550109069316, + 0.36990111403533393, + 0.255994859922006, + 0.6131150688623624, + 0.33095564501779845, + 0.40541143742887276, + 0.11412849188196665, + 0.74011793711724, + 0.48408362214489453, + 0.17416957755952128, + 0.14415377205857688, + 0.5231262822825086, + 0.2023459480313607, + 0.0890526343023921, + 0.10590178848451344, + 0.5819724153481859, + 0.21591068389753196, + 0.40617165736938976, + 0.378091070678357, + 0.3760553713257953, + 0.16443370257977244, + 0.4276707957431384 + ], + "hrv_lnrmssd_r": [ + 0.8431458659027611, + 0.72785071375408, + 0.8107701218572431, + 0.8769679303598475, + 0.8955563066667681, + 0.9296524799457472, + 0.9077102973863578, + -0.022100336617180326, + 0.783459996541337, + 0.8227469892098103, + 0.9559067819377219, + -0.02806551924827629, + 0.807447459806247, + 0.9485416454612231, + 0.04040447351863595, + 0.30931361225011694, + 0.8600727941798816, + 0.30550229821848907, + 0.09830665665841672, + 0.24686840166057758, + 0.957861546300649, + 0.1751244895742531, + 0.778707765575458, + 0.5430198011977599, + 0.36261741392564156, + 0.8893109575296578, + 0.42745915264447426 + ], + "hrv_lnrmssd_ctx": [ + 0.8182541697305781, + 0.5727517468001709, + 0.5557221243180296, + 0.8273328351287228, + 0.8031544648228732, + 0.8682689771757615, + 0.8953728516289271, + 0.1388191019548799, + 0.5759019330331803, + 0.7022149626908065, + 0.7869623709662857, + 0.04246532249025238, + 0.7361334257802066, + 0.7620893503558012, + 0.12982711293301327, + 0.3639140316289066, + 0.8667151495500158, + 0.2749738290205433, + 0.08052901701892286, + 0.29040269125558804, + 0.8042259332539743, + 0.3001100875006178, + 0.7015902967326691, + 0.533939401854552, + 0.37368162927642634, + 0.9018132127003685, + 0.4947982488176194 + ], + "hrv_pnn50_r": [ + 0.0, + 0.5861485445934538, + 1.0647128626476026, + 0.6139585120376687, + 1.0073331275348576, + 0.609509537312758, + 0.0, + 0.0, + 0.5444162857550849, + 0.60080225820829, + 0.609509537312758, + 0.0, + 0.5551968062650865, + 0.609509537312758, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.6139585120376687, + 0.5444162857550849, + 0.5881980150290952, + 0.0, + 0.564512188920541, + 0.0, + 0.0 + ], + "hrv_pnn50_ctx": [ + 0.0, + 0.2850941706720126, + 0.5093164074643768, + 0.4572571279834793, + 0.6326193109427026, + 0.2860777084668623, + 0.3950831346155799, + 0.33780864079527056, + 0.39006221770480515, + 0.28727069300747743, + 0.28202633662865295, + 0.07566209109846511, + 0.6477106049488122, + 0.5081518809783278, + 0.18259886162169553, + 0.10975672262849419, + 0.43268539395051386, + 0.08193798492894189, + 0.0, + 0.08259035492294908, + 0.5161097949470159, + 0.2647816867182601, + 0.370666692867728, + 0.22450318887645235, + 0.48883383151888216, + 0.11814327389709385, + 0.3006874783135913 + ], + "hrv_lnlf_r": [ + 0.22835778724381423, + 0.7584616306939235, + 1.155982848611391, + 0.7232051347608405, + 0.7773522897149018, + 0.7674283068729252, + 0.1538961414146202, + 0.01939776795099114, + 1.0036861647605884, + 0.7405342493539702, + 0.8037214817992085, + 0.020584755611554824, + 1.0249526900570844, + 0.8270885489161226, + 0.07336508420855624, + 0.08812589065100983, + 0.06449443713907564, + 0.261853081695705, + -0.04083076818367211, + 0.06318520520701178, + 0.7615797031070862, + 0.3424882338696959, + 0.6613212509601951, + 0.1103707433713119, + 0.38563470637364794, + 0.14457651927037313, + 0.30133522781026545 + ], + "hrv_lnlf_ctx": [ + 0.223085120462658, + 0.444974518547573, + 0.6285341598446902, + 0.550881037028494, + 0.8626117277869612, + 0.43983532607755343, + 0.4184751483554404, + 0.29709900626446906, + 0.5830071995562931, + 0.43024843699348636, + 0.46411283156284694, + 0.2034057909172406, + 0.7619767059113, + 0.5776390059779708, + 0.17987895130260004, + 0.1638641467976654, + 0.5256359559927194, + 0.3390888518720379, + 0.08504420684439835, + 0.2014620583327845, + 0.6079004924967047, + 0.27350848861201865, + 0.4515319575331584, + 0.44049425013756827, + 0.4560060353970514, + 0.24378861306104227, + 0.4843475084808773 + ], + "hrv_lnhf_r": [ + 0.8151233303088073, + 0.7776285865601636, + 0.7860974432571622, + 0.8828634253367941, + 0.7149609835244367, + 0.91652873077167, + 0.8855465799028538, + -0.026220504448674124, + 0.8101845212309788, + 0.824633661739665, + 0.9531257823819457, + -0.04248327810270968, + 0.8877459275245758, + 0.9417848481257454, + 0.056519693838731164, + 0.407537918399661, + 0.8560142035459701, + 0.437424971918698, + 0.06776656100148436, + 0.3655257444839637, + 0.9682122683048803, + 0.1772931838942821, + 0.7982809334109806, + 0.7068324919858532, + 0.434191138848846, + 0.9051897479282037, + 0.6587593798574287 + ], + "hrv_lnhf_ctx": [ + 0.772430562891628, + 0.5976831594868188, + 0.5534667573555784, + 0.8495107647349555, + 0.8186843857859399, + 0.9104443912125416, + 0.8815198312507054, + 0.17415844321560991, + 0.6838647426476095, + 0.7429552975519287, + 0.8042404570115282, + 0.014720131542307377, + 0.7619533634459523, + 0.7858480032336672, + 0.2010616133628037, + 0.4731413057781451, + 0.8643872959902139, + 0.3602248853693414, + 0.07392555793447964, + 0.3499579804494209, + 0.8304674293874528, + 0.40741742624948074, + 0.7483701734983786, + 0.6285481388673697, + 0.4068610962250513, + 0.9089713188446705, + 0.5436705382262602 + ], + "hrv_lnlfhf_r": [ + 0.16798017086999162, + 0.7364227913645233, + 1.1385136765458952, + 0.6307129335530465, + 0.7972147707507398, + 0.6538171226102095, + 0.04507008008150097, + 0.5097355356818757, + 0.9664023007297747, + 0.6869703805970268, + 0.6668508536767166, + 0.5217012500965834, + 0.9369509647420792, + 0.6983109836595591, + 0.510398582903401, + 0.29354246608397255, + -0.02709965416091513, + 0.45195996607910227, + 0.38584880779230407, + 0.2957305244867002, + 0.613660330817744, + 0.7065833398761306, + 0.6231484572503289, + 0.11854692926854818, + 0.5810447029133035, + 0.02252956798004854, + 0.34616789424651956 + ], + "hrv_lnlfhf_ctx": [ + 0.19078909085229798, + 0.5338472690163252, + 0.751325017451248, + 0.4760249049220646, + 0.8161020624712653, + 0.3218669518932456, + 0.3190772500916371, + 0.6621050460414195, + 0.6184512281322244, + 0.4227315134583192, + 0.4169575210613291, + 0.6713912887405303, + 0.750387802235875, + 0.5455431275166098, + 0.5241061984652793, + 0.32785929075428943, + 0.44030198527024733, + 0.5821940654855693, + 0.510872547995548, + 0.4478329426822419, + 0.5470887691410242, + 0.48374601162409936, + 0.4409805842221818, + 0.5088531809483776, + 0.6712787619074312, + 0.12177977697807375, + 0.6099251976192448 + ], + "hrv_resp_bpm_r": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "hrv_resp_bpm_ctx": [ + 0.0, + 0.0, + -0.22321428571428573, + 0.0, + 0.0, + 0.0, + 0.0, + -0.26785714285714285, + 0.0, + 0.0, + 0.0, + -0.9375, + -0.5357142857142857, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "hrv_hf_peaked_r": [ + 1.0227154950844681, + 0.6619942395876648, + 0.23344707133619014, + 0.7662915072993413, + 0.7306447557995126, + 0.8395416066100907, + 0.9687554410831812, + -0.10612504125730703, + 0.6909952447650105, + 0.6828817505936097, + 0.8563943519192557, + 0.054246804553251114, + 0.599077641504327, + 0.7718969276117387, + 0.10496997401713432, + 0.7684437574491192, + 0.9179915470729142, + 0.828692565220062, + 0.030657214379455573, + 0.7107414533402754, + 0.8713600563245484, + -0.035872793796455006, + 0.7714049044402447, + 1.0294586074189394, + 0.8542247875565819, + 0.9752056161985471, + 0.8663779683660715 + ], + "hrv_hf_peaked_ctx": [ + 0.9874859299173462, + 0.5991077096529098, + 0.3854327197996233, + 0.8023990232177867, + 0.7039779007698742, + 0.9174537974860024, + 0.8791543701069525, + 0.13708164242964993, + 0.7847242998207175, + 0.8253523245445901, + 0.7975059018135721, + 0.08212181208113548, + 0.567384993941703, + 0.7246799724726335, + 0.20896517691962962, + 0.7288083796264518, + 0.8141249144665201, + 0.5673736948801537, + 0.08538596579062332, + 0.6199477846068349, + 0.8131853126559937, + 0.5059678216875138, + 0.778899871304989, + 0.7768219626459947, + 0.5115435516456782, + 0.9005399272609198, + 0.6272562929335465 + ], + "hrv_dfa_a1_r": [ + 0.015621396337743875, + 0.8295793728481005, + 0.9784026981634922, + 0.6529896202483737, + 0.8970263670223104, + 0.4614953607046272, + -0.0951580238606455, + 0.5450263934767576, + 0.7451821846591348, + 0.6843977113803299, + 0.16033941983992908, + 0.6461316876444526, + 0.7593294388600111, + 0.1677669907126144, + 0.5501312058027871, + 0.26720074090584417, + -0.023030549926658442, + 0.6272947731896952, + 0.40532090945731747, + 0.3241709671914402, + 0.13381449278476867, + 0.8832554699220179, + 0.8325852988092897, + 0.38402086367363875, + 0.6839651752752397, + 0.05079383688995024, + 0.6960278761660711 + ], + "hrv_dfa_a1_ctx": [ + 0.019016563462499456, + 0.509865123988628, + 0.7815921768921781, + 0.4647887820747979, + 0.9169498699778497, + 0.3895305529619046, + 0.24529457972844493, + 0.6438211166301303, + 0.6348760699818617, + 0.4365542233407421, + 0.38729451341438836, + 0.6733018824420502, + 0.7860111290688536, + 0.488318020103527, + 0.6162866404455778, + 0.36795427905833494, + 0.385755992918367, + 0.7117180194101044, + 0.525753501508755, + 0.44417452531992363, + 0.48662750215707923, + 0.6681548320162224, + 0.5364552424511138, + 0.6223928608159388, + 0.7193226538435867, + 0.12726203161098565, + 0.6039554407564538 + ], + "hrv_beats_per_min_r": [ + -2.3846153846153855, + 0.38461538461538397, + 0.9423076923076922, + 0.1346153846153845, + 1.2884615384615385, + 0.17307692307692282, + 0.03846153846153833, + 0.9038461538461539, + 0.8076923076923084, + 0.2499999999999995, + 0.17307692307692282, + 0.7884615384615389, + 0.6923076923076926, + 0.17307692307692282, + 0.8653846153846149, + 0.6153846153846153, + 0.019230769230768822, + 0.5576923076923075, + 0.8846153846153844, + 0.5769230769230763, + 0.1346153846153845, + 0.8076923076923084, + 0.36538461538461514, + 0.5769230769230763, + 0.5961538461538465, + 0.057692307692307834, + 0.6346153846153848 + ], + "hrv_beats_per_min_ctx": [ + -0.6993006993006998, + 0.36630036630036616, + 0.8040293040293038, + 0.25366300366300354, + 0.887362637362637, + 0.22435897435897326, + 0.15293040293040353, + 0.8461538461538454, + 0.8818681318681331, + 0.24175824175824326, + 0.2683150183150171, + 0.8452380952380939, + 0.7289377289377289, + 0.25732600732600563, + 0.7829670329670323, + 0.5952380952380939, + 0.15109890109890026, + 0.6080586080586082, + 0.8095238095238095, + 0.5567765567765535, + 0.28663003663003955, + 0.7069597069597067, + 0.34065934065934095, + 0.5393772893772875, + 0.5714285714285714, + 0.04395604395604989, + 0.13173076923076935 + ] + }, + "beats": [ + 144, + 171, + 198, + 223, + 250, + 262, + 260, + 258, + 260, + 260, + 262, + 262, + 262, + 264, + 263, + 268, + 273, + 277, + 280, + 284, + 288, + 293, + 297, + 302, + 305, + 307, + 306, + 308, + 310, + 308, + 309, + 308, + 309, + 306, + 308, + 312, + 319, + 322, + 320, + 321, + 317, + 316, + 314, + 315, + 314, + 308, + 302, + 296, + 296, + 296, + 298, + 298, + 298, + 295, + 292, + 289, + 285, + 284, + 282, + 278, + 275, + 270, + 268, + 268, + 268, + 268, + 272, + 275, + 277, + 281, + 284, + 291, + 294, + 299, + 303, + 309, + 315, + 319, + 325, + 331, + 335, + 333, + 332, + 330, + 329, + 326, + 320, + 314, + 308, + 302, + 298, + 298, + 298, + 296, + 295, + 292, + 289, + 286, + 283, + 280, + 277, + 274, + 269, + 268, + 266, + 266, + 266, + 268, + 268, + 268, + 268, + 268, + 270, + 270, + 270, + 270, + 270, + 270, + 270, + 270, + 270, + 271, + 274, + 277, + 280, + 283, + 282, + 285, + 288, + 292, + 297, + 300, + 302, + 303, + 305, + 306, + 311, + 313, + 314, + 315, + 315, + 315, + 314, + 315, + 312, + 311, + 312, + 316, + 321, + 325, + 330, + 335, + 338, + 336, + 337, + 337, + 335, + 329, + 323, + 317, + 310, + 304, + 300, + 298, + 298, + 298, + 296, + 294, + 292, + 292, + 292, + 292, + 292, + 294, + 292, + 292, + 293, + 290, + 289, + 284, + 281, + 278, + 275, + 272, + 271, + 268, + 266, + 268, + 267, + 268, + 268, + 268, + 268, + 268, + 268, + 268, + 268, + 268, + 270, + 274, + 277, + 280, + 283, + 286, + 289, + 294, + 298, + 303, + 306, + 308, + 309, + 309, + 310, + 312, + 311, + 310, + 311, + 310, + 311, + 310, + 309, + 310, + 311, + 310, + 313, + 313, + 313, + 313, + 314, + 319, + 323, + 326, + 324, + 323, + 321, + 320, + 318, + 317, + 314, + 308, + 304, + 300, + 300, + 300, + 299, + 296, + 293, + 290, + 287, + 282, + 279, + 276, + 273, + 270, + 268, + 268, + 268, + 268, + 269, + 274, + 277, + 280, + 283, + 286, + 287, + 290, + 293, + 296, + 300, + 301, + 303, + 304, + 306, + 307, + 311, + 312, + 314, + 315, + 315, + 315, + 313, + 311, + 311, + 311, + 309, + 308, + 306, + 305, + 303, + 302, + 302, + 303, + 301, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 299, + 294, + 291, + 288, + 285, + 282, + 278, + 275, + 272, + 269, + 268, + 270, + 269, + 269, + 267, + 267, + 269, + 272, + 275, + 278, + 280, + 283, + 287, + 290, + 295, + 298, + 300, + 300, + 300, + 300, + 300, + 298, + 296, + 297, + 296, + 298, + 299, + 299, + 300, + 302, + 303, + 304, + 308, + 309, + 311, + 310, + 311, + 312, + 313, + 312, + 312, + 315, + 314, + 314, + 314, + 315, + 312, + 311, + 309, + 308, + 305, + 303, + 302, + 300, + 299, + 297, + 298, + 298, + 296, + 296, + 298, + 298, + 298, + 298, + 298, + 298, + 298, + 298, + 300, + 296, + 296, + 295, + 292, + 289, + 286, + 283, + 280, + 277, + 274, + 275, + 272, + 269, + 272, + 275, + 277, + 280, + 283, + 286, + 289, + 290, + 293, + 297, + 297, + 297, + 298, + 298, + 299, + 301, + 302, + 306, + 307, + 307, + 308, + 310, + 311, + 312, + 311, + 309, + 308, + 306, + 305, + 303, + 302, + 300, + 299, + 298, + 298, + 298, + 298, + 297, + 294, + 293, + 290, + 287, + 284, + 281, + 278, + 275, + 271, + 270, + 270, + 270, + 273, + 276, + 279, + 282, + 285, + 288, + 292, + 294, + 297, + 300, + 300, + 298, + 298, + 299, + 300, + 300, + 301, + 302, + 304, + 305, + 307, + 310, + 312, + 312, + 311, + 311, + 310, + 309, + 307, + 302, + 300, + 299, + 297, + 294, + 293, + 291, + 288, + 285, + 282, + 283, + 280, + 276, + 273, + 272, + 270, + 269, + 269, + 270, + 269, + 269, + 269, + 271, + 270, + 270, + 271, + 271, + 270, + 270, + 271, + 269, + 269, + 268, + 272, + 275, + 275, + 278, + 282, + 284, + 287, + 292, + 297, + 301, + 303, + 304, + 308, + 308, + 293, + 263, + 233, + 203, + 171 + ], + "hrvPosteriors": [ + [ + 0.05301560330435889, + 0.9469842947931665, + 1.018987245181659e-07, + 3.75001003788875e-12 + ], + [ + 0.023148698198501527, + 0.968014946285652, + 0.00883322214188201, + 3.133373964626324e-06 + ], + [ + 0.30033717559177464, + 0.6535357951039119, + 0.045566724330378894, + 0.0005603049739344654 + ], + [ + 0.009727720330958537, + 0.9140279316664034, + 0.07612128943391658, + 0.00012305856872153834 + ], + [ + 0.16218335255439606, + 0.712533317545325, + 0.12109692718621681, + 0.004186402714062025 + ], + [ + 0.004842347939832673, + 0.8592821109164551, + 0.1347050028609586, + 0.001170538282753624 + ], + [ + 0.0019625373616562304, + 0.682442054822224, + 0.3145435920547157, + 0.0010518157614040553 + ], + [ + 0.03418994091384001, + 0.23178305056438386, + 0.1403446805906191, + 0.5936823279311569 + ], + [ + 0.025539800899127094, + 0.9659197458918388, + 0.004563430297241541, + 0.003977022911792555 + ], + [ + 0.0035352895513437853, + 0.8350841535225112, + 0.15034706431866116, + 0.011033492607484019 + ], + [ + 0.0013527325411602966, + 0.765237056325098, + 0.22603059650556492, + 0.007379614628176816 + ], + [ + 0.0022338716644049934, + 0.021785018017121394, + 0.005597823823359431, + 0.9703832864951142 + ], + [ + 0.020909636849612546, + 0.782947714600823, + 0.0012836475000178099, + 0.19485900104954657 + ], + [ + 0.0023119630446437078, + 0.9443124058699469, + 0.04125205818112047, + 0.012123572904288983 + ], + [ + 0.0038426776409560236, + 0.14445778018531713, + 0.016120858810090583, + 0.8355786833636363 + ], + [ + 0.0028117541423450403, + 0.7075994669971724, + 0.24797365143800565, + 0.04161512742247696 + ], + [ + 0.0010858341909701814, + 0.8418934705813255, + 0.1526792711011929, + 0.004341424126511473 + ], + [ + 0.0023798962112252862, + 0.6409078516965755, + 0.12988206419660692, + 0.22683018789559228 + ], + [ + 0.002377688076217665, + 0.10545736023929833, + 0.013316170481594714, + 0.8788487812028892 + ], + [ + 0.0028255079719356094, + 0.6861103558811094, + 0.2140668079623685, + 0.0969973281845865 + ], + [ + 0.000974991096470333, + 0.8774212981824885, + 0.1029935189485115, + 0.01861019177252962 + ], + [ + 0.0013962807774528653, + 0.18537379523280112, + 0.07454058067619922, + 0.7386893433135469 + ], + [ + 0.0021090276408943266, + 0.8887692294546846, + 0.07408417625017469, + 0.03503756665424635 + ], + [ + 0.0017722132095984258, + 0.779477524261435, + 0.15665423948469442, + 0.06209602304427206 + ], + [ + 0.002913969196519506, + 0.49596047530316734, + 0.08580081778626, + 0.4153247377140531 + ], + [ + 0.0011096469650001479, + 0.7159338505230143, + 0.2738580976570011, + 0.009098404854984565 + ], + [ + 0.0017410387938502983, + 0.531821131379794, + 0.12710704225363645, + 0.3393307875727193 + ] + ], + "basePosteriors": [ + [ + 0.12560202599177717, + 0.8740930220253468, + 1.2085927320657767e-08, + 0.0003049398969487461 + ], + [ + 0.1386007827551168, + 0.85451789316123, + 0.00391111284825405, + 0.0029702112353992473 + ], + [ + 0.6762559159172481, + 0.28443163825373924, + 0.038939500129040704, + 0.00037294569997188857 + ], + [ + 0.024621454682696216, + 0.8868305737024721, + 0.08559900893093106, + 0.0029489626839005628 + ], + [ + 0.539407922271448, + 0.27733947833775713, + 0.18063758438657815, + 0.0026150150042166032 + ], + [ + 0.028170453570825017, + 0.8559587852041093, + 0.08389643971053452, + 0.0319743215145312 + ], + [ + 0.004232933925455445, + 0.633046825759432, + 0.34817241376529123, + 0.014547826549821301 + ], + [ + 0.013890512311232294, + 0.2640197119639544, + 0.6834445979046893, + 0.03864517782012405 + ], + [ + 0.07369836379643847, + 0.8605990793605545, + 0.0060282537129193806, + 0.05967430313008765 + ], + [ + 0.015064526877103116, + 0.7462739947430297, + 0.11512150024495678, + 0.12353997813491054 + ], + [ + 0.004939905561667594, + 0.7212050806110325, + 0.2009344164495326, + 0.07292059737776735 + ], + [ + 0.011121691180010539, + 0.3028947077117094, + 0.5651295617474739, + 0.12085403936080609 + ], + [ + 0.05574785461368475, + 0.7922332182801174, + 0.004569776119814329, + 0.14744915098638353 + ], + [ + 0.007883697633050355, + 0.8527775158559245, + 0.03759465409629827, + 0.10174413241472695 + ], + [ + 0.010342220729426038, + 0.48648271217717165, + 0.1542601743129006, + 0.3489148927805016 + ], + [ + 0.0032036187269078773, + 0.5588559075892404, + 0.36556947939716283, + 0.0723709942866888 + ], + [ + 0.001906908749923003, + 0.6864001549364731, + 0.2951629195684229, + 0.016530016745180988 + ], + [ + 0.006183538621823292, + 0.47302697004071725, + 0.3203668522072337, + 0.2004226391302258 + ], + [ + 0.0069269463036760485, + 0.4606086645704482, + 0.17351278263897973, + 0.35895160648689606 + ], + [ + 0.003857509212323103, + 0.5416318814066214, + 0.34763819245474126, + 0.10687241692631413 + ], + [ + 0.003359045902433152, + 0.7302654600538115, + 0.10679745356399842, + 0.15957804047975688 + ], + [ + 0.0043220503242471595, + 0.2867580709960926, + 0.3355175132984311, + 0.3734023653812292 + ], + [ + 0.011085431143682455, + 0.8035794016964779, + 0.038282859246305014, + 0.14705230791353466 + ], + [ + 0.004964669246193727, + 0.6109407613047244, + 0.27203101285093156, + 0.11206355659815033 + ], + [ + 0.00745447124174285, + 0.5192119241370438, + 0.22459352451140885, + 0.24874008010980453 + ], + [ + 0.0028277407711199843, + 0.6444062207062919, + 0.2775295109130447, + 0.0752365276095434 + ], + [ + 0.004674685418027357, + 0.41297049479235964, + 0.21165132994645286, + 0.37070348984316015 + ] + ], + "labels": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllddddddddlllllllllllllllllllllllllllldddddddddddddddddddddddddddddddddddddddddddllllllllllllllllllllllllllllllllllllllllllllllllllllllllllrrrrrrrrrrrrrrrrrrrrrrrrllllllllllllllllllllllllllllllllllllrrrrrrrrrrrrrrrrrllllllllllllllllllllllllllllllllllllllllllllllllllllllllrrrrrrrrrrrrrrrrrrrrrrrrrrrllllllllllllllllllllllllllllllllllllllllllllllrrrrrrrrrrrrrrrrlllllllllllllllllllllllllllllllllrrrrrrrrrrrrrrrrrrllllllllllllllllllllllllllllllllllrrrrrrrrrrrrrrwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "segments": [ + [ + 1700000017, + 1700001210, + "wake" + ], + [ + 1700001210, + 1700003370, + "light" + ], + [ + 1700003370, + 1700003610, + "deep" + ], + [ + 1700003610, + 1700004450, + "light" + ], + [ + 1700004450, + 1700005740, + "deep" + ], + [ + 1700005740, + 1700007480, + "light" + ], + [ + 1700007480, + 1700008200, + "rem" + ], + [ + 1700008200, + 1700009280, + "light" + ], + [ + 1700009280, + 1700009790, + "rem" + ], + [ + 1700009790, + 1700011470, + "light" + ], + [ + 1700011470, + 1700012280, + "rem" + ], + [ + 1700012280, + 1700013660, + "light" + ], + [ + 1700013660, + 1700014140, + "rem" + ], + [ + 1700014140, + 1700015130, + "light" + ], + [ + 1700015130, + 1700015670, + "rem" + ], + [ + 1700015670, + 1700016690, + "light" + ], + [ + 1700016690, + 1700017110, + "rem" + ], + [ + 1700017110, + 1700018017, + "wake" + ] + ] + }, + { + "name": "whole session, no beats, HR gap", + "seed": 2, + "start": 1700100000, + "epochs": 360, + "rr": "none", + "hrGap": [ + 3600, + 5400 + ], + "window": null, + "gravity": true, + "samples": { + "grav": 10681, + "hr": 8834, + "rr": 0 + }, + "spanStart": 1700100000, + "n": 360, + "head": "base", + "stride": 15, + "features": { + "act_mean": [ + 2.7702670874320168, + 2.329712953432918, + 0.5395789362540139, + 0.6190237563705396, + 0.8652330661069421, + 0.5989217992536808, + 0.5352701418709845, + 0.5909745174983142, + 0.6116029750009709, + 0.9754811096617862, + 0.5267746141864521, + 0.5653401610712889, + 0.6276789498468427, + 0.8707202833208015, + 0.9446396964165777, + 0.6760015720498086, + 0.598620888122983, + 0.960466358323977, + 0.9148760051510952, + 0.49398510187113226, + 0.6058761920307346, + 0.6192104443374192, + 0.5363964713180561, + 0.6176870315830391 + ], + "act_max": [ + 5.7502166405242106, + 5.726967029246557, + 0.8497526136524244, + 0.8711329629369936, + 1.1952605257105362, + 0.9151395411246207, + 0.8385731946209825, + 0.8711329629369936, + 1.0013917836574855, + 1.3877287399183422, + 0.9375192697880518, + 0.9197232575764418, + 0.9375192697880518, + 1.381962188219385, + 1.288165593554945, + 0.9287262836801645, + 0.8328514548546639, + 1.3976372850862882, + 1.371679948747555, + 0.8711329629369936, + 0.8962214665976243, + 0.9375192697880518, + 0.9151395411246207, + 0.8962214665976243 + ], + "move_frac": [ + 0.5666666666666667, + 0.4, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "since_move": [ + 0.0, + 0.0, + 2.4849066497880004, + 3.295836866004329, + 3.7376696182833684, + 4.04305126783455, + 4.276666119016055, + 4.465908118654584, + 4.624972813284271, + 4.762173934797756, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741 + ], + "until_move": [ + 0.0, + 0.0, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.795790545596741, + 4.663439094112067, + 4.51085950651685, + 4.330733340286331 + ], + "act_ctx2": [ + 2.2505149502644497, + 2.1549631468726957, + 0.5980433715081019, + 0.5855604905889606, + 0.8519978375355464, + 0.5818795657491108, + 0.5853129770883981, + 0.5925282106129259, + 0.5967502737241859, + 0.9535250256514416, + 0.5752443360285554, + 0.5801386402136757, + 0.5661312462796104, + 0.7990572440622998, + 0.9243587990071035, + 0.6237747929495185, + 0.5987282728721481, + 0.7860771496159373, + 0.8917234539366575, + 0.5641717704252244, + 0.5884361487548062, + 0.5909323808945146, + 0.5465916110115302, + 0.594891526984668 + ], + "act_ctx5": [ + 1.960221484540306, + 1.8448612563005822, + 0.5885962673729598, + 0.5815334345584285, + 0.8375150947797381, + 0.6764183708121126, + 0.5987654891483203, + 0.5978173098505434, + 0.6031030727926079, + 0.944189559119593, + 0.6768538121973016, + 0.5887892468226193, + 0.5840062140695828, + 0.7573830952640193, + 0.9131731417680659, + 0.6147148843901598, + 0.5917709589478993, + 0.748786848625351, + 0.9254053592131455, + 0.5737644082216103, + 0.5791575334184038, + 0.6051747005239644, + 0.6526148738362824, + 0.630181569371597 + ], + "act_ctx10": [ + 2.030333681094763, + 1.598103078709923, + 0.6052352977522839, + 0.5780340351580028, + 0.7825679018796088, + 0.7175258923433339, + 0.5863785334487049, + 0.5738231489880379, + 0.6255755871069159, + 0.8703690398830046, + 0.7083692829688912, + 0.5872860887482445, + 0.5849878112023464, + 0.7535812576770669, + 0.8984264058422866, + 0.6741747386986119, + 0.5886003777225277, + 0.7573419114426914, + 0.8649471336195166, + 0.6190019399944875, + 0.5922659804108827, + 0.6060486606255311, + 0.7111042218930311, + 0.6901664252655058 + ], + "act_ctx20": [ + 1.929961180350649, + 1.3739509729320227, + 0.9281399734054506, + 0.6506678552609428, + 0.7030718292123475, + 0.713984718054214, + 0.6138799790072931, + 0.5907826723080289, + 0.6971857589748836, + 0.750512924311242, + 0.7359969179647127, + 0.6098969307025928, + 0.6319182390526944, + 0.7493545257625022, + 0.789349524799858, + 0.7138235810184977, + 0.6394393381093335, + 0.7579631047352975, + 0.7650926258108073, + 0.6849107800345682, + 0.5902600384712772, + 0.6128794417234901, + 0.6566568758933613, + 0.6595537003226858 + ], + "dangn": [ + 5.149323137751794, + 5.971685937279725, + 0.8174154575068686, + 0.5636662561152287, + 0.869057949157838, + 0.9515594451891284, + 0.5788990018299041, + 0.5723151875599831, + 0.7047513995423603, + 0.9396158766912778, + 0.8667256787902352, + 0.5124129584046381, + 0.6768821914978232, + 1.2889490870169287, + 0.6128440064206966, + 0.4604475023785878, + 0.5257226921412952, + 0.675838011025597, + 0.9891580111861794, + 0.7877749482700366, + 0.7057038761938039, + 0.6681698940113048, + 0.44965739904958335, + 0.4487248609177658 + ], + "dangn_ctx2": [ + 4.739431828923633, + 4.627846174272605, + 0.7009749222392969, + 0.5347182924550026, + 0.8763137686763599, + 0.5538618256632901, + 0.6263121436457538, + 0.6214403812549335, + 0.805135843891253, + 0.934415371476689, + 0.6563671555534369, + 0.5214233397744351, + 0.578947156466046, + 0.9594849358712395, + 0.8012849375198698, + 0.6072305985160824, + 0.5308649584135197, + 0.7171150064148037, + 0.9444010964277482, + 0.6684391988535594, + 0.6355855477169825, + 0.5452996808535204, + 0.5336106940844161, + 0.5309113116782441 + ], + "dangn_ctx5": [ + 4.551795848117707, + 4.068830059107281, + 0.7440997215730363, + 0.5764356292784033, + 0.8650532477180093, + 0.7485874156902099, + 0.6681914774294843, + 0.6660313665387037, + 0.7345910647737939, + 0.9073265153928751, + 0.7289085555811219, + 0.5324660772892131, + 0.5423152140440508, + 0.8729794702629295, + 0.853147203758115, + 0.5898256688201141, + 0.6629303209945916, + 0.7016764930150198, + 0.9135929253609137, + 0.6361530391006961, + 0.6843212625430769, + 0.6054521639026947, + 0.6097525855803745, + 0.5807831022804635 + ], + "dangn_ctx10": [ + 4.611377300701932, + 3.388216853335786, + 0.6827151046880593, + 0.6212085213497479, + 0.7992491529399833, + 0.7312285760471521, + 0.6249889425305558, + 0.6224032319647718, + 0.7063769682023823, + 0.9024746140003537, + 0.7725200228753286, + 0.5551962654640111, + 0.5588142951340612, + 0.7841458169568065, + 0.90338114928771, + 0.6850841675632575, + 0.6211120016209266, + 0.7619330179148724, + 0.877798891903854, + 0.677277286275474, + 0.6585251598370919, + 0.6374263488313383, + 0.7316280890284231, + 0.6825260420590856 + ], + "dangn_ctx20": [ + 4.31174673150613, + 2.805690383244354, + 1.5630379810506183, + 0.7054831341909167, + 0.729020483087985, + 0.751462515097534, + 0.6602431813167574, + 0.6615828955620486, + 0.7457915513927615, + 0.8130442945912552, + 0.7526427671403433, + 0.5986717835620691, + 0.6239290134772437, + 0.7475753173521907, + 0.813427178772941, + 0.7171532818774972, + 0.6423788427648061, + 0.7770127691590678, + 0.7813107369875854, + 0.7352393785882535, + 0.6368227337746043, + 0.6423622666128994, + 0.6532081717878031, + 0.653628381188847 + ], + "hr_z": [ + 2.453513490681638, + 2.438615507411643, + 0.10198444717032314, + -1.2153109156502884, + 1.0361141350117624, + 0.34365661938847425, + -0.34338684216426546, + -1.5561349539673663, + null, + null, + null, + null, + -1.3363243587030553, + 0.5282758631942147, + 0.8011441883499117, + 0.34505680578603143, + -1.7001487922439846, + 0.4524791062065203, + 0.6743806464911847, + 0.2175091595446697, + -1.1262590607657323, + -0.9963217630725416, + -0.32143191945058847, + -0.14108791144538516 + ], + "hr_pct": [ + 0.9766666666666667, + 0.9733333333333334, + 0.56, + 0.15, + 0.9166666666666666, + 0.6616666666666666, + 0.28833333333333333, + 0.04666666666666667, + null, + null, + null, + null, + 0.12333333333333334, + 0.73, + 0.8383333333333334, + 0.6683333333333333, + 0.011666666666666667, + 0.71, + 0.7933333333333333, + 0.6183333333333333, + 0.17, + 0.21666666666666667, + 0.31, + 0.39 + ], + "hrsd5_pct": [ + 0.03870967741935484, + 0.7806451612903226, + 0.18387096774193548, + 0.9258064516129032, + 0.6161290322580645, + 0.6580645161290323, + 0.24838709677419354, + 0.7677419354838709, + 0.7903225806451613, + null, + null, + null, + 0.3580645161290323, + 0.6741935483870968, + 0.025806451612903226, + 0.08709677419354839, + 0.6419354838709678, + 0.6870967741935484, + 0.24516129032258063, + 0.04838709677419355, + 0.7516129032258064, + 0.41935483870967744, + 0.5774193548387097, + 0.4161290322580645 + ], + "hrsd5_z": [ + -0.8380233664172635, + 0.7447959840135315, + -0.7431810264666436, + 1.4689307206301396, + -0.10093695882818943, + -0.06016992015986352, + -0.7196161299303468, + 0.6488582941679918, + 0.7666557221832676, + null, + null, + null, + -0.665053824218379, + -0.017263185706956896, + -0.8474928685382602, + -0.7963810312188834, + -0.07241648612849219, + 0.06135945338377005, + -0.7227738996033972, + -0.8296831517718382, + 0.4754680616345506, + -0.5722030454480916, + -0.2110222224775358, + -0.5864383955242454 + ], + "hrsd11_pct": [ + 0.027950310559006212, + 0.9751552795031055, + 0.4720496894409938, + 0.6583850931677019, + 0.40372670807453415, + 0.5590062111801242, + 0.08385093167701864, + 0.6645962732919255, + 0.37267080745341613, + null, + null, + null, + 0.7981366459627329, + 0.8105590062111802, + 0.049689440993788817, + 0.8074534161490683, + 0.6863354037267081, + 0.9347826086956522, + 0.13354037267080746, + 0.11180124223602485, + 0.7453416149068323, + 0.3695652173913043, + 0.65527950310559, + 0.16770186335403728 + ], + "hrsd11_z": [ + -1.2602910461726944, + 2.86314207683331, + -0.18488566920268298, + 0.2102623562429907, + -0.3956080265868198, + 0.01609311370412474, + -1.1489581996215643, + 0.2323811913732413, + -0.5021572466577114, + null, + null, + null, + 0.6867931136232736, + 0.7865653981909589, + -1.233242701776898, + 0.706368392437661, + 0.2664205695667605, + 1.6029474110536002, + -0.992503758116166, + -1.0666541954093633, + 0.43957868046441795, + -0.5022588438225821, + 0.198788535991871, + -0.9602026902634422 + ], + "hrsd20_pct": [ + 0.08529411764705883, + 0.9588235294117647, + 0.95, + 0.5294117647058824, + 0.638235294117647, + 0.3382352941176471, + 0.6352941176470588, + 0.45294117647058824, + 0.1323529411764706, + 0.25882352941176473, + null, + 0.014705882352941176, + 0.7558823529411764, + 0.75, + 0.041176470588235294, + 0.8705882352941177, + 0.7294117647058823, + 0.8794117647058823, + 0.052941176470588235, + 0.5147058823529411, + 0.5029411764705882, + 0.25588235294117645, + 0.31176470588235294, + 0.12941176470588237 + ], + "hrsd20_z": [ + -1.1855192365404204, + 2.361671421774616, + 2.2725644518570975, + -0.19416692445349854, + 0.08427607654035128, + -0.48237106613558367, + 0.06478927317604324, + -0.36636238010565697, + -0.88010336886625, + -0.6260058543972675, + null, + -1.74783716069723, + 0.6709903746475665, + 0.6566121770465262, + -1.4348934190784954, + 1.026358058802556, + 0.612554173445191, + 1.0499435359999472, + -1.3601740455370925, + -0.23247291414621277, + -0.24847059786480724, + -0.642427477291693, + -0.52956244498783, + -0.8900036145791212 + ], + "hr_ctx5_z": [ + 2.6756449032868947, + 2.452892992522833, + -0.027318585550461787, + -0.6151327703775328, + 0.893213078338406, + 0.6008434700545876, + -0.27888018658002744, + -1.2921315063723522, + -1.256433833294602, + null, + null, + null, + -1.4821302963554264, + 0.47057495294257434, + 0.9271523013258939, + 0.4598827856657792, + -1.4265030610595266, + 0.15171331042425756, + 0.7351072283785293, + 0.226920677142385, + -0.931442410993018, + -1.1378506820337666, + -0.07434859889217511, + 0.010996553805979435 + ], + "hr_ctx10_z": [ + 2.912704053769253, + 2.0897397720965225, + -0.05333981929277211, + -0.2691691804272955, + 0.8767996373716882, + 0.5941972982180183, + -0.25293889092455757, + -1.1773660479731627, + -1.4536030805358235, + null, + null, + null, + -0.9783263318924607, + 0.3937938585714428, + 1.0398365192158148, + 0.2775189028426558, + -1.3239105633102177, + -0.16091543422477025, + 0.7088087859021578, + 0.36200619444212045, + -0.6825924311903371, + -1.0274488438637044, + -0.10167284314594778, + 0.13560424515944072 + ], + "hr_ctx30_z": [ + 2.9508017938573694, + 1.7835413238110653, + 1.5492043419556782, + 0.8850515950459307, + 0.530850447133664, + 0.448078839660317, + -0.1763365683897635, + -0.8611973472973278, + -1.3967855084145666, + -2.122375541142269, + -2.0706801270081825, + -0.7230442160695654, + 0.41537780794082513, + 0.7364358746067137, + 0.3779429351642151, + 0.19517703334036163, + 0.10444483115148004, + -0.0337410668080571, + 0.0656574641503635, + 0.1546485235066003, + -0.36603053066379243, + -0.4649197800128856, + -0.5875331048630105, + -0.2844421814146492 + ], + "hr_trend": [ + 0.1362252360050112, + -1.5192722675917258, + 0.20938366865126545, + 0.9477083463974897, + 1.025611631592119, + -0.932751118601719, + 0.01281226175210649, + -0.9473543918567965, + null, + null, + null, + null, + null, + 1.4403969841430349, + 0.13241047916344745, + -0.8789996218039562, + -0.9485352672400679, + 2.07454677359186, + 0.017741882701904514, + 0.05932216375671634, + -0.8799295280047283, + 0.2518694517384922, + 1.0873094961931706, + -0.3051199583548726 + ], + "hr_over_low": [ + 3.987667385122572, + 3.972769401852577, + 1.6361383416112574, + 0.3188429787906458, + 2.5702680294526967, + 1.8778105138294086, + 1.1907670522766687, + -0.02198105952643224, + null, + null, + null, + null, + 0.19782953573787884, + 2.062429757635149, + 2.335298082790846, + 1.8792107002269656, + -0.1659948978030505, + 1.9866330006474544, + 2.208534540932119, + 1.751663053985604, + 0.4078948336752019, + 0.5378321313683926, + 1.2127219749903457, + 1.393065982995549 + ], + "clock": [ + 0.0, + 0.04178272980501393, + 0.08356545961002786, + 0.12534818941504178, + 0.1671309192200557, + 0.20891364902506965, + 0.25069637883008355, + 0.2924791086350975, + 0.3342618384401114, + 0.37604456824512533, + 0.4178272980501393, + 0.4596100278551532, + 0.5013927576601671, + 0.5431754874651811, + 0.584958217270195, + 0.6267409470752089, + 0.6685236768802229, + 0.7103064066852368, + 0.7520891364902507, + 0.7938718662952646, + 0.8356545961002786, + 0.8774373259052924, + 0.9192200557103064, + 0.9610027855153204 + ], + "minutes": [ + 0.0, + 7.5, + 15.0, + 22.5, + 30.0, + 37.5, + 45.0, + 52.5, + 60.0, + 67.5, + 75.0, + 82.5, + 90.0, + 97.5, + 105.0, + 112.5, + 120.0, + 127.5, + 135.0, + 142.5, + 150.0, + 157.5, + 165.0, + 172.5 + ], + "time_e10": [ + 1.0, + 0.4723665527410147, + 0.22313016014842982, + 0.10539922456186433, + 0.049787068367863944, + 0.023517745856009107, + 0.011108996538242306, + 0.005247518399181385, + 0.0024787521766663585, + 0.0011708796207911744, + 0.0005530843701478336, + 0.00026125855730166754, + 0.00012340980408667956, + 5.829466373086881e-05, + 2.7536449349747158e-05, + 1.300729765406762e-05, + 6.14421235332821e-06, + 2.902320408650404e-06, + 1.3709590863840845e-06, + 6.475952175842209e-07, + 3.059023205018258e-07, + 1.4449802461092448e-07, + 6.82560337633487e-08, + 3.2241867372567335e-08 + ], + "time_e30": [ + 1.0, + 0.7788007830714049, + 0.6065306597126334, + 0.4723665527410147, + 0.36787944117144233, + 0.2865047968601901, + 0.22313016014842982, + 0.17377394345044514, + 0.1353352832366127, + 0.10539922456186433, + 0.0820849986238988, + 0.06392786120670757, + 0.049787068367863944, + 0.03877420783172201, + 0.0301973834223185, + 0.023517745856009107, + 0.01831563888873418, + 0.014264233908999256, + 0.011108996538242306, + 0.008651695203120634, + 0.006737946999085467, + 0.005247518399181385, + 0.004086771438464067, + 0.003182780796509667 + ], + "time_e90": [ + 1.0, + 0.9200444146293233, + 0.8464817248906141, + 0.7788007830714049, + 0.7165313105737893, + 0.6592406302004438, + 0.6065306597126334, + 0.5580351457700471, + 0.513417119032592, + 0.4723665527410147, + 0.4345982085070782, + 0.39984965434484737, + 0.36787944117144233, + 0.3384654251067422, + 0.3114032239145977, + 0.2865047968601901, + 0.26359713811572677, + 0.24252107463564868, + 0.22313016014842982, + 0.20528965757990927, + 0.18887560283756183, + 0.17377394345044514, + 0.1598797460796939, + 0.1470964673929768 + ], + "hrv_meanNN_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_meanNN_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_sdnn_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_sdnn_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_lnrmssd_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_lnrmssd_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_pnn50_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_pnn50_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_lnlf_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_lnlf_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_lnhf_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_lnhf_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_lnlfhf_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_lnlfhf_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_resp_bpm_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_resp_bpm_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_hf_peaked_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_hf_peaked_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_dfa_a1_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_dfa_a1_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_beats_per_min_r": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hrv_beats_per_min_ctx": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "beats": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "hrvPosteriors": [ + [ + 0.9997340036989492, + 0.00026599630063201815, + 4.179241168781319e-13, + 8.810505000595815e-16 + ], + [ + 0.9919223880644404, + 0.008076980891314842, + 6.308005016148668e-07, + 2.4374305296814347e-10 + ], + [ + 0.13441860518486495, + 0.8607775897242023, + 0.004796551613547951, + 7.253477384941637e-06 + ], + [ + 0.007140645028625998, + 0.9215179548204188, + 0.07113114096987486, + 0.0002102591810804987 + ], + [ + 0.013564457647152028, + 0.6192088579925017, + 0.3659895648483985, + 0.0012371195119477622 + ], + [ + 0.00653443942845453, + 0.6104337185453503, + 0.38058745367694996, + 0.0024443883492452603 + ], + [ + 0.0015366359026576387, + 0.42400796566331816, + 0.5732948185127846, + 0.0011605799212396305 + ], + [ + 0.0015165968137675222, + 0.6114990339910967, + 0.3814201637013723, + 0.00556420549376354 + ], + [ + 0.0019150416120762234, + 0.44037950491219885, + 0.5468029311100735, + 0.010902522365651505 + ], + [ + 0.0017485448989516179, + 0.36768130994989723, + 0.613623144957194, + 0.016947000193957038 + ], + [ + 0.0014997325969430026, + 0.36301228728534285, + 0.6183998999543314, + 0.017088080163382792 + ], + [ + 0.0015638681220174744, + 0.48284535284152474, + 0.4931565363769949, + 0.022434242659462762 + ], + [ + 0.0012678800752724997, + 0.5843513244868126, + 0.40481279257842534, + 0.009568002859489473 + ], + [ + 0.004895230916119355, + 0.5482774633707471, + 0.4005387943966588, + 0.0462885113164746 + ], + [ + 0.003094263533935711, + 0.4538077609706542, + 0.5273623627780952, + 0.01573561271731494 + ], + [ + 0.0035906279802268414, + 0.5672278433327084, + 0.4162403891637107, + 0.012941139523354092 + ], + [ + 0.0022409428771907975, + 0.7718015546115874, + 0.18924081201237952, + 0.03671669049884241 + ], + [ + 0.006251286187820903, + 0.5911255342314956, + 0.31844387055054924, + 0.08417930903013428 + ], + [ + 0.004823013144860285, + 0.5544458434159455, + 0.38614733186858535, + 0.05458381157060889 + ], + [ + 0.0032549092914373563, + 0.5456392193761712, + 0.4276400984298726, + 0.023465772902518816 + ], + [ + 0.002917235798719135, + 0.7522857209369533, + 0.15021775169752255, + 0.09457929156680482 + ], + [ + 0.0023436080447678384, + 0.6536879190907889, + 0.27667291322955745, + 0.0672955596348859 + ], + [ + 0.0026959794768900563, + 0.6153813791963195, + 0.26375710631467203, + 0.11816553501211849 + ], + [ + 0.003182636093696888, + 0.6165838299037918, + 0.2570853751066024, + 0.12314815889590884 + ] + ], + "basePosteriors": [ + [ + 0.9996564779156081, + 0.00034350497954204975, + 1.250121705482694e-12, + 1.710359973557234e-08 + ], + [ + 0.9939417611118385, + 0.006056294162746073, + 4.275700788352061e-07, + 1.5171553366051746e-06 + ], + [ + 0.16307311876867298, + 0.8306499160492502, + 0.005520416301061387, + 0.0007565488810154078 + ], + [ + 0.013802813723247886, + 0.9155026744525713, + 0.06760922064802041, + 0.0030852911761605167 + ], + [ + 0.021011706994879937, + 0.5031004709060577, + 0.46168308408196274, + 0.014204738017099566 + ], + [ + 0.01092120238699151, + 0.5236583250494641, + 0.44953461332063593, + 0.0158858592429085 + ], + [ + 0.0017890577720447369, + 0.3122718453218392, + 0.6836799060496475, + 0.002259190856468523 + ], + [ + 0.0028441758884045977, + 0.688241157851589, + 0.29555137438224116, + 0.013363291877765254 + ], + [ + 0.0036227635664865835, + 0.523163884056919, + 0.45300024249866805, + 0.020213109877926384 + ], + [ + 0.002811187294307395, + 0.340949679853929, + 0.6173263878952517, + 0.03891274495651184 + ], + [ + 0.00348376148919095, + 0.3753325791862245, + 0.5763294054317158, + 0.04485425389286872 + ], + [ + 0.002589591398927851, + 0.41715349394666246, + 0.528126061520081, + 0.052130853134328616 + ], + [ + 0.002626513063113342, + 0.6934839215044307, + 0.2796471971739157, + 0.02424236825854031 + ], + [ + 0.008585687053201925, + 0.49331215910942167, + 0.273373238313039, + 0.22472891552433744 + ], + [ + 0.002024566318373662, + 0.20964405113470735, + 0.7685752528985924, + 0.019756129648326588 + ], + [ + 0.0076941624837152675, + 0.5615691344976633, + 0.3871751804095392, + 0.043561522609082255 + ], + [ + 0.004351980664436752, + 0.8157022116418592, + 0.10554156455576713, + 0.07440424313793689 + ], + [ + 0.012076902020667501, + 0.5186694689714515, + 0.1449150971590311, + 0.3243385318488498 + ], + [ + 0.004449725425565392, + 0.3465290433659124, + 0.5579553432516057, + 0.0910658879569166 + ], + [ + 0.003755812253475904, + 0.3883544497983343, + 0.5741538584939633, + 0.03373587945422658 + ], + [ + 0.005062834151186395, + 0.7252714359514323, + 0.09554746553586525, + 0.174118264361516 + ], + [ + 0.003783799872073003, + 0.6490575934286652, + 0.2540703628003643, + 0.09308824389889743 + ], + [ + 0.005976140050753282, + 0.5510326370588743, + 0.22128632879222643, + 0.22170489409814612 + ], + [ + 0.005201466358499929, + 0.5010549574640011, + 0.31910602747053574, + 0.17463754870696327 + ] + ], + "labels": "wwwwwwwwwwwwwwwwwwwwwwwwwllllllllllllllllllllllllllllllllldddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddlllllllllllllllllldddddddddddddddddddddddddllllllllllllllllllllllllllllllllllllllddddddddddddddddddddddddddddddddllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll", + "segments": [ + [ + 1700100000, + 1700100750, + "wake" + ], + [ + 1700100750, + 1700101740, + "light" + ], + [ + 1700101740, + 1700105370, + "deep" + ], + [ + 1700105370, + 1700105910, + "light" + ], + [ + 1700105910, + 1700106660, + "deep" + ], + [ + 1700106660, + 1700107800, + "light" + ], + [ + 1700107800, + 1700108760, + "deep" + ], + [ + 1700108760, + 1700110800, + "light" + ] + ] + }, + { + "name": "short nap, sparse beats", + "seed": 3, + "start": 1700200010, + "epochs": 50, + "rr": "sparse", + "hrGap": null, + "window": null, + "gravity": true, + "samples": { + "grav": 1484, + "hr": 1467, + "rr": 582 + }, + "spanStart": 1700200020, + "n": 50, + "head": "hrv", + "stride": 2, + "features": { + "act_mean": [ + 2.196510527385624, + 2.6180502311800553, + 1.388028155750276, + 2.0740104783201914, + 1.8056942175071526, + 1.7718031127930585, + 1.7575630374882916, + 2.676140004799776, + 1.82433702641922, + 2.5612650253280314, + 0.5819216625204782, + 0.5931406434511814, + 0.5659491881970138, + 0.6133627845534625, + 0.6023426872789163, + 0.5586748447914843, + 0.5308412373780387, + 0.5662493688949922, + 0.6052124698910273, + 0.5855630889727385, + 0.6080749048835188, + 0.6053518463486547, + 0.5865553801643771, + 0.6160871228001845, + 0.6068961693715774 + ], + "act_max": [ + 5.962181191334865, + 5.777489029311302, + 5.219680822634464, + 5.754778392923836, + 4.5088572652052425, + 5.662398289036091, + 5.1301770200841, + 5.584994543201638, + 4.535964034353901, + 4.61037787659543, + 0.933824116699362, + 0.9288973261509677, + 0.8699328134319949, + 0.8459216324722841, + 0.8202104958385948, + 0.984180005392285, + 0.933824116699362, + 1.0362779917606744, + 0.9482307085514864, + 0.8459216324722841, + 0.9482307085514864, + 0.8924706260797746, + 0.8396632316180376, + 1.043709830134217, + 0.9482307085514864 + ], + "move_frac": [ + 0.36666666666666664, + 0.5, + 0.23333333333333334, + 0.36666666666666664, + 0.23333333333333334, + 0.3, + 0.23333333333333334, + 0.4666666666666667, + 0.3333333333333333, + 0.5, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "since_move": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.6931471805599453, + 1.3862943611198906, + 1.791759469228055, + 2.0794415416798357, + 2.302585092994046, + 2.4849066497880004, + 2.6390573296152584, + 2.772588722239781, + 2.8903717578961645, + 2.995732273553991, + 3.091042453358316, + 3.1780538303479458, + 3.258096538021482, + 3.332204510175204, + 3.4011973816621555 + ], + "until_move": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4.51085950651685, + 4.48863636973214, + 4.465908118654584, + 4.442651256490317, + 4.418840607796598, + 4.394449154672439, + 4.3694478524670215, + 4.343805421853684, + 4.31748811353631, + 4.290459441148391, + 4.2626798770413155, + 4.23410650459726, + 4.204692619390966, + 4.174387269895637, + 4.143134726391533 + ], + "act_ctx2": [ + 1.9761922285573597, + 1.8133121572375188, + 1.8410642043902108, + 1.719486219470232, + 2.0447348709354034, + 2.116682292277972, + 2.2691584215335303, + 2.0902393319474593, + 2.2057116028059816, + 1.6330390153145842, + 1.06502026685435, + 0.5927119048162183, + 0.5763077477326561, + 0.600543713800785, + 0.6052249396139231, + 0.5762384418561709, + 0.5751508969623316, + 0.5926015963703705, + 0.6004684838932036, + 0.5970667423011691, + 0.5993553785898242, + 0.602220838826095, + 0.623457389891297, + 0.6238965520412705, + 0.5701773907736367 + ], + "act_ctx5": [ + 1.7403061663538144, + 1.8087836887851656, + 1.8893708240006846, + 1.9181461278906609, + 1.966798514291149, + 2.077856071028488, + 2.137709623193956, + 2.1118442957968115, + 1.827009333599751, + 1.5318345768878452, + 1.1628445117120447, + 0.9597202368402097, + 0.6278474066254993, + 0.5969468660901291, + 0.5865445708957673, + 0.5947793797628755, + 0.598215592284965, + 0.5893693275642775, + 0.5962758771108114, + 0.6026147177428371, + 0.6130140758959134, + 0.6099256128665609, + 0.5988883043071428, + 0.6000157020015998, + 0.5966510776288843 + ], + "act_ctx10": [ + 1.8786828502545367, + 1.927206228590091, + 2.0159666898449666, + 1.9851361525835405, + 2.0311535977318225, + 1.9089606020987564, + 1.8116775338741107, + 1.6567275178665324, + 1.581062655377979, + 1.44934811315048, + 1.293486020693798, + 1.1375862606790694, + 0.9909825700820027, + 0.8426008850198188, + 0.7037825730281527, + 0.5948317339513604, + 0.5932708165117627, + 0.595089779349593, + 0.6048783950354775, + 0.6014017554599246, + 0.5949809441380627, + 0.5968483634156149, + 0.5993821104277868, + 0.5989625021085888, + 0.5977778696959426 + ], + "act_ctx20": [ + 1.9089606020987564, + 1.7980762898194933, + 1.69879242022538, + 1.616450102261498, + 1.548502754704876, + 1.485706924986342, + 1.4298614972255559, + 1.3821141232741936, + 1.3410161122410043, + 1.3028227529530205, + 1.2682370096227311, + 1.2173359686500527, + 1.1395661964229662, + 1.104668274978759, + 1.0356927939887703, + 0.9626037618134882, + 0.8974142310095652, + 0.8287360658242126, + 0.7492612101483944, + 0.6677251681553056, + 0.5963869243664323, + 0.5941158817910431, + 0.5959229767349574, + 0.598627864001641, + 0.5951646142447218 + ], + "dangn": [ + 5.228405008953735, + 5.274883010369076, + 3.6131603757231208, + 4.803124489114776, + 3.295523091717329, + 4.366956636450069, + 3.233323375813303, + 4.370991483620221, + 3.5255623476330156, + 3.133255203933447, + 0.3755528801541004, + 0.4527534786113542, + 0.2914500603203592, + 0.5213528610987848, + 0.6040835347015029, + 0.32152648652769306, + 0.7034196528217731, + 0.5619962072462089, + 0.4750864740726034, + 0.6193231186933214, + 0.2816919143381953, + 0.4397626827562408, + 0.7216201852129155, + 0.4696398911140748, + 0.9014706270780571 + ], + "dangn_ctx2": [ + 3.9593662759989896, + 4.0984433647761, + 4.268482994331151, + 3.834162046152289, + 4.468780063147808, + 4.287839720379594, + 4.566037848851934, + 4.2080639362559165, + 4.136918897613699, + 3.0008723524230874, + 1.4811939413794335, + 0.5002487705361574, + 0.5266187394450327, + 0.4683199236617696, + 0.4647685713081728, + 0.5707759563119936, + 0.5664212092130356, + 0.5458027001932777, + 0.5375518522767436, + 0.48237918448544403, + 0.5073947840951831, + 0.5267277806704811, + 0.5042231815228121, + 0.5741922523063266, + 0.5264463967554498 + ], + "dangn_ctx5": [ + 3.8570843833614785, + 4.094292948400187, + 4.111944997945784, + 4.157437618575675, + 4.336413537463665, + 4.362069029368385, + 4.4713051122680625, + 4.27762894090684, + 3.508799694237294, + 2.8290942392868206, + 2.0223004422660917, + 1.3682324009360087, + 0.7212743845901741, + 0.520201556777442, + 0.5392194151224691, + 0.5318593799017244, + 0.5290463173112504, + 0.5391370571835911, + 0.5558391575183053, + 0.5084300091182299, + 0.5141024717876235, + 0.5033297169644997, + 0.5122863384134682, + 0.5469890331652765, + 0.5310712743950786 + ], + "dangn_ctx10": [ + 4.135127874173446, + 4.1687340145461125, + 4.263309281581168, + 4.235325793797985, + 4.224381169903462, + 3.975206567097532, + 3.7110829006429342, + 3.2726653539722603, + 3.0273233642654493, + 2.614325469160088, + 2.257111284082264, + 1.854512184435031, + 1.4976532745059803, + 1.1169329708396993, + 0.7631020254584784, + 0.512552045429856, + 0.5233131956249921, + 0.5193212452705122, + 0.5218661665169658, + 0.538931959209149, + 0.5296509411375417, + 0.5327069026560278, + 0.5201818114957417, + 0.5239140872081934, + 0.5212060701034554 + ], + "dangn_ctx20": [ + 3.975206567097532, + 3.6754763796143264, + 3.424201155262994, + 3.211714701842345, + 3.0225923599849427, + 2.8554447401672705, + 2.7243931723158124, + 2.600839042992082, + 2.484609960393687, + 2.3885930656118046, + 2.289448243485879, + 2.1577942571467092, + 1.935128500579948, + 1.8051616411056568, + 1.6003493283183527, + 1.4417707325487672, + 1.2586858017926938, + 1.0892119872586457, + 0.8916265627786999, + 0.6768841330367561, + 0.5228831417504821, + 0.5252527153175137, + 0.5183346330828476, + 0.5248056330318338, + 0.5335992898430313 + ], + "hr_z": [ + 1.1884659306259318, + 1.2391813907879354, + 1.2167949517116086, + 1.2220862918569213, + 1.1821977276845592, + 1.1935944603052344, + 1.2505781234086106, + 1.2931259251924627, + 1.1754817959616615, + 1.2049911929259096, + -0.5466866108718407, + -0.6348213431383939, + -0.4931219675546677, + -0.5273121654166935, + -0.6293129223717345, + -0.6513466054383729, + -0.578597462209731, + -0.5672007295890557, + -0.6733802885050112, + -0.5360632279647111, + -0.44202661630530854, + -0.7009223923383088, + -1.4675426066223813, + -1.3364801814846192, + -1.51882790341542 + ], + "hr_pct": [ + 0.76, + 0.92, + 0.86, + 0.88, + 0.74, + 0.78, + 0.94, + 0.98, + 0.72, + 0.84, + 0.46, + 0.35, + 0.56, + 0.52, + 0.38, + 0.31, + 0.42, + 0.44, + 0.24, + 0.49, + 0.58, + 0.18, + 0.04, + 0.1, + 0.02 + ], + "hrsd5_pct": [ + 0.04, + 0.1, + 0.06, + 0.16, + 0.14, + 0.24, + 0.36, + 0.6, + 0.88, + 0.98, + 0.96, + 0.9, + 0.64, + 0.42, + 0.5, + 0.48, + 0.46, + 0.3, + 0.52, + 0.66, + 0.72, + 0.78, + 0.8, + 0.68, + 0.34 + ], + "hrsd5_z": [ + -0.7491986471893057, + -0.7165496886384449, + -0.7355358347691281, + -0.6768667799653489, + -0.6803594455101866, + -0.6327714520610691, + -0.6185619991737255, + -0.5609079387043935, + 1.6403706986180442, + 2.473890827192836, + 2.4444054908595443, + 1.7498613908184337, + -0.2386184315445283, + -0.6151186989061516, + -0.5961685647225153, + -0.606295815213617, + -0.6091058675890348, + -0.6236700802710028, + -0.5831615216813058, + -0.2259975226540462, + 0.32099751483823397, + 0.48334929069029264, + 0.5301492534841195, + 0.1133717233990146, + -0.6210450775569644 + ], + "hrsd11_pct": [ + 0.04, + 0.02, + 0.1, + 0.14, + 0.18, + 0.64, + 0.72, + 0.8, + 0.88, + 0.96, + 1.0, + 0.9, + 0.82, + 0.74, + 0.66, + 0.24, + 0.22, + 0.28, + 0.32, + 0.36, + 0.42, + 0.48, + 0.5, + 0.56, + 0.6 + ], + "hrsd11_z": [ + -1.2362022983709762, + -1.236579818927687, + -1.2207935845221154, + -1.2082553121721122, + -1.1953538008816638, + 0.055130344424094924, + 0.78093740039622, + 1.2414799857351073, + 1.5132237691929122, + 1.6819890396947126, + 1.7156585442021894, + 1.5289237494339372, + 1.2764906480527909, + 0.8147505778519933, + 0.15383908357655535, + -0.9912923137699942, + -1.0071730122288873, + -0.7012243857861107, + -0.567388068950632, + -0.41108435828675016, + -0.38071659946740677, + -0.33228470215504546, + -0.3009808321790042, + -0.2562358855884752, + -0.1878933173923297 + ], + "hrsd20_pct": [ + 0.22, + 0.32, + 0.4, + 0.46, + 0.52, + 0.6, + 0.64, + 0.72, + 0.8, + 0.86, + 0.84, + 0.7, + 0.92, + 1.0, + 0.94, + 0.66, + 0.54, + 0.44, + 0.34, + 0.28, + 0.02, + 0.06, + 0.1, + 0.14, + 0.2 + ], + "hrsd20_z": [ + -1.4931243510239562, + -0.588111554007297, + -0.030107456197750102, + 0.31573578992696755, + 0.5154593897018885, + 0.68873062339006, + 0.7857436831911965, + 0.850383076151955, + 0.8987440706803408, + 0.9233889293540245, + 0.9229824380980958, + 0.8422604000370784, + 0.964341888203353, + 1.0052679386208967, + 0.9713922302678498, + 0.7935837878321376, + 0.526055762378938, + 0.18433350783828245, + -0.32085925914039604, + -0.9707587816428194, + -1.623083254949176, + -1.5916587341714743, + -1.5648720478988964, + -1.523764336186452, + -1.504408389764513 + ], + "hr_ctx5_z": [ + 1.2877666790954618, + 1.2899186989696199, + 1.3064630108120825, + 1.3135527926588846, + 1.3101836951103187, + 1.3084965018324677, + 1.296531445428636, + 1.2343710667569001, + 0.879062178310019, + 0.5079616367930719, + 0.1585195795169049, + -0.1706655804107464, + -0.519922522436992, + -0.6428575599096292, + -0.6513741836577861, + -0.6697746394999488, + -0.6877678417922851, + -0.690452012916147, + -0.6456726339602241, + -0.7311403448488205, + -0.8862669242827423, + -1.0035929696827626, + -1.1619114649260467, + -1.2889087537163948, + -1.483414341981959 + ], + "hr_ctx10_z": [ + 1.4201920607328322, + 1.4295571181936877, + 1.4287763303962207, + 1.419365606794113, + 1.4093761043982902, + 1.2728771576431668, + 1.0735401813712835, + 0.870930929056027, + 0.6716878827615849, + 0.4698163244155015, + 0.2505287736668266, + 0.04350404875349521, + -0.16101893139482434, + -0.3779971792834921, + -0.5732905845752947, + -0.7332739549328217, + -0.7367065992303605, + -0.8251229633613864, + -0.9124381321471284, + -1.0107003434256916, + -1.0615352472167485, + -1.0939984147209352, + -1.1362893052144007, + -1.1847296818732302, + -1.2447329914867138 + ], + "hr_ctx30_z": [ + 1.795992479768539, + 1.5755952118722234, + 1.3769261347764516, + 1.1836301477368605, + 1.0261785930913498, + 0.8939202433241122, + 0.771526974747709, + 0.5395801581122643, + 0.3371416789884571, + 0.14388813318340107, + 0.057686162588312634, + 0.057686162588312634, + 0.057686162588312634, + 0.057686162588312634, + 0.057686162588312634, + 0.057686162588312634, + -0.09975139606786482, + -0.2770351500664289, + -0.46246814032699857, + -0.6700914326514877, + -0.9097599326850585, + -1.1677913729664378, + -1.4472127713441243, + -1.7626260962016698, + -2.098800706865547 + ], + "hr_trend": [ + 1.0000128944006312, + 1.0763693251585749, + 1.0178377029707415, + 1.0535176936633837, + 1.0144977288795525, + 0.8220381706712664, + 0.1008637288493919, + -0.6092824334256322, + -1.3297413100861302, + -1.947941369155115, + -2.389217272072463, + -1.7268022540815506, + -0.9976333145954812, + -0.32519502430684644, + 0.2688178603003634, + 0.8952911390439705, + 1.0128823472278277, + 0.8448665753322516, + 0.5806113092624576, + 0.3902682963207788, + 0.039627967156645495, + -0.37875588064060767, + -0.3867148680486778, + -0.059397407112089066, + 0.07973713646356431 + ], + "hr_over_low": [ + 2.5552614208815467, + 2.60597688104355, + 2.5835904419672233, + 2.5888817821125363, + 2.548993217940174, + 2.5603899505608494, + 2.617373613664226, + 2.659921415448078, + 2.5422772862172764, + 2.5717866831815246, + 0.8201088793837742, + 0.7319741471172211, + 0.8736735227009472, + 0.8394833248389215, + 0.7374825678838803, + 0.7154488848172421, + 0.788198028045884, + 0.7995947606665592, + 0.6934152017506038, + 0.8307322622909039, + 0.9247688739503064, + 0.6658730979173062, + -0.10074711636676634, + 0.030315308770995628, + -0.152032413159805 + ], + "clock": [ + 0.0, + 0.04081632653061224, + 0.08163265306122448, + 0.12244897959183673, + 0.16326530612244897, + 0.20408163265306123, + 0.24489795918367346, + 0.2857142857142857, + 0.32653061224489793, + 0.3673469387755102, + 0.40816326530612246, + 0.4489795918367347, + 0.4897959183673469, + 0.5306122448979592, + 0.5714285714285714, + 0.6122448979591837, + 0.6530612244897959, + 0.6938775510204082, + 0.7346938775510204, + 0.7755102040816326, + 0.8163265306122449, + 0.8571428571428571, + 0.8979591836734694, + 0.9387755102040817, + 0.9795918367346939 + ], + "minutes": [ + 0.0, + 1.0, + 2.0, + 3.0, + 4.0, + 5.0, + 6.0, + 7.0, + 8.0, + 9.0, + 10.0, + 11.0, + 12.0, + 13.0, + 14.0, + 15.0, + 16.0, + 17.0, + 18.0, + 19.0, + 20.0, + 21.0, + 22.0, + 23.0, + 24.0 + ], + "time_e10": [ + 1.0, + 0.9048374180359595, + 0.8187307530779818, + 0.7408182206817179, + 0.6703200460356393, + 0.6065306597126334, + 0.5488116360940264, + 0.4965853037914095, + 0.44932896411722156, + 0.4065696597405991, + 0.36787944117144233, + 0.33287108369807955, + 0.30119421191220214, + 0.2725317930340126, + 0.2465969639416065, + 0.22313016014842982, + 0.20189651799465538, + 0.18268352405273466, + 0.16529888822158653, + 0.14956861922263506, + 0.1353352832366127, + 0.1224564282529819, + 0.11080315836233387, + 0.10025884372280375, + 0.09071795328941251 + ], + "time_e30": [ + 1.0, + 0.9672161004820059, + 0.9355069850316178, + 0.9048374180359595, + 0.8751733190429475, + 0.8464817248906141, + 0.8187307530779818, + 0.7918895663367816, + 0.7659283383646487, + 0.7408182206817179, + 0.7165313105737893, + 0.6930406200864415, + 0.6703200460356393, + 0.6483443410015097, + 0.6270890852730561, + 0.6065306597126334, + 0.5866462195100318, + 0.5674136687970038, + 0.5488116360940264, + 0.530819450562014, + 0.513417119032592, + 0.4965853037914095, + 0.4803053010897994, + 0.46455902036091146, + 0.44932896411722156 + ], + "time_e90": [ + 1.0, + 0.9889503892939223, + 0.9780228724846005, + 0.9672161004820059, + 0.9565287391030293, + 0.9459594689067654, + 0.9355069850316178, + 0.925169997034202, + 0.914947228730031, + 0.9048374180359595, + 0.8948393168143698, + 0.8849516907190785, + 0.8751733190429475, + 0.865502994567177, + 0.8559395234122652, + 0.8464817248906141, + 0.8371284313607636, + 0.8278784880832378, + 0.8187307530779818, + 0.8096840969833764, + 0.8007374029168081, + 0.7918895663367816, + 0.7831394949065555, + 0.7744861083592839, + 0.7659283383646487 + ], + "hrv_meanNN_r": [ + 0.006150016400277804, + 0.0036533165004194413, + 0.0032626181667410156, + 0.0007496309182045582, + 0.0006589599641298825, + 0.0011890347974227028, + 0.0010892928125695658, + 4.338918998307198e-05, + -3.550024634982579e-05, + -0.00046344727595680593, + 0.2757282704497657, + 0.29262837521537693, + 0.5781111194697218, + 0.6404001305507478, + 0.6405677806006925, + 0.6363700031823056, + 0.6396471555859431, + 0.639885578321332, + 0.6443361360485892, + 0.7227466690747627, + 0.7403822760036479, + 0.8226466923604665, + 0.9461285019422186, + 0.9527462946877626, + 1.0698993322175314 + ], + "hrv_meanNN_ctx": [ + 0.002422595990723338, + 0.0022019255498032654, + 0.002031121627546408, + 0.0017879896423821451, + 0.001588748830475972, + 0.022204432796033863, + 0.04954584028108273, + 0.09457166304923348, + 0.15541892123612386, + 0.2163781417258853, + 0.2770256424275318, + 0.33775718378973785, + 0.3985611581113724, + 0.4598018466029863, + 0.5248515145554085, + 0.5877491054504252, + 0.6362986702349172, + 0.6926448987046105, + 0.7252816944675228, + 0.7646909108291554, + 0.7924563604581503, + 0.8097203208181097, + 0.8309645650511688, + 0.8581300893770306, + 0.8938308672012987 + ], + "hrv_sdnn_r": [ + 0.025154374143681373, + 0.023775389489465944, + 0.026676537722618028, + 0.03038077701029475, + 0.02353688485919015, + 0.02385418556439243, + 0.0253775420315451, + 0.025053138652123465, + 0.0260233199866328, + 0.031450334915265735, + 0.9997674413380341, + 1.0068552012164056, + 0.5786194053337245, + 0.006629118229338373, + 0.005163030733810305, + 0.013114531777532471, + 0.0029765812110270195, + 0.002389302208283704, + -0.012749477102766313, + 0.4585972985730541, + 0.5027434076186111, + 0.6094803416384105, + 0.5619979270960505, + 0.5515679533827765, + 0.048166209071756554 + ], + "hrv_sdnn_ctx": [ + 0.023999334253232273, + 0.024601275183439276, + 0.02456659052439486, + 0.024737970461128737, + 0.025499619396357937, + 0.11186055122972109, + 0.20579150455738385, + 0.27857044519502777, + 0.27644524191448866, + 0.27456946240366337, + 0.27320216991978374, + 0.2711034791636093, + 0.2689973801424632, + 0.2659518436592655, + 0.28457560242414226, + 0.28977679834636366, + 0.24940926540273356, + 0.20966135017997833, + 0.23463074107111342, + 0.24968440336288572, + 0.2642387952897361, + 0.29246721760977096, + 0.3286902520972854, + 0.3754846472681366, + 0.4398760539670285 + ], + "hrv_lnrmssd_r": [ + 0.11746982940109305, + 0.08222982290157475, + 0.1211044450504299, + 0.15937606322722003, + 0.15567915097591406, + 0.15080214494119568, + 0.20575709286002017, + 0.2363645216249748, + 0.24909004489527115, + 0.3086343336233928, + 0.8944548200192787, + 0.8730239925958807, + 1.211483713453068, + 0.08804429313847816, + 0.06735343460236114, + 0.23270397829675796, + 0.08220087617070909, + 0.08602359055635217, + -0.10185965300101654, + 0.5691809129493614, + 0.5559311169170534, + 0.892351845343652, + 0.8544206661487895, + 0.8675908633707322, + 0.7331251860250119 + ], + "hrv_lnrmssd_ctx": [ + 0.11408530002023674, + 0.12986333890257087, + 0.1438362093569043, + 0.15621901353788867, + 0.1725222568437761, + 0.2503901377270764, + 0.3274430364953932, + 0.4221560908428105, + 0.41527297939714053, + 0.4101467357214427, + 0.4103142595083866, + 0.40357557719310744, + 0.39087723791965573, + 0.36558936045227225, + 0.3625690534480593, + 0.34911251573344665, + 0.33915309824375656, + 0.33393961415528106, + 0.3592854309564781, + 0.4351623996966235, + 0.4894368709143471, + 0.522300915954987, + 0.577074501278919, + 0.6493499740857246, + 0.7718926074232897 + ], + "hrv_pnn50_r": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.6464429530201344, + 0.6168348788360061, + 1.2243237746593454, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.7345942647956072, + 0.6965980097199724, + 1.303312405282529, + 0.7769747031492, + 0.7345942647956072, + 0.0 + ], + "hrv_pnn50_ctx": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07128694218768399, + 0.13003312112444648, + 0.2260585152153755, + 0.2260585152153755, + 0.2260585152153755, + 0.2260585152153755, + 0.2260585152153755, + 0.2260585152153755, + 0.2260585152153755, + 0.2610391944913568, + 0.2868779176589147, + 0.33060905113859607, + 0.35048289405966604, + 0.3621431204849931, + 0.4068858497914808, + 0.4272301422810548, + 0.4747001580900609, + 0.5340376778513185, + 0.6103287746872211, + 0.7120502371350913 + ], + "hrv_lnlf_r": [ + null, + 0.28230487735085413, + 0.2823135041758825, + 0.12195187990794233, + 0.32578991191809487, + 0.3257415724093702, + 0.018996144300738375, + 0.37201922239690155, + 0.3720119735677249, + 0.3820354733450517, + 0.2581234520186987, + 0.25824391071207803, + -0.023937676069406562, + 0.31989041561316883, + 0.31989376820838555, + -0.020287091519987824, + 0.23383742520245687, + 0.23380233213012533, + 0.14313587854850368, + 0.6460238783822984, + 0.6457310509161154, + -0.010228693085012982, + 1.0000243170974508, + 1.0, + 0.28565415666721206 + ], + "hrv_lnlf_ctx": [ + 0.27724184328488793, + 0.2548361043600341, + 0.26318377092661144, + 0.27678729625675064, + 0.2885709453259502, + 0.2846654446974912, + 0.28338159808975166, + 0.264250520264728, + 0.26851270940011684, + 0.27843915797090346, + 0.25895583394421773, + 0.2494450706007503, + 0.25868736592000086, + 0.23994764436295646, + 0.24101719598050697, + 0.2728513960958953, + 0.25845397084471, + 0.3339730446329408, + 0.41558782545072903, + 0.41694975698279146, + 0.4301780187970096, + 0.4726395680293107, + 0.5044821932777133, + 0.5481591847936449, + 0.6228036402000775 + ], + "hrv_lnhf_r": [ + null, + 0.647594698551057, + 0.6475948002281197, + 0.08182131964123142, + 0.7431692065117581, + 0.7431692258547516, + -0.012146660619323272, + 0.6983452968260329, + 0.698345149800482, + 0.24456688807587815, + 0.5861283686557678, + 0.5861292651200014, + -0.05308944505868565, + 0.4412128709386625, + 0.4412129191273265, + 0.11837245201257662, + 0.5203972412578156, + 0.5203973698575645, + -0.04221853297772471, + 0.5368210938847925, + 0.5368294213796457, + 0.022558084007314596, + 1.0352684020647198, + 1.0352687680124033, + 0.41322874230200296 + ], + "hrv_lnhf_ctx": [ + 0.5939422339427048, + 0.5449052225187473, + 0.556495856758293, + 0.5742270183885667, + 0.5549940541715505, + 0.5547497488138353, + 0.5602975951688126, + 0.5190540543875424, + 0.4939652861529223, + 0.4990625659561135, + 0.4490797673429884, + 0.4309174573813972, + 0.45468923703708064, + 0.40459417926748936, + 0.37601054529614253, + 0.3907780844461608, + 0.3463938758500726, + 0.3893213086375104, + 0.4823387263937891, + 0.48808819620090815, + 0.4994901048217598, + 0.524646416045888, + 0.5252129641109811, + 0.5362218711818714, + 0.6267401202754257 + ], + "hrv_lnlfhf_r": [ + null, + 0.05513016717161106, + 0.05514469984809788, + 0.17778953939322054, + 0.062166609885037014, + 0.06208476722432246, + 0.06896180115349154, + 0.1716469905846327, + 0.1716348221722359, + 0.504694517019272, + 0.05701189769481459, + 0.057215185907349426, + 0.024803114473442966, + 0.2625160360827343, + 0.26252167779344715, + -0.08845373754741934, + 0.06168732017395012, + 0.061627824981350054, + 0.30005328743680426, + 0.7479965435541812, + 0.7474950442065849, + -0.004684667514423954, + 1.0000414189128248, + 1.0, + 0.2240539724287139 + ], + "hrv_lnlfhf_ctx": [ + 0.08393256935918497, + 0.08016218387443168, + 0.08621934895013528, + 0.09689628310289786, + 0.1302409199256219, + 0.12379986165568586, + 0.11776205780500826, + 0.114106264674829, + 0.13879758604358536, + 0.1520504430474713, + 0.15388594169616393, + 0.15043756866495045, + 0.14952405250233752, + 0.1526965318834126, + 0.17441779002996402, + 0.21802001834597307, + 0.22456508140351167, + 0.3225015477879414, + 0.3958652735096309, + 0.3941657992931137, + 0.4086162905832957, + 0.46297193726395935, + 0.5164805192221339, + 0.5827484131560012, + 0.6460536247181836 + ], + "hrv_resp_bpm_r": [ + null, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "hrv_resp_bpm_ctx": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "hrv_hf_peaked_r": [ + null, + 1.0000041500127952, + 1.0, + 0.09061035310232762, + 0.7677468237336585, + 0.7677367919465883, + 0.03678342520673468, + 0.816126980529058, + 0.816136735633322, + 0.09152121552411839, + 0.8645318070561122, + 0.8644806278755999, + 0.05205258216705915, + 0.7452597397612094, + 0.7452591916318211, + 0.1899731777372803, + 0.7762582348671878, + 0.7762510444731229, + -0.045161396494390015, + 0.22226792586022887, + 0.2221590112310166, + -0.1950594269710675, + 0.29952285312613997, + 0.2995277822266267, + 0.15875964500792522 + ], + "hrv_hf_peaked_ctx": [ + 0.7238573471001015, + 0.6372306777610501, + 0.6665301563029624, + 0.6852309787192573, + 0.6295141872249864, + 0.660096178323984, + 0.6680418873171251, + 0.5859519130069699, + 0.5980134595209948, + 0.6301375856958471, + 0.5808053759563867, + 0.6039649172867065, + 0.6347835643895494, + 0.5678705931362388, + 0.5530971411510327, + 0.521985732463609, + 0.4294200981003004, + 0.3978572745735934, + 0.38935909619523584, + 0.34064743125220737, + 0.32098962269738085, + 0.2976229112689404, + 0.23380534748211163, + 0.1891212654939465, + 0.17673391225585533 + ], + "hrv_dfa_a1_r": [ + null, + 0.058393370074777996, + 0.05362280635551381, + 0.10139601814700584, + 0.017752186124877237, + 0.017169190767867163, + 0.008114850495128686, + 0.013892373143569578, + 0.01296563486270326, + 0.005857256325213232, + 0.9740058198945752, + 0.9449605478938198, + 1.7489525341268817, + 0.08630557423315188, + 0.08848952286130671, + 0.045859987398809574, + 0.03826861434585846, + 0.0364004512214083, + -0.04490420946188569, + 0.6946922742393531, + 0.6262672901131637, + null, + 0.35944559146639843, + 0.3371168977139125, + null + ], + "hrv_dfa_a1_ctx": [ + 0.056974169467464766, + 0.05301886369948674, + 0.0466489782397796, + 0.04243856031764506, + 0.03799362419264998, + 0.1360591828135566, + 0.21487193520628006, + 0.32774912985736365, + 0.3304684948282937, + 0.3309772033534636, + 0.33722696400608193, + 0.33890503951186773, + 0.34184404399416224, + 0.3382621896834094, + 0.37392072801399323, + 0.38265333387076744, + 0.335670353360046, + 0.29393044825640124, + 0.23462722325548904, + 0.2908638997219542, + 0.31280107109746486, + 0.34661654933270114, + 0.39419839803255774, + 0.46287594965995404, + 0.5633806640106226 + ], + "hrv_beats_per_min_r": [ + -0.010962241169305711, + 0.7807551766138857, + 0.8660170523751526, + 0.20828258221680873, + 0.9147381242387335, + 1.0, + 0.20828258221680873, + 0.9147381242387335, + 1.0, + 0.20828258221680873, + 0.7807551766138857, + 0.8538367844092571, + 0.06211936662606577, + 0.6102314250913523, + 0.6833130328867235, + 0.02557856272838003, + 0.6102314250913523, + 0.6833130328867235, + 0.04993909866017045, + 0.5980511571254569, + 0.6711327649208285, + 0.01339829476248471, + 0.5249695493300854, + 0.5980511571254569, + -0.02314250913520103 + ], + "hrv_beats_per_min_ctx": [ + 0.6445576348134205, + 0.6111683687810363, + 0.6224116930572473, + 0.6668338468152183, + 0.6416436951086609, + 0.6374920248245461, + 0.7030334667362682, + 0.6514123310712835, + 0.6212516675366857, + 0.6537323821124064, + 0.582390812597877, + 0.5446899831796298, + 0.5771706977553503, + 0.5069891537613825, + 0.469868337103416, + 0.5040890899599788, + 0.44492778841134467, + 0.41476712487674694, + 0.4559480308566786, + 0.4078069717533784, + 0.3696711327649208, + 0.39572337258086343, + 0.3643422655298416, + 0.33965547242039335, + 0.36967113276492086 + ] + }, + "beats": [ + 61, + 90, + 126, + 133, + 133, + 115, + 79, + 101, + 137, + 144, + 144, + 115, + 79, + 101, + 137, + 144, + 144, + 115, + 79, + 96, + 126, + 132, + 132, + 103, + 67, + 84, + 112, + 118, + 118, + 94, + 64, + 82, + 112, + 118, + 118, + 94, + 66, + 84, + 111, + 117, + 117, + 93, + 63, + 78, + 105, + 111, + 111, + 87, + 60, + 54 + ], + "hrvPosteriors": [ + [ + 0.999627312085396, + 0.00037268791286168605, + 1.7405556924644365e-12, + 1.591183016735399e-15 + ], + [ + 0.9993397919722231, + 0.0006602079869040141, + 4.0859523269361574e-11, + 1.3316578511026839e-14 + ], + [ + 0.9977020083205366, + 0.002297991171178528, + 5.081136073379704e-10, + 1.7124398266719231e-13 + ], + [ + 0.9976583359712311, + 0.0023416597958414564, + 4.2318969637338084e-09, + 1.030403087316461e-12 + ], + [ + 0.9939605625537237, + 0.006039414993376317, + 2.2445379608853233e-08, + 7.520437512189397e-12 + ], + [ + 0.9918401304329748, + 0.008159703935974226, + 1.65616053018709e-07, + 1.4997864794867682e-11 + ], + [ + 0.9957565526611181, + 0.0042433303449495524, + 1.169126183051254e-07, + 8.131414740044443e-11 + ], + [ + 0.9947986558375244, + 0.005200846462368503, + 4.974522747138266e-07, + 2.478321509383101e-10 + ], + [ + 0.9887953794411187, + 0.011203440472105392, + 1.179105775321023e-06, + 9.810007385253982e-10 + ], + [ + 0.9890753445543086, + 0.010921789904138295, + 2.86307922361142e-06, + 2.462329362577806e-09 + ], + [ + 0.6497696422843763, + 0.3502240191220504, + 6.121160563551915e-06, + 2.1743300971867584e-07 + ], + [ + 0.38177976269020225, + 0.6181634429495451, + 5.560453531451649e-05, + 1.1898249383364502e-06 + ], + [ + 0.2727372544566638, + 0.7271143110360718, + 0.0001359498957371567, + 1.2484611527158284e-05 + ], + [ + 0.4377281931619806, + 0.5616592653432496, + 0.0006063076965490649, + 6.233798220678899e-06 + ], + [ + 0.2906015469096, + 0.707795562620257, + 0.001580473351386255, + 2.24171187567253e-05 + ], + [ + 0.26459260694859577, + 0.732113806467898, + 0.0032039549398101053, + 8.96316436959775e-05 + ], + [ + 0.1933490435365244, + 0.7949164879135445, + 0.011602888124750967, + 0.00013158042518000897 + ], + [ + 0.1292745493129278, + 0.8448646210865274, + 0.02573597055891537, + 0.00012485904162945605 + ], + [ + 0.1397413922942294, + 0.8336193900995232, + 0.02614244164024372, + 0.0004967759660035824 + ], + [ + 0.0334433332958109, + 0.9121030069594895, + 0.05372018672504437, + 0.000733473019655187 + ], + [ + 0.020842159504975973, + 0.8952058897974385, + 0.08281405887814698, + 0.0011378918194384963 + ], + [ + 0.020038459589073937, + 0.913988775467168, + 0.062290357015981845, + 0.003682407927776231 + ], + [ + 0.005832614570701384, + 0.905115129186786, + 0.08743604986810011, + 0.0016162063744125088 + ], + [ + 0.005005113900596854, + 0.8777313005683215, + 0.11451877431244734, + 0.002744811218634269 + ], + [ + 0.00990124028906633, + 0.8846470662307571, + 0.09887586348112486, + 0.006575829999051778 + ] + ], + "basePosteriors": [ + [ + 0.9980777898630911, + 0.0019220402037983767, + 4.789053457635291e-12, + 1.699283211936471e-07 + ], + [ + 0.9981813201064241, + 0.001818562063853942, + 6.954352935899366e-11, + 1.1776017826960431e-07 + ], + [ + 0.9955469832482489, + 0.004452611618731566, + 6.350023342013839e-10, + 4.044980171397474e-07 + ], + [ + 0.9943137998226769, + 0.005685624656851426, + 6.8964454298898115e-09, + 5.686240263721349e-07 + ], + [ + 0.9877058188704937, + 0.012292635296793277, + 3.327037842916852e-08, + 1.5125623347475842e-06 + ], + [ + 0.9917555678856104, + 0.008243654384243395, + 1.0459761028865232e-07, + 6.731325360471101e-07 + ], + [ + 0.99267165118113, + 0.007326723224746436, + 1.3776510023108868e-07, + 1.4878290234521048e-06 + ], + [ + 0.9958228487546654, + 0.004175798451102414, + 2.451013558735366e-07, + 1.1076928764210372e-06 + ], + [ + 0.9912145250528129, + 0.008783766055712666, + 4.776077807125355e-07, + 1.2312836936155888e-06 + ], + [ + 0.991081952193104, + 0.008915765602414789, + 1.0408015562155997e-06, + 1.2414029249263928e-06 + ], + [ + 0.8067127747329023, + 0.19317305505619173, + 1.283176993111887e-06, + 0.00011288703391291832 + ], + [ + 0.6045166107776079, + 0.39498365168525285, + 1.2873189090982315e-05, + 0.0004868643480481015 + ], + [ + 0.622773814148969, + 0.3758179578507619, + 4.5686979563800363e-05, + 0.0013625410207053306 + ], + [ + 0.48695165197525214, + 0.5114637840765561, + 0.0002623694155910634, + 0.0013221945326006505 + ], + [ + 0.36202569805515333, + 0.6348495117107572, + 0.0007899362972249887, + 0.002334853936864526 + ], + [ + 0.238361323553584, + 0.7552637138270228, + 0.003709754174963244, + 0.002665208444429919 + ], + [ + 0.19548915073944306, + 0.7905018076114226, + 0.010896873088683008, + 0.003112168560451331 + ], + [ + 0.1304827472309214, + 0.8384578379265765, + 0.029392860939926144, + 0.0016665539025759516 + ], + [ + 0.09108707983164017, + 0.8772541869772987, + 0.02855666503536979, + 0.0031020681556914276 + ], + [ + 0.10291239560957491, + 0.8500859786366185, + 0.04136462992423529, + 0.005636995829571405 + ], + [ + 0.07938190329030678, + 0.8493212675962272, + 0.06580573763414276, + 0.005491091479323188 + ], + [ + 0.04300102423813341, + 0.8918004748921907, + 0.061134601634287584, + 0.004063899235388239 + ], + [ + 0.03292413760540505, + 0.881706761026935, + 0.08064433947075988, + 0.004724761896900178 + ], + [ + 0.03292224924056486, + 0.8434808288471004, + 0.11879976934987085, + 0.004797152562463729 + ], + [ + 0.01808136976358759, + 0.738252940871337, + 0.24169459676034663, + 0.0019710926047288124 + ] + ], + "labels": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwlllllllllllllllllllll", + "segments": [ + [ + 1700200010, + 1700200890, + "wake" + ], + [ + 1700200890, + 1700201510, + "light" + ] + ] + }, + { + "name": "no gravity, beats", + "seed": 4, + "start": 1700300020, + "epochs": 240, + "rr": "full", + "hrGap": null, + "window": null, + "gravity": false, + "samples": { + "grav": 0, + "hr": 7064, + "rr": 7205 + }, + "spanStart": 1700300040, + "n": 240, + "head": "hrv", + "stride": 10, + "features": { + "act_mean": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "act_max": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "move_frac": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "since_move": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "until_move": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "act_ctx2": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "act_ctx5": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "act_ctx10": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "act_ctx20": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "dangn": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "dangn_ctx2": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "dangn_ctx5": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "dangn_ctx10": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "dangn_ctx20": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "hr_z": [ + 2.3411602494457213, + 2.331369555829622, + 0.010521545944149991, + -0.09143019414317188, + 0.05304869002951792, + -1.217095346653469, + 0.3229542978246517, + 0.24621642894172072, + 0.1694785600587914, + -0.49046711233441026, + -1.1323245727766353, + -1.5187545553656783, + 0.15029409283805825, + 0.08480229094659161, + 1.0749854128773695, + 1.044290265324199, + -0.29094865323879104, + -0.32657694950586597, + -1.5743233569705584, + -0.045387472813414625, + 0.14037109255147182, + 0.0054182886539049235, + -1.1657603585041987, + -1.113896143672976 + ], + "hr_pct": [ + 0.9583333333333334, + 0.9541666666666667, + 0.5166666666666667, + 0.44583333333333336, + 0.56875, + 0.14583333333333334, + 0.7125, + 0.6791666666666667, + 0.6395833333333333, + 0.2375, + 0.16666666666666666, + 0.05416666666666667, + 0.6333333333333333, + 0.5958333333333333, + 0.9125, + 0.8979166666666667, + 0.3375, + 0.32083333333333336, + 0.029166666666666667, + 0.4791666666666667, + 0.625, + 0.5125, + 0.15833333333333333, + 0.17083333333333334 + ], + "hrsd5_pct": [ + 0.13333333333333333, + 0.24583333333333332, + 0.9916666666666667, + 0.39166666666666666, + 0.3458333333333333, + 0.7333333333333333, + 0.6583333333333333, + 0.5208333333333334, + 0.6208333333333333, + 0.19583333333333333, + 0.8125, + 0.14583333333333334, + 0.9625, + 0.3125, + 0.375, + 0.4875, + 0.6166666666666667, + 0.14166666666666666, + 0.8833333333333333, + 0.5375, + 0.475, + 0.7083333333333334, + 0.2708333333333333, + 0.4583333333333333 + ], + "hrsd5_z": [ + -0.8152755984303446, + -0.7701862420786757, + 3.7785730027910622, + -0.7037489803575595, + -0.7400256788092873, + 0.45629541676544855, + 0.0675229353406017, + -0.304831940399149, + -0.051805749072383785, + -0.7866685719945897, + 0.9866996635310837, + -0.8126686559829882, + 1.8920173772936215, + -0.7482631956680109, + -0.729742545563961, + -0.44564849145505925, + -0.07844506184458797, + -0.8133618073796288, + 1.295712778314904, + -0.26108969599929444, + -0.5109892900562225, + 0.2712250070763567, + -0.7616754297914491, + -0.5500349631543163 + ], + "hrsd11_pct": [ + 0.020833333333333332, + 0.8166666666666667, + 0.9958333333333333, + 0.025, + 0.38333333333333336, + 0.7875, + 0.8375, + 0.14166666666666666, + 0.3958333333333333, + 0.26666666666666666, + 0.6416666666666667, + 0.4375, + 0.925, + 0.22083333333333333, + 0.25, + 0.32083333333333336, + 0.5666666666666667, + 0.2916666666666667, + 0.5875, + 0.85, + 0.07083333333333333, + 0.75, + 0.3875, + 0.5708333333333333 + ], + "hrsd11_z": [ + -1.5214172901615688, + 0.6348313593040741, + 3.3736184521054455, + -1.517460653680953, + -0.31624433447116346, + 0.5355183119131833, + 0.7111091140347844, + -1.0266351163638903, + -0.2535778072612446, + -0.5870389291714155, + 0.13394456711255814, + -0.17423755390620288, + 1.1248957077352273, + -0.7331511664122413, + -0.6526898158807884, + -0.5175038321139347, + 0.03424566511059156, + -0.5533741893881878, + 0.06650422721113387, + 0.7545204346071532, + -1.2267827716759958, + 0.4293799859560731, + -0.2907695358340785, + 0.04928179593775968 + ], + "hrsd20_pct": [ + 0.09583333333333334, + 0.9458333333333333, + 0.9916666666666667, + 0.9083333333333333, + 0.06666666666666667, + 0.22083333333333333, + 0.6458333333333334, + 0.4583333333333333, + 0.0375, + 0.7708333333333334, + 0.5833333333333334, + 0.6041666666666666, + 0.8291666666666667, + 0.8333333333333334, + 0.020833333333333332, + 0.175, + 0.6291666666666667, + 0.4708333333333333, + 0.2125, + 0.2833333333333333, + 0.5, + 0.5375, + 0.42083333333333334, + 0.3125 + ], + "hrsd20_z": [ + -0.9365240653054472, + 2.3068769495224477, + 2.5630261176101325, + 1.9253470908125445, + -1.0236577158943259, + -0.7245761401583716, + 0.012080717243757073, + -0.3679429829341354, + -1.250278184711585, + 0.5019004364023874, + -0.17201611005867484, + -0.12812962459359362, + 0.9267248028031793, + 0.991235918713685, + -1.3263653145086602, + -0.8018264875915553, + -0.056462934927783155, + -0.3621228503915239, + -0.7380601399568604, + -0.6608728040346199, + -0.2913138427340407, + -0.2398955320584387, + -0.4506939208626474, + -0.6387212028919468 + ], + "hr_ctx5_z": [ + 2.605267860884022, + 2.6446869371199875, + 0.9730530096471797, + -0.13635010633648242, + -0.016893722141059952, + -1.094919383801233, + 0.06254319755653386, + 0.555853017380927, + 0.595780010231093, + -0.3487159631413567, + -1.0243704362795945, + -1.6693450813341955, + -0.6359668699669112, + 0.11667769371557182, + 0.9958708605027355, + 1.0063824335128981, + -0.06337676609300741, + -0.4082393965906453, + -0.9357278696980335, + 0.11501425248891503, + 0.3334209612158023, + -0.1640727288712498, + -1.313576457941474, + -1.0676188597397889 + ], + "hr_ctx10_z": [ + 2.86203027119439, + 2.6568300777307874, + 1.2372324051503285, + -0.1085154228295245, + -0.296084355850834, + -0.5852158539509096, + -0.2332726093609809, + 0.6403320449233384, + 0.41094018441549185, + -0.25300381661953475, + -1.1190537424425306, + -1.6664528956974307, + -0.7921855586469985, + 0.35445683491134733, + 0.8928396619583783, + 0.9170490201708349, + 0.14261453386058295, + -0.6090394624357904, + -0.6771722363192106, + -0.12936438642126413, + 0.2825659601289601, + -0.42543793033781335, + -1.1021515275500844, + -0.8874482896113158 + ], + "hr_ctx30_z": [ + 3.285536761880079, + 2.4438050091254073, + 1.7944687998089308, + 1.2849151444335793, + 0.5404758059823656, + -0.002609034943535832, + 0.005263165538497896, + -0.09828681306692935, + -0.5420033519629176, + -0.7295904235621135, + -0.8314798383818396, + -0.9098101286940515, + -0.5474433301864203, + -0.24966861076489452, + 0.1577308340688359, + 0.4355808517285746, + 0.24548963526555997, + 0.22581157988765985, + -0.1010677185616886, + -0.639487297020871, + -0.9009084642688495, + -0.8179562293821325, + -0.7183236427328216, + -1.1166281680181023 + ], + "hr_trend": [ + 0.12528600529436992, + -0.039392722970893586, + -2.871241234829645, + 0.09405146987169746, + -0.09956570593606572, + -0.4944180986010313, + 1.5418268327690199, + 0.6720520043721849, + -0.849551841638305, + -0.3019618983142571, + -1.3343202895869049, + 0.1462443275221887, + 2.067670157157693, + 0.619541829616051, + 0.8388376558980476, + -0.3624306561370517, + -1.1141035685557565, + -0.15562308084762785, + 0.0572364743034796, + 1.3943837517747428, + -0.20535382781543365, + -0.9766142525655815, + -0.2926603212336791, + 0.9752513344143734 + ], + "hr_over_low": [ + 3.860298494155814, + 3.8505078005397144, + 1.5296597906542424, + 1.4277080505669206, + 1.5721869347396105, + 0.3020428980566234, + 1.842092542534744, + 1.7653546736518133, + 1.6886168047688839, + 1.0286711323756823, + 0.38681367193345717, + 0.0003836893444143031, + 1.6694323375481508, + 1.603940535656684, + 2.594123657587462, + 2.5634285100342917, + 1.2281895914713015, + 1.1925612952042266, + -0.055185112260465886, + 1.4737507718966778, + 1.6595093372615644, + 1.5245565333639974, + 0.3533778862058938, + 0.40524210103711655 + ], + "clock": [ + 0.0, + 0.04184100418410042, + 0.08368200836820083, + 0.12552301255230125, + 0.16736401673640167, + 0.20920502092050208, + 0.2510460251046025, + 0.2928870292887029, + 0.33472803347280333, + 0.37656903765690375, + 0.41841004184100417, + 0.4602510460251046, + 0.502092050209205, + 0.5439330543933054, + 0.5857740585774058, + 0.6276150627615062, + 0.6694560669456067, + 0.7112970711297071, + 0.7531380753138075, + 0.7949790794979079, + 0.8368200836820083, + 0.8786610878661087, + 0.9205020920502092, + 0.9623430962343096 + ], + "minutes": [ + 0.0, + 5.0, + 10.0, + 15.0, + 20.0, + 25.0, + 30.0, + 35.0, + 40.0, + 45.0, + 50.0, + 55.0, + 60.0, + 65.0, + 70.0, + 75.0, + 80.0, + 85.0, + 90.0, + 95.0, + 100.0, + 105.0, + 110.0, + 115.0 + ], + "time_e10": [ + 1.0, + 0.6065306597126334, + 0.36787944117144233, + 0.22313016014842982, + 0.1353352832366127, + 0.0820849986238988, + 0.049787068367863944, + 0.0301973834223185, + 0.01831563888873418, + 0.011108996538242306, + 0.006737946999085467, + 0.004086771438464067, + 0.0024787521766663585, + 0.0015034391929775724, + 0.0009118819655545162, + 0.0005530843701478336, + 0.00033546262790251185, + 0.00020346836901064417, + 0.00012340980408667956, + 7.48518298877006e-05, + 4.5399929762484854e-05, + 2.7536449349747158e-05, + 1.670170079024566e-05, + 1.013009359863071e-05 + ], + "time_e30": [ + 1.0, + 0.8464817248906141, + 0.7165313105737893, + 0.6065306597126334, + 0.513417119032592, + 0.4345982085070782, + 0.36787944117144233, + 0.3114032239145977, + 0.26359713811572677, + 0.22313016014842982, + 0.18887560283756183, + 0.1598797460796939, + 0.1353352832366127, + 0.11455884399268773, + 0.09697196786440505, + 0.0820849986238988, + 0.06948345122280154, + 0.05881647164242988, + 0.049787068367863944, + 0.042143843509276406, + 0.035673993347252395, + 0.0301973834223185, + 0.025561533206507402, + 0.02163737071949309 + ], + "time_e90": [ + 1.0, + 0.9459594689067654, + 0.8948393168143698, + 0.8464817248906141, + 0.8007374029168081, + 0.7574651283969664, + 0.7165313105737893, + 0.6778095780054503, + 0.6411803884299546, + 0.6065306597126334, + 0.5737534207374327, + 0.5427474811642219, + 0.513417119032592, + 0.4856717852477124, + 0.45942582403592663, + 0.4345982085070782, + 0.41111229050718745, + 0.3888955639892229, + 0.36787944117144233, + 0.34799904079225524, + 0.32919298780790557, + 0.3114032239145977, + 0.29457482831010734, + 0.2786558481415308 + ], + "hrv_meanNN_r": [ + -0.002719403399987848, + -0.0005609017098501406, + 0.3463946668430642, + 0.6020168111484406, + 0.6005475912193039, + 0.9201190196154588, + 0.6325300645277229, + 0.5606619081462223, + 0.42879912886632665, + 0.6013853285259175, + 0.7955606400726384, + 1.0012738182290262, + 0.7468034867389377, + 0.6000416634331057, + 0.430741740545994, + 0.44785388491925326, + 0.6020906759246307, + 0.600529471287771, + 0.7950595691356288, + 0.5470004497038873, + 0.5326133170902129, + 0.6441956080377552, + 1.000886943667794, + 0.9936741040048841 + ], + "hrv_meanNN_ctx": [ + -0.0019708283002036245, + 0.05645450867080472, + 0.32643275914373976, + 0.5714300140789816, + 0.6686080444623306, + 0.7651560533028258, + 0.7096209133423648, + 0.5381477532252451, + 0.5002987854550047, + 0.5837749641251166, + 0.8000758529239523, + 0.9277530635576106, + 0.7768536360238398, + 0.5720112562745715, + 0.4744560001498252, + 0.48378024781583084, + 0.5652252585668827, + 0.6477689266230862, + 0.6867752382482369, + 0.6013656315015685, + 0.5480593141790402, + 0.7168829734796202, + 0.914084366023762, + 0.9155880595937773 + ], + "hrv_sdnn_r": [ + 0.045213011755007734, + 0.05619622527358553, + 1.5459319436385, + 0.005380813171889943, + 0.015817516613313022, + 0.7902405555424817, + 0.4825789599183631, + 0.2969126294930314, + 0.012388961183334183, + 0.006368943569367927, + 1.0028161933768183, + 0.08579070279464351, + 0.9611080940861682, + 0.06032801899059419, + -0.030957046904594547, + 0.18939346926763642, + 0.0005697251278408684, + 0.029256384304805812, + 0.9912678286792092, + 0.3206387508183525, + 0.34015522006785365, + 0.5566187637850174, + 0.09167862149342855, + 0.20462028231879 + ], + "hrv_sdnn_ctx": [ + 0.05596371761926713, + 0.4334354360608779, + 0.5785850780333868, + 0.2527081808390169, + 0.34219710614388565, + 0.7193494143963066, + 0.4505279253871971, + 0.1503598221997405, + 0.23677136513625133, + 0.3335549268259832, + 0.3902821651831347, + 0.5079368616472214, + 0.38875666139173354, + 0.27223687619020476, + 0.12345119605608158, + 0.11562972903635242, + 0.13406392030549816, + 0.31404018124077365, + 0.6303954847223755, + 0.5255955245291764, + 0.24273008535954452, + 0.4228940810624028, + 0.4044450781371682, + 0.4440127399577968 + ], + "hrv_lnrmssd_r": [ + 0.49005534100340453, + 0.5385240001691896, + 0.6777017773896942, + 0.22516564089975366, + 0.36774814110449444, + 1.0015268704083782, + 0.5025798309325178, + 0.40306517413209975, + -0.050113044879425216, + 0.2872476500781809, + 0.8714892843531356, + 0.971012547110118, + 0.714985872647502, + 0.47114397108788286, + -0.19322638505130096, + 0.10635346293582787, + 0.23495912637872512, + 0.4812389785189321, + 0.9578778133657385, + 0.14875359034985922, + 0.16955384113822922, + 0.5630958247975684, + 1.0068844364882208, + 0.9703090486566022 + ], + "hrv_lnrmssd_ctx": [ + 0.5562835645138086, + 0.5895706371265174, + 0.49903445312131584, + 0.3657095871627563, + 0.5177910186716522, + 0.754902069125495, + 0.5771997988428536, + 0.23079291434040358, + 0.17501433033691122, + 0.3507467699290567, + 0.746613564935093, + 0.9467445700530047, + 0.6805584261159506, + 0.323617825179536, + 0.07924652162728646, + 0.06540127428668405, + 0.29208580823574115, + 0.5468956901662753, + 0.6476047033122574, + 0.35944166553489815, + 0.2148984333318849, + 0.6000232756958427, + 0.8927184261081681, + 0.8788277125594461 + ], + "hrv_pnn50_r": [ + 0.0, + 0.0, + 0.8340602602791402, + 0.0, + 0.0, + 0.9885158640345364, + 0.9047433331841519, + 0.8956351788232376, + 0.855446420799118, + 0.0, + 0.9397862087652282, + 0.0, + 0.9364887132958766, + 0.9016867678693407, + 0.0, + 0.86655611457573, + 0.0, + 0.0, + 1.8929027183640057, + 0.0, + 0.0, + 0.9016867678693407, + 0.0, + 0.9996227838551491 + ], + "hrv_pnn50_ctx": [ + 0.0, + 0.2641732416324861, + 0.38772143214546423, + 0.1632653457643658, + 0.4024036324904561, + 0.9032742847183997, + 0.6770522442106526, + 0.5048147163238769, + 0.7513418885012823, + 0.6792317776045087, + 0.4466615314953034, + 0.551516942703849, + 0.49725307867413787, + 0.5933211555726923, + 0.4994696141150272, + 0.4202137044147072, + 0.37894912753014864, + 0.48607610171650134, + 0.8870513155669513, + 0.4911134385344499, + 0.08544411958628066, + 0.4472297858298448, + 0.45232421632568226, + 0.615867055797338 + ], + "hrv_lnlf_r": [ + 0.09092938549833479, + 0.09471349191242782, + 1.0138492800900385, + 0.05582127890877683, + 0.022823087107738532, + 0.7880191963845488, + 0.7843161507192326, + 0.3665417235792691, + 0.07487903605669881, + 0.055714453744299515, + 0.7213943337664416, + 0.2786019218786395, + 0.7457687073625964, + 0.07248749996981012, + 0.05587543133233195, + 0.3363017978515242, + 0.030674611683002662, + 0.04106169041477794, + 1.0051287717588948, + 0.28297137103161984, + 0.32428918135974044, + 0.6817839472146124, + 0.26381846133978515, + 0.3470969114236488 + ], + "hrv_lnlf_ctx": [ + 0.07499580831288055, + 0.3511954131007081, + 0.4752529108975061, + 0.2530291925735788, + 0.31840489225151797, + 0.6193048260357131, + 0.4476916957257312, + 0.23351303178815624, + 0.3064806242421674, + 0.33740536928592324, + 0.41364905340663427, + 0.537290562944568, + 0.41425755722334706, + 0.30858523057498455, + 0.1679635130144876, + 0.14464788986425384, + 0.17620448764996763, + 0.3122245422862012, + 0.5924792393245052, + 0.5376530188237061, + 0.29170807026779194, + 0.41042159584622306, + 0.43541833683965914, + 0.5581759746700484 + ], + "hrv_lnhf_r": [ + 0.7693709555508927, + 0.8423862144436906, + 0.8133676406065959, + 0.35280372502043067, + 0.5389434597117765, + 0.9656907083576425, + 0.5682902975587656, + 0.6064124960086341, + -0.08942944584552216, + 0.3216376900987564, + 0.9449073569119695, + 0.9718546724059178, + 0.7181193099189087, + 0.6500189567522139, + -0.05297077437423508, + 0.10240904200227544, + 0.36741411340186253, + 0.6625300685001507, + 0.9188095828277086, + 0.33538431263238716, + 0.38688333120312585, + 0.6591124370055138, + 1.0516540833336412, + 0.8518603026673497 + ], + "hrv_lnhf_ctx": [ + 0.8473569856179322, + 0.8282909389094971, + 0.6760652400397633, + 0.532891732627216, + 0.6071402741191791, + 0.7638264002544561, + 0.648945992284509, + 0.33066942159068347, + 0.2556332989917022, + 0.4439810391674775, + 0.8034092009904251, + 0.9515705392021002, + 0.7389615360392899, + 0.4537940410068762, + 0.18095610656424452, + 0.14939418831279536, + 0.3898509084708053, + 0.6028615861815467, + 0.6792646412783099, + 0.4593013398625602, + 0.38977459054882707, + 0.7131044821594101, + 0.9099573732574927, + 0.8510192120258807 + ], + "hrv_lnlfhf_r": [ + 0.006744759473603615, + -0.03952596667896198, + 1.0206888935763914, + 0.25543034689351, + 0.0892125728130447, + 0.659669736367922, + 0.9306239350935211, + 0.4314626689004222, + 0.5831818814114574, + 0.27688761835443615, + 0.5986644972493782, + 0.07892913136989232, + 0.7832665964242331, + 0.06851003919881919, + 0.5364342148903611, + 0.7461947152996833, + 0.21685790091433144, + 0.024285372166295525, + 0.9378165622116869, + 0.5245412167516759, + 0.5356418463148037, + 0.7517134705554603, + 0.006949665999661144, + 0.23951972189462836 + ], + "hrv_lnlfhf_ctx": [ + -0.0652807014394089, + 0.260476220003073, + 0.5062588480716623, + 0.3539111670091214, + 0.3764855909243565, + 0.608510189161014, + 0.493846022322955, + 0.47183697763525534, + 0.6063615618100672, + 0.5109522926567542, + 0.34837792084893, + 0.385713437919104, + 0.3936875948481349, + 0.4715443553395507, + 0.5013147524993193, + 0.49678229674180213, + 0.3660098154505249, + 0.3724541052986838, + 0.6367007869204253, + 0.7269517722192559, + 0.4967702169255241, + 0.40724912685234793, + 0.29924290956670535, + 0.4789658881547091 + ], + "hrv_resp_bpm_r": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "hrv_resp_bpm_ctx": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "hrv_hf_peaked_r": [ + 0.9305430901244022, + 1.12491704605271, + 0.6327195813548686, + 0.6183732213244908, + 0.8499082461155616, + 0.7202789538110497, + 0.47664475903145304, + 0.7951140606305166, + -0.23289078535946003, + 0.7092663201378917, + 0.6440946852713111, + 0.8887909493045563, + 0.5526413659831384, + 0.8667727612916069, + -0.04484164374577108, + 0.025942042952775045, + 0.5791297632335857, + 0.9903287039360658, + 0.6343219544882419, + 0.3325584446226623, + 0.5350520417073862, + 0.574286925166517, + 0.8868334498691692, + 0.8891691732921323 + ], + "hrv_hf_peaked_ctx": [ + 1.0235307189365945, + 0.9401278999279771, + 0.7210532455649575, + 0.6657975142627424, + 0.7351232730445658, + 0.7261460533817524, + 0.7439353427036339, + 0.5119717015789663, + 0.3274549568625362, + 0.41813876401296296, + 0.690968220335096, + 0.7883194002914476, + 0.7277527848727636, + 0.5304370226055038, + 0.2293182366396348, + 0.21817554485316681, + 0.6014066659127953, + 0.7968535904102503, + 0.6773284266819137, + 0.37177843889426887, + 0.48108858777529157, + 0.7464733588800047, + 0.8316076058584831, + 0.7926021676458831 + ], + "hrv_dfa_a1_r": [ + 0.254582104744594, + 0.20287112822618222, + 1.0397420757854463, + 0.31688492066257495, + 0.044923335752407485, + 0.6718785476992133, + 0.9675587685293179, + 0.4973081998309081, + 0.7184520483546892, + 0.3200860796134341, + 0.4793224925165473, + 0.00040835133737849097, + 0.7705750286138328, + 0.06773346887791361, + 0.5154727862559998, + 0.6322912578986598, + 0.2800102696536117, + 0.016250949797128742, + 1.0023905978252468, + 0.7404922875499071, + 0.6503651547232135, + 0.8366875181920367, + -0.02569276156063294, + 0.04563051822162137 + ], + "hrv_dfa_a1_ctx": [ + 0.18915184592736514, + 0.39624611952164546, + 0.5311131506120575, + 0.34084849465897976, + 0.3700403866978386, + 0.5722601780716946, + 0.4536281378898043, + 0.5385068483491674, + 0.7096005853599779, + 0.5680019164356599, + 0.3093058481392109, + 0.25090885899135784, + 0.29128411795299025, + 0.5172400739600727, + 0.5774412945156824, + 0.536394884884748, + 0.3451134888615347, + 0.3710343769505383, + 0.6851773461618743, + 0.783845480320473, + 0.6377821115433455, + 0.49880906015936044, + 0.27539947480242416, + 0.31237291059012406 + ], + "hrv_beats_per_min_r": [ + -0.9042553191489363, + 1.22340425531915, + 0.7579787234042561, + 0.45212765957446815, + 0.46542553191489433, + 0.09308510638297915, + 0.42553191489361764, + 0.46542553191489433, + 0.6515957446808512, + 0.42553191489361764, + 0.27925531914893653, + 0.0, + 0.2925531914893622, + 0.43882978723404287, + 0.6250000000000002, + 0.5984042553191498, + 0.42553191489361764, + 0.4122340425531919, + 0.2526595744680855, + 0.4787234042553196, + 0.531914893617022, + 0.43882978723404287, + 0.05319148936170244, + 0.05319148936170244 + ], + "hrv_beats_per_min_ctx": [ + 0.6745647969052231, + 0.8592958459979744, + 0.8054711246200615, + 0.49265450861195603, + 0.38183890577507656, + 0.2786220871327257, + 0.3153495440729486, + 0.49835359675785285, + 0.5559777102330304, + 0.4761904761904769, + 0.24696048632218884, + 0.10701621073961576, + 0.2469604863221895, + 0.4780901722391098, + 0.5806737588652497, + 0.549012158054712, + 0.4533941236068909, + 0.3856382978723406, + 0.36220871327254234, + 0.4343971631205688, + 0.5027862208713287, + 0.34891084093211705, + 0.14754305977710386, + -0.19614361702127425 + ] + }, + "beats": [ + 196, + 230, + 266, + 302, + 338, + 356, + 356, + 354, + 354, + 354, + 356, + 358, + 358, + 356, + 355, + 349, + 343, + 339, + 333, + 327, + 321, + 315, + 309, + 305, + 300, + 300, + 300, + 298, + 298, + 298, + 298, + 298, + 298, + 297, + 297, + 297, + 297, + 299, + 299, + 299, + 299, + 299, + 298, + 296, + 293, + 290, + 287, + 284, + 277, + 274, + 271, + 269, + 270, + 273, + 276, + 279, + 282, + 285, + 292, + 294, + 296, + 298, + 297, + 295, + 295, + 295, + 293, + 293, + 294, + 296, + 299, + 300, + 301, + 304, + 306, + 307, + 311, + 312, + 313, + 313, + 313, + 311, + 311, + 310, + 306, + 305, + 303, + 302, + 300, + 299, + 296, + 297, + 297, + 297, + 299, + 299, + 296, + 293, + 290, + 287, + 285, + 282, + 279, + 276, + 271, + 266, + 266, + 266, + 264, + 264, + 264, + 264, + 264, + 263, + 266, + 271, + 274, + 277, + 282, + 283, + 286, + 289, + 289, + 293, + 295, + 295, + 295, + 295, + 295, + 297, + 297, + 299, + 303, + 305, + 306, + 308, + 309, + 311, + 310, + 311, + 311, + 310, + 311, + 311, + 311, + 308, + 307, + 306, + 309, + 309, + 309, + 308, + 304, + 302, + 301, + 302, + 302, + 301, + 297, + 296, + 296, + 296, + 296, + 293, + 293, + 293, + 293, + 293, + 295, + 295, + 295, + 295, + 297, + 300, + 297, + 294, + 291, + 288, + 285, + 285, + 283, + 283, + 283, + 283, + 286, + 289, + 292, + 295, + 298, + 297, + 300, + 302, + 303, + 305, + 306, + 304, + 304, + 304, + 304, + 305, + 304, + 302, + 301, + 299, + 298, + 300, + 300, + 300, + 300, + 300, + 297, + 294, + 291, + 288, + 283, + 280, + 277, + 274, + 271, + 268, + 268, + 268, + 268, + 268, + 270, + 270, + 270, + 268, + 268, + 268, + 268, + 269, + 270, + 271, + 270, + 243, + 216, + 191, + 164, + 137 + ], + "hrvPosteriors": [ + [ + 0.6630229923565356, + 0.3369769997375736, + 7.893952726099068e-09, + 1.1937908251505206e-11 + ], + [ + 0.34107157219073336, + 0.6589185606493329, + 9.853956947703745e-06, + 1.3202985952762966e-08 + ], + [ + 0.06272143613955376, + 0.9364636567197727, + 0.0008146904659852659, + 2.1667468825181615e-07 + ], + [ + 0.11238382916944845, + 0.8714783014910832, + 0.016121947815530654, + 1.5921523937729443e-05 + ], + [ + 0.08825679021137794, + 0.8295038411845533, + 0.08220493012612677, + 3.443847794197866e-05 + ], + [ + 0.015015039760413493, + 0.9118442667844349, + 0.07303155212869951, + 0.0001091413264522707 + ], + [ + 0.039030619418502575, + 0.6833094306942749, + 0.2771316670589959, + 0.0005282828282266668 + ], + [ + 0.035808992275908225, + 0.5667015697551866, + 0.3928944102000902, + 0.00459502776881493 + ], + [ + 0.063713644438824, + 0.6075720647239699, + 0.22521648831308377, + 0.10349780252412236 + ], + [ + 0.04224942499164556, + 0.7031196471271708, + 0.2464189289521255, + 0.008211998929058202 + ], + [ + 0.010984718040772747, + 0.752653793393228, + 0.23436577056558355, + 0.001995718000415575 + ], + [ + 0.011150653537765419, + 0.7753556511961023, + 0.21307004456564957, + 0.000423650700482714 + ], + [ + 0.018059775743481353, + 0.6137833284581716, + 0.3636506565240949, + 0.004506239274252117 + ], + [ + 0.04728848314697269, + 0.6480186831517185, + 0.2738910979871525, + 0.030801735714156283 + ], + [ + 0.08853105945340559, + 0.3881430915819746, + 0.17474245509109576, + 0.3485833938735241 + ], + [ + 0.06589842733663262, + 0.3987617217952359, + 0.1253584396642075, + 0.4099814112039239 + ], + [ + 0.05824351673022055, + 0.8050559896343462, + 0.10383058891774759, + 0.03286990471768572 + ], + [ + 0.033857691168362784, + 0.7928277620508648, + 0.17014735340566733, + 0.0031671933751050395 + ], + [ + 0.0061230110320725775, + 0.8846722779533702, + 0.06969058391285048, + 0.03951412710170679 + ], + [ + 0.030967388874213336, + 0.5548129366483311, + 0.04621736807785344, + 0.3680023063996021 + ], + [ + 0.03205682087845152, + 0.5906387404400931, + 0.08322458393678422, + 0.29407985474467124 + ], + [ + 0.02800674232645841, + 0.830952181601397, + 0.07868584504298796, + 0.062355231029156445 + ], + [ + 0.016545620013742234, + 0.8888092533515851, + 0.0894876548994615, + 0.005157471735211094 + ], + [ + 0.012872600884489504, + 0.8598343239250086, + 0.10965350959475641, + 0.01763956559574554 + ] + ], + "basePosteriors": [ + [ + 0.7829648345461296, + 0.216369376126902, + 2.072540776731093e-09, + 0.0006657872544276434 + ], + [ + 0.6982296337814832, + 0.30063348238074844, + 5.1741141063622415e-06, + 0.0011317097236620563 + ], + [ + 0.2660128003423844, + 0.7333606804850378, + 0.0003162590731673326, + 0.00031026009941044133 + ], + [ + 0.15255338765220466, + 0.8220089174470333, + 0.02394085006444397, + 0.0014968448363180376 + ], + [ + 0.08390114951363285, + 0.7589104273820234, + 0.15554026443845706, + 0.0016481586658865406 + ], + [ + 0.04212079332097494, + 0.8410480009513547, + 0.11375504047668414, + 0.003076165250986435 + ], + [ + 0.10415838488226693, + 0.6480537501860236, + 0.23608294571769614, + 0.011704919214013362 + ], + [ + 0.04703528049557026, + 0.4476117619957885, + 0.49039801956067397, + 0.014954937947967204 + ], + [ + 0.05113252424062886, + 0.5396958891064962, + 0.3843193884429929, + 0.024852198209882027 + ], + [ + 0.021817553361672022, + 0.5410766934393667, + 0.43272481091018383, + 0.004380942288777365 + ], + [ + 0.03899967090067558, + 0.8295639779009817, + 0.1126663931969407, + 0.018769958001401976 + ], + [ + 0.015518992233654539, + 0.6743374529146402, + 0.30698291029981867, + 0.0031606445518865896 + ], + [ + 0.08070572797183045, + 0.732607415792772, + 0.1261930329939876, + 0.06049382324140997 + ], + [ + 0.050456904393455224, + 0.5606952732169819, + 0.33907122475760537, + 0.049776597631957534 + ], + [ + 0.05534193901591815, + 0.4411449069240357, + 0.4024418516214792, + 0.10107130243856695 + ], + [ + 0.08304434543405814, + 0.5253895678773526, + 0.22624358363402428, + 0.16532250305456497 + ], + [ + 0.06009199277747122, + 0.7503879352671784, + 0.07899847990046274, + 0.11052159205488779 + ], + [ + 0.03377406296570884, + 0.7259211645755165, + 0.2203446041397204, + 0.019960168319054197 + ], + [ + 0.029579765858554794, + 0.8102345031659944, + 0.06433553194208527, + 0.09585019903336543 + ], + [ + 0.06279832885200405, + 0.6437484290529035, + 0.11815794256351875, + 0.17529529953157366 + ], + [ + 0.06909972823642112, + 0.5427132524246802, + 0.14202502284536636, + 0.24616199649353246 + ], + [ + 0.0928145229771929, + 0.6419127194918476, + 0.03987655833539677, + 0.22539619919556278 + ], + [ + 0.036903507085532715, + 0.8083020030297724, + 0.10940127457260604, + 0.04539321531208885 + ], + [ + 0.042350538367505616, + 0.7423027886624943, + 0.0926519507314424, + 0.12269472223855775 + ] + ], + "labels": "wwwwwwwwwwwwwwwwwllllllllllllllllllllllllllllllllllllllllllllddddddddddddddddddlllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll", + "segments": [ + [ + 1700300020, + 1700300550, + "wake" + ], + [ + 1700300550, + 1700301870, + "light" + ], + [ + 1700301870, + 1700302410, + "deep" + ], + [ + 1700302410, + 1700307220, + "light" + ] + ] + } + ] +} diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift index 3290abd585..87dde9cd45 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -206,22 +206,24 @@ enum PuffinExperiment { static let pauseHrvDisabledKey = "noopPowerSavingPauseHrvDisabled" static var pauseHrvOnPowerSaveEnabled: Bool { !UserDefaults.standard.bool(forKey: pauseHrvDisabledKey) } - /// "Experimental sleep staging (V2)": re-stage each detected night with `SleepStagerV2` — a transparent - /// cardiorespiratory recipe (reimplemented from contributor PR #600) — instead of the older V1 stager. - /// Pure analysis switch: it changes ONLY which staging engine runs over an already-detected sleep window; - /// sleep DETECTION, scoring and the V1 path are all untouched. Model-agnostic (WHOOP 4 and 5). **Default - /// ON**: V2 was promoted to the default staging engine after a 44-subject cross-subject benchmark (AAUWSS - /// + Walch sleep-accel, leave-one-subject-out) showed V2 strictly dominates V1 (kappa 0.35 vs 0.03, deep - /// recall 55 % vs 1 %) — the multi-subject validation this recipe originally lacked. V1 remains available. - /// Read at the staging call site (Repository). Mirrors the Android `PuffinExperiment.KEY_EXPERIMENTAL_SLEEP_V2`. + /// Which recipe stages each detected night (Settings → Experimental → Sleep staging): `SleepStagerV3`, + /// fitted to polysomnography, by default; the hand-set `SleepStagerV2` and the original V1 stay + /// selectable. Pure analysis switch: it changes ONLY which staging engine runs over an already-detected + /// sleep window; sleep DETECTION is untouched. Stored as the `SleepStagerVersion` raw value. + static let sleepStagerKey = "noopSleepStager" + + /// The pre-V3 switch, `true` = V2 and `false` = V1. Never written any more; read only so a user who had + /// turned V2 OFF keeps V1 after the upgrade instead of being moved to V3 unasked. static let experimentalSleepV2Key = "noopExperimentalSleepV2" - /// Default ON when the key is unset (mirrors the Android `getBoolean(KEY, true)`): a `bool(forKey:)` alone - /// reads a missing key as false, so an absent preference must resolve to the promoted V2 default explicitly. - static var experimentalSleepV2Enabled: Bool { - UserDefaults.standard.object(forKey: experimentalSleepV2Key) == nil - ? true - : UserDefaults.standard.bool(forKey: experimentalSleepV2Key) + /// The ONE resolver for the staging choice: the engine, the self-heal restage, the diagnostics line and + /// the Settings picker all read it, so no two of them can disagree. An explicit choice wins; otherwise a + /// legacy explicit V2-OFF means V1; otherwise V3. + static var sleepStager: SleepStagerVersion { + let d = UserDefaults.standard + if let raw = d.string(forKey: sleepStagerKey), let v = SleepStagerVersion(rawValue: raw) { return v } + if d.object(forKey: experimentalSleepV2Key) != nil, !d.bool(forKey: experimentalSleepV2Key) { return .v1 } + return .v3 } /// Opt-in "HR-from-PPG sub-lag interpolation" (default off): the v26 optical-PPG gap-fill HR estimator diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 517dfe0259..473ddfe0cd 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -90,22 +90,6 @@ final class IntelligenceEngine: ObservableObject { /// potentially stale and the whole cache is dropped. Empty until the first pass. private var dayScanCacheConfigSig = "" - /// Names of the config-signature fields, in the exact order `dayCacheConfigSig` builds them. - /// - /// Kept beside the reader rather than at the construction site so that site stays a plain value list. - /// The cost is that the two must stay in step, which `changedConfigField` refuses to guess about when - /// they are not. Kotlin twin: `IntelligenceEngine.DAY_CACHE_CONFIG_FIELDS`. - /// - /// `nonisolated` for the same reason the reader below is: the engine is @MainActor, so a plain - /// `static let` inherits that isolation, and neither a nonisolated function nor a synchronous test - /// could then touch it. `[String]` is Sendable, so sharing it is safe. - nonisolated static let dayCacheConfigFields: [String] = [ - "hrvBaseline", "rhrBaseline", "age", "sex", "stepTicksPerStep", "maxHROverride", - "tzOffset", "sleepNeedHours", "sleepConsistency", "habitualMidsleep", - "experimentalSleepV2", "motionAwareWake", "deepHrvWindow", "spo2CandidateDisplay", - "effortMethod", "dayCycleMode", - ] - /// Which config field(s) moved between two signatures, for the `configDropped` tally. /// /// `configDropped` said a 21-day re-score happened because the pass-global config changed, and stopped @@ -114,18 +98,21 @@ final class IntelligenceEngine: ObservableObject { /// /// Never guesses: "first" on the first drop of a process, because the signature starts EMPTY rather /// than nil and there is nothing to diff against, and "unknown" when the two signatures do not have - /// the field count this list describes, because a mislabelled field sends a reader somewhere the data - /// never pointed. + /// one value per name in `fields`, because a mislabelled field sends a reader somewhere the data never + /// pointed. + /// + /// `fields` are the names `analyzeRecent` builds the signature with, value beside name. They used to + /// live in a separate list that fell a name behind the values when the Healthspan zone set joined the + /// signature, and from then on every drop read "unknown". /// /// `nonisolated` because it is a pure function of its arguments: the engine is @MainActor, and without /// this the rule would inherit that isolation and could not be driven from a synchronous test. - /// Kotlin twin: `IntelligenceEngine.changedConfigField`. - nonisolated static func changedConfigField(previous: String, current: String) -> String { + nonisolated static func changedConfigField(previous: String, current: String, fields: [String]) -> String { if previous.isEmpty { return "first" } let a = previous.split(separator: "|", omittingEmptySubsequences: false) let b = current.split(separator: "|", omittingEmptySubsequences: false) - guard a.count == b.count, b.count == dayCacheConfigFields.count else { return "unknown" } - let moved = b.indices.filter { a[$0] != b[$0] }.map { dayCacheConfigFields[$0] } + guard a.count == b.count, b.count == fields.count else { return "unknown" } + let moved = b.indices.filter { a[$0] != b[$0] }.map { fields[$0] } return moved.isEmpty ? "none" : moved.joined(separator: "+") } @@ -1010,10 +997,9 @@ final class IntelligenceEngine: ObservableObject { // the day loop below, which is the scoring half of the owner probes. See `StoreProbeTally`. _ = StoreProbeRecorder.take() // ── #1005 BATTERY: per-day reuse cache setup (see `dayScanCache`) ──────────────────────────── - // The stager toggles are read per-day inside the loop below, but they are global (same value every - // day); read them ONCE here too so the config signature can fold them without reaching into the - // detached loop. - let useSleepStagerV2Global = PuffinExperiment.experimentalSleepV2Enabled + // The stager toggles are global (same value every day): read them ONCE for the whole pass, so the + // config signature folds exactly the values the day loop below stages with. + let sleepStagerGlobal = PuffinExperiment.sleepStager let useMotionAwareWakeGlobal = PuffinExperiment.motionAwareWakeEnabled // Cache eligibility for the whole pass: never reuse while a Test-Centre trace is active (a cached // scan carries no fresh gate trace). Owner-level eligibility (registered WHOOP) is checked per day. @@ -1045,34 +1031,37 @@ final class IntelligenceEngine: ObservableObject { // trailing delay. So this fires once per completed backfill, not once per chunk. That coalescing is // load-bearing for the cache — removing it would reintroduce the #1402 storm in a form no signature // change can fix. - // Field names live in `dayCacheConfigFields`, in this exact order. Kept there rather than here so - // the construction stays a plain value list. Add or reorder here and add or reorder there: - // `changedConfigField` refuses to name anything when the counts disagree, but it cannot see a - // REORDER, which would quietly label the wrong field. + // Each value sits beside the name `changedConfigField` reports it under, so the two cannot fall out + // of step: add a field here and it is named, reorder and the names move with the values. let healthspanZoneSignature: String = healthspanZoneSet.zones.map { "\($0.lower)-\($0.upper)" }.joined(separator: ",") - let dayCacheConfigSig = [ - String(describing: baselines1.hrv), - String(describing: baselines1.restingHR), - String(up.age.bitPattern), up.sex, String(up.stepTicksPerStep.bitPattern), - maxHR.map { String($0.bitPattern) } ?? "nil", - "\(tzOffset)", - String(sleepNeedHours.bitPattern), - sleepConsistency.map { String($0.bitPattern) } ?? "nil", - habitualMidsleepSec.map { "\($0)" } ?? "nil", - "\(useSleepStagerV2Global)", "\(useMotionAwareWakeGlobal)", "\(deepHrvWindow)", - "\(spo2CandidateDisplayOn)", + let dayCacheConfig: [(field: String, value: String)] = [ + ("hrvBaseline", String(describing: baselines1.hrv)), + ("rhrBaseline", String(describing: baselines1.restingHR)), + ("age", String(up.age.bitPattern)), + ("sex", up.sex), + ("stepTicksPerStep", String(up.stepTicksPerStep.bitPattern)), + ("maxHROverride", maxHR.map { String($0.bitPattern) } ?? "nil"), + ("tzOffset", "\(tzOffset)"), + ("sleepNeedHours", String(sleepNeedHours.bitPattern)), + ("sleepConsistency", sleepConsistency.map { String($0.bitPattern) } ?? "nil"), + ("habitualMidsleep", habitualMidsleepSec.map { "\($0)" } ?? "nil"), + ("sleepStager", sleepStagerGlobal.rawValue), + ("motionAwareWake", "\(useMotionAwareWakeGlobal)"), + ("deepHrvWindow", "\(deepHrvWindow)"), + ("spo2CandidateDisplay", "\(spo2CandidateDisplayOn)"), // #1545: MUST be here. The Effort recipe changes every day's strain, so a cached scan // produced under one method is stale the moment the user switches — serving it would show a // window of days scored by a recipe the user just turned off, with nothing to explain it. - "\(effortMethodGlobal)", - dayCycleMode.rawValue, + ("effortMethod", "\(effortMethodGlobal)"), + ("dayCycleMode", dayCycleMode.rawValue), // MUST be here, for the same reason the Effort recipe is: a cached scan carries the day's // moderate/vigorous zone minutes, and those are binned against THIS zone set. Move an HR-max // override or a custom threshold and every cached day's minutes are stale — served silently, // with nothing on screen to explain why the Healthspan drivers didn't move. - healthspanZoneSignature, - ].joined(separator: "|") + ("healthspanZones", healthspanZoneSignature), + ] + let dayCacheConfigSig = dayCacheConfig.map(\.value).joined(separator: "|") // Drop the whole cache on a config change, then snapshot it into a Sendable `let` for the detached // loop (the engine is @MainActor; the loop can't touch `self`). The loop returns the updated cache // and we write it back after `.value`. @@ -1082,7 +1071,8 @@ final class IntelligenceEngine: ObservableObject { var dayCacheConfigMoved = "" if dayCacheConfigSig != dayScanCacheConfigSig { dayCacheConfigMoved = Self.changedConfigField(previous: dayScanCacheConfigSig, - current: dayCacheConfigSig) + current: dayCacheConfigSig, + fields: dayCacheConfig.map(\.field)) dayScanCache.removeAll() dayScanCacheConfigSig = dayCacheConfigSig dayCacheConfigDropped = true @@ -1326,20 +1316,17 @@ final class IntelligenceEngine: ObservableObject { from: from, to: to, store: store) } - // #690: read the experimental-V2 toggle ONCE here (off the detached executor, matching the - // Repository self-heal call site) and capture the Bool, so the Settings toggle now drives the - // NORMAL detected-night staging path , not only the userEdited self-heal restage. - // V2 is the default staging engine for EVERY strap (the toggle defaults on); turn it off to - // fall back to V1. WHOOP 4.0 is unvalidated either way — V2 can over-stage on sparse motion - // (#319) and V1 can badly UNDER-stage deep/REM (kavemang, #347), so neither is proven; the - // toggle is the honest escape until real 4.0 ground truth settles it (#271/#319). Matches the - // self-heal restage below, which reads the same toggle. - let useSleepStagerV2 = PuffinExperiment.experimentalSleepV2Enabled - // #364 follow-up: read the motion-aware wake refinement toggle the same way (once, off the - // detached executor). Default OFF — see `PuffinExperiment.motionAwareWakeEnabled`. It only - // ever runs AFTER whichever stager above just ran, and self-gates on the night's observed - // gravity + step density, so flipping it on is a no-op for any night too sparse to trust. - let useMotionAwareWake = PuffinExperiment.motionAwareWakeEnabled + // #690: the staging choice drives the NORMAL detected-night staging path, not only the + // userEdited self-heal restage. V3 is the default for every strap; V2 and V1 stay selectable. + // It is the value read ONCE for the pass above, the one `dayCacheConfigSig` records: a second + // read here could see a picker change made mid-pass and cache later days staged by a recipe + // the signature does not name. + let sleepStager = sleepStagerGlobal + // #364 follow-up: the motion-aware wake refinement toggle, from the same single read. Default + // OFF — see `PuffinExperiment.motionAwareWakeEnabled`. It only ever runs AFTER whichever stager + // above just ran, and self-gates on the night's observed gravity + step density, so flipping it + // on is a no-op for any night too sparse to trust. + let useMotionAwareWake = useMotionAwareWakeGlobal // Already OFF the main actor , score directly (the prior nested `Task.detached` here only // existed to hop off the main actor; the whole loop now runs off it, so the score is computed @@ -1396,6 +1383,7 @@ final class IntelligenceEngine: ObservableObject { attempted: true, reason: "no-motion-no-hypnogram", gravRows: grav.count, storedNights: 0)) providedSleep = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: resp, + stager: sleepStager, traceSink: traceSink) } } else { @@ -1424,9 +1412,9 @@ final class IntelligenceEngine: ObservableObject { sleepConsistency: sleepConsistency, habitualMidsleepSec: habitualMidsleepSec, bandSleepState: bandSleepState, - // #690: thread the V2 toggle into the NORMAL staging path so - // it affects detected nights, not just the self-heal restage. - useSleepStagerV2: useSleepStagerV2, + // #690: thread the staging choice into the NORMAL staging + // path so it affects detected nights, not just the restage. + stager: sleepStager, // #364 follow-up: same threading for the motion-aware wake // refinement post-pass. useMotionAwareWake: useMotionAwareWake, diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 1fd698f236..859389b0f7 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -1531,7 +1531,7 @@ final class Repository: ObservableObject { /// computed sources , only the namespace that holds the night updates; the other is a no-op. /// /// Stages are **re-derived from the raw streams** for the corrected `[newStartTs, newEndTs]` window - /// via `SleepStager.stageSession` , exactly what WHOOP does, so extending a boundary recovers real + /// via `SleepStager.stageWindow` , exactly what WHOOP does, so extending a boundary recovers real /// stages instead of a fabricated "awake" block. Only when the night has no raw data (an imported /// night) does it fall back to reshaping the stored summary (`SleepWindowReclip`). Refreshes so the /// hero re-reads the corrected night immediately. @@ -1770,17 +1770,20 @@ final class Repository: ObservableObject { let steps = useMotionAwareWake ? ((try? await store.stepSamples(deviceId: deviceId, from: lo, to: hi, limit: 200_000)) ?? []) : [] - // Which staging engine re-stages this window (Settings → Experimental · Sleep staging). The flag is - // **default ON** (#277 promoted V2 over V1; #351 extended it to every strap family), so unless the - // user has explicitly turned it OFF this re-stages with the cardiorespiratory recipe `SleepStagerV2`; - // turning it off falls back to V1 `SleepStager`. Read once here off the actor; the switch is purely - // which engine runs over the already-detected window — detection is identical either way. - // (V7 Pillar 3b) - let useV2 = PuffinExperiment.experimentalSleepV2Enabled + // The strap's own band state: the detected path stages inside its sleep window and trims the + // lying-awake lead-in and tail outside it, and this window must be staged the same way. + let band = ((try? await store.sleepStateSamples(deviceId: deviceId, from: lo, to: hi)) ?? []) + .map { (ts: $0.ts, state: $0.state) } + // Which staging engine re-stages this window (Settings → Experimental · Sleep staging): V3 unless + // the user picked V2 or V1. Read once here off the actor, through the same resolver the normal + // staging path uses; the choice is purely which engine runs over the already-detected window — + // detection is identical either way. (V7 Pillar 3b) + let stager = PuffinExperiment.sleepStager let segs = await Task.detached(priority: .utility) { - let staged = useV2 - ? SleepStagerV2.stageSession(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp) - : SleepStager.stageSession(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp) + // `stageWindow` is the function `detectSleep` stages every detected night with, so an edit that + // keeps a night's bounds reproduces its detected hypnogram. + let staged = SleepStager.stageWindow(start: start, end: end, grav: grav, hr: hr, rr: rr, resp: resp, + bandSleepState: band, stager: stager).stages // #364 follow-up: motion-aware wake refinement post-pass, same toggle-shaped no-op when off // as every other Experimental switch here. return WakeMotionRefinement.apply(staged, grav: grav, steps: steps, enabled: useMotionAwareWake) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index ee816d7542..b982162454 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -27200,58 +27200,6 @@ } } }, - "A transparent cardiorespiratory recipe that recovers deep and REM better than the older V1 staging, and is now the default. It only changes how already-detected nights are split into stages (detection and scores are unchanged); turn it off to fall back to V1. Takes effect on the next nights staged.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ein transparentes kardiorespiratorisches Rezept, das sich tief und REM besser erholt als das ältere V1-Staging und jetzt standardmäßig ist. Es ändert sich nur, wie bereits erkannte Nächte in Etappen unterteilt sind (Erkennung und Ergebnisse sind unverändert); Schalte es aus, um auf V1 zurückzugreifen. Tritt in den nächsten Nächten inszeniert." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Una receta cardiorrespiratoria transparente que recupera profundo y REM mejor que el estadificación V1 más antiguo, y ahora es el predeterminado. Sólo cambia cómo las noches ya detectadas se dividen en etapas (la detección y las puntuaciones no cambian); apágalo para caer a V1. Efectúa las noches siguientes." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Une recette cardiorespiratoire transparente qui récupère profonde et REM mieux que l'ancienne mise en scène V1, et est maintenant la par défaut. Cela ne fait que changer la façon dont les nuits déjà détectées sont divisées en étapes (la détection et les scores sont inchangés); éteindre pour revenir en V1. Ça prend effet les nuits suivantes." - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Uma receita cardiorrespiratória transparente que recupera melhor o REM profundo e o REM do que o antigo estadiamento V1 e é agora o padrão. Isto apenas altera a forma como as noites já detetadas são divididas em fases (a deteção e as pontuações permanecem inalteradas); desligue-o para voltar a V1. Entra em vigor nas próximas noites encenadas." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Przejrzysta receptura krążeniowo-oddechowa, która regeneruje głębiej i w fazie REM lepiej niż starsza wersja V1 i jest teraz domyślna. Zmienia się jedynie sposób podziału już wykrytych nocy na etapy (wykrywanie i wyniki pozostają niezmienione); wyłącz go, aby powrócić do wersji V1. Obowiązuje od kolejnych nocy scenicznych." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Прозрачная кардиореспираторная методика, которая восстанавливает глубокий сон и REM лучше прежней разметки V1 и теперь используется по умолчанию. Она меняет только то, как уже определённые ночи делятся на стадии (определение и оценки не меняются); выключите, чтобы вернуться к V1. Вступает в силу для следующих размеченных ночей." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "一套透明的心肺分期方法,比旧的 V1 分期能更好地还原深睡和 REM,现已成为默认。它只改变已检测到的夜间如何划分睡眠阶段(检测与评分不变);关闭它即可回到 V1。对之后分期的夜晚生效。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "一套透明的心肺分期方法,比舊的 V1 分期能更好地還原深睡和 REM,現已成為預設。它只改變已偵測到的夜間如何劃分睡眠階段(偵測與評分不變);關閉它即可回到 V1。對之後分期的夜晚生效。" - } - } - } - }, "A typical recent day": { "localizations": { "de": { @@ -100024,6 +99972,64 @@ } } }, + "How NOOP splits a night into light / deep / REM. V3 is the default; V2 and V1 stay available if you want to switch back.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie NOOP eine Nacht in Leicht- / Tief- / REM-Schlaf aufteilt. V3 ist der Standard; V2 und V1 bleiben verfügbar, falls du zurückwechseln möchtest." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cómo divide NOOP una noche en sueño ligero / profundo / REM. V3 es el predeterminado; V2 y V1 siguen disponibles si quieres volver a ellos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Comment NOOP répartit une nuit en sommeil léger / profond / paradoxal. V3 est la méthode par défaut ; V2 et V1 restent disponibles si vous voulez y revenir." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Come NOOP divide una notte in sonno leggero / profondo / REM. V3 è il predefinito; V2 e V1 restano disponibili se vuoi tornare indietro." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jak NOOP dzieli noc na sen lekki / głęboki / REM. Domyślnie używana jest wersja V3; V2 i V1 pozostają dostępne, jeśli chcesz do nich wrócić." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Como o NOOP divide uma noite em sono leve / profundo / REM. O V3 é o predefinido; o V2 e o V1 continuam disponíveis se quiser voltar atrás." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Как NOOP делит ночь на лёгкий / глубокий / быстрый сон (REM). По умолчанию используется V3; V2 и V1 остаются доступны, если захотите вернуться." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP 如何把一夜划分为浅睡 / 深睡 / REM。默认使用 V3;如需切换回去,V2 和 V1 仍可选用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP 如何把一夜劃分為淺睡 / 深睡 / REM。預設使用 V3;如需切換回去,V2 和 V1 仍可選用。" + } + } + } + }, "How NOOP works": { "localizations": { "de": { @@ -107348,6 +107354,64 @@ } } }, + "It only changes how detected nights are split into stages, and what is worked out from those stages; which nights are detected stays the same. Takes effect on the next nights staged.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ändert nur, wie erkannte Nächte in Phasen aufgeteilt werden, und was daraus berechnet wird; welche Nächte erkannt werden, bleibt gleich. Wirkt ab den nächsten eingeteilten Nächten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo cambia cómo se dividen en fases las noches detectadas y lo que se calcula a partir de esas fases; qué noches se detectan no cambia. Se aplica a las próximas noches clasificadas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela ne change que la façon dont les nuits détectées sont réparties en stades, et ce qui en est calculé ; les nuits détectées restent les mêmes. Prend effet sur les prochaines nuits réparties." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cambia solo come le notti rilevate vengono divise in fasi e ciò che viene calcolato da quelle fasi; quali notti vengono rilevate resta invariato. Ha effetto sulle prossime notti elaborate." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmienia tylko sposób podziału wykrytych nocy na fazy oraz to, co jest z tych faz wyliczane; to, które noce są wykrywane, pozostaje bez zmian. Obowiązuje od kolejnych przetworzonych nocy." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Altera apenas a forma como as noites detetadas são divididas em fases e o que é calculado a partir dessas fases; as noites detetadas continuam as mesmas. Aplica-se às próximas noites processadas." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Меняет только то, как обнаруженные ночи делятся на стадии, и то, что рассчитывается из этих стадий; какие ночи обнаруживаются, не меняется. Вступает в силу для следующих обработанных ночей." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "它只改变已检测到的夜晚如何划分阶段,以及由这些阶段计算出的结果;哪些夜晚被检测到保持不变。将在下次分期的夜晚生效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "它只改變已偵測到的夜晚如何劃分階段,以及由這些階段計算出的結果;哪些夜晚被偵測到保持不變。將在下次分期的夜晚生效。" + } + } + } + }, "It shows the shape of your heartbeat timing and a plain-language description of how steady it looked. It does not tell you whether anything is right or wrong.": { "localizations": { "de": { @@ -112942,6 +113006,64 @@ } } }, + "Learned from lab sleep studies: motion, heart rate and beat-to-beat heart-rate variability, fitted to the stages sleep technicians scored. It agreed with the lab better than V2 on every test set so far, but no lab study has used a WHOOP strap, so treat the stages as estimates.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus Schlaflaborstudien gelernt: Bewegung, Herzfrequenz und Schlag-zu-Schlag-Herzfrequenzvariabilität, angepasst an die Phasen, die Schlaftechniker bewertet haben. In allen bisherigen Testdatensätzen stimmte es besser mit dem Labor überein als V2, aber keine Laborstudie hat ein WHOOP-Armband verwendet – betrachte die Phasen daher als Schätzungen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aprendido de estudios de sueño en laboratorio: movimiento, frecuencia cardíaca y variabilidad de la frecuencia cardíaca latido a latido, ajustados a las fases que puntuaron técnicos del sueño. Hasta ahora ha coincidido con el laboratorio mejor que V2 en todos los conjuntos de prueba, pero ningún estudio de laboratorio ha usado una banda WHOOP, así que toma las fases como estimaciones." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appris à partir d'études du sommeil en laboratoire : mouvement, fréquence cardiaque et variabilité de la fréquence cardiaque battement par battement, ajustés aux stades notés par des techniciens du sommeil. Il a mieux concordé avec le laboratoire que V2 sur tous les jeux de test jusqu'ici, mais aucune étude en laboratoire n'a utilisé de bracelet WHOOP : considérez donc les stades comme des estimations." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Appreso da studi del sonno in laboratorio: movimento, frequenza cardiaca e variabilità della frequenza cardiaca battito per battito, adattati alle fasi valutate dai tecnici del sonno. Finora ha concordato con il laboratorio meglio di V2 in ogni set di test, ma nessuno studio di laboratorio ha usato un bracciale WHOOP, quindi considera le fasi come stime." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyuczony na laboratoryjnych badaniach snu: ruch, tętno i zmienność rytmu serca uderzenie po uderzeniu, dopasowane do faz ocenionych przez techników snu. Jak dotąd na każdym zbiorze testowym zgadzał się z laboratorium lepiej niż V2, ale żadne badanie laboratoryjne nie użyło opaski WHOOP, więc traktuj fazy jako szacunki." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Aprendido a partir de estudos do sono em laboratório: movimento, frequência cardíaca e variabilidade da frequência cardíaca batimento a batimento, ajustados às fases classificadas por técnicos do sono. Até agora concordou com o laboratório melhor do que o V2 em todos os conjuntos de teste, mas nenhum estudo em laboratório usou uma pulseira WHOOP, por isso considere as fases como estimativas." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обучен на лабораторных исследованиях сна: движение, пульс и вариабельность сердечного ритма от удара к удару, сопоставленные со стадиями, которые разметили специалисты по сну. На всех тестовых наборах до сих пор совпадал с лабораторией лучше, чем V2, но ни одно лабораторное исследование не проводилось с браслетом WHOOP, поэтому считайте стадии оценкой." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从睡眠实验室研究中学习:将体动、心率和逐搏心率变异性拟合到睡眠技师评分的阶段。在迄今所有测试集上,它与实验室的一致性都优于 V2,但尚无实验室研究使用 WHOOP 手环,因此请把各阶段视为估计值。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從睡眠實驗室研究中學習:將體動、心率和逐搏心率變異性擬合到睡眠技師評分的階段。在迄今所有測試集上,它與實驗室的一致性都優於 V2,但尚無實驗室研究使用 WHOOP 手環,因此請把各階段視為估計值。" + } + } + } + }, "Learning": { "localizations": { "de": { @@ -184825,52 +184947,6 @@ } } }, - "Sleep staging (V2)": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Schlafstadien (V2)" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Estadificación de sueño (V2)" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Stade de sommeil (V2)" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Определение стадий сна (V2)" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "睡眠分期 (V2 算法)" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Estadiamento do sono (V2)" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Ocena snu (V2)" - } - } - } - }, "Sleep steadiness: Rest varied ±%@ pts night to night.": { "localizations": { "de": { @@ -201379,6 +201455,64 @@ } } }, + "The earlier hand-tuned heart-rate and motion recipe, the default before V3.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das frühere, von Hand abgestimmte Rezept aus Herzfrequenz und Bewegung, der Standard vor V3." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La receta anterior de frecuencia cardíaca y movimiento ajustada a mano, la predeterminada antes de V3." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'ancienne recette de fréquence cardiaque et de mouvement réglée à la main, la méthode par défaut avant V3." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La precedente ricetta di frequenza cardiaca e movimento regolata a mano, quella predefinita prima di V3." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wcześniejsza, ręcznie dostrojona receptura oparta na tętnie i ruchu, domyślna przed V3." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A receita anterior de frequência cardíaca e movimento afinada à mão, a predefinida antes do V3." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прежний настроенный вручную метод по пульсу и движению, использовавшийся по умолчанию до V3." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "早先手动调校的心率与体动算法,V3 之前的默认选项。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "早先手動調校的心率與體動演算法,V3 之前的預設選項。" + } + } + } + }, "The export file could not be created or shared.": { "shouldTranslate": false, "localizations": { @@ -202030,6 +202164,64 @@ } } }, + "The original percentile-band staging.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die ursprüngliche Phaseneinteilung nach Perzentilbändern." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La clasificación original por bandas de percentiles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La répartition d'origine par bandes de percentiles." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Lo staging originale a bande di percentili." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pierwotny podział na fazy według przedziałów percentylowych." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O estadiamento original por bandas de percentis." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исходное разбиение на стадии по процентильным диапазонам." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最初的百分位区间分期方法。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最初的百分位區間分期方法。" + } + } + } + }, "The scatter looked rounder and more spread out than a tight, steady beat. This has many ordinary causes and is not a diagnosis.": { "localizations": { "de": { diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 6904fc2a42..f8f54bff4d 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -157,11 +157,10 @@ struct SettingsView: View { // iPhone (between Test Centre and Settings) and its own sidebar item on macOS. Its `@AppStorage` // keys live there now; nothing here reads them. - /// "Experimental sleep staging (V2)" (ON by default, promoted after the 44-subject cross-subject - /// benchmark). When on, detected nights are re-staged with `SleepStagerV2` (the transparent - /// cardiorespiratory recipe) instead of the older V1 stager. Read at the staging call site in - /// `Repository`. See [PuffinExperiment.experimentalSleepV2Key]. - @AppStorage(PuffinExperiment.experimentalSleepV2Key) private var experimentalSleepV2Enabled = true + /// The stored sleep-staging choice. Observed here only so the picker redraws when it changes; the + /// picker READS through `PuffinExperiment.sleepStager` (see `sleepStagerChoice`), the resolver the + /// staging engine uses, so an upgraded user whose old V2 switch was off sees V1 here, as staged. + @AppStorage(PuffinExperiment.sleepStagerKey) private var sleepStagerRaw = "" /// "Motion-aware wake refinement" (#364 follow-up, OFF by default). A post-pass over the already-staged /// hypnogram: reclassifies a scored WAKE segment to `light` when its per-minute step-tick cadence shows @@ -1950,25 +1949,29 @@ struct SettingsView: View { } } - /// Sleep staging engine. V2 (the transparent cardiorespiratory recipe) is the DEFAULT after a 44-subject - /// cross-subject benchmark; model-agnostic — it works on WHOOP 4 and 5 — so it renders on every strap. - /// Turning the toggle OFF falls back to the older V1 percentile-band stager; either way only future - /// (and re-derived) nights are affected. + /// Which recipe stages detected nights: V3 (fitted to polysomnography) by default, with V2 and V1 kept + /// to switch back to. Model-agnostic, so it renders on every strap; only future (and re-derived) nights + /// are affected. private var sleepStagingCard: some View { SettingsSection( icon: "bed.double.fill", title: "Sleep staging", - blurb: "How NOOP splits a night into light / deep / REM. The V2 recipe is the default; turn it off to fall back to the older V1 staging." + blurb: "How NOOP splits a night into light / deep / REM. V3 is the default; V2 and V1 stay available if you want to switch back." ) { VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { - Toggle(isOn: $experimentalSleepV2Enabled) { - Text("Sleep staging (V2)") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) + Picker("Sleep staging", selection: sleepStagerChoice) { + ForEach([SleepStagerVersion.v3, .v2, .v1], id: \.self) { version in + Text(verbatim: version.label).tag(version) + } } - .toggleStyle(.switch) + .pickerStyle(.segmented) + .labelsHidden() .tint(StrandPalette.accent) - Text("A transparent cardiorespiratory recipe that recovers deep and REM better than the older V1 staging, and is now the default. It only changes how already-detected nights are split into stages (detection and scores are unchanged); turn it off to fall back to V1. Takes effect on the next nights staged.") + Text(sleepStagerCaption(sleepStagerChoice.wrappedValue)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + Text("It only changes how detected nights are split into stages, and what is worked out from those stages; which nights are detected stays the same. Takes effect on the next nights staged.") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -1991,6 +1994,24 @@ struct SettingsView: View { } } + /// The picker's selection, read through the engine's own resolver and written to the new key. + private var sleepStagerChoice: Binding { + Binding( + get: { _ = sleepStagerRaw; return PuffinExperiment.sleepStager }, + set: { sleepStagerRaw = $0.rawValue }) + } + + private func sleepStagerCaption(_ version: SleepStagerVersion) -> String { + switch version { + case .v3: + return String(localized: "Learned from lab sleep studies: motion, heart rate and beat-to-beat heart-rate variability, fitted to the stages sleep technicians scored. It agreed with the lab better than V2 on every test set so far, but no lab study has used a WHOOP strap, so treat the stages as estimates.") + case .v2: + return String(localized: "The earlier hand-tuned heart-rate and motion recipe, the default before V3.") + case .v1: + return String(localized: "The original percentile-band staging.") + } + } + /// The R22 "send enable sequence" button is structurally impossible on macOS — a Mac can't form the /// encrypted bond a 5/MG needs to accept the write (BLEManager forms a live-HR-only link there), so it /// stays disabled regardless of bond/wear state (#587). On iOS/Android it gates on the real bond + wear. diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index bb3100172c..0bc6ba6269 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -234,6 +234,9 @@ struct SleepView: View { // a phantom night. let coverageLo = min(edit.detectedStartTs, edit.bedTs) SleepTimeEditor(bedTs: edit.bedTs, wakeTs: edit.wakeTs, + title: edit.isNap ? "Edit nap times" : "Edit sleep times", + bedLabel: edit.isNap ? "Nap started" : "Bedtime", + wakeLabel: edit.isNap ? "Nap ended" : "Got up", coverage: coverageLo...max(edit.wakeTs, coverageLo + 1), suppressesReDetection: !edit.userEdited, onSave: { newBedTs, newWakeTs in @@ -744,7 +747,8 @@ struct SleepView: View { bedTs: nap.effectiveStartTs, wakeTs: nap.endTs, stagesJSON: nap.stagesJSON, - userEdited: true) // a nap row is always manually added → no tombstone on delete + userEdited: true, // a nap row is always manually added → no tombstone on delete + isNap: true) } label: { Image(systemName: isEdited ? "pencil.circle.fill" : "pencil.circle") .font(StrandFont.headline) @@ -2690,6 +2694,9 @@ private struct WakeEdit: Identifiable { /// never re-detected), so the editor's delete-confirm copy must NOT promise re-detection suppression /// for it. Mirrors the undo-banner branch (#65 banner/confirm honesty). let userEdited: Bool + /// True when a nap row opened the editor. Its fields then name the nap's start and end, as the Add-a-nap + /// sheet does; a night's are Bedtime and Got up, which read wrongly on a 14:00 nap. + var isNap = false var id: Int { detectedStartTs } } diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift index 142e44bfb9..ffd4c166c0 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -309,17 +309,16 @@ enum DebugDataDiagnostics { return lines } if let rem = SleepStager.remFunnelDiagnostic(start: cs.startTs, end: cs.endTs, grav: grav, hr: hr, rr: rr, resp: resp) { - // The funnel replays the V1 classifier, but the shipped hypnogram is staged by V2 whenever - // the default-on flag says so — name both, or the two totals read as one fact disagreeing. + // The funnel replays the V1 classifier, while the screen shows the stored hypnogram. Name both, or + // the two totals read as one fact disagreeing. A stored night keeps no record of the recipe that + // staged it: the picker may have moved since it was staged, and a night the merge takes from an + // import was never staged here. So the line names the current setting, not the night's recipe, + // and says the totals can differ whatever that setting is. // On a 5/MG the gap is maximal: V1's primary REM gate needs the raw resp channel that - // hardware never emits, while V2 recovers respiration from R-R, so the funnel can report + // hardware never emits, while V2 and V3 read the R-R stream instead, so the funnel can report // ~46min REM against a 231min screen for the same night. - let screenStager = PuffinExperiment.experimentalSleepV2Enabled ? "V2" : "V1" - var summary = rem.summary + " · funnel replays V1; screen staged by \(screenStager)" - if screenStager != "V1" { - summary += " — totals can differ" - } - lines.append(summary) + lines.append(rem.summary + " · funnel replays V1; the screen shows the stored hypnogram " + + "(staging setting now \(PuffinExperiment.sleepStager.label)) — totals can differ") } else { lines.append("REM funnel: insufficient motion data (<2 gravity samples)") } diff --git a/Strand/System/TestCentre.swift b/Strand/System/TestCentre.swift index 12f3e9dbb2..9dfa51782a 100644 --- a/Strand/System/TestCentre.swift +++ b/Strand/System/TestCentre.swift @@ -104,7 +104,7 @@ public enum TestCentre { // experimental flags, gathered by the IA but not domain activations), so the migration only // stamps the guard. The legacy keys are read in place through their existing accessors: // PuffinExperiment.defaultsKey / .deepDataKey / .broadcastHrKey / .keepRealtimeForDataKey / - // .experimentalSleepV2Key / .autoDetectWorkoutsKey, and the ScheduledDebugExport "debugExport.*" + // .sleepStagerKey / .autoDetectWorkoutsKey, and the ScheduledDebugExport "debugExport.*" // keys. Nothing is moved; the Test Centre screen reads them where they already live. UserDefaults.standard.set(true, forKey: migratedKey) } diff --git a/StrandTests/DayCacheConfigFieldTests.swift b/StrandTests/DayCacheConfigFieldTests.swift index 483c2bfc4f..fd8554f092 100644 --- a/StrandTests/DayCacheConfigFieldTests.swift +++ b/StrandTests/DayCacheConfigFieldTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import Strand -/// Swift twin of the Kotlin `DayCacheConfigFieldTest`: which config field dropped the day cache. +/// Which config field dropped the day cache. /// /// #2073 made a wholesale drop visible (`configDropped`), and a field log on build 487 showed it firing /// for real: `reused=0/21 missBy=absent:21,configDropped:1`, against a healthy pass that reuses 20 of 21 @@ -9,73 +9,69 @@ import XCTest /// field moved is the difference between a config the user actually changed and a value that drifts on /// its own. /// -/// The signature is a plain "|"-joined value list, and the field NAMES live beside the reader rather than -/// the construction, which on the Kotlin side keeps them off a bytecode ratchet. That split is the risk -/// pinned here: a reader that guessed an index would name the wrong field and send someone hunting a bug -/// that is not there, which is worse than saying nothing. +/// The signature is a plain "|"-joined value list. `analyzeRecent` builds it from (name, value) pairs and +/// hands the names to the reader. The names used to live in a separate list, which fell one behind when +/// the Healthspan zone set joined the signature, and every drop since read "unknown". A reader that guessed +/// an index would name the wrong field, which is worse than saying nothing, so a count mismatch is refused. final class DayCacheConfigFieldTests: XCTestCase { - /// A signature with one value per known field, so a changed index has a name to resolve to. + /// The names `analyzeRecent` passes, in its order. + private let fields = [ + "hrvBaseline", "rhrBaseline", "age", "sex", "stepTicksPerStep", "maxHROverride", + "tzOffset", "sleepNeedHours", "sleepConsistency", "habitualMidsleep", + "sleepStager", "motionAwareWake", "deepHrvWindow", "spo2CandidateDisplay", + "effortMethod", "dayCycleMode", "healthspanZones", + ] + + /// A signature with one value per field, so a changed index has a name to resolve to. private func fullSig(_ mutate: (inout [String]) -> Void = { _ in }) -> String { - var v = (0.. String { + IntelligenceEngine.changedConfigField(previous: previous, current: current, fields: fields) + } + func testTheMovedFieldIsNamed() { - XCTAssertEqual( - IntelligenceEngine.changedConfigField(previous: fullSig(), current: fullSig { $0[0] = "moved" }), - "hrvBaseline" - ) + XCTAssertEqual(moved(fullSig(), fullSig { $0[0] = "moved" }), "hrvBaseline") } /// The field log's leading suspicion was a rolling baseline, which is index 0 and 1. Naming them /// apart is the whole point: one is HRV drifting, the other resting heart rate. func testTheTwoBaselinesAreToldApart() { - XCTAssertEqual( - IntelligenceEngine.changedConfigField(previous: fullSig(), current: fullSig { $0[1] = "moved" }), - "rhrBaseline" - ) - XCTAssertEqual( - IntelligenceEngine.changedConfigField(previous: fullSig(), current: fullSig { $0[15] = "moved" }), - "dayCycleMode" - ) + XCTAssertEqual(moved(fullSig(), fullSig { $0[1] = "moved" }), "rhrBaseline") + XCTAssertEqual(moved(fullSig(), fullSig { $0[15] = "moved" }), "dayCycleMode") } /// Several at once happens on a settings change that touches more than one knob. func testSeveralMoversAreAllNamed() { let after = fullSig { $0[0] = "a"; $0[14] = "b" } - XCTAssertEqual(IntelligenceEngine.changedConfigField(previous: fullSig(), current: after), - "hrvBaseline+effortMethod") + XCTAssertEqual(moved(fullSig(), after), "hrvBaseline+effortMethod") } /// The signature starts EMPTY rather than nil, so the first drop of a process has nothing to diff /// against. Reporting that as "unknown" would describe a shape mismatch that never happened. func testTheFirstDropOfAProcessSaysFirst() { - XCTAssertEqual(IntelligenceEngine.changedConfigField(previous: "", current: fullSig()), "first") + XCTAssertEqual(moved("", fullSig()), "first") } - /// The names and the construction can fall out of step, because they live apart. When they do, this - /// refuses to name anything rather than resolve an index against a list that no longer describes it. - /// A wrong field name is worse than none: a diagnostic asserting what it cannot attribute. + /// A signature whose value count differs from the names is refused rather than resolved against a list + /// that no longer describes it. A wrong field name is worse than none: a diagnostic asserting what it + /// cannot attribute. func testAShapeMismatchIsRefusedRatherThanGuessed() { - XCTAssertEqual(IntelligenceEngine.changedConfigField(previous: "a|b", current: "a|c"), "unknown") - XCTAssertEqual(IntelligenceEngine.changedConfigField(previous: fullSig(), current: "a"), "unknown") + XCTAssertEqual(moved("a|b", "a|c"), "unknown") + XCTAssertEqual(moved(fullSig(), "a"), "unknown") } - /// Equal signatures never reach the caller, but the helper still answers honestly if they do. - func testAnUnchangedSignatureNamesNothing() { - XCTAssertEqual(IntelligenceEngine.changedConfigField(previous: fullSig(), current: fullSig()), "none") + /// The Healthspan zone set, the field the old separate name list was missing: moving it is named. + func testTheHealthspanZoneSetIsNamed() { + XCTAssertEqual(moved(fullSig(), fullSig { $0[16] = "moved" }), "healthspanZones") } - /// The two platforms must describe the same fields in the same order, or the same drop is reported - /// as two different causes depending on which phone the reporter happens to hold. - func testTheFieldListMatchesTheKotlinTwin() { - XCTAssertEqual(IntelligenceEngine.dayCacheConfigFields, [ - "hrvBaseline", "rhrBaseline", "age", "sex", "stepTicksPerStep", "maxHROverride", - "tzOffset", "sleepNeedHours", "sleepConsistency", "habitualMidsleep", - "experimentalSleepV2", "motionAwareWake", "deepHrvWindow", "spo2CandidateDisplay", - "effortMethod", "dayCycleMode", - ]) + /// Equal signatures never reach the caller, but the helper still answers honestly if they do. + func testAnUnchangedSignatureNamesNothing() { + XCTAssertEqual(moved(fullSig(), fullSig()), "none") } } diff --git a/StrandTests/DetectedWorkoutReconciliationTests.swift b/StrandTests/DetectedWorkoutReconciliationTests.swift index dbb14d7646..2001dcd069 100644 --- a/StrandTests/DetectedWorkoutReconciliationTests.swift +++ b/StrandTests/DetectedWorkoutReconciliationTests.swift @@ -22,7 +22,8 @@ final class DetectedWorkoutReconciliationTests: XCTestCase { "noop.hrvBaselineEpoch", "noop.recoveryBaselineEpoch", UnitPrefs.hrvWindowKey, RescoreBackgroundScheduler.owedKey, RescoreBackgroundScheduler.owedTokenKey, RescoreBackgroundScheduler.lastPassSecondsKey, DayCycleMode.storageKey, - PuffinExperiment.experimentalSleepV2Key, PuffinExperiment.motionAwareWakeKey, + PuffinExperiment.experimentalSleepV2Key, PuffinExperiment.sleepStagerKey, + PuffinExperiment.motionAwareWakeKey, PuffinExperiment.autoDetectWorkoutsKey, WorkoutSource.dismissedDefaultsKey, "testcentre.active.workouts", "testcentre.active.master", @@ -36,7 +37,7 @@ final class DetectedWorkoutReconciliationTests: XCTestCase { } for key in keys { defaults.removeObject(forKey: key) } defaults.set(DayCycleMode.midnight.rawValue, forKey: DayCycleMode.storageKey) - defaults.set(true, forKey: PuffinExperiment.experimentalSleepV2Key) + defaults.set(SleepStagerVersion.v2.rawValue, forKey: PuffinExperiment.sleepStagerKey) defaults.set(false, forKey: PuffinExperiment.motionAwareWakeKey) defaults.set(false, forKey: PuffinExperiment.autoDetectWorkoutsKey) TestCentre.activate(.workouts) diff --git a/StrandTests/IntelligenceRRSourceTests.swift b/StrandTests/IntelligenceRRSourceTests.swift index 7fef38e679..a4c3230e06 100644 --- a/StrandTests/IntelligenceRRSourceTests.swift +++ b/StrandTests/IntelligenceRRSourceTests.swift @@ -22,7 +22,8 @@ final class IntelligenceRRSourceTests: XCTestCase { "noop.hrvBaselineEpoch", "noop.recoveryBaselineEpoch", UnitPrefs.hrvWindowKey, RescoreBackgroundScheduler.owedKey, RescoreBackgroundScheduler.owedTokenKey, RescoreBackgroundScheduler.lastPassSecondsKey, DayCycleMode.storageKey, - PuffinExperiment.experimentalSleepV2Key, PuffinExperiment.motionAwareWakeKey, + PuffinExperiment.experimentalSleepV2Key, PuffinExperiment.sleepStagerKey, + PuffinExperiment.motionAwareWakeKey, ] let saved = keys.map { ($0, defaults.object(forKey: $0)) } defer { @@ -33,7 +34,7 @@ final class IntelligenceRRSourceTests: XCTestCase { } for key in keys { defaults.removeObject(forKey: key) } defaults.set(DayCycleMode.midnight.rawValue, forKey: DayCycleMode.storageKey) - defaults.set(true, forKey: PuffinExperiment.experimentalSleepV2Key) + defaults.set(SleepStagerVersion.v2.rawValue, forKey: PuffinExperiment.sleepStagerKey) defaults.set(false, forKey: PuffinExperiment.motionAwareWakeKey) try await body() } diff --git a/StrandTests/SleepStagerSettingTests.swift b/StrandTests/SleepStagerSettingTests.swift new file mode 100644 index 0000000000..e196e408c9 --- /dev/null +++ b/StrandTests/SleepStagerSettingTests.swift @@ -0,0 +1,53 @@ +import XCTest +import StrandAnalytics +@testable import Strand + +/// `PuffinExperiment.sleepStager` is the one resolver every reader of the staging choice goes through. These +/// pin the upgrade from the old V2 on/off switch: nobody who chose V1 is moved to V3 unasked, everyone on +/// the old default gets the new one, and a choice made in the new picker always wins. +final class SleepStagerSettingTests: XCTestCase { + + private let keys = [PuffinExperiment.sleepStagerKey, PuffinExperiment.experimentalSleepV2Key] + private var saved: [(String, Any?)] = [] + + override func setUp() { + super.setUp() + saved = keys.map { ($0, UserDefaults.standard.object(forKey: $0)) } + keys.forEach { UserDefaults.standard.removeObject(forKey: $0) } + } + + override func tearDown() { + for (key, value) in saved { + if let value { UserDefaults.standard.set(value, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + super.tearDown() + } + + func testFreshInstallStagesWithV3() { + XCTAssertEqual(PuffinExperiment.sleepStager, .v3) + } + + func testTheOldDefaultMovesToV3() { + UserDefaults.standard.set(true, forKey: PuffinExperiment.experimentalSleepV2Key) + XCTAssertEqual(PuffinExperiment.sleepStager, .v3) + } + + func testAnExplicitV2OffStaysOnV1() { + UserDefaults.standard.set(false, forKey: PuffinExperiment.experimentalSleepV2Key) + XCTAssertEqual(PuffinExperiment.sleepStager, .v1) + } + + func testThePickerWinsOverTheOldSwitch() { + UserDefaults.standard.set(false, forKey: PuffinExperiment.experimentalSleepV2Key) + for version in SleepStagerVersion.allCases { + UserDefaults.standard.set(version.rawValue, forKey: PuffinExperiment.sleepStagerKey) + XCTAssertEqual(PuffinExperiment.sleepStager, version) + } + } + + func testAnUnknownStoredValueFallsBackInsteadOfCrashing() { + UserDefaults.standard.set("v9", forKey: PuffinExperiment.sleepStagerKey) + XCTAssertEqual(PuffinExperiment.sleepStager, .v3) + } +} diff --git a/Tools/SleepPSG/Sources/sleeppsg/Nights.swift b/Tools/SleepPSG/Sources/sleeppsg/Nights.swift new file mode 100644 index 0000000000..25b6000a72 --- /dev/null +++ b/Tools/SleepPSG/Sources/sleeppsg/Nights.swift @@ -0,0 +1,185 @@ +import Foundation +import StrandAnalytics +import WhoopProtocol + +// Nights — score the shipped V2 and V3 stagers over cohorts in the `Tools/SleepTrain` night format. +// +// `Tools/SleepTrain` prepares each PSG cohort (Wearanize+, sleep-accel, DREAMT) as a directory of per-night +// CSV files on the PSG clock: `{id}_grav.csv`, `{id}_hr.csv`, `{id}_rr.csv`, `{id}_truth.csv`. This section +// replays both shipped stagers over them, exactly as the app calls them, in two ways per night: +// +// window — inside the sleep window `SleepStager.bandSleepWindow` would allow, emulated from the PSG +// hypnogram with the same persist / grace rule (the strap's band plays that role in the app); +// span — over the whole scored span, as when the band gives no window. +// +// Every scored epoch counts, and an epoch the stager leaves outside its window is wake. The V3 rows are the +// in-repo check on `Tools/SleepTrain/train.py`'s numbers: on a cohort the model never trained on (DREAMT) the +// two must agree, since this is the Swift port and that is the Python reference. On a cohort V3 WAS trained +// on (Wearanize+, sleep-accel) its row here is in-sample and flatters it; `train.py`'s cross-validated rows +// are the honest figures there. + +struct NightSubject { + let id: String + let start: Int + let end: Int + let grav: [GravitySample] + let hr: [HRSample] + let rr: [RRInterval] + /// One label per epoch of `[start, end)`, the scored span; nil = unscored. + let truth: [String?] + let window: (from: Int, to: Int)? +} + +enum Nights { + /// A multiple of 30, so night-format second 0 lands on the stagers' wall-clock epoch grid. + static let timeBase = 1_699_999_980 + static let bandPersist = 10 + static let bandGrace = 10 + + static func rows(_ path: String) -> [[Substring]] { + guard let s = try? String(contentsOfFile: path, encoding: .utf8) else { return [] } + return s.split(separator: "\n").dropFirst().map { $0.split(separator: ",") } + } + + static func load(dir: String) -> [NightSubject] { + let files = ((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? []) + .filter { $0.hasSuffix("_truth.csv") }.sorted() + var out = [NightSubject?](repeating: nil, count: files.count) + out.withUnsafeMutableBufferPointer { buf in + let dst = buf + DispatchQueue.concurrentPerform(iterations: files.count) { i in + dst[i] = loadOne(dir: dir, id: String(files[i].dropLast("_truth.csv".count))) + } + } + return out.compactMap { $0 } + } + + static func loadOne(dir: String, id: String) -> NightSubject? { + let labels = rows("\(dir)/\(id)_truth.csv").map { $0.first.map(String.init) ?? "none" } + guard let a = labels.firstIndex(where: { $0 != "none" }), + let b = labels.lastIndex(where: { $0 != "none" }) else { return nil } + let truth: [String?] = labels[a...b].map { $0 == "none" ? nil : $0 } + let grav = rows("\(dir)/\(id)_grav.csv").compactMap { r -> GravitySample? in + guard r.count >= 4, let t = Int(r[0]), let x = Double(r[1]), let y = Double(r[2]), let z = Double(r[3]) + else { return nil } + return GravitySample(ts: timeBase + t, x: x, y: y, z: z) + } + let hr = rows("\(dir)/\(id)_hr.csv").compactMap { r -> HRSample? in + guard r.count >= 2, let t = Int(r[0]), let v = Int(r[1]) else { return nil } + return HRSample(ts: timeBase + t, bpm: v) + } + let rr = rows("\(dir)/\(id)_rr.csv").compactMap { r -> RRInterval? in + guard r.count >= 2, let t = Int(r[0]), let v = Int(r[1]) else { return nil } + return RRInterval(ts: timeBase + t, rrMs: v) + } + let start = timeBase + 30 * a, end = timeBase + 30 * (b + 1) + let window = bandWindow(truth.map { $0 == "light" || $0 == "deep" || $0 == "rem" }) + .map { (from: start + 30 * $0.lowerBound, to: start + 30 * $0.upperBound) } + return NightSubject(id: id, start: start, end: end, grav: grav, hr: hr, rr: rr, truth: truth, window: window) + } + + /// `SleepStager.bandSleepWindow`'s rule on a per-epoch asleep flag: the first and last runs of + /// `bandPersist` asleep epochs, widened by `bandGrace` epochs each side. + static func bandWindow(_ asleep: [Bool]) -> Range? { + var run = 0, onset: Int?, final: Int? + for i in asleep.indices { + run = asleep[i] ? run + 1 : 0 + if run >= bandPersist { onset = i - bandPersist + 1; break } + } + run = 0 + for i in asleep.indices.reversed() { + run = asleep[i] ? run + 1 : 0 + if run >= bandPersist { final = i + bandPersist - 1; break } + } + guard let o = onset, let f = final, o <= f else { return nil } + return max(0, o - bandGrace).. [StageSegment] + + static let v2: Stager = { s, w in + SleepStagerV2.stageSession(start: s.start, end: s.end, grav: s.grav, hr: s.hr, rr: s.rr, resp: [], sleepWindow: w) + } + static let v3: Stager = { s, w in + SleepStagerV3.stageSession(start: s.start, end: s.end, grav: s.grav, hr: s.hr, rr: s.rr, resp: [], sleepWindow: w) + } + + /// One report row: pooled kappa, mean per-night kappa, per-stage F1, pooled stage-share bias (pp) and the + /// per-night minute bias of each stage with its 95 % limits of agreement. + static func row(_ name: String, _ nights: [NightSubject], _ stager: Stager, windowed: Bool, + dump: inout [String]) -> String { + var pooled = Confusion() + var kappas: [Double] = [], minutes: [[Double]] = [[], [], [], []] + var latencyBias: [String: [Double]] = ["deep": [], "rem": []], early: [String: Int] = ["deep": 0, "rem": 0] + /// Minutes from the first sleep epoch to the first `stage` epoch. + func first(_ labels: [String?], _ stage: String) -> Double? { + guard let on = labels.firstIndex(where: { $0 != nil && $0 != "wake" }), + let f = labels.firstIndex(where: { $0 == stage }) else { return nil } + return Double(f - on) / 2 + } + for s in nights { + let labels = epochLabels(stager(s, windowed ? s.window : nil), start: s.start, end: s.end) + for stage in ["deep", "rem"] { + let p = first(labels, stage) + if let p = p, p < 5 { early[stage]! += 1 } + if let p = p, let t = first(s.truth, stage) { latencyBias[stage]!.append(p - t) } + } + var c = Confusion() + var counts = [[0, 0], [0, 0], [0, 0], [0, 0]] + for (i, t) in s.truth.enumerated() { + dump.append("\(name),\(s.id),\(i),\(t ?? "none"),\(labels[i])") + guard let t = t else { continue } + c.add(ref: t, pred: labels[i]) + counts[stageOrder.firstIndex(of: t)!][0] += 1 + counts[stageOrder.firstIndex(of: labels[i])!][1] += 1 + } + pooled.merge(c) + // Undefined (one class on both sides, or no scored epoch): out of the per-night mean, as + // `Tools/SleepTrain/train.py`'s summary leaves it out. + if !c.kappa.isNaN { kappas.append(c.kappa) } + for k in 0..<4 { minutes[k].append(Double(counts[k][1] - counts[k][0]) / 2) } + } + let f1 = stageOrder.map { String(format: "%.2f", pooled.prf($0).f1) }.joined(separator: " / ") + let total = Double(pooled.total) + let share = stageOrder.indices.map { k -> String in + let predK = (0..<4).reduce(0) { $0 + pooled.m[$1][k] } + let truthK = pooled.m[k].reduce(0, +) + return String(format: "%+.1f", Double(predK - truthK) / total * 100) + }.joined(separator: " / ") + let mins = minutes.map { String(format: "%+.0f ± %.0f", mean($0), 1.96 * sd($0)) }.joined(separator: " / ") + let lat = ["deep", "rem"].map { String(format: "%+.0f (%d)", mean(latencyBias[$0]!), early[$0]!) } + .joined(separator: " / ") + return "| \(name) | \(nights.count) | \(String(format: "%.3f", pooled.kappa)) | " + + "\(String(format: "%.3f", mean(kappas))) | \(f1) | \(share) | \(mins) | \(lat) |" + } + + static func run(dirs: [String], dump path: String?) { + print(""" + + ================================================================================ + V3 — the shipped V2 and V3 stagers over Tools/SleepTrain night-format cohorts + ================================================================================ + window = staged inside the band-style sleep window emulated from the PSG hypnogram, wake outside + span = the whole scored span staged + V3 is in-sample on the cohorts it was trained on (Wearanize+, sleep-accel): quote Tools/SleepTrain's + cross-validated figures for those, and these rows only for a cohort it never saw. + + | Stager, cohort, view | nights | kappa | mean night kappa | F1 wake / light / deep / rem | stage share bias pp | minute bias ± 1.96 SD wake / light / deep / rem | first deep / REM latency bias, min (nights < 5 min) | + |---|---|---|---|---|---|---|---| + """) + var dump: [String] = ["row,night,epoch,truth,pred"] + for dir in dirs { + let nights = load(dir: dir) + let cohort = (dir as NSString).lastPathComponent + for (name, stager) in [("V2", v2), ("V3", v3)] { + for windowed in [true, false] { + print(row("\(name) \(cohort) \(windowed ? "window" : "span")", nights, stager, + windowed: windowed, dump: &dump)) + } + } + } + if let path = path { + try? dump.joined(separator: "\n").write(toFile: path, atomically: true, encoding: .utf8) + } + } +} diff --git a/Tools/SleepPSG/Sources/sleeppsg/main.swift b/Tools/SleepPSG/Sources/sleeppsg/main.swift index 133cbb872b..37a40318d3 100644 --- a/Tools/SleepPSG/Sources/sleeppsg/main.swift +++ b/Tools/SleepPSG/Sources/sleeppsg/main.swift @@ -6,6 +6,7 @@ import WhoopProtocol // // USAGE: sleeppsg --dataset /path/to/sleep-accel-1.0.0 [--section all] [--csv out.csv] [--subjects N] // sleeppsg --section port (no dataset needed — the port check alone) +// sleeppsg --section v3 --nights DIR [--nights DIR ...] [--dump out.csv] // // The dataset lives outside this repository and is always an argument. Nothing is written to it. @@ -15,6 +16,8 @@ struct Args { var csv: String? var subjects: Int? var seed: UInt64 = PortValidation.defaultSeed + var nights: [String] = [] + var dump: String? } var a = Args() var it = CommandLine.arguments.dropFirst().makeIterator() @@ -25,15 +28,20 @@ while let k = it.next() { case "--csv": a.csv = it.next() case "--subjects": a.subjects = Int(it.next() ?? "") case "--seed": a.seed = UInt64(it.next() ?? "") ?? a.seed + case "--nights": if let d = it.next() { a.nights.append(d) } + case "--dump": a.dump = it.next() case "-h", "--help": print(""" usage: sleeppsg --dataset [--section all|port|baseline|strata|rem|variants] [--subjects N] [--csv ] [--seed ] + sleeppsg --section v3 --nights [--nights ...] [--dump ] --dataset PhysioNet sleep-accel v1.0.0, extracted. See README.md for the download step and the attribution its licence requires. Never committed; read-only here. --section which report to print. `port` needs no dataset. --subjects score only the first N subject ids (a fast smoke run; full cohort is the default). + --nights a cohort prepared by Tools/SleepTrain (night format). `v3` scores V2 and V3 over it. + --dump with `v3`: write every scored epoch's truth and label per report row as CSV. """) exit(0) default: FileHandle.standardError.write(Data("unknown argument \(k)\n".utf8)); exit(2) @@ -76,6 +84,14 @@ if want("port") { if a.section == "port" { exit(0) } +if a.section == "v3" { + guard !a.nights.isEmpty else { + FileHandle.standardError.write(Data("--section v3 needs at least one --nights \n".utf8)); exit(2) + } + Nights.run(dirs: a.nights, dump: a.dump) + exit(0) +} + // ───────────────────────────────────────────────────────────────────────────────────────────────────── // Dataset // ───────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/Tools/SleepTrain/README.md b/Tools/SleepTrain/README.md new file mode 100644 index 0000000000..119f04f3cc --- /dev/null +++ b/Tools/SleepTrain/README.md @@ -0,0 +1,140 @@ +# SleepTrain — fitting `SleepStagerV3` to polysomnography + +`SleepStagerV3` stages a night from a model fitted to human-scored polysomnography (PSG). This directory holds +everything that model came from: the feature definition, the dataset preparation, the fit, the +cross-validated report, and the generator for the oracle fixture that pins the Swift port. Nothing here ships +in the app, and no dataset is committed. + +| File | What it does | +|---|---| +| `sleeptrain/features.py` | **The feature definition.** Plain numpy, every reduction spelled out; `SleepStagerV3.swift` is a line-for-line port of it. | +| `sleeptrain/model.py` | The two logistic-regression heads, the HMM decoder, and a reference of `SleepStagerV3.stageSession`. | +| `sleeptrain/data.py` | The shared night format (below) and the emulation of `SleepStager.bandSleepWindow`. | +| `prepare_sleep_accel.py`, `prepare_wearanize.py`, `prepare_dreamt.py` | Dataset → night format. | +| `train.py` | Features, 10-fold grouped cross-validation report, final fit, and `SleepStagerV3Model.swift`. | +| `make_oracle.py` | Regenerates `Packages/StrandAnalytics/Tests/StrandAnalyticsTests/oracles/sleep_stager_v3.json`. | + +## Setup + +```bash +python3 -m venv ~/.venvs/sleeptrain && . ~/.venvs/sleeptrain/bin/activate +pip install -r Tools/SleepTrain/requirements.txt +``` + +Keep the datasets **outside** the repository, e.g. `~/datasets/noop-sleep/`. + +## Data + +### The night format + +Each prepared cohort is a directory of per-night CSV files, timed in whole seconds from the night's first PSG +epoch: `{id}_grav.csv` (`ts,x,y,z`, per-second gravity in g), `{id}_hr.csv` (`ts,bpm`), `{id}_rr.csv` +(`ts,rr`, interval in ms stamped with the beat's whole second), `{id}_truth.csv` (one label per 30 s epoch: +`wake`, `light`, `deep`, `rem` or `none`) and optionally `{id}_truth2.csv` (a second scorer). That is exactly +what the app hands the stager (whole-second stamps, integer bpm and ms), so the model trains on what it will +read. `Tools/SleepPSG --section v3` replays the Swift stagers over the same directories. + +### Training cohorts (open) + +**Wearanize+ OA** — 88 usable nights of an Empatica E4 wristband (accelerometer, HR, PPG inter-beat +intervals) with lab PSG scored by two humans. Radboud University Data Repository, +DOI [10.34973/xrmf-5726](https://doi.org/10.34973/xrmf-5726), **CC BY 4.0** (attribution required). No login. + +```bash +python3 Tools/SleepTrain/prepare_wearanize.py ~/datasets/noop-sleep/wearanize-nights +``` + +Downloads about 360 MB per subject one at a time and deletes it after processing; the server refuses more +than about four parallel downloads. The E4 clock drifts against the PSG recorder, so each subject's E4 is +mapped onto the PSG clock with a linear lag fitted hour by hour from the E4 inter-beat intervals against the +PSG ECG's R-R intervals. No sleep label takes part in that alignment. + +**PhysioNet sleep-accel v1.0.0** — 31 nights of Apple Watch accelerometer and heart rate with PSG. Walch O, +Huang Y, Forger D, Goldstein C. *Sleep stage prediction with raw acceleration and photoplethysmography heart +rate data derived from a consumer wearable device.* SLEEP 42(12), zsz180 (2019). **ODC-By 1.0** +(attribution required). Download as in `Tools/SleepPSG/README.md`, then: + +```bash +python3 Tools/SleepTrain/prepare_sleep_accel.py ~/datasets/noop-sleep/sleep-accel-nights +``` + +### Independent test cohort (restricted, never trained on) + +**DREAMT v2.2.0** — 100 sleep-clinic patients on an Empatica E4 with PSG. PhysioNet, **Restricted Health Data +License 1.5.0**: it needs a PhysioNet account and the signed data use agreement, the files may not be shared, +and use is limited to scientific research. For that last reason DREAMT only ever *tests* the model and never +enters a fit, so no number in `SleepStagerV3Model.swift` is derived from it. Download `data_64Hz/` from + after signing, then: + +```bash +python3 Tools/SleepTrain/prepare_dreamt.py ~/datasets/noop-sleep/dreamt-nights +``` + +## Train and regenerate + +```bash +cd Tools/SleepTrain +python3 train.py --wearanize ~/datasets/noop-sleep/wearanize-nights \ + --sleep-accel ~/datasets/noop-sleep/sleep-accel-nights \ + --dreamt ~/datasets/noop-sleep/dreamt-nights \ + --swift ../../Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV3Model.swift \ + --report report.md +python3 make_oracle.py +cd ../../Packages/StrandAnalytics && swift test --filter SleepStagerV3 +``` + +`--no-cv` skips the cross-validation and only refits and writes the model. Regenerating the model changes +what every night stages as, so a new model is its own reviewed change with its report attached. + +## Results + +Four-class Cohen's kappa against PSG over every scored epoch (wake outside the staged span), 10-fold +cross-validated by subject for the training cohorts. V2 is the shipped `SleepStagerV2`, replayed by +`Tools/SleepPSG --section v3` over the same prepared nights. + +| Cohort | V2 | V3 | Second human scorer | +|---|---|---|---| +| Wearanize+ (88), band-style window | 0.363 | 0.547 | 0.742 | +| Wearanize+, whole span | 0.301 | 0.533 | — | +| sleep-accel (31, no beat intervals), band-style window | 0.434 | 0.504 | — | +| sleep-accel, whole span | 0.373 | 0.463 | — | +| DREAMT (10, never trained on), band-style window | 0.259 | 0.313 | — | +| DREAMT, whole span | 0.142 | 0.256 | — | + +On Wearanize+ in the window, V3's stage shares are within 1 percentage point of PSG for every stage (V2: +light +13.5, deep −5.4, REM −7.0); per-night minute bias is +1 wake / +3 light / −4 deep / +0 REM. First deep +is 6 min early on average and never within 5 min of onset; first REM is 47 min late (V2: 48). `train.py +--report` writes the full table, with F1, limits of agreement and latencies. + +## How the model is built, and why + +- **Features are relative to the night.** Motion is per-second gravity jerk over the night's median jerk + (V2's self-calibrating floor), posture change over the night's median change, heart rate as z-scores, + percentile ranks and 5 / 11 / 20-minute variability, HRV scaled to the night's 5th-95th percentiles. An + absolute-degree posture feature was tried first and failed across devices: the WHOOP strap reads about + seven times the angle change of an Apple Watch on still sleep, and the model staged WHOOP nights as light. +- **Elapsed time as decays too.** Besides minutes since the start of the staged span, `exp(-t/10)`, + `exp(-t/30)` and `exp(-t/90)`. A linear head reads minutes only as a straight line; without the decays it + staged deep within 5 min of onset on 37 of 88 Wearanize+ nights (PSG: none). +- **Two heads, blended.** The base head reads motion and heart rate; the HRV head adds 5-minute beat-interval + HRV. When at least half the staged epochs have 20 or more beats in their window both run and their + log-posteriors are averaged with equal weight (untuned); otherwise the base head alone. The HRV head alone + scored slightly higher (Wearanize+ 0.554 vs 0.547, DREAMT 0.320 vs 0.313) but called REM high out of its + training cohort (+7.9 pp on DREAMT against +5.9 blended, and a quarter to over a third of sleep on WHOOP + nights). +- **Two views of every night.** Each training night is used over its whole scored span and cropped to the + band-style sleep window the app will stage inside, so both of the app's cases are in-distribution. +- **Decoder.** Emission = log posterior − 0.5 · log class prior; Viterbi with the transition matrix counted + from the training hypnograms (add-one smoothed) and the prior as the start distribution. +- **Why a linear model.** Gradient-boosted trees scored about 0.02 kappa higher in the same cross-validation, + and cost a far larger generated file and a tree evaluator to port and pin. The linear head is the smaller + thing to verify. + +## Limits + +- No public dataset pairs a WHOOP strap with PSG, so the stage minutes on a WHOOP night are unverified; the + cross-device check (train on one wrist device, test on the other) keeps kappa but shifts the deep share by + about 8-10 percentage points toward the training cohort. +- Sleep-clinic nights (DREAMT) are much harder than healthy adults' for every stager tried: long still wake + and little deep sleep. V3 over-counts deep there by about 50 min a night. +- First REM comes late: 47 min on Wearanize+ and 60 min on sleep-accel, where V2 is within 5 min. diff --git a/Tools/SleepTrain/make_oracle.py b/Tools/SleepTrain/make_oracle.py new file mode 100644 index 0000000000..66c9e321b1 --- /dev/null +++ b/Tools/SleepTrain/make_oracle.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Write the oracle fixture that pins the Swift SleepStagerV3 to this reference. + + python3 make_oracle.py [--model PATH] [--out PATH] + +Defaults: the committed `SleepStagerV3Model.swift` and +`Packages/StrandAnalytics/Tests/StrandAnalyticsTests/oracles/sleep_stager_v3.json`. + +The synthetic nights are generated from SplitMix64 with integer arithmetic only (gravity is an integer over +1024, exact in binary), so `SleepStagerV3OracleTests` rebuilds byte-identical inputs from the same seeds +instead of the fixture carrying hours of samples. For each case the fixture keeps the reference's features +and both heads' posteriors on every `stride`-th staged epoch, every epoch's beat count, the head chosen, every +label and the final segments. Rerun whenever the model file or the feature definition changes. +""" +import argparse +import json +import math +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from sleeptrain.model import Head, stage_session # noqa: E402 +from sleeptrain.features import BASE, HRV # noqa: E402 + +REPO = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) +MODEL = os.path.join(REPO, "Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStagerV3Model.swift") +OUT = os.path.join(REPO, "Packages/StrandAnalytics/Tests/StrandAnalyticsTests/oracles/sleep_stager_v3.json") +MASK = (1 << 64) - 1 + +# name, seed, start, epochs, rr mode, HR gap [from, to) in seconds from start or None, +# sleep window as seconds from start or None, stride, gravity kept (False = an HR-only night) +CASES = [ + ("band night with beats", 1, 1_700_000_017, 600, "full", None, (1207, 600 * 30 - 900), 20, True), + ("whole session, no beats, HR gap", 2, 1_700_100_000, 360, "none", (3600, 5400), None, 15, True), + ("short nap, sparse beats", 3, 1_700_200_010, 50, "sparse", None, None, 2, True), + ("no gravity, beats", 4, 1_700_300_020, 240, "full", None, None, 10, False), +] + + +class SplitMix64: + def __init__(self, seed): + self.s = seed & MASK + + def next(self): + self.s = (self.s + 0x9E3779B97F4A7C15) & MASK + z = self.s + z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & MASK + z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & MASK + return z ^ (z >> 31) + + def below(self, n): + return self.next() % n + + +def synth(seed, start, n_epochs, rr_mode, hr_gap): + """Streams for a synthetic night. Mirrored exactly by the Swift test; change both or neither.""" + rng = SplitMix64(seed) + stages = [] + while len(stages) < n_epochs: + if len(stages) < 20: + stages.append(0) + continue + stages += [1] * (10 + rng.below(20)) + [2] * (5 + rng.below(25)) + [1] * (5 + rng.below(15)) + stages += [3] * (5 + rng.below(25)) + if rng.below(3) == 0: + stages += [0] * (1 + rng.below(6)) + stages = stages[:n_epochs] + base_hr, rsa = [72, 60, 54, 63], [30, 20, 35, 8] + posture = [700, 300, 600] + grav, hr = [], [] + for s in range(30 * n_epochs): + st = stages[s // 30] + if st == 0 and rng.below(40) == 0: + posture = [rng.below(2048) - 1024, rng.below(2048) - 1024, rng.below(2048) - 1024] + amp = 300 if st == 0 and rng.below(4) == 0 else (6 if st == 3 else 3) + if rng.below(97) != 0: + grav.append((start + s, *[(p + rng.below(2 * amp + 1) - amp) / 1024.0 for p in posture])) + bpm = base_hr[st] + rng.below(7) - 3 + (s // 600) % 4 + if not (hr_gap and hr_gap[0] <= s < hr_gap[1]) and rng.below(53) != 0: + hr.append((start + s, bpm)) + rr = [] + if rr_mode != "none": + t, end_ms = start * 1000 + 400, (start + 30 * n_epochs) * 1000 + while t < end_ms: + s = t // 1000 - start + st = stages[min(s // 30, n_epochs - 1)] + tri = abs((t // 250) % 16 - 8) + v = 60000 // base_hr[st] + rsa[st] * tri // 8 - rsa[st] // 2 + rng.below(21) - 10 + if rng.below(211) == 0: + v *= 2 + elif rng.below(307) == 0: + v = 250 + t += v + if rr_mode == "sparse" and (s // 60) % 3 != 0: + continue + rr.append((t // 1000, v)) + return grav, hr, rr + + +def parse_head(text, name): + """Read one `Head(...)` literal back out of the generated Swift file.""" + body = text[text.index(f"static let {name} = Head("):] + + def arr(label): + i = body.index("[", body.index(f"{label}:")) + depth = 0 + for j in range(i, len(body)): + depth += {"[": 1, "]": -1}.get(body[j], 0) + if depth == 0: + return json.loads(re.sub(r",\s*\]", "]", body[i:j + 1])) + return Head(arr("features"), arr("mean"), arr("scale"), arr("coef"), arr("intercept"), arr("prior"), + arr("transition")) + + +def load_heads(path): + text = open(path).read() + "\n" + return parse_head(text, "hrvHead"), parse_head(text, "baseHead") + + +def num(x): + x = float(x) + return None if math.isnan(x) else x + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--model", default=MODEL) + ap.add_argument("--out", default=OUT) + a = ap.parse_args() + hrv, base = load_heads(a.model) + cases = [] + for name, seed, start, n_epochs, rr_mode, hr_gap, window, stride, gravity in CASES: + grav, hr, rr = synth(seed, start, n_epochs, rr_mode, hr_gap) + if not gravity: + grav = [] + end = start + 30 * n_epochs + w = (start + window[0], start + window[1]) if window else None + labels, segments, d = stage_session(start, end, grav, hr, rr, w, hrv, base) + picks = list(range(0, d["n"], stride)) + cases.append(dict( + name=name, seed=seed, start=start, epochs=n_epochs, rr=rr_mode, + hrGap=list(hr_gap) if hr_gap else None, window=list(w) if w else None, gravity=gravity, + samples=dict(grav=len(grav), hr=len(hr), rr=len(rr)), + spanStart=d["span_start"], n=d["n"], head=d["head"], stride=stride, + features={k: [num(d["F"][k][i]) for i in picks] for k in BASE + HRV}, + beats=[int(b) for b in d["F"]["beats"]], + hrvPosteriors=[[num(p) for p in d["P_hrv"][i]] for i in picks], + basePosteriors=[[num(p) for p in d["P_base"][i]] for i in picks], + labels="".join(x[0] for x in labels), + segments=[[s0, s1, st] for s0, s1, st in segments])) + print(name, d["head"], d["n"], "epochs", "".join(x[0] for x in labels)[:80]) + doc = dict(schemaVersion=1, + note=("Generated by Tools/SleepTrain/make_oracle.py from sleeptrain/features.py and model.py over " + "SplitMix64 synthetic nights, with the committed SleepStagerV3Model.swift. Do not edit."), + cases=cases) + with open(a.out, "w") as f: + json.dump(doc, f, indent=1) + f.write("\n") + print("wrote", a.out, os.path.getsize(a.out), "bytes") + + +if __name__ == "__main__": + main() diff --git a/Tools/SleepTrain/prepare_dreamt.py b/Tools/SleepTrain/prepare_dreamt.py new file mode 100644 index 0000000000..db15236ed0 --- /dev/null +++ b/Tools/SleepTrain/prepare_dreamt.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""DREAMT v2.2.0 `data_64Hz` (PhysioNet, Restricted Health Data License 1.5.0) -> the SleepTrain night format. + + python3 prepare_dreamt.py + +DREAMT is used as an INDEPENDENT TEST cohort only (sleep-clinic patients on an Empatica E4), never as training +data for the shipped model: its licence limits use to scientific research. Access needs a PhysioNet account and +the signed data use agreement; the files must stay on the machine that downloaded them. + +Each `S###_whole_df.csv` is the E4 merged onto a 64 Hz grid with the PSG label per row. The first PSG-scored +row starts epoch 0; stages W / N1 / N2 / N3 / R map to wake / light / light / deep / rem, and an epoch takes +the label at its midpoint. Gravity is the per-second mean of ACC / 64 g, HR the per-second mean of the E4's +1 Hz HR. The E4 IBI column is forward-filled, so a beat shows only as a CHANGE of value: consecutive equal +intervals merge into one run, and a run whose length is (k - 1) * v + v_next (within 50 ms) hides k - 1 beats +of value v. Any other run length is a detection gap. Incomplete (still downloading) files are skipped. +""" +import os +import sys +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from sleeptrain.data import write_night # noqa: E402 + +MAP = {"W": "wake", "N1": "light", "N2": "light", "N3": "deep", "R": "rem"} +PAD = 1800 + + +def beats(ts, ibi): + c = np.nonzero(~np.isnan(ibi[1:]) & ((ibi[1:] != ibi[:-1]) | np.isnan(ibi[:-1])))[0] + 1 + if not c.size: + return np.zeros(0), np.zeros(0) + bt, bv = [ts[c[0]]], [ibi[c[0]]] + for a, b in zip(c[:-1], c[1:]): + gap, va, vb = ts[b] - ts[a], ibi[a], ibi[b] + k = int(round((gap - vb) / va)) + if 1 <= k <= 4 and abs(gap - vb - k * va) < 0.05: + for j in range(1, k + 1): + bt.append(ts[a] + j * va) + bv.append(va) + bt.append(ts[b]) + bv.append(vb) + return np.array(bt), np.array(bv) * 1000.0 + + +def complete(path): + with open(path, "rb") as f: + f.seek(-300, 2) + return f.read().decode(errors="ignore").strip().split("\n")[-1].count(",") == 13 + + +def main(src, out): + import pandas as pd + for name in sorted(os.listdir(src)): + if not name.endswith("_whole_df.csv"): + continue + sid = name.split("_")[0] + if os.path.exists(os.path.join(out, f"{sid}_truth.csv")) or not complete(os.path.join(src, name)): + continue + d = pd.read_csv(os.path.join(src, name), engine="pyarrow") + st = d.Sleep_Stage.astype(str).to_numpy() + ts = d.TIMESTAMP.to_numpy(float) + scored = np.isin(st, list(MAP)) + t0 = ts[np.argmax(st != "P")] + n = int((ts[np.nonzero(scored)[0][-1]] - t0) // 30) + 1 + rel = ts - t0 + sec = np.floor(rel).astype(np.int64) + keep = (sec >= -PAD) & (sec < 30 * n + PAD) + u, inv = np.unique(sec[keep], return_inverse=True) + cnt = np.bincount(inv) + g = np.stack([np.bincount(inv, weights=d[c].to_numpy(float)[keep] / 64.0) / cnt + for c in ("ACC_X", "ACC_Y", "ACC_Z")], axis=1) + hr = np.bincount(inv, weights=np.nan_to_num(d.HR.to_numpy(float)[keep])) / cnt + bt, rr = beats(rel, d.IBI.to_numpy(float)) + kb = (bt >= -PAD) & (bt < 30 * n + PAD) + mid = np.clip(np.searchsorted(rel, 30 * np.arange(n) + 15), 0, len(st) - 1) + write_night(out, sid, np.column_stack([u, g]), np.column_stack([u, np.round(hr)])[np.round(hr) > 0], + np.column_stack([np.floor(bt[kb]), np.round(rr[kb])]), [MAP.get(st[i]) for i in mid]) + print(sid, n, "epochs", flush=True) + + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2]) diff --git a/Tools/SleepTrain/prepare_sleep_accel.py b/Tools/SleepTrain/prepare_sleep_accel.py new file mode 100644 index 0000000000..f715bc27ad --- /dev/null +++ b/Tools/SleepTrain/prepare_sleep_accel.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""PhysioNet sleep-accel v1.0.0 (Walch et al., SLEEP 2019; ODC-By 1.0) -> the SleepTrain night format. + + python3 prepare_sleep_accel.py + +Same mapping as Tools/SleepPSG/Sources/sleeppsg/SleepAccel.swift: per-second mean of the raw accelerometer +as gravity, heart rate at its own (sparse) sample times, no R-R (the dataset has none), labels on the PSG +30 s grid with N1+N2 -> light and N3+N4 -> deep. Samples within 30 minutes either side of the scored span +are kept. +""" +import os +import sys +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from sleeptrain.data import write_night # noqa: E402 + +CODE = {0: "wake", 1: "light", 2: "light", 3: "deep", 4: "deep", 5: "rem"} +PAD = 1800 + + +def main(root, out): + ids = sorted(f.split("_")[0] for f in os.listdir(f"{root}/labels") if f.endswith("_labeled_sleep.txt")) + for sid in ids: + lab = np.loadtxt(f"{root}/labels/{sid}_labeled_sleep.txt", ndmin=2) + scored = [(int(round(t / 30.0)) * 30, CODE.get(int(round(c)))) for t, c in lab] + scored = [(t, s) for t, s in scored if s is not None] + t0, t1 = scored[0][0], scored[-1][0] + 30 + truth = [None] * ((t1 - t0) // 30) + for t, s in scored: + truth[(t - t0) // 30] = s + lo, hi = t0 - PAD, t1 + PAD + m = np.loadtxt(f"{root}/motion/{sid}_acceleration.txt", ndmin=2) + sec = np.floor(m[:, 0]).astype(np.int64) + keep = (sec >= lo) & (sec < hi) + sec, m = sec[keep], m[keep] + u, inv = np.unique(sec, return_inverse=True) + cnt = np.bincount(inv) + g = np.stack([np.bincount(inv, weights=m[:, j]) / cnt for j in (1, 2, 3)], axis=1) + grav = np.column_stack([u - t0, g]) + h = np.loadtxt(f"{root}/heart_rate/{sid}_heartrate.txt", delimiter=",", ndmin=2) + hs = np.floor(h[:, 0]).astype(np.int64) + bpm = np.round(h[:, 1]) + keep = (hs >= lo) & (hs < hi) & (bpm > 0) & (bpm < 300) + hr = np.column_stack([hs[keep] - t0, bpm[keep]]) + hr = hr[np.argsort(hr[:, 0], kind="stable")] + write_night(out, sid, grav, hr, np.zeros((0, 2)), truth) + print(sid, len(truth), "epochs", flush=True) + + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2]) diff --git a/Tools/SleepTrain/prepare_wearanize.py b/Tools/SleepTrain/prepare_wearanize.py new file mode 100644 index 0000000000..2e138bed52 --- /dev/null +++ b/Tools/SleepTrain/prepare_wearanize.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Wearanize+ OA (Radboud University, CC BY 4.0, DOI 10.34973/xrmf-5726) -> the SleepTrain night format. + + python3 prepare_wearanize.py [subject ids ...] # download + process + python3 prepare_wearanize.py --npz + +Keeps what a wrist strap could see: the Empatica E4's per-second gravity (ACC / 64 g), its 1 Hz HR and its +PPG inter-beat intervals, plus both human hypnograms. Nothing from the PSG montage is kept except the ECG, +which is used for one purpose only: to put the E4 on the PSG clock. The E4 clock drifts against the PSG +recorder (about -5 s per hour), so a linear E4 -> PSG time map is fitted per subject from the +cross-correlation of E4 inter-beat intervals with ECG R-R intervals, hour by hour. No sleep label is involved +in the alignment. + +Each subject needs ~360 MB of Parquet and the raw E4 zip; they are downloaded to a temporary directory and +deleted after processing. The Radboud server refuses more than ~4 parallel downloads. +""" +import io +import os +import subprocess +import sys +import tempfile +import zipfile +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from sleeptrain.data import write_night # noqa: E402 + +BASE = "https://webdav.data.ru.nl/dcmn/DSC_wrnzpoa_t0000925a_195_v1" +CODE = {0: "wake", 1: "light", 2: "light", 3: "deep", 4: "rem"} +CURL = ["curl", "-sf", "--retry", "6", "--retry-delay", "15", "--retry-all-errors"] + + +def process(sid, tmp): + """Download one subject and return its arrays on the PSG clock (seconds from the first PSG epoch).""" + import pyarrow.parquet as pq + import neurokit2 as nk + pqf, zf = f"{tmp}/Sub{sid}s1.parquet", f"{tmp}/Sub{sid}s1_e4.zip" + try: + subprocess.run(CURL + ["-o", pqf, f"{BASE}/Wearanize%2B_OA_PlugNPlay_Parquet_v1.1/Sub{sid}s1.parquet"], + check=True) + subprocess.run(CURL + ["-o", zf, f"{BASE}/Wearanize%2B_OA_raw_v1.1/1.Raw_data/Sub{sid}s1/3.Empatica/" + f"Sub{sid}s1_Empatica_data.zip"], check=True) + cols = ["Device", "SamplingRate", "SleepScores"] + [f"SignalData.{c}" for c in ("ACCX", "ACCY", "ACCZ", "HR", + "ECG 2")] + tb = pq.read_table(pqf, columns=cols) + devs = tb.column("Device").to_pylist() + e4 = devs.index("Empatica E4") + psg = next(i for i in range(len(devs)) if (tb.column("SleepScores")[i].as_py() or {}).get("ManualScores1")) + fs_e4, fs_psg = tb.column("SamplingRate")[e4].as_py(), tb.column("SamplingRate")[psg].as_py() + + def get(c, i): + return np.array(tb.column(c)[i].as_py(), float) + ax, ay, az, hr, ecg = get("ACCX", e4), get("ACCY", e4), get("ACCZ", e4), get("HR", e4), get("ECG 2", psg) + sc = tb.column("SleepScores")[psg].as_py() + fsa, fse = fs_e4["ACCX"], fs_psg["ECG 2"] + dur = len(ecg) / fse + ns = int(len(ax) // fsa) + g = np.stack([a[: int(ns * fsa)].reshape(ns, int(fsa)).mean(1) / 64.0 for a in (ax, ay, az)], 1) + _, info = nk.ecg_peaks(nk.ecg_clean(ecg, sampling_rate=fse), sampling_rate=fse, correct_artifacts=False) + pk = np.asarray(info["ECG_R_Peaks"]) / fse + # The parquet E4 streams start at an unknown offset into the raw E4 recording: find it by matching the + # raw HR sequence exactly, then carry the raw IBI timestamps across. + z = zipfile.ZipFile(zf) + rh = np.loadtxt(io.StringIO(z.read("HR.csv").decode())) + rh0, rhr = rh[0], rh[2:] + ibi_text = z.read("IBI.csv").decode() + ib = np.loadtxt(io.StringIO(ibi_text), delimiter=",", skiprows=1, ndmin=2) + ib0 = float(ibi_text.split(",")[0]) + n = 600 + off = next((k for k in range(0, len(rhr) - n) + if abs(rhr[k] - hr[0]) < 0.01 and np.allclose(rhr[k:k + n], hr[:n], atol=0.01)), None) + if off is None: + raise RuntimeError("raw HR does not match parquet HR") + ibt = ib[:, 0] + ib0 - (rh0 + off) + lags, centres = [], [] + for h0 in range(0, int(dur), 3600): + m = (ibt >= h0 + 60) & (ibt < h0 + 3600) + if m.sum() < 300: + continue + shifts = np.arange(-240, 240, 0.25) + corr = [np.corrcoef(np.interp(ibt[m] + s, pk[1:], np.diff(pk)), ib[m, 1])[0, 1] for s in shifts] + j = int(np.nanargmax(corr)) + if corr[j] > 0.5: + lags.append(shifts[j]) + centres.append(np.median(ibt[m])) + if len(lags) >= 2: + b, a = np.polyfit(centres, lags, 1) + elif len(lags) == 1: + b, a = 0.0, lags[0] + else: + raise RuntimeError("no reliable E4/ECG alignment") + + def warp(t): + return t + a + b * t + return dict(grav_t=warp(np.arange(ns) + 0.5), grav=g, hr_t=warp(np.arange(len(hr)) + 0.5), hr=hr, + ibi_t=warp(ibt), ibi=ib[:, 1], s1=np.array(sc["ManualScores1"], int), + s2=np.array(sc["ManualScores2"], int)) + finally: + for f in (pqf, zf): + if os.path.exists(f): + os.remove(f) + + +def write(out, sid, d): + """Store the arrays the way the strap would: whole-second stamps, integer bpm and integer ms.""" + grav = np.column_stack([np.floor(d["grav_t"]), d["grav"]]) + hr = np.column_stack([np.floor(d["hr_t"]), np.round(d["hr"])]) + hr = hr[hr[:, 1] > 0] + rr = np.column_stack([np.floor(d["ibi_t"]), np.round(np.asarray(d["ibi"], float) * 1000.0)]) + order = np.argsort(rr[:, 0], kind="stable") + write_night(out, sid, grav, hr, rr[order], [CODE.get(int(c)) for c in d["s1"]], + [CODE.get(int(c)) for c in d["s2"]]) + + +def main(argv): + out = argv[0] + if len(argv) > 2 and argv[1] == "--npz": + for f in sorted(os.listdir(argv[2])): + if f.endswith(".npz"): + write(out, f[:-4], dict(np.load(os.path.join(argv[2], f)))) + print(f[:-4], flush=True) + return + ids = argv[1:] or [f"{i:03d}" for i in range(1, 131)] + with tempfile.TemporaryDirectory() as tmp: + for sid in ids: + if os.path.exists(os.path.join(out, f"{sid}_truth.csv")): + continue + try: + write(out, sid, process(sid, tmp)) + print(sid, "ok", flush=True) + except Exception as e: # a subject without E4, ECG or scores is skipped, not fatal + print(sid, "skipped:", e, flush=True) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/Tools/SleepTrain/requirements.txt b/Tools/SleepTrain/requirements.txt new file mode 100644 index 0000000000..a36921bbe3 --- /dev/null +++ b/Tools/SleepTrain/requirements.txt @@ -0,0 +1,7 @@ +numpy>=2.0 +scikit-learn>=1.5 +# prepare_wearanize.py +pyarrow>=15 +neurokit2>=0.2.10 +# prepare_dreamt.py +pandas>=2.2 diff --git a/Tools/SleepTrain/sleeptrain/__init__.py b/Tools/SleepTrain/sleeptrain/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Tools/SleepTrain/sleeptrain/data.py b/Tools/SleepTrain/sleeptrain/data.py new file mode 100644 index 0000000000..172cb3d0fd --- /dev/null +++ b/Tools/SleepTrain/sleeptrain/data.py @@ -0,0 +1,114 @@ +"""The on-disk night format every SleepTrain step shares, and the band-window emulation. + +A prepared cohort is a directory of per-night CSV files, all timed in whole seconds from the night's first +PSG epoch (`ts` 0 = the start of truth epoch 0; earlier samples have negative `ts`): + + {id}_grav.csv ts,x,y,z per-second gravity (g) + {id}_hr.csv ts,bpm heart rate, integer bpm + {id}_rr.csv ts,rr beat-to-beat intervals (ms), stamped with the beat's whole second + {id}_truth.csv truth one PSG label per 30 s epoch: wake, light, deep, rem, or none (unscored) + {id}_truth2.csv truth optional second scorer, same grid + +That is the shape the app hands `SleepStagerV3`, so training reads exactly what the stager will read. The +`Tools/SleepPSG` replay reads the same files. +""" +import csv +import os +import numpy as np + +STAGES = ["wake", "light", "deep", "rem"] +SI = {s: i for i, s in enumerate(STAGES)} + +# SleepStager.bandSleepWindow: a persistent run is 10 consecutive asleep epochs; the window keeps 10 epochs of +# grace before the first run and after the last. +BAND_PERSIST = 10 +BAND_GRACE = 10 + + +class Night: + def __init__(self, sid, cohort, grav, hr, rr, truth, truth2=None): + self.sid, self.cohort = sid, cohort + self.grav, self.hr, self.rr = grav, hr, rr + self.truth = truth + self.truth2 = truth2 + + def scored_span(self): + """First and one-past-last scored epoch.""" + idx = [i for i, t in enumerate(self.truth) if t is not None] + return idx[0], idx[-1] + 1 + + +def _read(path, cols): + if not os.path.exists(path): + return np.zeros((0, cols)) + with open(path) as f: + rows = f.read().split("\n")[1:] + rows = [r for r in rows if r] + if not rows: + return np.zeros((0, cols)) + return np.array([[float(v) for v in r.split(",")] for r in rows]).reshape(-1, cols) + + +def _labels(path): + if not os.path.exists(path): + return None + with open(path) as f: + rows = list(csv.reader(f))[1:] + return [None if r[0] == "none" else r[0] for r in rows] + + +def load_dir(path, cohort=None): + cohort = cohort or os.path.basename(os.path.normpath(path)) + ids = sorted(f[: -len("_truth.csv")] for f in os.listdir(path) if f.endswith("_truth.csv")) + out = [] + for sid in ids: + p = os.path.join(path, sid) + out.append(Night(sid, cohort, _read(p + "_grav.csv", 4), _read(p + "_hr.csv", 2), _read(p + "_rr.csv", 2), + _labels(p + "_truth.csv"), _labels(p + "_truth2.csv"))) + return out + + +def write_night(path, sid, grav, hr, rr, truth, truth2=None): + """grav (m, 4) float, hr (m, 2), rr (m, 2); ts already relative to truth epoch 0.""" + os.makedirs(path, exist_ok=True) + p = os.path.join(path, sid) + with open(p + "_grav.csv", "w") as f: + f.write("ts,x,y,z\n") + for t, x, y, z in grav: + f.write(f"{int(t)},{float(x)!r},{float(y)!r},{float(z)!r}\n") + with open(p + "_hr.csv", "w") as f: + f.write("ts,bpm\n") + for t, b in hr: + f.write(f"{int(t)},{int(b)}\n") + with open(p + "_rr.csv", "w") as f: + f.write("ts,rr\n") + for t, v in rr: + f.write(f"{int(t)},{int(v)}\n") + for suffix, labels in (("_truth.csv", truth), ("_truth2.csv", truth2)): + if labels is None: + continue + with open(p + suffix, "w") as f: + f.write("truth\n") + for s in labels: + f.write(f"{s or 'none'}\n") + + +def band_window(asleep): + """Emulate SleepStager.bandSleepWindow on a per-epoch asleep flag: [first persistent run - grace, + last persistent run + grace] as a half-open epoch range, or None when no run is persistent.""" + n = len(asleep) + run, onset = 0, None + for i in range(n): + run = run + 1 if asleep[i] else 0 + if run >= BAND_PERSIST: + onset = i - BAND_PERSIST + 1 + break + run, final = 0, None + for i in range(n - 1, -1, -1): + run = run + 1 if asleep[i] else 0 + if run >= BAND_PERSIST: + final = i + BAND_PERSIST - 1 + break + if onset is None or final is None or onset > final: + return None + return max(0, onset - BAND_GRACE), min(n, final + BAND_GRACE + 1) diff --git a/Tools/SleepTrain/sleeptrain/features.py b/Tools/SleepTrain/sleeptrain/features.py new file mode 100644 index 0000000000..1b48d52a33 --- /dev/null +++ b/Tools/SleepTrain/sleeptrain/features.py @@ -0,0 +1,398 @@ +"""Reference implementation of the SleepStagerV3 per-epoch features. + +This file is the definition the Swift port (`Packages/StrandAnalytics/.../SleepStagerV3.swift`) is pinned +against: `make_oracle.py` runs it over synthetic nights and writes the expected values into the Swift test +fixture. It is plain numpy on purpose, with every reduction spelled out, so each line has a direct Swift +counterpart. No pandas or scipy semantics are relied on. + +Inputs are exactly what the app hands a stager, restricted to one night's staging span: + grav (m, 4) rows of (ts, x, y, z): per-second gravity in g + hr (m, 2) rows of (ts, bpm): 1 Hz heart rate, integer bpm, <= 0 means no reading + rr (m, 2) rows of (ts, rrMs): beat-to-beat intervals stamped with the whole second of the beat +The span is `n` 30 s epochs starting at `span_start` (seconds). Only samples inside +[span_start, span_start + 30 n) are read, so a feature never depends on data the span does not own. +""" +import numpy as np + +EPOCH_S = 30 +MOVE_MULT = 38.0 # a per-second jerk counts as movement above night floor x this (V2's constant) +MOVE_COUNT_INIT = 60 # epochs assumed since/until movement at the span edges +MOVE_COUNT_CAP = 120 +CTX = (2, 5, 10, 20) # +- epochs of motion context +HRSD = ((150, "5"), (330, "11"), (600, "20")) # HR std half-windows (s) around the epoch centre +HRSD_MIN = 10 # more than this many present seconds for an HR std +HR_CTX = (5, 10, 30) +HR_TREND = 10 +MINUTES_CAP = 720.0 # elapsed-time feature stops growing after 12 h, the longest night trained on +TIME_DECAYS = (10, 30, 90) # minutes: exp(-t / tau) curves that let a linear head learn the sleep-onset descent +HRV_HALF = 150 # 5-min centred beat window +HRV_MIN_BEATS = 20 +SPEC_MIN_BEATS = 30 +SPEC_MIN_SPAN = 120.0 +DFA_MIN_BEATS = 64 +HRV_CTX = 10 +RESAMPLE_HZ = 4.0 +NPERSEG = 256 +LF = (0.04, 0.15) +HF = (0.15, 0.40) + +MOTION = ["act_mean", "act_max", "move_frac", "since_move", "until_move"] + [f"act_ctx{w}" for w in CTX] +POSTURE = ["dangn"] + [f"dangn_ctx{w}" for w in CTX] +BASE = (MOTION + POSTURE + ["hr_z", "hr_pct"] + + [f"hrsd{name}_{k}" for _, name in HRSD for k in ("pct", "z")] + [f"hr_ctx{w}_z" for w in HR_CTX] + + ["hr_trend", "hr_over_low", "clock", "minutes"] + [f"time_e{tau}" for tau in TIME_DECAYS]) +HRV_NAMES = ["meanNN", "sdnn", "lnrmssd", "pnn50", "lnlf", "lnhf", "lnlfhf", "resp_bpm", "hf_peaked", "dfa_a1", + "beats_per_min"] +HRV = [f"hrv_{name}_{k}" for name in HRV_NAMES for k in ("r", "ctx")] + + +# ---------------------------------------------------------------------------------------------- reductions +# Every sum is taken left to right (numpy's `sum` is pairwise), so the Swift port's plain loops reproduce it +# to the last bit wherever no transcendental function intervenes. + +def seqsum(x): + return np.cumsum(x)[-1] if x.size else 0.0 + + +def rowsum(a): + return np.cumsum(a, axis=1)[:, -1] + + +def nanmean(x): + x = x[~np.isnan(x)] + return seqsum(x) / x.size if x.size else np.nan + + +def nanstd(x): + """Population standard deviation over the present values.""" + x = x[~np.isnan(x)] + if not x.size: + return np.nan + m = seqsum(x) / x.size + return np.sqrt(seqsum((x - m) ** 2) / x.size) + + +def median(x): + s = np.sort(x[~np.isnan(x)]) + if not s.size: + return np.nan + h = s.size // 2 + return s[h] if s.size % 2 else 0.5 * (s[h - 1] + s[h]) + + +def percentile(x, q): + """Linear-interpolation percentile over the present values (numpy's default method).""" + s = np.sort(x[~np.isnan(x)]) + if not s.size: + return np.nan + pos = q / 100.0 * (s.size - 1) + lo = int(np.floor(pos)) + hi = min(lo + 1, s.size - 1) + return s[lo] + (pos - lo) * (s[hi] - s[lo]) + + +def zscore(x): + m, sd = nanmean(x), nanstd(x) + if np.isnan(m): + return np.full_like(x, np.nan) + return (x - m) / (sd if sd > 0 else 1.0) + + +def pct_rank(x): + """Percentile rank of each present value: average rank of its ties (1-based) / count present.""" + ok = ~np.isnan(x) + v = x[ok] + out = np.full_like(x, np.nan) + if not v.size: + return out + s = np.sort(v) + below = np.searchsorted(s, v, side="left") + through = np.searchsorted(s, v, side="right") + out[ok] = (below + (through - below + 1) / 2.0) / v.size + return out + + +def centred_mean(x, w): + """Mean of the present values in [i - w, i + w], clipped to the span; NaN when none is present.""" + n = x.size + ok = ~np.isnan(x) + cs = np.concatenate([[0.0], np.cumsum(np.where(ok, x, 0.0))]) + cc = np.concatenate([[0], np.cumsum(ok)]) + lo = np.clip(np.arange(n) - w, 0, n) + hi = np.clip(np.arange(n) + w + 1, 0, n) + cnt = cc[hi] - cc[lo] + with np.errstate(invalid="ignore", divide="ignore"): + return np.where(cnt > 0, (cs[hi] - cs[lo]) / np.maximum(cnt, 1), np.nan) + + +def robust(x): + """Per-night scaling to the 5th-95th percentile range.""" + lo, hi = percentile(x, 5), percentile(x, 95) + if np.isnan(lo): + return np.full_like(x, np.nan) + return (x - lo) / (hi - lo if hi > lo else 1.0) + + +# ------------------------------------------------------------------------------------------ per-second grid + +def per_second(span_start, n, grav, hr): + S = EPOCH_S * n + g = np.full((S, 3), np.nan) + if len(grav): + idx = grav[:, 0].astype(np.int64) - span_start + ok = (idx >= 0) & (idx < S) + cnt = np.bincount(idx[ok], minlength=S) + for j in range(3): + s = np.bincount(idx[ok], weights=grav[ok, j + 1], minlength=S) + g[:, j] = np.where(cnt > 0, s / np.maximum(cnt, 1), np.nan) + h = np.full(S, np.nan) + if len(hr): + idx = hr[:, 0].astype(np.int64) - span_start + ok = (idx >= 0) & (idx < S) & (hr[:, 1] > 0) + cnt = np.bincount(idx[ok], minlength=S) + s = np.bincount(idx[ok], weights=hr[ok, 1].astype(float), minlength=S) + h = np.where(cnt > 0, s / np.maximum(cnt, 1), np.nan) + return g, h + + +def beat_times(rr, span_start, n): + """Beats inside the span as (time, interval ms). A second that carries c beats places them at + ts + j / c in their stored order, so times are strictly increasing.""" + if not len(rr): + return np.zeros(0), np.zeros(0) + ts = rr[:, 0].astype(np.int64) + order = np.argsort(ts, kind="stable") + ts, v = ts[order], rr[order, 1].astype(float) + keep = (ts >= span_start) & (ts < span_start + EPOCH_S * n) + ts, v = ts[keep], v[keep] + t = (ts - span_start).astype(float) + i = 0 + while i < ts.size: + j = i + while j + 1 < ts.size and ts[j + 1] == ts[i]: + j += 1 + c = j - i + 1 + t[i:j + 1] = float(ts[i] - span_start) + np.arange(c) / c + i = j + 1 + return t, v + + +def clean_beats(t, rr): + """Drop impossible intervals, then any more than 20 % away from the median of its 11-beat neighbourhood.""" + ok = (rr >= 300) & (rr <= 2000) + t, rr = t[ok], rr[ok] + if rr.size < 5: + return t, rr + med = np.array([median(rr[max(0, i - 5): i + 6]) for i in range(rr.size)]) + ok = np.abs(rr - med) <= 0.2 * med + return t[ok], rr[ok] + + +# ------------------------------------------------------------------------------------------------- HRV + +def linear_detrend(x, y): + xm, ym = seqsum(x) / x.size, seqsum(y) / y.size + sxx = seqsum((x - xm) ** 2) + slope = seqsum((x - xm) * (y - ym)) / sxx if sxx > 0 else 0.0 + return y - (ym + slope * (x - xm)) + + +def welch_psd(y): + """Welch power spectral density: Hann (periodic) windows of NPERSEG samples, 50 % overlap, each segment + mean-removed, one-sided density, averaged. Same definition as scipy.signal.welch's defaults.""" + step = NPERSEG // 2 + nseg = (y.size - NPERSEG) // step + 1 + k = np.arange(NPERSEG) + w = 0.5 - 0.5 * np.cos(2 * np.pi * k / NPERSEG) + p = np.zeros(NPERSEG // 2 + 1) + for s in range(nseg): + seg = y[s * step: s * step + NPERSEG] + seg = seg - seqsum(seg) / NPERSEG + p += np.abs(np.fft.rfft(seg * w)) ** 2 + p *= 1.0 / (RESAMPLE_HZ * seqsum(w ** 2) * nseg) + p[1:-1] *= 2.0 + return np.arange(NPERSEG // 2 + 1) * RESAMPLE_HZ / NPERSEG, p + + +def band_power(f, p, lo, hi): + m = (f >= lo) & (f < hi) + ff, pp = f[m], p[m] + return seqsum((ff[1:] - ff[:-1]) * (pp[1:] + pp[:-1]) / 2.0) + + +def spectral(t, rr): + """(lf, hf, hf peak frequency, hf peakedness) of the 4 Hz resampled, linearly detrended tachogram.""" + if rr.size < SPEC_MIN_BEATS or t[-1] - t[0] < SPEC_MIN_SPAN: + return (np.nan,) * 4 + m = int(np.ceil((t[-1] - t[0]) * RESAMPLE_HZ)) + g = t[0] + np.arange(m) / RESAMPLE_HZ + y = linear_detrend(g - t[0], np.interp(g, t, rr)) + f, p = welch_psd(y) + lf, hf = band_power(f, p, *LF), band_power(f, p, *HF) + m = (f >= HF[0]) & (f < HF[1]) + ph = p[m] + peak = f[m][int(np.argmax(ph))] + total = seqsum(ph) + return lf, hf, peak, (ph.max() / total if total > 0 else np.nan) + + +def dfa_alpha1(rr): + x = np.cumsum(rr - seqsum(rr) / rr.size) + scales = np.arange(4, 17) + F = [] + for n in scales: + k = x.size // n + if k < 2: + return np.nan + seg = x[: k * n].reshape(k, n) + i = np.arange(n, dtype=float) + im = (n - 1) / 2.0 + sm = (rowsum(seg) / n)[:, None] + slope = (rowsum((i - im) * (seg - sm)) / seqsum((i - im) ** 2))[:, None] + res = seg - sm - slope * (i - im) + F.append(seqsum(np.sqrt(rowsum(res ** 2) / n)) / k) + F = np.array(F) + if np.any(F <= 0): + return np.nan + lx, ly = np.log(scales.astype(float)), np.log(F) + lxm, lym = seqsum(lx) / lx.size, seqsum(ly) / ly.size + return seqsum((lx - lxm) * (ly - lym)) / seqsum((lx - lxm) ** 2) + + +def epoch_hrv(t, rr, n): + """Raw HRV measures per epoch over the 5-min window centred on it, and the beat count per window.""" + out = np.full((n, len(HRV_NAMES)), np.nan) + count = np.zeros(n, dtype=int) + for e in range(n): + c = EPOCH_S * e + EPOCH_S / 2 + a, b = np.searchsorted(t, c - HRV_HALF, "left"), np.searchsorted(t, c + HRV_HALF, "left") + tt, x = t[a:b], rr[a:b] + count[e] = x.size + if x.size < HRV_MIN_BEATS: + continue + d = np.diff(x) + rmssd = np.sqrt(seqsum(d ** 2) / d.size) + lf, hf, peak, peaked = spectral(tt, x) + out[e] = [seqsum(x) / x.size, nanstd(x), np.log(rmssd + 1e-6), np.count_nonzero(np.abs(d) > 50) / d.size, + np.log(lf + 1e-6), np.log(hf + 1e-6), + np.log(lf / hf + 1e-9) if hf > 0 else np.nan, + peak * 60.0, peaked, + dfa_alpha1(x) if x.size >= DFA_MIN_BEATS else np.nan, + x.size / (2 * HRV_HALF) * 60.0] + return out, count + + +# ------------------------------------------------------------------------------------------ the features + +def features(grav, hr, rr, span_start, n): + """Every V3 feature for the n epochs of the span, as {name: array(n)} (NaN = no measurement), plus + `beats` = beat count in each epoch's 5-min HRV window (the HRV head's coverage test).""" + grav = np.asarray(grav, float).reshape(-1, 4) + hr = np.asarray(hr, float).reshape(-1, 2) + rr = np.asarray(rr, float).reshape(-1, 2) + S = EPOCH_S * n + g, h = per_second(span_start, n, grav, hr) + F = {} + + # motion: per-second gravity jerk relative to the night's own floor + jerk = np.full(S, np.nan) + if S > 1: + d = g[1:] - g[:-1] + jerk[1:] = np.sqrt(d[:, 0] * d[:, 0] + d[:, 1] * d[:, 1] + d[:, 2] * d[:, 2]) + floor = median(jerk) + floor = floor if floor > 0 else 1e-6 + rel = jerk / floor + ljerk = np.log1p(np.where(np.isnan(rel), 0.0, rel)) + moving = np.where(np.isnan(rel), 0.0, (rel > MOVE_MULT).astype(float)) + F["act_mean"] = rowsum(ljerk.reshape(n, EPOCH_S)) / EPOCH_S + F["act_max"] = ljerk.reshape(n, EPOCH_S).max(axis=1) + F["move_frac"] = rowsum(moving.reshape(n, EPOCH_S)) / EPOCH_S + mv = F["move_frac"] > 0 + since, until = np.zeros(n), np.zeros(n) + c = MOVE_COUNT_INIT + for i in range(n): + c = 0 if mv[i] else min(c + 1, MOVE_COUNT_CAP) + since[i] = c + c = MOVE_COUNT_INIT + for i in range(n - 1, -1, -1): + c = 0 if mv[i] else min(c + 1, MOVE_COUNT_CAP) + until[i] = c + F["since_move"], F["until_move"] = np.log1p(since), np.log1p(until) + for w in CTX: + F[f"act_ctx{w}"] = centred_mean(F["act_mean"], w) + # No jerk anywhere in the span (no gravity, or none in two consecutive seconds) is no motion measurement: + # its columns are missing, not the stillest night the model ever saw. + if np.all(np.isnan(jerk)): + for k in MOTION: + F[k] = np.full(n, np.nan) + + # posture: change of the z-angle between 5 s block means, relative to the night's median change + ang = np.arctan2(g[:, 2], np.sqrt(g[:, 0] * g[:, 0] + g[:, 1] * g[:, 1])) * (180.0 / np.pi) + blocks = np.array([nanmean(ang[5 * j: 5 * j + 5]) for j in range(S // 5)]) + dblk = np.zeros(blocks.size) + if blocks.size > 1: + d = np.abs(blocks[1:] - blocks[:-1]) + dblk[1:] = np.where(np.isnan(d), 0.0, d) + dang = rowsum(dblk.reshape(n, EPOCH_S // 5)) + med = median(dang) + F["dangn"] = np.log1p(dang / (med if med > 0 else 1e-6)) + for w in CTX: + F[f"dangn_ctx{w}"] = centred_mean(F["dangn"], w) + # Likewise no two consecutive blocks with gravity is no posture measurement. + if blocks.size < 2 or np.all(np.isnan(blocks[1:] - blocks[:-1])): + for k in POSTURE: + F[k] = np.full(n, np.nan) + + # heart rate + ehr = np.array([nanmean(h[EPOCH_S * e: EPOCH_S * e + EPOCH_S]) for e in range(n)]) + F["hr_z"], F["hr_pct"] = zscore(ehr), pct_rank(ehr) + for half, name in HRSD: + sd = np.full(n, np.nan) + for e in range(n): + c = EPOCH_S * e + EPOCH_S // 2 + win = h[max(0, c - half): c + half] + if np.count_nonzero(~np.isnan(win)) > HRSD_MIN: + sd[e] = nanstd(win) + F[f"hrsd{name}_pct"], F[f"hrsd{name}_z"] = pct_rank(sd), zscore(sd) + for w in HR_CTX: + F[f"hr_ctx{w}_z"] = zscore(centred_mean(ehr, w)) + trend = np.zeros(n) + for e in range(1, n): + trend[e] = nanmean(ehr[e: e + HR_TREND]) - nanmean(ehr[max(0, e - HR_TREND): e]) + F["hr_trend"] = zscore(trend) + sd = nanstd(ehr) + F["hr_over_low"] = (ehr - percentile(ehr, 5)) / (sd if sd > 1e-6 else 1e-6) + + # time of night + F["clock"] = np.arange(n) / max(1, n - 1) + F["minutes"] = np.minimum(np.arange(n) * (EPOCH_S / 60.0), MINUTES_CAP) + # A linear head reads `minutes` as a straight line, which cannot say "little deep in the first 20 minutes, + # then plenty" or "no REM for the first hour"; these decays can. Without them the model staged deep within + # 5 min of onset on 37 of 88 Wearanize+ nights (PSG: 0). + for tau in TIME_DECAYS: + F[f"time_e{tau}"] = np.exp(-F["minutes"] / tau) + + # heart-rate variability from the beat stream + t, v = beat_times(rr, span_start, n) + t, v = clean_beats(t, v) + H, count = epoch_hrv(t, v, n) + for j, name in enumerate(HRV_NAMES): + r = robust(H[:, j]) + F[f"hrv_{name}_r"] = r + F[f"hrv_{name}_ctx"] = centred_mean(r, HRV_CTX) + F["beats"] = count.astype(float) + return F + + +def impute(F, names, absent=None): + """Model input matrix: each feature's missing values take that night's median of the feature, so a gap + reads as typical for the night, not as extreme. A feature missing all night takes `absent[j]`, the head's + training mean, so it standardises to 0 (`Head.posteriors` passes it). Fitting passes none and would fill 0, + but no training night has a whole-night gap in a column its head reads.""" + X = np.empty((F[names[0]].size, len(names))) + for j, name in enumerate(names): + x = F[name].astype(float).copy() + m = median(x) + x[np.isnan(x)] = (0.0 if absent is None else absent[j]) if np.isnan(m) else m + X[:, j] = x + return X diff --git a/Tools/SleepTrain/sleeptrain/model.py b/Tools/SleepTrain/sleeptrain/model.py new file mode 100644 index 0000000000..51f53e6d94 --- /dev/null +++ b/Tools/SleepTrain/sleeptrain/model.py @@ -0,0 +1,156 @@ +"""The SleepStagerV3 model: two multinomial logistic-regression heads and an HMM decoder. + +A head turns one night's features into per-epoch stage posteriors; the decoder is a Viterbi pass whose +transition matrix and start distribution are counted from the training hypnograms. The base head reads motion +and heart rate; the HRV head reads the beat features as well. On a night where at least half the epochs have +enough beats, both heads run and their log-posteriors are averaged with equal weight before decoding (with +the HRV head's decoder statistics); otherwise the base head stages the night alone. `SleepStagerV3.swift` +mirrors every step here. +""" +import numpy as np +from .data import SI, STAGES +from .features import BASE, HRV, HRV_MIN_BEATS, impute, features + +HRV_COVERAGE = 0.5 # fraction of the span's epochs that need HRV_MIN_BEATS beats for the HRV head +PROB_FLOOR = 1e-6 +PRIOR_POWER = 0.5 # emission = log P - PRIOR_POWER * log prior: tempers the classifier's class prior +C = 0.1 # inverse L2 strength; the Wearanize+ CV is flat across 0.01-1 +# Weight of the HRV head's log-posterior when both heads run; the base head gets the rest. Equal weights, not +# tuned: against the HRV head alone this moved Wearanize+ CV kappa 0.554 -> 0.547 and DREAMT 0.320 -> 0.313, +# and cut the REM over-count 2.1 -> 0.1 pp and 7.9 -> 5.9 pp, where the HRV head alone out of domain calls REM +# high (a quarter to over a third of sleep on a WHOOP night). +HRV_BLEND = 0.5 + + +class Head: + def __init__(self, names, mean, scale, coef, intercept, prior, transition): + self.names, self.mean, self.scale = list(names), np.asarray(mean), np.asarray(scale) + self.coef, self.intercept = np.asarray(coef), np.asarray(intercept) + self.prior, self.transition = np.asarray(prior), np.asarray(transition) + + def posteriors(self, F): + z = (impute(F, self.names, self.mean) - self.mean) / self.scale + logits = np.tile(self.intercept, (z.shape[0], 1)) + for j in range(z.shape[1]): # feature by feature, the order the Swift port accumulates in + logits += self.coef[:, j] * z[:, j:j + 1] + e = np.exp(logits - logits.max(axis=1, keepdims=True)) + return e / (((e[:, 0] + e[:, 1]) + e[:, 2]) + e[:, 3])[:, None] + + def decode(self, P): + return self.decode_log(np.log(np.maximum(P, PROB_FLOOR))) + + def decode_log(self, logp): + em = logp - PRIOR_POWER * np.log(self.prior) + return viterbi(em, np.log(self.transition), np.log(self.prior)) + + +def viterbi(logem, logT, logp0): + """Most likely state path. A tie keeps the lower state index, in both the step and the final pick.""" + n, k = logem.shape + if n == 0: + return np.zeros(0, int) + V = logp0 + logem[0] + back = np.zeros((n, k), int) + for t in range(1, n): + cand = V[:, None] + logT + back[t] = cand.argmax(axis=0) + V = cand.max(axis=0) + logem[t] + path = [int(V.argmax())] + for t in range(n - 1, 0, -1): + path.append(int(back[t][path[-1]])) + return np.array(path[::-1]) + + +def uses_hrv(F): + return F["beats"].size > 0 and np.mean(F["beats"] >= HRV_MIN_BEATS) >= HRV_COVERAGE + + +def blended_log_posteriors(F, hrv_head, base_head): + return (HRV_BLEND * np.log(np.maximum(hrv_head.posteriors(F), PROB_FLOOR)) + + (1 - HRV_BLEND) * np.log(np.maximum(base_head.posteriors(F), PROB_FLOOR))) + + +def stage(F, hrv_head, base_head): + """Stage indices (into STAGES) for every epoch of the span.""" + if uses_hrv(F): + return hrv_head.decode_log(blended_log_posteriors(F, hrv_head, base_head)) + return base_head.decode(base_head.posteriors(F)) + + +def labelled(rec): + """(features dict, stage index per span epoch or -1 when unscored) for a built record.""" + s0, s1 = rec["span"] + return rec["F"], np.array([SI[t] if t else -1 for t in rec["truth"][s0:s1]]) + + +def fit_head(recs, names, c=C): + from sklearn.linear_model import LogisticRegression + Xs, ys = [], [] + for r in recs: + F, y = labelled(r) + Xs.append(impute(F, names)) + ys.append(y) + X, y = np.vstack(Xs), np.concatenate(ys) + m = y >= 0 + mean = X[m].mean(axis=0) + scale = X[m].std(axis=0) + scale[scale == 0] = 1.0 + lr = LogisticRegression(max_iter=5000, C=c).fit((X[m] - mean) / scale, y[m]) + T = np.ones((4, 4)) + for yy in ys: + ok = (yy[:-1] >= 0) & (yy[1:] >= 0) + np.add.at(T, (yy[:-1][ok], yy[1:][ok]), 1) + prior = np.bincount(y[m], minlength=4) / m.sum() + return Head(names, mean, scale, lr.coef_, lr.intercept_, prior, T / T.sum(axis=1, keepdims=True)) + + +HRV_NAMES = BASE + HRV +BASE_NAMES = BASE +__all__ = ["Head", "viterbi", "uses_hrv", "stage", "fit_head", "HRV_NAMES", "BASE_NAMES", "STAGES"] + + +# ------------------------------------------------------------------- the app's entry point, for the oracle + +def epoch_starts(start, end): + """SleepStagerV3.epochStarts: the wall-clock 30 s grid inside [start, end).""" + e = ((start + 29) // 30) * 30 + out = [] + while e < end: + out.append(e) + e += 30 + return out + + +def in_window(e, window, end): + """SleepStagerV2.epochInSleepWindow: the session-grid instant the epoch holds, or the one before `end` for + the session's cut-short last epoch.""" + t = e + ((window[0] - e) % 30 + 30) % 30 + if t >= end: + t -= 30 + return window[0] <= t < window[1] + + +def stage_session(start, end, grav, hr, rr, window, hrv_head, base_head): + """SleepStagerV3.stageSession without the memo: (labels per grid epoch, segments, span detail or None).""" + epochs = epoch_starts(start, end) + if not epochs: + return [], [(start, end, "light")], None + idx = [i for i, e in enumerate(epochs) if window is None or in_window(e, window, end)] + labels = ["wake"] * len(epochs) + detail = None + if idx: + a, b = idx[0], idx[-1] + F = features(grav, hr, rr, epochs[a], b - a + 1) + for k, s in enumerate(stage(F, hrv_head, base_head)): + labels[a + k] = STAGES[s] + detail = dict(span_start=epochs[a], n=b - a + 1, F=F, P_hrv=hrv_head.posteriors(F), + P_base=base_head.posteriors(F), head="hrv" if uses_hrv(F) else "base") + segments = [] + for i, e in enumerate(epochs): + s0 = start if i == 0 else e + s1 = end if i == len(epochs) - 1 else epochs[i + 1] + if segments and segments[-1][2] == labels[i]: + segments[-1] = (segments[-1][0], s1, labels[i]) + else: + segments.append((s0, s1, labels[i])) + return labels, segments, detail diff --git a/Tools/SleepTrain/train.py b/Tools/SleepTrain/train.py new file mode 100644 index 0000000000..5f560ea96c --- /dev/null +++ b/Tools/SleepTrain/train.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Train SleepStagerV3, report its cross-validated agreement with PSG, and write the Swift model file. + + python3 train.py --wearanize DIR --sleep-accel DIR [--dreamt DIR] [--swift PATH] [--report PATH] + +DIRs are prepared night-format cohorts (prepare_*.py). Wearanize+ and sleep-accel are the training data. +DREAMT, when given, is scored as an independent test only and never enters a fit. + +Every night is used twice: once over its whole PSG-scored span (the stager's view when the band gives no +sleep window) and once cropped to the window `SleepStager.bandSleepWindow` would allow, emulated from the +PSG hypnogram (the stager's view when it does). The HRV head trains on Wearanize+ (the only training cohort +with beat intervals); the base head trains on both cohorts. + +Agreement is Cohen's kappa over every scored epoch of the PSG span, with every epoch outside the staged span +counted as wake, plus the mean of per-subject kappas, per-stage F1, the pooled stage-share bias in percentage +points, and the per-subject minute bias of each stage with its 95 % limits of agreement (Menghini 2021). +Cross-validation is 10-fold grouped by subject, the held-out subjects' nights never in the fit. +""" +import argparse +import os +import sys +from multiprocessing import Pool + +# One BLAS thread per process: the folds already run in parallel, and a single-threaded reduction order makes +# the fitted coefficients, and so the generated Swift file, the same on every run. +for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"): + os.environ.setdefault(_v, "1") +import numpy as np # noqa: E402 + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from sleeptrain.data import load_dir, band_window, SI # noqa: E402 +from sleeptrain.features import features # noqa: E402 +from sleeptrain.model import fit_head, stage, HRV_NAMES, BASE_NAMES, uses_hrv # noqa: E402 + +FOLDS = 10 + + +def build(args): + night, view = args + a, b = night.scored_span() + if view == "window": + w = band_window([t in ("light", "deep", "rem") for t in night.truth[a:b]]) + if w is None: + return None + s0, s1 = a + w[0], a + w[1] + else: + s0, s1 = a, b + return dict(sid=night.sid, cohort=night.cohort, view=view, span=(s0, s1), scored=(a, b), + F=features(night.grav, night.hr, night.rr, 30 * s0, s1 - s0), truth=night.truth, + truth2=night.truth2) + + +def whole_night(rec, path): + """(truth, prediction) over the scored span; epochs outside the staged span are wake.""" + a, b = rec["scored"] + s0, s1 = rec["span"] + pred = np.zeros(b - a, int) + pred[s0 - a: s1 - a] = path + return np.array([SI[t] if t else -1 for t in rec["truth"][a:b]]), pred + + +def kappa(m): + """Cohen's kappa of a confusion matrix, NaN where it is undefined (no epochs, or one class on both sides), + as `Confusion.kappa` in Tools/SleepPSG returns it.""" + n = m.sum() + if n == 0: + return np.nan + po, pe = np.trace(m) / n, (m.sum(1) @ m.sum(0)) / n / n + return np.nan if pe >= 1 else (po - pe) / (1 - pe) + + +def first_after_onset(labels, k): + """Minutes from the first sleep epoch to the first epoch of stage `k`, or None.""" + on = next((i for i, x in enumerate(labels) if x in (1, 2, 3)), None) + f = next((i for i, x in enumerate(labels) if x == k), None) if on is not None else None + return None if f is None else (f - on) / 2.0 + + +def latency(pairs, k): + d, early = [], 0 + for t, p in pairs: + a, b = first_after_onset(list(t), k), first_after_onset(list(p), k) + if a is not None and b is not None: + d.append(b - a) + early += b is not None and b < 5 + return f"{np.mean(d):+.0f} ({early})" + + +def summary(name, pairs): + M, ks, mins = np.zeros((4, 4)), [], [] + for t, p in pairs: + ok = t >= 0 + m = np.zeros((4, 4)) + np.add.at(m, (t[ok], p[ok]), 1) + M += m + ks.append(kappa(m)) + mins.append([(np.sum(p[ok] == i) - np.sum(t[ok] == i)) / 2.0 for i in range(4)]) + mins = np.array(mins) + # A night whose kappa is undefined is left out of the per-night mean, as `sleeppsg --section v3` leaves + # it out, so the two reports print the same figure for the same cohort. + ks = [k for k in ks if not np.isnan(k)] + f1 = [2 * M[i, i] / (M[i].sum() + M[:, i].sum()) for i in range(4)] + share = (M.sum(0) - M.sum(1)) / M.sum() * 100 + return (f"| {name} | {len(pairs)} | {kappa(M):.3f} | {np.mean(ks) if ks else np.nan:.3f} | " + + " / ".join(f"{x:.2f}" for x in f1) + " | " + " / ".join(f"{x:+.1f}" for x in share) + " | " + + " / ".join(f"{mins[:, i].mean():+.0f} ± {1.96 * mins[:, i].std(ddof=1):.0f}" for i in range(4)) + " | " + + f"{latency(pairs, 2)} / {latency(pairs, 3)} |") + + +HEADER = ("| Evaluation | nights | kappa | mean subject kappa | F1 wake / light / deep / rem | stage share bias pp " + "| minute bias ± 1.96 SD wake / light / deep / rem | first deep / REM latency bias, min (nights < 5 min) |" + "\n|---|---|---|---|---|---|---|---|") + + +def folds(ids): + ids = sorted(ids) + return [set(ids[i::FOLDS]) for i in range(FOLDS)] + + +_RECS = None + + +def _init(recs): + global _RECS + _RECS = recs + + +def _fold(args): + """One fold: fit on every training night except the held-out subjects', stage those subjects' `view`. + `shipped` refits both heads and picks per night by beat coverage, as the app does; `base` forces the + base head.""" + kind, cohort, view, held = args + train = [r for r in _RECS if not (r["cohort"] == cohort and r["sid"] in held)] + if kind == "shipped": + hrv = fit_head([r for r in train if r["cohort"] == "wearanize"], HRV_NAMES) + base = fit_head(train, BASE_NAMES) + + def run(F): + return stage(F, hrv, base) + else: + head = fit_head(train, BASE_NAMES) + + def run(F): + return head.decode(head.posteriors(F)) + return [whole_night(r, run(r["F"])) for r in _RECS + if r["cohort"] == cohort and r["sid"] in held and r["view"] == view] + + +def cross_validate(pool, kind, recs, cohort, view): + ids = {r["sid"] for r in recs if r["cohort"] == cohort} + return [p for part in pool.map(_fold, [(kind, cohort, view, held) for held in folds(ids)]) for p in part] + + +def inter_scorer(recs): + """The second Wearanize+ scorer against the first, over the epochs both scored.""" + pairs = [] + for r in recs: + if r["cohort"] != "wearanize" or r["view"] != "span" or not r["truth2"]: + continue + a, b = r["scored"] + t1 = np.array([SI[x] if x else -1 for x in r["truth"][a:b]]) + t2 = np.array([SI[x] if x else -1 for x in r["truth2"][a:b]]) + ok = (t1 >= 0) & (t2 >= 0) + pairs.append((np.where(ok, t1, -1), np.where(ok, t2, 0))) + return pairs + + +def swift_array(v, indent, fmt=lambda x: repr(float(x))): + items = [fmt(x) for x in np.ravel(v)] + lines, line = [], "" + for it in items: + if len(line) + len(it) + 2 > 100: + lines.append(line) + line = "" + line += it + ", " + lines.append(line) + pad = " " * indent + return "[\n" + "\n".join(pad + " " + ln.rstrip() for ln in lines) + "\n" + pad + "]" + + +def swift_head(name, head, doc): + pad = " " * 8 + rows = ",\n".join(pad + " " + swift_array(row, 12) for row in head.coef) + trans = ",\n".join(pad + " " + swift_array(row, 12) for row in head.transition) + names = swift_array(np.array(head.names, dtype=object), 8, fmt=lambda n: f'"{n}"') + return f""" /// {doc} + static let {name} = Head( + features: {names}, + mean: {swift_array(head.mean, 8)}, + scale: {swift_array(head.scale, 8)}, + coef: [ +{rows} + ], + intercept: {swift_array(head.intercept, 8)}, + prior: {swift_array(head.prior, 8)}, + transition: [ +{trans} + ]) +""" + + +def write_swift(path, hrv, base, counts): + text = f"""// GENERATED by Tools/SleepTrain/train.py. Do not edit by hand: rerun the tool (see its README). +// +// The two SleepStagerV3 heads (per-feature standardisation, multinomial logistic-regression weights for +// wake / light / deep / rem in that order, class prior and the counted stage-transition matrix). +// Trained on open polysomnography only: +// Wearanize+ OA, Radboud University, CC BY 4.0, DOI 10.34973/xrmf-5726 ({counts['wearanize']} nights, Empatica E4) +// PhysioNet sleep-accel v1.0.0, Walch et al. SLEEP 2019, ODC-By 1.0 ({counts['sleep-accel']} nights, Apple Watch) + +extension SleepStagerV3 {{ +{swift_head("hrvHead", hrv, "Motion, heart rate and beat-interval features. Trained on Wearanize+.")} +{swift_head("baseHead", base, "Motion and heart rate only. Trained on Wearanize+ and sleep-accel.")}}} +""" + with open(path, "w") as f: + f.write(text) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--wearanize", required=True) + ap.add_argument("--sleep-accel", required=True) + ap.add_argument("--dreamt") + ap.add_argument("--swift") + ap.add_argument("--report") + ap.add_argument("--jobs", type=int, default=os.cpu_count()) + ap.add_argument("--no-cv", action="store_true", help="only refit and write the model") + a = ap.parse_args() + + cohorts = [(a.wearanize, "wearanize"), (a.sleep_accel, "sleep-accel")] + ([(a.dreamt, "dreamt")] if a.dreamt else []) + jobs = [(n, v) for path, c in cohorts for n in load_dir(path, c) for v in ("span", "window")] + with Pool(a.jobs) as pool: + recs = [r for r in pool.map(build, jobs, chunksize=1) if r is not None] + train = [r for r in recs if r["cohort"] != "dreamt"] + test = [r for r in recs if r["cohort"] == "dreamt"] + counts = {c: len({r["sid"] for r in recs if r["cohort"] == c}) for _, c in cohorts} + + out = ["## SleepStagerV3 agreement with PSG", "", + f"Training nights: Wearanize+ {counts['wearanize']}, sleep-accel {counts['sleep-accel']}. " + "`window` = staged inside the band-style sleep window taken from the PSG hypnogram, wake outside; " + "`span` = the whole scored span staged.", "", HEADER] + + def add(line): + out.append(line) + print(line, flush=True) + + if not a.no_cv: + add(summary("Wearanize+ second human scorer vs first (ceiling)", inter_scorer(train))) + with Pool(a.jobs, initializer=_init, initargs=(train,)) as pool: + for view in ("window", "span"): + for cohort, name in (("wearanize", "Wearanize+"), ("sleep-accel", "sleep-accel")): + add(summary(f"{name}, V3 as shipped, {view}", cross_validate(pool, "shipped", train, cohort, view))) + add(summary("Wearanize+, base head forced (no beats), window", + cross_validate(pool, "base", train, "wearanize", "window"))) + + hrv = fit_head([r for r in train if r["cohort"] == "wearanize"], HRV_NAMES) + base = fit_head(train, BASE_NAMES) + for view in ("window", "span"): + if test: + pairs = [whole_night(r, stage(r["F"], hrv, base)) for r in test if r["view"] == view] + add(summary(f"DREAMT (independent test), V3 as shipped, {view}", pairs)) + hrv_share = np.mean([uses_hrv(r["F"]) for r in recs if r["cohort"] == "wearanize" and r["view"] == "window"]) + out += ["", f"HRV head chosen on {hrv_share:.0%} of Wearanize+ windows (beat coverage rule)."] + if a.report: + with open(a.report, "w") as f: + f.write("\n".join(out) + "\n") + if a.swift: + write_swift(a.swift, hrv, base, counts) + print("wrote", a.swift) + + +if __name__ == "__main__": + main() diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md index e3e1595ab5..7337503683 100644 --- a/docs/ANALYTICS.md +++ b/docs/ANALYTICS.md @@ -283,7 +283,9 @@ nap credit, and 14-usable-night bound are pinned by golden-vector tests. Source: `SleepStager.swift`. Detects in-bed sessions from gravity/HR/RR/respiration and produces a 30-second hypnogram of `{wake, light, deep, rem}`. These stages and the AASM roll-up below are the raw material the **Rest** score composite consumes (see *The Rest score composite* immediately after this section). -> **Honest hedging.** These stages are **approximations**, not PSG-validated, not medical advice. The EEG-free 4-class ceiling is ~65–73% epoch agreement (Walch 2019). **Light/deep separation is the weakest link — deep-minute estimates are the least reliable output.** +> **Honest hedging.** These stages are **approximations**, not medical advice. The default recipe (V3, below) is measured against polysomnography on other wrist devices, never on a WHOOP strap: no public dataset pairs one with PSG. The EEG-free 4-class ceiling is ~65–73% epoch agreement (Walch 2019). **Light/deep separation is the weakest link — deep-minute estimates are the least reliable output.** + +Three recipes can stage a detected night, chosen in Settings → Experimental → Sleep staging (`PuffinExperiment.sleepStager`, a `SleepStagerVersion`): **V3** (`SleepStagerV3`, the default), **V2** (`SleepStagerV2`, the default before V3) and **V1** (`SleepStager.stageSession`, stages 1–3 below). Detection (stage 0) is the same for all three. A user who had switched the old V2 toggle off stays on V1. ### Stage 0 — gravity-stillness sleep/wake spine (`detectSleep`) @@ -324,6 +326,31 @@ Reference distributions are taken over the session's **sleep-period** epochs (Co Consecutive same-stage epochs are merged into `StageSegment`s tiling `[start, end]`. +### `SleepStagerV3` — the default: a stager fitted to polysomnography + +V1 and V2 set every coefficient by hand. Every published wrist stager that reaches a four-class kappa of 0.5 fits its per-epoch emissions to human-scored PSG instead (Fitbit, Oura, Philips, Fonseca et al. 2023, Walch et al. 2019). V3 does the same, on open data only, and is fitted and reported by `Tools/SleepTrain`: + +- **Features, all relative to the night itself.** Motion is per-second gravity jerk over the night's median jerk (V2's self-calibrating floor), with time since and until the last movement; posture change is the change of the wrist's z-angle between 5 s blocks over the night's median change. Heart rate enters as within-night z-scores, percentile ranks and 5 / 11 / 20-minute variability. When the strap delivers beat intervals, 5-minute HRV (RMSSD, SDNN, pNN50, LF and HF power, LF/HF, the HF peak as a breathing rate, its peakedness, DFA α1) is scaled to the night's 5th–95th percentiles. Elapsed time since the start of the staged span enters both as minutes and as three decays, `exp(−t/10)`, `exp(−t/30)` and `exp(−t/90)`. A linear head reads minutes only as a straight line: before the decays it staged deep within 5 min of onset on 37 of 88 Wearanize+ nights, against none in the PSG. An absolute-angle posture feature was tried first and failed across devices, because a WHOOP strap reads about seven times the angle change of an Apple Watch on still sleep. +- **Two heads.** Multinomial logistic regressions turn the features into stage posteriors: the base head reads motion and heart rate, the HRV head the beat features as well. When at least half the staged epochs have 20 or more beats in their 5-minute window, both run and their log-posteriors are averaged with equal weight. Otherwise the base head stages the night alone. The HRV head alone scored 0.554 on Wearanize+ and 0.320 on DREAMT against the blend's 0.547 and 0.313, but it called REM high out of its training cohort (+7.9 points on DREAMT, a quarter to over a third of sleep on a WHOOP night), and the blend cuts that to +5.9. The weights are in the generated `SleepStagerV3Model.swift`. +- **Decoder.** Viterbi over `log P − 0.5 · log prior`, with the stage-transition matrix and start distribution counted from the training hypnograms. +- **Band window.** With `SleepStager.bandSleepWindow` present, V3 stages only the epochs inside it (features, normalisation and decoding all run over that span) and every other epoch is wake. Training used every night twice, over its whole scored span and cropped by the same window rule applied to its PSG hypnogram, so both cases are what the model was fitted on. +- **Missing channels.** A gap in a feature takes the night's median of it. A feature missing for the whole span takes the head's training mean, so it standardises to 0 and moves no logit; it used to take 0, which for a percentile rank is the night's extreme, and a span with no heart rate staged a third to a half of itself deep. A span with no motion measurement at all (no gravity, or none in two consecutive seconds) has its motion and posture features missing, not read as the stillest night the model ever saw. No night V3 was trained or tested on has either gap, so neither rule moves a figure below. +- **Nights with no motion.** `SleepStager.hrOnlySessions` stages a night the strap banked no gravity for with the user's choice too (V1, which cannot stage without motion, hands it to V2). With the motion stripped from the PSG nights, V3 scores four-class kappa 0.477 / 0.446 / 0.275 in the window on Wearanize+ / sleep-accel / DREAMT against V2's 0.303 / 0.363 / 0.224, though its first REM comes 40–74 min late there. Only DREAMT is out of sample; V3 never saw any night without motion. + +Training data: Wearanize+ (88 nights, Empatica E4 wristband, CC BY 4.0) and PhysioNet sleep-accel (31 nights, Apple Watch, ODC-By). Agreement with PSG, cross-validated 10-fold by subject, every scored epoch counted and wake outside the window: + +| Cohort | V2 | V3 | Second human scorer | +|---|---|---|---| +| Wearanize+, in the band-style window | 0.363 | 0.547 | 0.742 | +| Wearanize+, whole span | 0.301 | 0.533 | — | +| sleep-accel, in the band-style window (no beat intervals) | 0.434 | 0.504 | — | +| sleep-accel, whole span | 0.373 | 0.463 | — | +| DREAMT, 10 sleep-clinic patients never trained on, in the window | 0.259 | 0.313 | — | + +On Wearanize+ (in the window) V3's pooled stage shares sit within 1 percentage point of PSG, where V2 over-counts light by 13.5 points and under-counts deep and REM by 5 and 7. First deep comes 6 min early on average, and never within 5 min of onset. First REM comes 47 min late, as V2's does on this cohort (48), and 60 min late on sleep-accel, where V2 is close (5). Clinic nights are harder for every recipe: long still wake and little deep sleep, which V3 over-counts by about 50 min a night. DREAMT's licence limits it to research, so it only ever tests the model and never enters the fit. + +`Tools/SleepTrain/sleeptrain/features.py` is the feature definition. The Swift port is pinned to it by `SleepStagerV3OracleTests`, which rebuilds four synthetic nights (one of them without gravity) from a seeded generator and requires every feature and posterior within 1e-9 of the reference and every label equal. `Tools/SleepPSG --section v3` replays both shipped stagers over the prepared cohorts. On DREAMT it reproduces the Python figures exactly. + ### Outputs - `SleepSession` — `start`, `end`, `efficiency` (AASM `asleep / in-bed`, where `asleep = in-bed − wake`), `stages`, per-session `restingHR` (lowest 5-min rolling-mean HR) and `avgHRV` (mean RMSSD over 5-min tumbling windows). @@ -339,11 +366,11 @@ Because these two functions are the shipped path, the rule can move a displayed ### Motion-corroborated wake — elevated-but-motionless HR is not wake (default ON) -Both stagers (`SleepStager` V1 and the default `SleepStagerV2`) and the HR-led session confirmation (`confirmSleepWithHR`) previously called **wake** primarily off HR / HR-variability with no motion or posture cross-check. On a night whose resting HR is held elevated **without the wearer getting up** — a supplement protocol, a fever, a hot room, alcohol — that logic scored hot-but-motionless sleep as wake, over-calling WASO, mis-placing onset, and tanking efficiency and Rest. Two confirmed nights required manual relabeling (2026-07-13: 194 min WAKE vs ~67; 2026-07-14: onset 1:41 vs ~1:29 plus a 44-min WAKE block). +The two hand-set stagers (`SleepStager` V1 and `SleepStagerV2`) and the HR-led session confirmation (`confirmSleepWithHR`) previously called **wake** primarily off HR / HR-variability with no motion or posture cross-check. On a night whose resting HR is held elevated **without the wearer getting up** — a supplement protocol, a fever, a hot room, alcohol — that logic scored hot-but-motionless sleep as wake, over-calling WASO, mis-placing onset, and tanking efficiency and Rest. Two confirmed nights required manual relabeling (2026-07-13: 194 min WAKE vs ~67; 2026-07-14: onset 1:41 vs ~1:29 plus a 44-min WAKE block). The rule: **elevated HR alone is insufficient to call wake.** An epoch or run at the night's quiescent **motion** floor with **unchanged posture** cannot be scored WAKE on cardiac evidence alone; corroboration comes from the **gravity posture/jerk** signal both stagers already consume (always present), not step ticks. This acts where the mis-scoring is produced, and is **on by default** — distinct from the prior default-OFF post-pass over an already-staged hypnogram (upstream #402). -- **`SleepStagerV2` (default).** On a motion-quiescent epoch (no observed movement; peak jerk at/below the night-relative wake-gate floor) the AWAKE cardiac term is clamped to **≤ 0** — wake-*suppressing* (low, flat HR) evidence is kept, the wake-*promoting* half is dropped. A still low-HR epoch is byte-identical; genuine motion and the night-relative jerk gate still drive wake. +- **`SleepStagerV2`.** On a motion-quiescent epoch (no observed movement; peak jerk at/below the night-relative wake-gate floor) the AWAKE cardiac term is clamped to **≤ 0** — wake-*suppressing* (low, flat HR) evidence is kept, the wake-*promoting* half is dropped. A still low-HR epoch is byte-identical; genuine motion and the night-relative jerk gate still drive wake. - **`confirmSleepWithHR` (V1 detection).** When a run is deeply motion-quiescent (≥ ~90% of its dense-gravity minutes posture-stable), the HR sleep band widens from **×1.05 → ×1.30** so a supplement-elevated but motionless run is not rejected. The band keeps a **floor** (genuine all-night in-bed wakefulness is still dropped); with no gravity evidence the strict ×1.05 band stands. - **`adaptiveOvernightHRBaseline`.** A personalised sleep band derived from recent overnight medians (self-calibrating across a supplement/fitness era), with a floor. Threaded through `detectSleep` as an optional argument that defaults to `nil` (byte-identical when unset); live cross-night wiring in `IntelligenceEngine` is a follow-up. @@ -354,7 +381,7 @@ Session bounds come from gravity stillness alone, so the time a wearer lies stil `SleepStager.applyBandStateLatencyTrim` relabels as **wake** every epoch more than 5 min before the band's first, or after its last, *persistent* (≥ 5 min) SLEEP run. It never touches the interior hypnogram and only ever turns sleep into wake, so it moves wake% toward the PSG truth set (which says the shipped recipe under-calls wake) — the opposite direction to the dormant wake-veto. It is a no-op without a band stream (WHOOP 4.0), with a sparse band (< 0.5 samples/s), or when the band never holds a persistent SLEEP run. Session start/end (in-bed) are unchanged; the trimmed minutes show up as onset latency / final wake, and efficiency drops accordingly. -`SleepStagerV2` takes the span the trim keeps (`SleepStager.bandSleepWindow`) as its `sleepWindow` and forces wake outside it inside the Viterbi lattice, rather than having the trim relabel its output afterwards. The trim still runs after it and finds nothing left to change. V1 has no such input and relies on the trim alone. This matters because the recipe measures its REM-latency guard from the onset its own first pass finds. When the trim came afterwards, that onset was the recipe's own, up to two hours before the band's on a long lead-in, so the guard was spent before sleep began: on one 5/MG night REM appeared 31 min after the band's onset. On PhysioNet sleep-accel, with each PSG hypnogram standing in for the band (31 subjects), the lattice form scores four-class kappa 0.414 against 0.413 for relabelling afterwards, and first-REM latency error 44.0 min against 47.4. Without a band it is inert. +`SleepStagerV2` takes the span the trim keeps (`SleepStager.bandSleepWindow`) as its `sleepWindow` and forces wake outside it inside the Viterbi lattice, rather than having the trim relabel its output afterwards. `SleepStagerV3` takes the same window and stages only inside it. The trim still runs after it and finds nothing left to change. V1 has no such input and relies on the trim alone. This matters because the recipe measures its REM-latency guard from the onset its own first pass finds. When the trim came afterwards, that onset was the recipe's own, up to two hours before the band's on a long lead-in, so the guard was spent before sleep began: on one 5/MG night REM appeared 31 min after the band's onset. On PhysioNet sleep-accel, with each PSG hypnogram standing in for the band (31 subjects), the lattice form scores four-class kappa 0.414 against 0.413 for relabelling afterwards, and first-REM latency error 44.0 min against 47.4. Without a band it is inert. Only the onset guards move to the window: V2's cycle prior (deep early, REM rising toward morning) still reads the fraction of the whole session, so a long lead-in spends part of its early deep prior before sleep begins. Measuring that fraction over the window instead scored no better against PSG (kappa 0.363 → 0.360 on Wearanize+, 0.434 → 0.427 on sleep-accel, 0.259 → 0.261 on DREAMT) and moved first REM later on all three, so the session clock stays. ### V2 respiration-regularity term — one-sided, REM-only