Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,54 @@ extension SleepStager {
/// The gate-trace line formatters. Compact, parseable, no em-dashes.
public enum GateTrace {

/// Why the HR-only spine did or did not run (#1801). Twin of the Kotlin `hrOnlyGateLine`.
///
/// The funnel line below only exists when the spine is CALLED, so its absence was ambiguous in
/// exactly the way that made the first field report unreadable: it could mean the gate never
/// opened, or that the sink was never wired. This says which, on any day with no motion — the
/// only days where the question arises.
public static func hrOnlyGateLine(attempted: Bool, reason: String,
gravRows: Int, storedNights: Int) -> String {
"[sleep] hr-only gate attempted=\(attempted) reason=\(reason) "
+ "grav=\(gravRows) stored=\(storedNights)"
}

/// The HR-only spine's own funnel line (#1801). Byte-identical twin of the Kotlin
/// `SleepStagerTrace.hrOnlyLine`, so an Android and an Apple log of the same night compare
/// directly — which is the entire reason these formatters are twinned at all.
///
/// The path shipped SILENT on both platforms, and a field log then showed `reason=no-motion`
/// with no way to tell whether the spine had run and found nothing or never ran. Every number
/// separates the two failures that actually happen: a band too tight (`sleepRuns` near zero)
/// from a duration gate eating real runs (`sleepRuns` high, `longestMin` under `minSleepMin`).
///
/// `anchorBpm` and `bandBpm` are printed because they are DERIVED, not configured: the anchor is
/// a percentile of THIS window, so the same code gives every wearer a different threshold.
public static func hrOnlyLine(anchorBpm: Double?, bandBpm: Double?,
hrP50: Double?, hrP90: Double?, epochs: Int,
runs: Int, mergedRuns: Int, sleepRuns: Int,
longestSleepMin: Int, staged: Int, kept: Int,
minSleepMin: Int) -> String {
"[sleep] hr-only spine anchorBpm=\(round1(anchorBpm)) bandBpm=\(round1(bandBpm)) "
+ "hrP50=\(round1(hrP50)) hrP90=\(round1(hrP90)) "
+ "epochs=\(epochs) runs=\(runs) merged=\(mergedRuns) sleepRuns=\(sleepRuns) "
+ "longestMin=\(longestSleepMin) staged=\(staged) kept=\(kept) minSleepMin=\(minSleepMin)"
}

/// One decimal, by ARITHMETIC rather than `String(format:)`. Twin of the Kotlin `round1`, and
/// see its note: `printf` rounds half-to-even on the binary value while Java's `String.format`
/// rounds HALF_UP on the decimal expansion, so the two disagreed on 64.05. This does the same
/// IEEE arithmetic on both platforms and cannot.
///
/// NON-NEGATIVE ONLY, same as the Kotlin twin: `-0.5` would print "0.5", because the integer
/// division truncates toward zero and the sign is then lost to `abs`. Every current caller is a
/// bpm, which cannot be negative.
private static func round1(_ v: Double?) -> String {
guard let v else { return "nil" }
let tenths = Int((v * 10.0).rounded())
return "\(tenths / 10).\(abs(tenths % 10))"
}

/// One verdict line for a candidate run. `gate` names the constant that decided it
/// (minSleepMin, maxMainSleepSpanS, offWrist, daytimeGuard, morningStillness, hrConfirm,
/// sparseBridge, accepted); `detail` carries that gate's numbers. `startTs`/`endTs` give the
Expand Down
73 changes: 67 additions & 6 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -514,12 +514,40 @@ public enum SleepStager {
/// the value is always one the wearer actually recorded and the two platforms cannot disagree on a
/// rounding rule.
static func hrOnlyBaseline(_ hr: [HRSample]) -> Double? {
if hr.isEmpty { return nil }
let sorted = hr.map { Double($0.bpm) }.sorted()
let idx = min(max(Int(Double(sorted.count - 1) * hrOnlyAnchorPercentile), 0), sorted.count - 1)
hrPercentile(hr, hrOnlyAnchorPercentile)
}

/// The `p` percentile of `hr` by bpm, nearest-rank. Shared with `hrOnlyBaseline` so the spread the
/// trace reports is measured by the SAME rule as the anchor it is meant to be judged against.
static func hrPercentile(_ hr: [HRSample], _ p: Double) -> Double? {
percentileOfSorted(hr.map { Double($0.bpm) }.sorted(), p)
}

/// The `p` percentile of an ALREADY-SORTED bpm list, nearest-rank. Split out because the caller
/// needs three percentiles from the same window, and the obvious spelling sorts once per
/// percentile — ~160k samples sorted three times per scored day across a 21-day rescore.
static func percentileOfSorted(_ sorted: [Double], _ p: Double) -> Double? {
if sorted.isEmpty { return nil }
let idx = min(max(Int(Double(sorted.count - 1) * p), 0), sorted.count - 1)
return sorted[idx]
}

/// How many distinct `hrOnlyEpochS` buckets `sortedByTs` spans.
///
/// A single pass rather than a Set, because `hrS` is already sorted by timestamp so the bucket key
/// is non-decreasing. The obvious spelling (`Set(hrS.map { … })`) builds a full intermediate array
/// AND a set over every sample to end up with a few thousand distinct keys — per scored day, across
/// the 21-day rescore. A diagnostic must not cost what it is measuring.
static func distinctEpochs(_ sortedByTs: [HRSample]) -> Int {
var count = 0
var last = Int.min
for s in sortedByTs {
let key = s.ts / hrOnlyEpochS
if key != last { count += 1; last = key }
}
return count
}

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

Expand Down Expand Up @@ -615,26 +643,59 @@ 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],
minMinutes: Int = minSleepMin) -> [SleepSession] {
minMinutes: Int = minSleepMin,
traceSink: ((String) -> Void)? = nil) -> [SleepSession] {
let hrS = hr.sorted { $0.ts < $1.ts }
guard let baseline = hrOnlyBaseline(hrS) else { return [] }
// ONE sort of the bpm axis, reused for the anchor and for the spread the trace reports.
let sortedBpm = hrS.map { Double($0.bpm) }.sorted()
guard let baseline = percentileOfSorted(sortedBpm, hrOnlyAnchorPercentile) else {
traceSink?(GateTrace.hrOnlyLine(anchorBpm: nil, bandBpm: nil, hrP50: nil, hrP90: nil,
epochs: 0, runs: 0,
mergedRuns: 0, sleepRuns: 0, longestSleepMin: 0,
staged: 0, kept: 0, minSleepMin: minMinutes))
return []
}
let rrS = rr.sorted { $0.ts < $1.ts }
var out: [SleepSession] = []
// mergePeriods for the same reason the motion path calls it: a run boundary is a threshold
// crossing, and a sleeping heart rate oscillates across the band all night. Without this the
// spine returns the night's minutes correctly but shredded into sub-mergeMin fragments, every one
// of which then fails the minimum-duration gate below — 8 h of detected sleep yielding zero
// sessions. Absorbing the short runs first is what turns a spine into a night.
for p in mergePeriods(hrOnlySleepRuns(hrS, baseline: baseline)) {
let rawRuns = hrOnlySleepRuns(hrS, baseline: baseline)
let merged = mergePeriods(rawRuns)
var staged = 0
var longestSleepS = 0
for p in merged {
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)
staged += 1
if stages.isEmpty { continue }
out.append(SleepSession(start: p.start, end: p.end,
efficiency: efficiency(start: p.start, end: p.end, stages: stages),
stages: stages, restingHR: nil, avgHRV: nil, hrOnly: true))
}
traceSink?(GateTrace.hrOnlyLine(
anchorBpm: baseline,
bandBpm: baseline * hrOnlyBandMult,
// The wearer's own spread. An anchor alone cannot be judged: p10 of 60 means one thing when
// the median is 63 and quite another when it is 74, and only the second leaves a night the
// band can separate.
hrP50: percentileOfSorted(sortedBpm, 0.50),
hrP90: percentileOfSorted(sortedBpm, 0.90),
// The real epoch count, not the sample count: the spine buckets by `hrOnlyEpochS` before it
// decides anything, so this is the axis every other number here is measured on.
epochs: distinctEpochs(hrS),
runs: rawRuns.count,
mergedRuns: merged.count,
sleepRuns: merged.filter { $0.stage == "sleep" }.count,
longestSleepMin: longestSleepS / 60,
staged: staged,
kept: out.count,
minSleepMin: minMinutes))
return out
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import XCTest
import WhoopProtocol
@testable import StrandAnalytics

/// Twin of the Kotlin `SleepStagerHrOnlyTraceTest`. The expected strings are byte-identical on both
/// platforms on purpose — these lines exist to be read side by side.
final class SleepStagerHrOnlyTraceTests: XCTestCase {

func testLineNamesTheDerivedThresholdAndLongestCandidate() {
XCTAssertEqual(
SleepStager.GateTrace.hrOnlyLine(anchorBpm: 61.0, bandBpm: 64.05, hrP50: 74.0, hrP90: 88.0, epochs: 3021, runs: 48,
mergedRuns: 12, sleepRuns: 7, longestSleepMin: 41,
staged: 0, kept: 0, minSleepMin: 60),
"[sleep] hr-only spine anchorBpm=61.0 bandBpm=64.1 hrP50=74.0 hrP90=88.0 "
+ "epochs=3021 runs=48 merged=12 "
+ "sleepRuns=7 longestMin=41 staged=0 kept=0 minSleepMin=60"
)
}

func testAbsentAnchorPrintsNilRatherThanZero() {
let line = SleepStager.GateTrace.hrOnlyLine(anchorBpm: nil, bandBpm: nil, hrP50: nil, hrP90: nil, epochs: 0, runs: 0,
mergedRuns: 0, sleepRuns: 0, longestSleepMin: 0,
staged: 0, kept: 0, minSleepMin: 60)
XCTAssertTrue(line.contains("anchorBpm=nil bandBpm=nil"))
}

/// `epochs` counts EPOCHS, not samples — the axis every other number is measured on. Twin of the
/// Kotlin test, which guards a bug I actually made: the first version reported the HR sample count.
func testEpochsCountsEpochsNotSamples() {
var lines: [String] = []
// 10 epochs x 6 samples = 60 samples.
var hr: [HRSample] = []
for i in 0..<10 {
let base = (1000 + i) * 60
for k in 0..<6 { hr.append(HRSample(ts: base + k * 10, bpm: 120)) }
}
_ = SleepStager.hrOnlySessions(hr: hr, rr: [], resp: [], traceSink: { lines.append($0) })
XCTAssertEqual(lines.count, 1, "exactly one funnel line per call")
XCTAssertTrue(lines[0].contains("epochs=10 "), "got: \(lines[0])")
}

/// The rounding is ARITHMETIC, not `printf`. A harness caught `String(format: "%.1f", 64.05)`
/// giving 64.0 on Apple against Java's 64.1 — a divergence that `anchor * 1.05` would have hit
/// constantly. These are the values that harness compared.
func testOneDecimalRoundingMatchesTheKotlinTwin() {
let cases: [(Double, String)] = [
(64.05, "64.1"), (61.0, "61.0"), (77.7, "77.7"), (66.15, "66.2"),
(71.4, "71.4"), (1.05, "1.1"), (0.0, "0.0"), (120.0, "120.0"),
]
for (v, expected) in cases {
let line = SleepStager.GateTrace.hrOnlyLine(anchorBpm: v, bandBpm: nil, hrP50: nil, hrP90: nil, epochs: 0, runs: 0,
mergedRuns: 0, sleepRuns: 0, longestSleepMin: 0,
staged: 0, kept: 0, minSleepMin: 60)
XCTAssertTrue(line.contains("anchorBpm=\(expected) "), "\(v) -> expected \(expected), got: \(line)")
}
}
}
28 changes: 25 additions & 3 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1178,10 +1178,32 @@ final class IntelligenceEngine: ObservableObject {
// Fall back to the HR-only spine ONLY when the device supplied nothing of its own: a
// hypnogram the device actually recorded is always better evidence than one inferred
// from heart rate.
providedSleep = stored.isEmpty
? SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: resp)
: stored
// Route the spine's funnel through the SAME `traceSink` the gate lines use, so one
// report explains both halves rather than one going quiet — which is exactly what
// shipping it silent cost on Android (#1801). That sink is nil unless Sleep test mode
// is active, so this collects nothing on a normal pass; passing a closure of my own
// here would have appended on every scored day regardless.
if stored.isEmpty {
traceSink?(SleepStager.GateTrace.hrOnlyGateLine(
attempted: true, reason: "no-motion-no-hypnogram",
gravRows: grav.count, storedNights: 0))
providedSleep = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: resp,
traceSink: traceSink)
} else {
traceSink?(SleepStager.GateTrace.hrOnlyGateLine(
attempted: false, reason: "stored-hypnogram",
gravRows: grav.count, storedNights: stored.count))
providedSleep = stored
}
} else {
// Only worth saying on a day with NO motion: there the spine was the remaining option
// and something declined it, which a silent absence could not distinguish. A day WITH
// motion skips this — the motion spine is the answer there.
if grav.count < 2 {
traceSink?(SleepStager.GateTrace.hrOnlyGateLine(
attempted: false, reason: "imported-owner",
gravRows: grav.count, storedNights: 0))
}
providedSleep = []
}
let tScore0 = Date()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -966,8 +966,32 @@ object IntelligenceEngine {
// `reason=no-motion` however much HR it held. Fall back to the HR-only spine ONLY when
// the device supplied nothing of its own: a hypnogram the device actually recorded is
// always better evidence than one inferred from heart rate.
if (stored.isNotEmpty()) stored else SleepStager.hrOnlySessions(hr, rr, resp)
if (stored.isNotEmpty()) {
dayDiag(SleepStagerTrace.hrOnlyGateLine(
attempted = false, reason = "stored-hypnogram",
gravRows = grav.size, storedNights = stored.size,
))
stored
} else {
dayDiag(SleepStagerTrace.hrOnlyGateLine(
attempted = true, reason = "no-motion-no-hypnogram",
gravRows = grav.size, storedNights = 0,
))
// Route the spine's funnel through the SAME per-day recorder as `sleep-detect`,
// so one report explains both halves rather than one of them going quiet.
SleepStager.hrOnlySessions(hr, rr, resp, traceSink = ::dayDiag)
}
} else {
// Only worth saying on a day with NO motion: there the spine was the remaining
// option and something declined it, which is precisely what a silent absence could
// not distinguish. A day WITH motion skips this — the motion spine is the answer
// there and nothing was passed over.
if (grav.size < 2) {
dayDiag(SleepStagerTrace.hrOnlyGateLine(
attempted = false, reason = "imported-owner",
gravRows = grav.size, storedNights = 0,
))
}
emptyList()
}

Expand Down
Loading
Loading