From 35c8991b7d3f5e06c9d4b71007979a4c043a8a45 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Sat, 26 Sep 2026 11:22:08 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(analytics):=20resolve=20the=20SpO?= =?UTF-8?q?=E2=82=82=20candidate=20night=20per=20strap=20reading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strap does not report byte 82 every second. It measures in windows of 30 records, 1200 s apart while the band scores SLEEP, and inside a window its value is a rolling figure that moves a few points and can blip for a single second. The night's low, its dips and its evidence count were resolved per second, so a one-second blip inside a steady window became the night's low and a dip that no window showed, and the count was seconds of one measurement. nightlySpo2CandidateNight now groups nonzero seconds into windows (no two more than 30 s apart, none spanning 60 s or more, so a strap that reported the byte continuously is still cut into readings) and values each window by the median of its in-band seconds, the lower middle value for an even count. The mean, low, high and dips are per window. `windows` and `windowsAttempted` are new, stored under two new metricSeries keys whose presence also marks a night resolved this way. The dip properties are renamed `dipSamples` and `dipSpanSeconds`: they are the dip readings' seconds and measured span, not time below the threshold. The Blood Oxygen card shows readings as valid of attempted, counts dip readings without claiming a duration, and shows dashes for the low and dips of a night scored before this change. The strap-log line names the windows. validate_spo2_candidate.py values a window the same way, so the comparison tool and the app give one nightly figure. PROTOCOL_SENSORS.md records the window facts measured on one MG: the schedule predicts 175 of 175 windows, and the status words at 77 and 79 mark every window with no exception. Also corrects the Polish "%lld of %lld" (it read "or") and adds the Italian "Readings". Replayed on seven stored nights against main: in-band seconds unchanged, the displayed mean moves on one night by one point, the low rises on six, and dips fall from ten to one, the one left being a whole window below the threshold. On all eleven backups the resolver matches an independent Python implementation night by night. The oracle is re-pinned and matched line for line by the same implementation. W03-003; W03-005 recorded from its review. --- .../StrandAnalytics/AnalyticsEngine.swift | 12 +- .../StrandAnalytics/Spo2CandidateNight.swift | 231 +++++++++++------ .../Spo2CandidateNightStatsTests.swift | 235 +++++++++++++----- Strand/Data/IntelligenceEngine.swift | 38 +-- Strand/Resources/Localizable.xcstrings | 116 +++------ Strand/Screens/HealthView.swift | 20 +- Strand/Screens/Spo2EstimateCard.swift | 54 ++-- Strand/System/DebugDataDiagnostics.swift | 24 +- Tools/i18n_extra_locale_baseline.txt | 2 +- .../test_validate_spo2_candidate.py | 24 ++ .../linux-capture/validate_spo2_candidate.py | 18 +- docs/PROTOCOL_SENSORS.md | 47 +++- docs/review/workstreams/W03-analytics.md | 9 + 13 files changed, 548 insertions(+), 282 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index f2c0ba2ef6..1071ca3d2f 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -1219,9 +1219,10 @@ public enum AnalyticsEngine { /// it, which is a poor instrument to ask a volunteer to use and produces a number nobody can check. /// /// This makes the comparison one number against one number: the wearer reads this and the figure the - /// WHOOP app reports for the same night. `samples` travels with the mean on purpose — a mean over 11 - /// readings and a mean over 1100 are not the same evidence, and a correlation built from the first - /// would be worthless. + /// WHOOP app reports for the same night. `samples` (in-band seconds) travels with the mean on purpose, + /// but the independent readings behind it are the night's measurement windows: 30 seconds of one + /// window are one rolling reading, not 30. The full `nightlySpo2CandidateNight` result carries that + /// count as `windows`, and a correlation should be judged by it. /// /// Gated to `70...100`, the SAME in-band window the decoder applies when it emits /// `spo2_candidate_82`: sub-70 nonzero values are diagnostic codes and bit-7 values are saturation @@ -1240,8 +1241,9 @@ public enum AnalyticsEngine { /// DELEGATES to `nightlySpo2CandidateNight` (`Spo2CandidateNight.swift`) rather than counting the /// readings a second time. The night's minimum and its below-threshold runs are now shown beside this /// mean, and a mean resolved by its own loop could disagree with the stats printed next to it — which - /// is the failure AGENTS.md's "two readouts of one fact" rule is about. The returned pair is - /// unchanged: the same in-band gate, the same inclusive session bounds, the same rounding. + /// is the failure AGENTS.md's "two readouts of one fact" rule is about. The same in-band gate, the + /// same inclusive session bounds and the same rounding; since W03-003 the mean averages measurement + /// WINDOWS rather than seconds (see `Spo2CandidateNight`), and `samples` is still in-band seconds. public static func nightlySpo2CandidateMean(_ sessions: [SleepSession], aux: [V18AuxSample]) -> (mean: Int, samples: Int)? { guard let night = nightlySpo2CandidateNight(sessions, aux: aux) else { return nil } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift index 76a8c2eccd..f56631923b 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift @@ -3,31 +3,60 @@ import WhoopProtocol // MARK: - Nightly SpO2 candidate statistics (#103/#112) -/// One run of consecutive below-threshold in-band `@82` readings inside a night. +/// One below-threshold `@82` measurement window inside a night. /// -/// `spanSeconds` is measured between the FIRST and LAST reading in the run, not multiplied out of the -/// sample count. The R18 stream is documented as "roughly one packet per second" -/// (`docs/PROTOCOL_SENSORS.md`), which is not a cadence guarantee — so a run of one reading has a span of -/// zero seconds, because one reading is all that was observed. Turning `samples` into seconds would state -/// a duration nothing measured. +/// The strap does not report this byte every second. It measures in windows (30 records every 1200 s +/// while the band scores the wearer asleep, on the straps measured so far; `docs/PROTOCOL_SENSORS.md` +/// §R18), and inside one window its value is a rolling figure that moves a few points and can blip for a +/// single second. So the window is the measurement, and a dip is a window whose value sits below the +/// threshold — not a second that did (W03-003). +/// +/// `spanSeconds` is measured between the FIRST and LAST in-band second of the window, not multiplied out +/// of the sample count. The R18 stream is documented as "roughly one packet per second", which is not a +/// cadence guarantee — so a window with one in-band second has a span of zero seconds, because one second +/// is all that was observed. Turning `samples` into seconds would state a duration nothing measured. public struct Spo2DesatEvent: Equatable, Sendable { - /// Unix seconds of the first below-threshold reading in the run. + /// Unix seconds of the window's first in-band second. public let start: Int - /// Unix seconds of the last below-threshold reading in the run. + /// Unix seconds of the window's last in-band second. public let end: Int - /// The lowest in-band reading in the run. + /// The window's value: its median in-band reading (see `Spo2CandidateWindow.value`). public let nadir: Int - /// How many readings the run rests on. + /// How many in-band seconds the window rests on. public let samples: Int public init(start: Int, end: Int, nadir: Int, samples: Int) { self.start = start; self.end = end; self.nadir = nadir; self.samples = samples } - /// Observed span, first reading to last. Zero for a single-reading run — see the type's note. + /// Observed span, first in-band second to last. Zero for a one-second window — see the type's note. public var spanSeconds: Int { max(0, end - start) } } +/// One `@82` measurement window: a run of nonzero seconds, no two further apart than +/// `AnalyticsEngine.spo2CandidateWindowGapSeconds` and spanning less than +/// `AnalyticsEngine.spo2CandidateWindowMaxSpanSeconds`. +/// +/// A nonzero byte means the strap was measuring that second. On one MG this is exact: the byte is nonzero +/// in precisely the seconds its optical status words at `@77`/`@79` leave their idle values, with no +/// exception either way over 588,883 records. A window can therefore hold only failure codes (sub-70 or +/// bit-7 values) and no in-band reading at all; it still counts as ATTEMPTED. +struct Spo2CandidateWindow { + /// The window's in-band seconds, ascending by timestamp. Empty for a window of failure codes only. + var inBand: [(ts: Int, value: Int)] = [] + + /// The window's value: its median in-band reading, taking the LOWER of the two middle readings when + /// the count is even, so the value is always one the strap actually reported. The median rather than + /// the mean or the minimum because the strap's figure is a rolling value: a one-second blip of 18 + /// points between steady neighbours is not a physiological change, and must neither become the + /// window's value nor drag it. nil for a window with no in-band second. + var value: Int? { + guard !inBand.isEmpty else { return nil } + let sorted = inBand.map(\.value).sorted() + return sorted[(sorted.count - 1) / 2] + } +} + /// Everything one night's in-band `@82` readings support, resolved ONCE. /// /// WHY ONE TYPE. The nightly mean already had four readers (the Today tile, `VitalSignsSummary`, the @@ -43,39 +72,54 @@ public struct Spo2DesatEvent: Equatable, Sendable { /// it a "sleep-adjacent raw byte", and the cross-device evidence is split (an 8-night check tracked the /// WHOOP app at corr +0.99, while two nights on the original #103 strap moved the opposite way). Nothing /// here may write `spo2Pct` or feed a downstream gate. +/// +/// PER WINDOW, NOT PER SECOND (W03-003). Every figure below is resolved over measurement windows (see +/// `Spo2DesatEvent`): the mean averages the windows' values, the minimum and maximum are the lowest and +/// highest window values, and a dip is a window below the threshold. Per-second figures turned a one-second +/// blip inside a steady window into the night's "low" and into a dip no window showed. public struct Spo2CandidateNight: Equatable, Sendable { - /// Unrounded mean of the in-band readings. Stored unrounded so a sub-1 % night-to-night move survives - /// to the series; every display rounds it at the edge. + /// Unrounded mean of the window values. Stored unrounded so a sub-1 % night-to-night move survives to + /// the series; every display rounds it at the edge. public let mean: Double + /// The lowest and highest window values. public let minimum: Int public let maximum: Int - /// In-band readings the whole night rests on. A mean over 11 readings and a mean over 1100 are not the - /// same evidence, so this travels with it everywhere. + /// In-band SECONDS the night's windows hold. Kept for the stored series, whose meaning it has always + /// had; the evidence a night rests on is `windows`, because the seconds inside one window are one + /// rolling measurement, not independent readings. public let samples: Int + /// Windows with at least one in-band second: the measurements the figures rest on. + public let windows: Int + /// Every window the strap measured in, including those that reported only failure codes. + public let windowsAttempted: Int /// The threshold `events` were cut at, carried so a surface can name it instead of assuming one. public let threshold: Int - /// Below-threshold runs, ascending by `start`. + /// Below-threshold windows, ascending by `start`. public let events: [Spo2DesatEvent] - public init(mean: Double, minimum: Int, maximum: Int, samples: Int, - threshold: Int, events: [Spo2DesatEvent]) { + public init(mean: Double, minimum: Int, maximum: Int, samples: Int, windows: Int, + windowsAttempted: Int, threshold: Int, events: [Spo2DesatEvent]) { self.mean = mean; self.minimum = minimum; self.maximum = maximum - self.samples = samples; self.threshold = threshold; self.events = events + self.samples = samples; self.windows = windows; self.windowsAttempted = windowsAttempted + self.threshold = threshold; self.events = events } /// The shipped display value — round-half-away-from-zero, matching what `nightlySpo2CandidateMean` /// has always returned. Every in-band value is positive, so no platform-divergence risk. public var meanRounded: Int { Int(mean.rounded()) } - /// Readings below `threshold`. Derived from `events`, so it cannot disagree with them. - public var samplesBelowThreshold: Int { events.reduce(0) { $0 + $1.samples } } + /// In-band seconds the dip readings hold — every in-band second of each below-threshold window, not + /// only the seconds that were themselves below it: the reading is the unit, and a second inside it is + /// not a separate observation. Derived from `events`, so it cannot disagree with them. + public var dipSamples: Int { events.reduce(0) { $0 + $1.samples } } - /// Summed observed span of the below-threshold runs. See `Spo2DesatEvent.spanSeconds`: this is time - /// BETWEEN readings, so it under-reports rather than inventing a cadence, and it is zero for a night - /// whose only dips were single readings. - public var secondsBelowThreshold: Int { events.reduce(0) { $0 + $1.spanSeconds } } + /// Summed observed span of the dip readings. See `Spo2DesatEvent.spanSeconds`: this is time BETWEEN + /// in-band seconds, so it under-reports rather than inventing a cadence, and it is zero for a night + /// whose only dips were readings of one in-band second. It is how long the dip READINGS were measured, + /// not how long the value stayed below the threshold. + public var dipSpanSeconds: Int { events.reduce(0) { $0 + $1.spanSeconds } } - /// The deepest reading of the night's dips, or nil when nothing went below the threshold. This is + /// The lowest below-threshold window value, or nil when no window went below the threshold. This is /// `minimum` whenever any event exists; kept separate so a surface can say "no dips" without having /// to infer it from a minimum that may sit above the threshold. public var nadir: Int? { events.map(\.nadir).min() } @@ -90,66 +134,82 @@ extension AnalyticsEngine { /// Default cut for a "dip". 90 % is the ordinary convention for flagging a desaturation, adopted here /// as a DISPLAY threshold only — `@82` is not a validated SpO2 percentage, so this does not make a - /// flagged run a clinical desaturation event. Carried on the result (`Spo2CandidateNight.threshold`) + /// flagged reading a clinical desaturation event. Carried on the result (`Spo2CandidateNight.threshold`) /// so a surface names the number it was cut at rather than assuming this one. public static let spo2CandidateDipThreshold = 90 - /// Below-threshold readings further apart than this start a NEW event. The stream is roughly 1 Hz but - /// gaps are ordinary (off-wrist, a window not yet offloaded), and bridging one would report a single - /// dip spanning a gap nothing was measured across. - public static let spo2CandidateEventGapSeconds = 30 + /// Nonzero seconds further apart than this belong to DIFFERENT measurement windows. On the straps + /// measured so far a window is 30 consecutive records and the next starts 1200 s after the last began, + /// so this neither splits one window over a few missing records nor merges two windows; and bridging a + /// longer gap would report one measurement across a stretch nothing was measured in. + public static let spo2CandidateWindowGapSeconds = 30 + + /// No window spans this many seconds or more: a nonzero second this far after its window's first one + /// opens the next window. It exists for a strap or firmware that reports the byte continuously, which + /// would otherwise collapse a whole night into ONE window with one median and hide a real ten-minute + /// dip; such a stream is resolved in readings of under a minute instead. It is twice the observed + /// 30-record window so a window stretched by a stepped or skipped timestamp (a relaunch's SET_CLOCK + /// steps the strap clock by a few seconds) stays whole: splitting off its last second would turn that + /// second into a reading of its own, which is the blip-becomes-a-dip failure this type removes. + public static let spo2CandidateWindowMaxSpanSeconds = 60 - /// THE resolver for a night's `@82` candidate readings — mean, range, and below-threshold runs — over - /// the detected in-bed `sessions`. nil when no in-band reading fell inside any span, which is the same - /// "no answer" `nightlySpo2CandidateMean` has always returned. + /// THE resolver for a night's `@82` candidate readings — mean, range, below-threshold windows and the + /// window counts — over the detected in-bed `sessions`. nil when no in-band reading fell inside any + /// span, which is the same "no answer" `nightlySpo2CandidateMean` has always returned. /// - /// Gated to `spo2CandidateInBand`, session-bounded inclusively on both ends (unchanged from the mean - /// this replaces), and it reads `aux` in TIMESTAMP ORDER rather than arrival order: the event runs - /// depend on adjacency, and `v18AuxSamples` ordering is a property of that query, not of this input. + /// Session-bounded inclusively on both ends (unchanged from the mean this replaces). A window is a run + /// of NONZERO seconds, codes included, so a window of failure codes still counts as attempted; only + /// `spo2CandidateInBand` seconds contribute a value. It reads `aux` in TIMESTAMP ORDER rather than + /// arrival order: windows depend on adjacency, and `v18AuxSamples` ordering is a property of that + /// query, not of this input. /// /// DIAGNOSTIC ONLY. Nothing scores this and it never writes `spo2Pct`. public static func nightlySpo2CandidateNight( _ sessions: [SleepSession], aux: [V18AuxSample], threshold: Int = AnalyticsEngine.spo2CandidateDipThreshold, - eventGapSeconds: Int = AnalyticsEngine.spo2CandidateEventGapSeconds + windowGapSeconds: Int = AnalyticsEngine.spo2CandidateWindowGapSeconds ) -> Spo2CandidateNight? { guard !sessions.isEmpty, !aux.isEmpty else { return nil } - var inBand: [(ts: Int, value: Int)] = [] + var measured: [(ts: Int, value: Int)] = [] for a in aux { - guard let v = a.auxByte82, spo2CandidateInBand.contains(v) else { continue } + guard let v = a.auxByte82, v != 0 else { continue } guard sessions.contains(where: { $0.start <= a.ts && a.ts <= $0.end }) else { continue } - inBand.append((ts: a.ts, value: v)) + measured.append((ts: a.ts, value: v)) } - guard !inBand.isEmpty else { return nil } - inBand.sort { $0.ts < $1.ts } - - let sum = inBand.reduce(0) { $0 + $1.value } - // `minimum`/`maximum` are safe to force: `inBand` is non-empty by the guard above. - let minimum = inBand.map(\.value).min()! - let maximum = inBand.map(\.value).max()! - - var events: [Spo2DesatEvent] = [] - var runStart: Int?, runEnd = 0, runNadir = 0, runSamples = 0 - func closeRun() { - guard let s = runStart else { return } - events.append(Spo2DesatEvent(start: s, end: runEnd, nadir: runNadir, samples: runSamples)) - runStart = nil - } - for r in inBand { - guard r.value < threshold else { closeRun(); continue } - if let _ = runStart, r.ts - runEnd <= max(0, eventGapSeconds) { - runEnd = r.ts; runNadir = min(runNadir, r.value); runSamples += 1 + measured.sort { $0.ts < $1.ts } + + var windows: [Spo2CandidateWindow] = [] + var lastTs: Int?, windowStart = 0 + for m in measured { + if let last = lastTs, m.ts - last <= max(0, windowGapSeconds), + m.ts - windowStart < spo2CandidateWindowMaxSpanSeconds { + // Same window. } else { - // A gap wider than the budget ends the previous run and opens a new one. - closeRun() - runStart = r.ts; runEnd = r.ts; runNadir = r.value; runSamples = 1 + windows.append(Spo2CandidateWindow()) + windowStart = m.ts + } + lastTs = m.ts + if spo2CandidateInBand.contains(m.value) { + windows[windows.count - 1].inBand.append(m) } } - closeRun() - return Spo2CandidateNight(mean: Double(sum) / Double(inBand.count), - minimum: minimum, maximum: maximum, samples: inBand.count, + let valued = windows.compactMap { w in w.value.map { (window: w, value: $0) } } + guard !valued.isEmpty else { return nil } + let values = valued.map(\.value) + + let events = valued.filter { $0.value < threshold }.map { v in + // A valued window has at least one in-band second, so `first`/`last` exist. + Spo2DesatEvent(start: v.window.inBand.first!.ts, end: v.window.inBand.last!.ts, + nadir: v.value, samples: v.window.inBand.count) + } + + // `min`/`max` are safe to force: `valued` is non-empty by the guard above. + return Spo2CandidateNight(mean: Double(values.reduce(0, +)) / Double(values.count), + minimum: values.min()!, maximum: values.max()!, + samples: valued.reduce(0) { $0 + $1.window.inBand.count }, + windows: valued.count, windowsAttempted: windows.count, threshold: threshold, events: events) } } @@ -161,23 +221,32 @@ extension AnalyticsEngine { /// /// WHY THE KEYS ARE CONSTANTS. `V18AuxSlot.decoderKey` exists because a key spelled once in the writer /// and again in the reader can drift apart while each side still looks right — and neither the compiler -/// (the lookup is optional) nor a runtime check (absence is a legal state) says a word. These four keys -/// are read exactly that way, so they are spelled ONCE here and used by both ends. +/// (the lookup is optional) nor a runtime check (absence is a legal state) says a word. These keys are +/// read exactly that way, so they are spelled ONCE here and used by both ends. /// /// `meanKey` predates this and already has readers spelling it literally; its value is unchanged, so the /// constant and those literals cannot disagree about what is stored. +/// +/// PER-WINDOW SINCE W03-003. The mean, minimum, dips and dip span are resolved per measurement window on +/// every night that also carries `windowsKey`; a night without it was scored per second. The window keys +/// are therefore also the marker that tells a reader which of the two a stored night used. public enum Spo2CandidateSeries { - /// The night's UNROUNDED mean. Every display rounds it at the edge. + /// The night's UNROUNDED mean of its window values. Every display rounds it at the edge. public static let meanKey = "spo2_candidate" - /// The lowest in-band reading of the night. + /// The lowest window value of the night. public static let minimumKey = "spo2_candidate_min" - /// How many below-threshold RUNS the night held. 0 is written, not omitted. + /// How many below-threshold READINGS (windows) the night held. 0 is written, not omitted. public static let dipsKey = "spo2_candidate_dips" - /// Summed observed span of those runs, in seconds. Legitimately 0 when every dip was one reading — - /// see `Spo2DesatEvent.spanSeconds`. + /// Summed observed span of those readings, in seconds (`Spo2CandidateNight.dipSpanSeconds`): how long + /// the dip readings were measured, not how long the value stayed below the threshold. Legitimately 0 + /// when every dip reading held one in-band second. public static let dipSecondsKey = "spo2_candidate_dip_seconds" - /// In-band readings the night's figures rest on. + /// In-band SECONDS inside the night's windows. public static let samplesKey = "spo2_candidate_samples" + /// Windows with an in-band value: the measurements the night's figures rest on (W03-003). + public static let windowsKey = "spo2_candidate_windows" + /// Every window the strap measured in, failed ones included. + public static let windowsAttemptedKey = "spo2_candidate_windows_attempted" /// One night as the series carry it. The companions are optional INDEPENDENTLY of the mean: a night /// scored before those keys shipped has a mean and nothing else, and a surface must be able to show @@ -189,11 +258,16 @@ public enum Spo2CandidateSeries { public let dips: Int? public let dipSeconds: Int? public let samples: Int? + /// nil on a night scored before the window keys shipped. Such a night's `minimum` and `dips` were + /// resolved per second, so a surface that has no window count must not present them as per window. + public let windows: Int? + public let windowsAttempted: Int? public init(day: String, mean: Double, minimum: Int?, dips: Int?, - dipSeconds: Int?, samples: Int?) { + dipSeconds: Int?, samples: Int?, windows: Int? = nil, windowsAttempted: Int? = nil) { self.day = day; self.mean = mean; self.minimum = minimum self.dips = dips; self.dipSeconds = dipSeconds; self.samples = samples + self.windows = windows; self.windowsAttempted = windowsAttempted } /// The displayed mean — the same rounding `Spo2CandidateNight.meanRounded` applies, so the card @@ -220,10 +294,13 @@ public enum Spo2CandidateSeries { minimum: [String: Double] = [:], dips: [String: Double] = [:], dipSeconds: [String: Double] = [:], - samples: [String: Double] = [:]) -> Night? { + samples: [String: Double] = [:], + windows: [String: Double] = [:], + windowsAttempted: [String: Double] = [:]) -> Night? { guard let day = mean.keys.max(), let m = mean[day] else { return nil } func int(_ d: [String: Double]) -> Int? { d[day].map { Int($0.rounded()) } } return Night(day: day, mean: m, minimum: int(minimum), dips: int(dips), - dipSeconds: int(dipSeconds), samples: int(samples)) + dipSeconds: int(dipSeconds), samples: int(samples), + windows: int(windows), windowsAttempted: int(windowsAttempted)) } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift index 63f6201565..784d021e70 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift @@ -2,14 +2,16 @@ import XCTest import WhoopProtocol @testable import StrandAnalytics -/// #103/#112 — the nightly `@82` candidate STATISTICS: mean, range and below-threshold runs. +/// #103/#112 — the nightly `@82` candidate STATISTICS: mean, range, dip windows and window counts. /// /// `Spo2CandidateNightlyTests` beside this file pins the mean's gate. This file pins everything the mean /// did not carry, because the night's minimum and its dips now reach a screen and a stored series — and /// per AGENTS.md a helper whose output feeds stored rows is verified by ORACLE, not by eye. `testOracle` -/// below is that oracle: its expected literal is the real helper's own stdout over a spread of cases -/// (including the whole legal byte space), captured once and pinned verbatim. The named tests after it -/// say what each line is FOR, so a diff explains itself instead of only failing. +/// below is that oracle: its expected literal is the real helper's own output over a spread of cases +/// (including the whole legal byte space and a night of real-shaped 30-second windows), captured once, +/// checked line for line against an independent Python implementation of the per-window rule (W03-003), +/// and pinned verbatim. The named tests after it say what each line is FOR, so a diff explains itself +/// instead of only failing. final class Spo2CandidateNightStatsTests: XCTestCase { private func sess(_ s: Int, _ d: Int) -> SleepSession { @@ -25,15 +27,22 @@ final class Spo2CandidateNightStatsTests: XCTestCase { let ev = n.events.map { "\($0.start)-\($0.end)/n\($0.nadir)/s\($0.samples)/sp\($0.spanSeconds)" } .joined(separator: ",") return "\(name) -> mean=\(String(format: "%.6f", n.mean)) rounded=\(n.meanRounded) " - + "min=\(n.minimum) max=\(n.maximum) samples=\(n.samples) thr=\(n.threshold) " - + "below=\(n.samplesBelowThreshold) secs=\(n.secondsBelowThreshold) " + + "min=\(n.minimum) max=\(n.maximum) samples=\(n.samples) " + + "windows=\(n.windows)/\(n.windowsAttempted) thr=\(n.threshold) " + + "below=\(n.dipSamples) secs=\(n.dipSpanSeconds) " + "nadir=\(n.nadir.map(String.init) ?? "-") events=[\(ev)]" } + /// A 30-second window starting at `start`, one value per second from `values` (cycled). + private func window(_ start: Int, _ values: [Int?]) -> [V18AuxSample] { + (0..<30).map { aux(start + $0, values[$0 % values.count]) } + } + func testOracle() { var out: [String] = [] out.append(line("flat-96", [sess(1000, 600)], (0..<10).map { aux(1000 + $0, 96) })) - out.append(line("all-bytes", [sess(0, 300)], (0..<256).map { aux($0, $0) })) + out.append(line("all-bytes-contiguous", [sess(0, 300)], (0..<256).map { aux($0, $0) })) + out.append(line("all-bytes-per-window", [sess(0, 256 * 1200)], (0..<256).map { aux($0 * 1200, $0) })) out.append(line("one-dip-77", [sess(1000, 600)], [aux(1000, 96), aux(1001, 95), aux(1002, 77), aux(1003, 95)])) out.append(line("run-of-5", [sess(1000, 600)], @@ -51,101 +60,179 @@ final class Spo2CandidateNightStatsTests: XCTestCase { [aux(1050, 92), aux(3000, 70), aux(5050, 88)])) out.append(line("edges", [sess(1000, 100)], [aux(999, 80), aux(1000, 91), aux(1100, 89), aux(1101, 70)])) - out.append(line("round-half-up", [sess(1000, 600)], [aux(1000, 96), aux(1001, 97)])) + out.append(line("round-half-up", [sess(1000, 1800)], [aux(1000, 96), aux(2200, 97)])) out.append(line("all-below", [sess(1000, 600)], (0..<4).map { aux(1000 + $0, 85 - $0) })) + // Real window shapes: a night of 30-second windows 1200 s apart. + out.append(line("night-of-windows", [sess(0, 6 * 1200)], + window(0, [16, 32, 95, 96, 96, 95, 97]) + window(1200, [94, 95, 94]) + + window(2400, [8, 40, 136, 168]) + window(3600, [96, 77, 96, 97, 95]) + + window(4800, [89, 88, 89, 90, 88]) + window(6000, [nil, 93, 0, 94]))) + out.append(line("even-count-lower-median", [sess(1000, 600)], [aux(1000, 91), aux(1001, 89)])) + out.append(line("gap-at-budget", [sess(1000, 600)], [aux(1000, 95), aux(1030, 85), aux(1061, 85)])) + out.append(line("window-straddles-session-end", [sess(1000, 15)], window(1000, [88, 97]))) let expected = """ - flat-96 -> mean=96.000000 rounded=96 min=96 max=96 samples=10 thr=90 below=0 secs=0 nadir=- events=[] - all-bytes -> mean=85.000000 rounded=85 min=70 max=100 samples=31 thr=90 below=20 secs=19 nadir=70 events=[70-89/n70/s20/sp19] - one-dip-77 -> mean=90.750000 rounded=91 min=77 max=96 samples=4 thr=90 below=1 secs=0 nadir=77 events=[1002-1002/n77/s1/sp0] - run-of-5 -> mean=90.428571 rounded=90 min=88 max=97 samples=7 thr=90 below=5 secs=4 nadir=88 events=[1001-1005/n88/s5/sp4] - gap-split -> mean=84.500000 rounded=85 min=83 max=86 samples=4 thr=90 below=4 secs=2 nadir=83 events=[1000-1001/n85/s2/sp1,2000-2001/n83/s2/sp1] - recovery-split -> mean=88.666667 rounded=89 min=85 max=95 samples=3 thr=90 below=2 secs=0 nadir=85 events=[1000-1000/n85/s1/sp0,1002-1002/n86/s1/sp0] - run-of-5-shuffled -> mean=90.428571 rounded=90 min=88 max=97 samples=7 thr=90 below=5 secs=4 nadir=88 events=[1001-1005/n88/s5/sp4] + flat-96 -> mean=96.000000 rounded=96 min=96 max=96 samples=10 windows=1/1 thr=90 below=0 secs=0 nadir=- events=[] + all-bytes-contiguous -> mean=85.000000 rounded=85 min=85 max=85 samples=31 windows=1/5 thr=90 below=31 secs=30 nadir=85 events=[70-100/n85/s31/sp30] + all-bytes-per-window -> mean=85.000000 rounded=85 min=70 max=100 samples=31 windows=31/255 thr=90 below=20 secs=0 nadir=70 events=[84000-84000/n70/s1/sp0,85200-85200/n71/s1/sp0,86400-86400/n72/s1/sp0,87600-87600/n73/s1/sp0,88800-88800/n74/s1/sp0,90000-90000/n75/s1/sp0,91200-91200/n76/s1/sp0,92400-92400/n77/s1/sp0,93600-93600/n78/s1/sp0,94800-94800/n79/s1/sp0,96000-96000/n80/s1/sp0,97200-97200/n81/s1/sp0,98400-98400/n82/s1/sp0,99600-99600/n83/s1/sp0,100800-100800/n84/s1/sp0,102000-102000/n85/s1/sp0,103200-103200/n86/s1/sp0,104400-104400/n87/s1/sp0,105600-105600/n88/s1/sp0,106800-106800/n89/s1/sp0] + one-dip-77 -> mean=95.000000 rounded=95 min=95 max=95 samples=4 windows=1/1 thr=90 below=0 secs=0 nadir=- events=[] + run-of-5 -> mean=88.000000 rounded=88 min=88 max=88 samples=7 windows=1/1 thr=90 below=7 secs=6 nadir=88 events=[1000-1006/n88/s7/sp6] + gap-split -> mean=84.000000 rounded=84 min=83 max=85 samples=4 windows=2/2 thr=90 below=4 secs=2 nadir=83 events=[1000-1001/n85/s2/sp1,2000-2001/n83/s2/sp1] + recovery-split -> mean=86.000000 rounded=86 min=86 max=86 samples=3 windows=1/1 thr=90 below=3 secs=2 nadir=86 events=[1000-1002/n86/s3/sp2] + run-of-5-shuffled -> mean=88.000000 rounded=88 min=88 max=88 samples=7 windows=1/1 thr=90 below=7 secs=6 nadir=88 events=[1000-1006/n88/s7/sp6] out-of-band-only -> nil - two-sessions -> mean=90.000000 rounded=90 min=88 max=92 samples=2 thr=90 below=1 secs=0 nadir=88 events=[5050-5050/n88/s1/sp0] - edges -> mean=90.000000 rounded=90 min=89 max=91 samples=2 thr=90 below=1 secs=0 nadir=89 events=[1100-1100/n89/s1/sp0] - round-half-up -> mean=96.500000 rounded=97 min=96 max=97 samples=2 thr=90 below=0 secs=0 nadir=- events=[] - all-below -> mean=83.500000 rounded=84 min=82 max=85 samples=4 thr=90 below=4 secs=3 nadir=82 events=[1000-1003/n82/s4/sp3] + two-sessions -> mean=90.000000 rounded=90 min=88 max=92 samples=2 windows=2/2 thr=90 below=1 secs=0 nadir=88 events=[5050-5050/n88/s1/sp0] + edges -> mean=90.000000 rounded=90 min=89 max=91 samples=2 windows=2/2 thr=90 below=1 secs=0 nadir=89 events=[1100-1100/n89/s1/sp0] + round-half-up -> mean=96.500000 rounded=97 min=96 max=97 samples=2 windows=2/2 thr=90 below=0 secs=0 nadir=- events=[] + all-below -> mean=83.000000 rounded=83 min=83 max=83 samples=4 windows=1/1 thr=90 below=4 secs=3 nadir=83 events=[1000-1003/n83/s4/sp3] + night-of-windows -> mean=93.600000 rounded=94 min=89 max=96 samples=125 windows=5/6 thr=90 below=30 secs=29 nadir=89 events=[4800-4829/n89/s30/sp29] + even-count-lower-median -> mean=89.000000 rounded=89 min=89 max=89 samples=2 windows=1/1 thr=90 below=2 secs=1 nadir=89 events=[1000-1001/n89/s2/sp1] + gap-at-budget -> mean=85.000000 rounded=85 min=85 max=85 samples=3 windows=2/2 thr=90 below=3 secs=30 nadir=85 events=[1000-1030/n85/s2/sp30,1061-1061/n85/s1/sp0] + window-straddles-session-end -> mean=88.000000 rounded=88 min=88 max=88 samples=16 windows=1/1 thr=90 below=16 secs=15 nadir=88 events=[1000-1015/n88/s16/sp15] """ XCTAssertEqual(out.joined(separator: "\n"), expected) } // MARK: - What each oracle line is for - /// The whole legal byte space in one pass: exactly the 31 values in 70...100 may survive, everything - /// else — 0, the sub-70 diagnostic codes, every bit-7 sentinel — is refused. This is the single most - /// damaging thing the helper could get wrong, because an average that swallowed a 0x80 would still - /// look like a percentage. - func testOnlyTheThirtyOneInBandBytesAreCounted() { - let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(0, 300)], aux: (0..<256).map { aux($0, $0) }) + /// The whole legal byte space, one byte per window: exactly the 31 values in 70...100 may give a window + /// a value, and everything else — the sub-70 diagnostic codes and every bit-7 sentinel — only marks a + /// window as attempted. This is the single most damaging thing the helper could get wrong, because an + /// average that swallowed a 0x80 would still look like a percentage. (0 is "not measuring" and opens + /// no window at all.) + func testOnlyTheThirtyOneInBandBytesGiveAWindowAValue() { + let n = AnalyticsEngine.nightlySpo2CandidateNight( + [sess(0, 256 * 1200)], aux: (0..<256).map { aux($0 * 1200, $0) }) XCTAssertEqual(n?.samples, 31) + XCTAssertEqual(n?.windows, 31) + XCTAssertEqual(n?.windowsAttempted, 255) XCTAssertEqual(n?.minimum, 70) XCTAssertEqual(n?.maximum, 100) } /// The mean is stored UNROUNDED. The shipped series rounded it to an Int, so four nights that actually - /// climbed 95.36 → 95.66 → 96.00 → 96.61 landed as 95/96/96/97 and the climb inside a percent was - /// invisible. `meanRounded` still reproduces the old value for display. + /// climbed within one percent landed as three whole numbers and the climb was invisible. `meanRounded` + /// still reproduces the rounded value for display. func testMeanKeepsSubPercentPrecisionAndRoundsOnlyForDisplay() { let n = AnalyticsEngine.nightlySpo2CandidateNight( - [sess(1000, 600)], aux: [aux(1000, 96), aux(1001, 97), aux(1002, 97)]) + [sess(1000, 3000)], aux: [aux(1000, 96), aux(2200, 97), aux(3400, 97)]) XCTAssertEqual(n!.mean, 96.66666666666667, accuracy: 1e-12) XCTAssertEqual(n?.meanRounded, 97) } - /// The mean this replaces must not move. Same gate, same inclusive bounds, same rounding — the one - /// resolver now answers both, so the pair cannot drift from the stats printed beside it. - func testLegacyMeanDelegatesWithoutChangingItsAnswer() { + /// The legacy mean is the resolver's own mean, rounded, so the pair cannot drift from the stats + /// printed beside it. Since W03-003 that mean is per window: the one-second 77 inside this window no + /// longer drags it from 95 to 91. + func testLegacyMeanDelegatesToTheResolver() { let sessions = [sess(1000, 600)] let samples = [aux(1000, 96), aux(1001, 95), aux(1002, 77), aux(1003, 95)] let legacy = AnalyticsEngine.nightlySpo2CandidateMean(sessions, aux: samples) let night = AnalyticsEngine.nightlySpo2CandidateNight(sessions, aux: samples) XCTAssertEqual(legacy?.mean, night?.meanRounded) XCTAssertEqual(legacy?.samples, night?.samples) - XCTAssertEqual(legacy?.mean, 91) + XCTAssertEqual(legacy?.mean, 95) XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([sess(1000, 600)], aux: [aux(1000, 8)])) } - /// A dip is a run of ADJACENT below-threshold readings, so the result cannot depend on the order the - /// rows arrived in — `v18AuxSamples` sorts, but that is a property of the query, not of this input. - func testEventsAreResolvedInTimestampOrderNotArrivalOrder() { + /// Windows are resolved by adjacency, so the result cannot depend on the order the rows arrived in — + /// `v18AuxSamples` sorts, but that is a property of the query, not of this input. + func testWindowsAreResolvedInTimestampOrderNotArrivalOrder() { let ordered = [aux(1000, 96)] + (1...5).map { aux(1000 + $0, 88) } + [aux(1006, 97)] - let shuffled = [aux(1003, 88), aux(1006, 97), aux(1000, 96), aux(1005, 88), - aux(1001, 88), aux(1004, 88), aux(1002, 88)] - XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 600)], aux: ordered), - AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 600)], aux: shuffled)) + + [aux(2200, 85), aux(2201, 86)] + let shuffled = [aux(1003, 88), aux(2201, 86), aux(1006, 97), aux(1000, 96), aux(1005, 88), + aux(1001, 88), aux(2200, 85), aux(1004, 88), aux(1002, 88)] + XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 1800)], aux: ordered), + AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 1800)], aux: shuffled)) } - /// Two dips separated by a stretch the strap did not report are TWO dips. Bridging them would print - /// one event spanning a gap nothing was measured across. - func testAGapWiderThanTheBudgetSplitsOneRunIntoTwo() { + /// Two measurements separated by a stretch the strap did not report are TWO windows. Bridging them + /// would print one dip spanning a gap nothing was measured across. + func testAGapWiderThanTheBudgetSplitsTwoWindows() { let n = AnalyticsEngine.nightlySpo2CandidateNight( [sess(1000, 6000)], aux: [aux(1000, 85), aux(1001, 86), aux(2000, 84), aux(2001, 83)]) + XCTAssertEqual(n?.windows, 2) XCTAssertEqual(n?.events.count, 2) XCTAssertEqual(n?.events.first?.nadir, 85) XCTAssertEqual(n?.events.last?.nadir, 83) - // Neither run's span may absorb the 999 s the strap said nothing across. - XCTAssertEqual(n?.secondsBelowThreshold, 2) + // Neither window's span may absorb the 999 s the strap said nothing across. + XCTAssertEqual(n?.dipSpanSeconds, 2) + } + + /// The gap budget is inclusive: seconds exactly `spo2CandidateWindowGapSeconds` apart are one window, + /// one second more makes two. + func testTheWindowGapBudgetIsInclusive() { + let gap = AnalyticsEngine.spo2CandidateWindowGapSeconds + let joined = AnalyticsEngine.nightlySpo2CandidateNight( + [sess(1000, 600)], aux: [aux(1000, 95), aux(1000 + gap, 95)]) + let split = AnalyticsEngine.nightlySpo2CandidateNight( + [sess(1000, 600)], aux: [aux(1000, 95), aux(1001 + gap, 95)]) + XCTAssertEqual(joined?.windows, 1) + XCTAssertEqual(split?.windows, 2) + } + + /// A strap that reported the byte continuously must not collapse the night into one window with one + /// median: the span cap cuts the stream into readings, so a real ten-minute stretch below the threshold + /// still shows as dips and as the night's low. + func testAContinuousStreamIsCutIntoReadingsSoALongDipSurvives() { + let night = (0..<(8 * 3600)).map { aux($0, (3600..<4200).contains($0) ? 85 : 96) } + let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(0, 8 * 3600)], aux: night) + XCTAssertEqual(n?.minimum, 85) + XCTAssertEqual(n?.events.count, 10) + XCTAssertEqual(n?.windows, 480) + } + + /// A window stretched by a stepped or skipped timestamp stays ONE window: splitting off its last second + /// would make that second a reading of its own, and a single blip a dip. + func testAWindowStretchedByAClockStepStaysWhole() { + let stretched = (0..<29).map { aux(1000 + $0, 96) } + [aux(1033, 77)] + let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 600)], aux: stretched) + XCTAssertEqual(n?.windowsAttempted, 1) + XCTAssertEqual(n?.events.count, 0) + XCTAssertEqual(n?.minimum, 96) + } + + /// Failure codes are the strap MEASURING and failing, so they hold a window together and a window of + /// nothing but codes still counts as attempted. Zero is the strap not measuring at all. + func testCodesHoldAWindowTogetherAndACodeOnlyWindowIsAttemptedNotValued() { + let window1 = [aux(1000, 32), aux(1001, 95)] + (2..<20).map { aux(1000 + $0, 8) } + [aux(1020, 96)] + let window2 = (0..<30).map { aux(2200 + $0, $0 % 2 == 0 ? 136 : 40) } + let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 1800)], + aux: window1 + window2 + [aux(3000, 0)]) + XCTAssertEqual(n?.windowsAttempted, 2) + XCTAssertEqual(n?.windows, 1) + XCTAssertEqual(n?.samples, 2) + XCTAssertEqual(n?.minimum, 95, "the lower of the two middle readings: always a reported value") } - /// A one-reading dip has a span of ZERO seconds, not one. The R18 stream is "roughly one packet per - /// second", which is not a cadence guarantee, so `samples × 1 s` would state a duration nothing - /// measured. The reading itself is still reported — via `samplesBelowThreshold` and `nadir`. - func testASingleReadingDipReportsNoDurationButIsStillCounted() { + /// A window whose only in-band second is below the threshold is a dip with a span of ZERO seconds, not + /// one. The R18 stream is "roughly one packet per second", which is not a cadence guarantee, so + /// `samples × 1 s` would state a duration nothing measured. The window itself is still reported — via + /// `dipSamples` and `nadir`. + func testASingleReadingDipWindowReportsNoDurationButIsStillCounted() { let n = AnalyticsEngine.nightlySpo2CandidateNight( - [sess(1000, 600)], aux: [aux(1000, 96), aux(1002, 77), aux(1003, 95)]) + [sess(1000, 1800)], aux: [aux(1000, 8), aux(1001, 88), aux(1002, 8), aux(2200, 96)]) XCTAssertEqual(n?.events.count, 1) - XCTAssertEqual(n?.secondsBelowThreshold, 0) - XCTAssertEqual(n?.samplesBelowThreshold, 1) - XCTAssertEqual(n?.nadir, 77) + XCTAssertEqual(n?.dipSpanSeconds, 0) + XCTAssertEqual(n?.dipSamples, 1) + XCTAssertEqual(n?.nadir, 88) + } + + /// A window that sits below the threshold for most of its seconds is a dip, whatever single seconds + /// inside it read — the counterpart to the blip test below. + func testAWindowMostlyBelowTheThresholdIsADip() { + let window = (0..<30).map { aux(1000 + $0, $0 < 19 ? 88 + $0 % 2 : 91) } + let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 600)], aux: window) + XCTAssertEqual(n?.events.count, 1) + XCTAssertEqual(n?.nadir, 89) + XCTAssertEqual(n?.dipSamples, 30) + XCTAssertEqual(n?.dipSpanSeconds, 29) } /// `nadir` is nil for a night that never dipped, so a surface can say "no dips" without inferring it /// from a `minimum` that legitimately sits above the threshold. func testNadirIsNilWithoutDipsAndMinimumStillReports() { let n = AnalyticsEngine.nightlySpo2CandidateNight( - [sess(1000, 600)], aux: [aux(1000, 93), aux(1001, 98)]) + [sess(1000, 1800)], aux: [aux(1000, 93), aux(2200, 98)]) XCTAssertNil(n?.nadir) XCTAssertEqual(n?.minimum, 93) XCTAssertTrue(n!.events.isEmpty) @@ -154,14 +241,26 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// The threshold travels with the result so a screen names the number the dips were cut at instead of /// assuming the default. func testThresholdIsCarriedAndHonoured() { - let samples = [aux(1000, 93), aux(1001, 96)] - XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 600)], aux: samples)?.threshold, 90) - let strict = AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 600)], aux: samples, threshold: 95) + let samples = [aux(1000, 93), aux(2200, 96)] + XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 1800)], aux: samples)?.threshold, 90) + let strict = AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 1800)], aux: samples, threshold: 95) XCTAssertEqual(strict?.threshold, 95) - XCTAssertEqual(strict?.samplesBelowThreshold, 1) + XCTAssertEqual(strict?.dipSamples, 1) XCTAssertEqual(strict?.nadir, 93) } + /// W03-003: byte 82 arrives in 30-second windows, and inside one the strap's rolling value moves a few + /// points and can blip for a single second. A window that reads 95–96 with one second at 77 is not a + /// night that dipped to 77. + func testASingleSecondBlipInsideAWindowIsNotADip() { + let window = (0..<30).map { aux(1000 + $0, $0 == 12 ? 77 : 95 + $0 % 2) } + let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 600)], aux: window) + XCTAssertEqual(n?.events.count, 0) + XCTAssertEqual(n?.minimum, 95) + XCTAssertEqual(n?.windows, 1) + XCTAssertEqual(n?.samples, 30) + } + /// Readings outside every in-bed span are daytime readings, and a night with none of its own has no /// answer at all rather than a zero. func testSessionBoundsAreInclusiveAndDaytimeIsExcluded() { @@ -240,5 +339,27 @@ final class Spo2CandidateSeriesTests: XCTestCase { XCTAssertEqual(Spo2CandidateSeries.dipsKey, "spo2_candidate_dips") XCTAssertEqual(Spo2CandidateSeries.dipSecondsKey, "spo2_candidate_dip_seconds") XCTAssertEqual(Spo2CandidateSeries.samplesKey, "spo2_candidate_samples") + XCTAssertEqual(Spo2CandidateSeries.windowsKey, "spo2_candidate_windows") + XCTAssertEqual(Spo2CandidateSeries.windowsAttemptedKey, "spo2_candidate_windows_attempted") + } + + /// The window counts resolve from the same night as every other figure (W03-003). + func testWindowCountsResolveFromTheSameNight() { + let n = Spo2CandidateSeries.latest( + mean: ["2026-09-21": 95.66, "2026-09-22": 96.61], + windows: ["2026-09-21": 26, "2026-09-22": 20], + windowsAttempted: ["2026-09-21": 26, "2026-09-22": 22]) + XCTAssertEqual(n?.windows, 20) + XCTAssertEqual(n?.windowsAttempted, 22) + } + + /// A night scored before the window keys shipped has no window count. Its minimum and dips were + /// resolved per second, and the nil count is how a surface knows not to present them as per window. + func testPreWindowKeysNightReportsNoWindowCount() { + let n = Spo2CandidateSeries.latest(mean: ["2026-09-20": 96.0], minimum: ["2026-09-20": 77], + dips: ["2026-09-20": 4], samples: ["2026-09-20": 639]) + XCTAssertEqual(n?.minimum, 77) + XCTAssertNil(n?.windows) + XCTAssertNil(n?.windowsAttempted) } } diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 473ddfe0cd..78f52ee810 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -1646,12 +1646,13 @@ final class IntelligenceEngine: ObservableObject { beatAccurate: respGateAcc, rrIntegrity: respGateIntegrity) // #103/queue-11a: the night's SpO₂ candidate result — the in-band (70–100) - // `spo2_candidate_82` V18Aux readings resolved into a mean, a range and the night's - // below-threshold runs. Only computed when the display toggle is ON; nil when there is no - // v18 aux stream, no in-band reading, or the toggle is OFF. + // `spo2_candidate_82` V18Aux readings resolved per measurement window (W03-003) into a + // mean, a range, the night's dip readings and its window counts. Only computed when the + // display toggle is ON; nil when there is no v18 aux stream, no in-band reading, or the + // toggle is OFF. // // Resolved ONCE, here, for everything pass 2 writes: the mean, the minimum, the dip count, - // the dip span and the reading count are all read off this single value rather than + // the dip span and the window counts are all read off this single value rather than // recomputed per key, so no two of them can describe a different set of readings. // // Persisted to metricSeries in pass 2 under the `Spo2CandidateSeries` keys, never to @@ -2235,17 +2236,18 @@ final class IntelligenceEngine: ObservableObject { // (`intText`, `MetricDescriptor.format` with `decimals: 0`), so the number ON SCREEN is // unchanged — only the stored precision improves, and a re-score refills prior nights. // - // The four keys beside it are FACTS THE MEAN CANNOT CARRY, not restatements of it: a night - // averaging 96 % with a run down to 77 % and a night flat at 96 % are the same mean and - // different nights. They are separate keys rather than fields on `dailyMetric` because - // metricSeries takes any key with no migration, and because none of them may ever look like a - // calibrated column. All four come off the ONE `Spo2CandidateNight` resolved in pass 1, so - // they cannot disagree with the mean they sit next to. + // The keys beside it are FACTS THE MEAN CANNOT CARRY, not restatements of it: a night with a + // reading below the threshold and a night flat at the same mean are different nights. They are + // separate keys rather than fields on `dailyMetric` because metricSeries takes any key with no + // migration, and because none of them may ever look like a calibrated column. All of them come + // off the ONE `Spo2CandidateNight` resolved in pass 1, so they cannot disagree with the mean + // they sit next to. Since W03-003 every figure is per measurement window (the strap's + // 30-second reading), and the window keys mark a night resolved that way. // - // `spo2_candidate_dip_seconds` is time measured BETWEEN readings, never `samples × 1 s` — see - // `Spo2DesatEvent.spanSeconds`. It is therefore legitimately 0 on a night whose only dips were - // single readings, which is why the dip COUNT is stored separately instead of being inferred - // from a duration. + // `spo2_candidate_dip_seconds` is the observed span of the dip readings, measured BETWEEN + // in-band seconds, never `samples × 1 s` — see `Spo2DesatEvent.spanSeconds`. It is therefore + // legitimately 0 on a night whose only dip readings held one in-band second, which is why the + // dip COUNT is stored separately instead of being inferred from a duration. // // Every key comes from `Spo2CandidateSeries`, never a literal spelled here — the reader looks // these up optionally, so a key spelled twice could drift on one side and bank nothing for a @@ -2257,12 +2259,18 @@ final class IntelligenceEngine: ObservableObject { value: Double(cand.minimum))) restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.samplesKey, value: Double(cand.samples))) + // W03-003: the window counts. Their presence is also what tells a reader that this + // night's minimum and dips were resolved per window, not per second. + restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.windowsKey, + value: Double(cand.windows))) + restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.windowsAttemptedKey, + value: Double(cand.windowsAttempted))) // 0 written, not omitted, for both dip keys: a night that dipped and then re-scores clean // must CLEAR its prior figure rather than keep showing a stale one. restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.dipsKey, value: Double(cand.events.count))) restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.dipSecondsKey, - value: Double(cand.secondsBelowThreshold))) + value: Double(cand.dipSpanSeconds))) } // #1118: persist the HRV over-count flag (1/0) so the HRV card can mark an over-counted 4.0 // night's reading "unverified" until the two-channel de-dup lands. 0 written on a clean night diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 9d085abb14..93bde60e9a 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -11859,7 +11859,7 @@ "pl": { "stringUnit": { "state": "translated", - "value": "%1$lld lub %2$lld" + "value": "%1$lld z %2$lld" } } } @@ -159965,6 +159965,12 @@ "value": "Leituras" } }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Letture" + } + }, "pl": { "stringUnit": { "state": "translated", @@ -251635,66 +251641,66 @@ } } }, - "in-band, this night": { + "readings with a value": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "im Messbereich, diese Nacht" + "value": "Messungen mit Wert" } }, "en": { "stringUnit": { "state": "translated", - "value": "in-band, this night" + "value": "readings with a value" } }, "es": { "stringUnit": { "state": "translated", - "value": "en rango, esta noche" + "value": "lecturas con valor" } }, "fr": { "stringUnit": { "state": "translated", - "value": "dans la plage, cette nuit" + "value": "mesures avec valeur" } }, "it": { "stringUnit": { "state": "translated", - "value": "nell’intervallo, questa notte" + "value": "letture con valore" } }, "pl": { "stringUnit": { "state": "translated", - "value": "w zakresie, tej nocy" + "value": "odczyty z wartością" } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "no intervalo, esta noite" + "value": "leituras com valor" } }, "ru": { "stringUnit": { "state": "translated", - "value": "в диапазоне, за эту ночь" + "value": "измерения со значением" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "本夜有效范围内" + "value": "有数值的读数" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "本夜有效範圍內" + "value": "有數值的讀數" } } } @@ -251955,130 +251961,66 @@ } } }, - "momentary, below %@": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "kurzzeitig, unter %@" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "momentary, below %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "momentánea, por debajo de %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "momentanée, sous %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "momentanea, sotto %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "chwilowo, poniżej %@" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "momentânea, abaixo de %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "кратковременно, ниже %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "瞬时,低于 %@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "瞬時,低於 %@" - } - } - } - }, - "%llds below %@": { + "readings below %@": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "%llds unter %@" + "value": "Messungen unter %@" } }, "en": { "stringUnit": { "state": "translated", - "value": "%llds below %@" + "value": "readings below %@" } }, "es": { "stringUnit": { "state": "translated", - "value": "%llds por debajo de %@" + "value": "lecturas por debajo de %@" } }, "fr": { "stringUnit": { "state": "translated", - "value": "%llds sous %@" + "value": "mesures sous %@" } }, "it": { "stringUnit": { "state": "translated", - "value": "%llds sotto %@" + "value": "letture sotto %@" } }, "pl": { "stringUnit": { "state": "translated", - "value": "%llds poniżej %@" + "value": "odczyty poniżej %@" } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "%llds abaixo de %@" + "value": "leituras abaixo de %@" } }, "ru": { "stringUnit": { "state": "translated", - "value": "%lld с ниже %@" + "value": "измерения ниже %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%lld 秒低于 %@" + "value": "低于 %@ 的读数" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "%lld 秒低於 %@" + "value": "低於 %@ 的讀數" } } } diff --git a/Strand/Screens/HealthView.swift b/Strand/Screens/HealthView.swift index fe38bbeb73..0adaabfd65 100644 --- a/Strand/Screens/HealthView.swift +++ b/Strand/Screens/HealthView.swift @@ -1330,14 +1330,15 @@ private struct VitalsSection: View { // the experimental toggle is ON. Empty when the toggle is OFF or no candidate data exists. @State private var spo2CandidateByDay: [String: Double] = [:] @State private var hrvOverCountByDay: [String: Double] = [:] // #1118 - // #103: the figures the candidate MEAN cannot carry — the night's low, its dips, the dip duration and - // the reading count behind all of it. Four separate metricSeries rows, loaded beside the mean above + // #103: the figures the candidate MEAN cannot carry — the night's low, its dips and the readings + // (windows) behind all of it. Separate metricSeries rows, loaded beside the mean above // and resolved into ONE night by `Spo2CandidateSeries.latest` so the card cannot show last night's // mean beside an older night's low. Empty whenever the mean is. @State private var spo2CandidateMinByDay: [String: Double] = [:] @State private var spo2CandidateDipsByDay: [String: Double] = [:] - @State private var spo2CandidateDipSecondsByDay: [String: Double] = [:] - @State private var spo2CandidateSamplesByDay: [String: Double] = [:] + // W03-003: the night's measurement windows, valued and attempted. + @State private var spo2CandidateWindowsByDay: [String: Double] = [:] + @State private var spo2CandidateWindowsAttemptedByDay: [String: Double] = [:] /// The resolved night's per-second in-band readings, for the card's chart. Keyed to that night's day. @State private var spo2NightTrace: [TrendPoint] = [] @@ -1402,14 +1403,15 @@ private struct VitalsSection: View { } let pts = await repo.exploreSeries(key: Spo2CandidateSeries.meanKey, source: "my-whoop", days: 14) spo2CandidateByDay = Dictionary(pts.map { ($0.day, $0.value) }, uniquingKeysWith: { a, _ in a }) - // #103: the four companion series behind the strap-estimate card. Read with the SAME window + // #103: the companion series behind the strap-estimate card. Read with the SAME window // and source as the mean above so they describe the same span, and keyed off // `Spo2CandidateSeries` rather than literals — the writer spells them from that same enum, // which is the only thing stopping a rename from silently banking rows nothing reads. spo2CandidateMinByDay = await Self.loadCandidateSeries(repo, Spo2CandidateSeries.minimumKey) spo2CandidateDipsByDay = await Self.loadCandidateSeries(repo, Spo2CandidateSeries.dipsKey) - spo2CandidateDipSecondsByDay = await Self.loadCandidateSeries(repo, Spo2CandidateSeries.dipSecondsKey) - spo2CandidateSamplesByDay = await Self.loadCandidateSeries(repo, Spo2CandidateSeries.samplesKey) + spo2CandidateWindowsByDay = await Self.loadCandidateSeries(repo, Spo2CandidateSeries.windowsKey) + spo2CandidateWindowsAttemptedByDay = await Self.loadCandidateSeries( + repo, Spo2CandidateSeries.windowsAttemptedKey) } } @@ -1446,8 +1448,8 @@ private struct VitalsSection: View { Spo2CandidateSeries.latest(mean: spo2CandidateByDay, minimum: spo2CandidateMinByDay, dips: spo2CandidateDipsByDay, - dipSeconds: spo2CandidateDipSecondsByDay, - samples: spo2CandidateSamplesByDay) + windows: spo2CandidateWindowsByDay, + windowsAttempted: spo2CandidateWindowsAttemptedByDay) } /// The nightly means oldest → newest for the card's sparkline. Sorted by day KEY (`YYYY-MM-DD` sorts diff --git a/Strand/Screens/Spo2EstimateCard.swift b/Strand/Screens/Spo2EstimateCard.swift index b993e27559..eb7c85aaa0 100644 --- a/Strand/Screens/Spo2EstimateCard.swift +++ b/Strand/Screens/Spo2EstimateCard.swift @@ -12,12 +12,15 @@ import StrandAnalytics // reading of that combination is "NOOP is not collecting this", which is what a 5/MG owner concluded. // // This card is the other half of the fix: the figures the mean cannot carry — the night's low, its dips, -// and how many readings the whole thing rests on. Nothing here is a blood-oxygen measurement: `@82` is an +// and how many readings the whole thing rests on. Those figures are PER WINDOW (W03-003): the strap +// measures in 30-second windows, and within one its rolling value can blip for a second. The "run" above +// turned out to be one such second inside a steady window; counted per second it became the night's low +// and a dip. Nothing here is a blood-oxygen measurement: `@82` is an // unvalidated candidate (`docs/PROTOCOL_SENSORS.md` §R18 calls it a "sleep-adjacent raw byte", and the // cross-device evidence is split), so every figure sits under the same "unverified" framing the tile uses // and none of it is written to `spo2Pct` or fed to a score. // -// ONE NIGHT, ONE FUNNEL. The four figures are read from four separate metricSeries rows, and a card that +// ONE NIGHT, ONE FUNNEL. The figures are read from separate metricSeries rows, and a card that // resolved each one itself could pair last night's mean with an older night's low the moment one row // lagged a scoring pass — the failure AGENTS.md's "two readouts of one fact" rule is about. So the night // is resolved by `Spo2CandidateSeries.latest` (pure, in the package, CI-tested) and this view only @@ -25,7 +28,7 @@ import StrandAnalytics // resolves its own day through a staleness-bounded carry: the two can legitimately be showing different // nights, and the only wrong answer is not saying which. -/// The night's SpO₂ strap-estimate figures: mean, low, dips and the reading count behind them, plus a +/// The night's SpO₂ strap-estimate figures: mean, low, dips and the window count behind them, plus a /// 14-night trend of the mean. Renders nothing at all unless the experimental candidate toggle is ON and /// at least one night has been scored — an empty card would be a claim about the strap. struct Spo2EstimateCard: View { @@ -58,17 +61,20 @@ struct Spo2EstimateCard: View { accent: StrandPalette.metricCyan, sparkline: Self.spark(meanTrend), sparkColor: StrandPalette.metricCyan) + // A night scored before the window keys shipped carries a per-second low and + // per-second dips. Shown under this card's per-reading copy they would say a blip + // was a dip, so they show the dash every unknown figure here shows (W03-003). StatTile(label: "Low", - value: night.minimum.map { "\($0)%" } ?? "—", - caption: lowCaption(night), + value: Self.perReading(night) ? night.minimum.map { "\($0)%" } ?? "—" : "—", + caption: Self.perReading(night) ? lowCaption(night) : "", accent: StrandPalette.metricCyan) StatTile(label: "Dips", - value: night.dips.map(String.init) ?? "—", - caption: dipsCaption(night), + value: Self.perReading(night) ? night.dips.map(String.init) ?? "—" : "—", + caption: Self.perReading(night) ? dipsCaption(night) : "", accent: StrandPalette.metricCyan) StatTile(label: "Readings", - value: night.samples.map(String.init) ?? "—", - caption: String(localized: "in-band, this night"), + value: readingsValue(night), + caption: readingsCaption(night), accent: StrandPalette.textPrimary) } if trace.count >= 2 { @@ -126,6 +132,18 @@ struct Spo2EstimateCard: View { // MARK: - Captions + /// "20 of 22": the readings that gave a value, of every reading the strap attempted. A night scored + /// before the window counts shipped has none, and its per-second figures must not be presented as a + /// reading count, so it shows the dash every other unknown figure on this card shows. + private func readingsValue(_ night: Spo2CandidateSeries.Night) -> String { + guard let windows = night.windows, let attempted = night.windowsAttempted else { return "—" } + return String(localized: "\(windows) of \(attempted)") + } + + private func readingsCaption(_ night: Spo2CandidateSeries.Night) -> String { + night.windows == nil ? "" : String(localized: "readings with a value") + } + /// The threshold as a display string, interpolated into the captions below as `%@`. /// /// Pre-formatted rather than interpolated as a number with a literal "%" after it, because that would @@ -145,20 +163,20 @@ struct Spo2EstimateCard: View { : String(localized: "stayed above \(thresholdLabel)") } - /// The Dips tile's caption. The seconds are measured BETWEEN readings (see - /// `Spo2DesatEvent.spanSeconds`), so a night whose only dips were single readings legitimately has - /// zero seconds — and captioning that "0 s below" beside a dip count of 2 would read as a - /// contradiction. So the duration is shown only when there is one to show, and the count stands alone - /// otherwise, which is exactly what was measured. + /// The Dips tile's caption. A dip is a READING whose middle value is under the threshold, so the tile + /// counts readings and the caption says so. It gives no duration: a reading's seconds are one rolling + /// measurement, and how long the value itself stayed under the line is not something one 30-second + /// reading establishes (W03-003). private func dipsCaption(_ night: Spo2CandidateSeries.Night) -> String { guard let dips = night.dips else { return "" } guard dips > 0 else { return String(localized: "none below \(thresholdLabel)") } - guard let secs = night.dipSeconds, secs > 0 else { - return String(localized: "momentary, below \(thresholdLabel)") - } - return String(localized: "\(secs)s below \(thresholdLabel)") + return String(localized: "readings below \(thresholdLabel)") } + /// True when the night was resolved per reading (window). A night scored before the window keys + /// shipped has no window count, and its low and dips were resolved per second. + static func perReading(_ night: Spo2CandidateSeries.Night) -> Bool { night.windows != nil } + /// "Night of 21 Sep" — the night this card's figures came from, stamped because the Blood Oxygen tile /// resolves its own day through a carry and the two can honestly differ. static func nightLabel(_ day: String) -> String { diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift index ffd4c166c0..05ab3dd737 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -343,23 +343,27 @@ enum DebugDataDiagnostics { lines.append("SpO₂ candidate @82: could not read the aux stream for this night — " + "a read failure, NOT an absence of readings.") } else if let cand = AnalyticsEngine.nightlySpo2CandidateNight([det], aux: auxRead ?? []) { - // The MEAN alone sent a reader the wrong way: a night averaging 96 % with a run down to 77 % - // and a night flat at 96 % printed the same line. The range and the dips are what separate + // The MEAN alone sent a reader the wrong way: a night with a reading below the threshold and a + // night flat at the same mean printed the same line. The range and the dips are what separate // them, and they cost nothing here — the readings are already in hand. One decimal on the // mean because the stored series keeps the unrounded value now, and the rounded figure a // screen shows is derived from it; printing only the rounded one would make this line // unable to explain a screen that disagreed with it. // - // Dip SECONDS are measured between readings, never samples × 1 s, so they are legitimately 0 - // when every dip was a single reading — the count is therefore printed beside them rather - // than inferred from them (see `Spo2DesatEvent.spanSeconds`). + // The dip span is measured between in-band seconds, never samples × 1 s, so it is legitimately + // 0 when every dip reading held one in-band second — the count is therefore printed beside it + // rather than inferred from it (see `Spo2DesatEvent.spanSeconds`). + // + // W03-003: every figure is per measurement WINDOW (the strap's 30-second readings), so the + // line names the windows it rests on — valued of attempted — beside the in-band seconds. let dips = cand.events.isEmpty - ? "no dips below \(cand.threshold)%" - : "\(cand.events.count) dip(s) below \(cand.threshold)% " - + "(lowest \(cand.nadir.map(String.init) ?? "?")%, \(cand.secondsBelowThreshold)s measured)" + ? "no dip windows below \(cand.threshold)%" + : "\(cand.events.count) dip window(s) below \(cand.threshold)% " + + "(lowest \(cand.nadir.map(String.init) ?? "?")%, \(cand.dipSpanSeconds)s measured)" lines.append("SpO₂ candidate @82 (5/MG): mean \(String(format: "%.1f", cand.mean))% " - + "(shown as \(cand.meanRounded)%), range \(cand.minimum)-\(cand.maximum)%, " - + "\(dips), over \(cand.samples) in-band readings " + + "(shown as \(cand.meanRounded)%) over \(cand.windows) of \(cand.windowsAttempted) " + + "windows (\(cand.samples) in-band seconds), window range " + + "\(cand.minimum)-\(cand.maximum)%, \(dips) " + "— UNVERIFIED, compare against the WHOOP app's figure for this night (#103).") } else { lines.append("SpO₂ candidate @82 (5/MG): no in-band readings inside this night's span.") diff --git a/Tools/i18n_extra_locale_baseline.txt b/Tools/i18n_extra_locale_baseline.txt index 201413fe47..85c9230125 100644 --- a/Tools/i18n_extra_locale_baseline.txt +++ b/Tools/i18n_extra_locale_baseline.txt @@ -14,7 +14,7 @@ NOOPWatch/Localizable.xcstrings:zh-Hant 0 NOOPWatchComplications/Localizable.xcstrings:it 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hans 0 NOOPWatchComplications/Localizable.xcstrings:zh-Hant 0 -Strand/Resources/Localizable.xcstrings:it 177 +Strand/Resources/Localizable.xcstrings:it 176 Strand/Resources/Localizable.xcstrings:ru 1 Strand/Resources/Localizable.xcstrings:zh-Hans 0 Strand/Resources/Localizable.xcstrings:zh-Hant 78 diff --git a/Tools/linux-capture/test_validate_spo2_candidate.py b/Tools/linux-capture/test_validate_spo2_candidate.py index 5e8bcf1113..92ce081f03 100644 --- a/Tools/linux-capture/test_validate_spo2_candidate.py +++ b/Tools/linux-capture/test_validate_spo2_candidate.py @@ -558,6 +558,30 @@ def test_the_nightly_aggregate_is_per_window_not_per_second(self): self.assertAlmostEqual(per_window[0], (90 + 100 + 100) / 3) # 96.67, window-weighted self.assertEqual(per_window[1], 36) # sample count still reported honestly + def test_a_window_is_valued_by_its_median_like_the_app(self): + """W03-003: a one-second blip inside a window must not move it, and the tool must give the same + nightly figure as the app's `nightlySpo2CandidateNight` (lower median per window).""" + frames = [] + for u in range(0, 3600): # three windows: the duty detector's minimum + if (u - 337) % 1200 >= 30: + v = 0 + elif u == 337 + 5: + v = 77 # a one-second blip in the first window + elif u < 1200: + v = 96 + elif u < 2400: + v = 95 if u % 2 else 97 # even count per value: lower median 95 + else: + v = 94 + frames.append({"hex": make_v18(unix=u, sleep_state=2, aux_byte_82=v).hex()}) + recs = list(vs.iter_v18_records(frames)) + duty = vs.detect_duty_cycle(recs) + self.assertEqual(duty["mode"], "duty_cycled") + night = vs.night_mean_at_offset(recs, 0, 3600, 82, duty=duty) + self.assertAlmostEqual(night[0], (96 + 95 + 94) / 3) # medians; the 77 blip moves nothing + self.assertEqual(vs.window_value([95, 97]), 95) + self.assertEqual(vs.window_value([96, 77, 96]), 96) + class VarianceFloorTests(unittest.TestCase): """`value in 70..100` is not a specific screen. On a real subscription-free strap six offsets pass diff --git a/Tools/linux-capture/validate_spo2_candidate.py b/Tools/linux-capture/validate_spo2_candidate.py index 2768af0be1..750721fa47 100644 --- a/Tools/linux-capture/validate_spo2_candidate.py +++ b/Tools/linux-capture/validate_spo2_candidate.py @@ -656,6 +656,14 @@ def night_window_coverage( return (len(sampled & expected), len(expected)) +def window_value(samples: Sequence[int]) -> int: + """One measurement window's value: its median in-band sample, the lower of the two middle ones when + the count is even, so it is always a value the strap reported. Mirrors `Spo2CandidateWindow.value` + in `Packages/StrandAnalytics` (W03-003).""" + ordered = sorted(samples) + return ordered[(len(ordered) - 1) // 2] + + def night_mean_at_offset( records: Sequence[dict], t0: int, @@ -668,9 +676,15 @@ def night_mean_at_offset( """Nightly aggregate of in-band u8 samples at `offset` inside [t0, t1). Returns (mean, n) or None. On a duty-cycled capture only in-window samples count, and the aggregate is the mean of the - per-window means — one number per firing of the window, not one per second. A 30-second burst is + per-window values — one number per firing of the window, not one per second. A 30-second burst is one measurement the strap took, not 30 independent ones; averaging per second silently weights the night towards whichever windows the capture happened to sample most densely. + + A window's value is its MEDIAN in-band sample (the lower of the two middle ones for an even count), + the same rule the app's `nightlySpo2CandidateNight` applies (W03-003): inside a window the strap's + figure is a rolling value that can blip for one second, and a blip must not move the window. Using + the app's rule keeps this tool's nightly figure equal to the one the app shows for the same night, + which is the number a volunteer compares against the WHOOP app. """ duty_cycled = is_duty_cycled(duty) per_window: Dict[int, List[int]] = {} @@ -692,7 +706,7 @@ def night_mean_at_offset( if not vals: return None if duty_cycled: - return (statistics.fmean(statistics.fmean(w) for w in per_window.values()), len(vals)) + return (statistics.fmean(window_value(w) for w in per_window.values()), len(vals)) return (statistics.fmean(vals), len(vals)) diff --git a/docs/PROTOCOL_SENSORS.md b/docs/PROTOCOL_SENSORS.md index 4c26347177..6e575fb5e9 100644 --- a/docs/PROTOCOL_SENSORS.md +++ b/docs/PROTOCOL_SENSORS.md @@ -256,7 +256,7 @@ sensor guarantees for every firmware version. | 73 | 2 / u16 | Skin-temperature convention, raw/100 °C; accept 5–45 °C | | 75, 77, 79 | 2 each / u16 | Raw status words; no sleep-stage meaning established | | 81 | 1 / bitfield | Four two-bit groups; detailed below | -| 82 | 1 / u8 | Sleep-adjacent raw byte; 80/A0 hex are candidate sentinels, not established physiological labels | +| 82 | 1 / u8 | Sleep-adjacent raw byte; 80/A0 hex are candidate sentinels, not established physiological labels. On the strap measured so far, nonzero only in 30-record measurement windows; see [byte 82 windows](#byte-82-measurement-windows) | | 106, 107 | 1 each / u8 | Optical baseline-like raw values; per-byte optical identity provisional | | 108, 109 | 1 each / u8 | Optical amplitude-like values; simultaneous 128 has been treated as a signal-quality sentinel | | 113 | 4 / f32 | Unknown finite float; zero may mean unset, no established quantity | @@ -317,6 +317,51 @@ SLEEP is not a mapping to light, deep or REM sleep, and is unrelated to processo power-saving sleep. Byte 75 is not a deep-sleep indicator. The record supplies neither a validated hypnogram nor an established production SpO₂ value. +#### Byte 82 measurement windows + +**Hardware observations, one MG (`WS50_r00`, firmware `50.39.1.0`), 588,883 records +over seven nights.** They describe when the byte is written, not what its value +means; the value remains an unvalidated candidate. + +- **Windows.** The byte is nonzero only in runs of exactly 30 consecutive records: + 175 windows, no other length. +- **Schedule.** The first window of a SLEEP bout (byte 81 bits 4–5 = 2) starts + 1173–1175 s after the bout's first SLEEP record. Each later window starts 1200 s + after the previous one, with at most 2 s of drift in a night, while the state stays + SLEEP. Applied to every bout, this rule predicted all 175 windows, with no miss and + no extra window, over 15 bouts. The phase against unix time therefore differs from + night to night, and a phase measured on one capture does not carry to another. +- **Completion.** A window that has started completes even if the state leaves SLEEP; + one ran three records into UP. +- **Not observed.** No SLEEP bout was shorter than 1173 s, and every interruption was + UP lasting 900–2190 s. What a shorter bout or a brief interruption does is unknown; + a bout shorter than 1173 s is predicted to hold no window. +- **Status words.** The byte is nonzero in exactly the records whose status words at + 77 and 79 leave the pair `0x0c01`/`0x0c02`, with no exception in either direction + over the same records. Inside windows the pair read `0x0b51`/`0x04a2` or + `0x0701`/`0x07f2`, apart from two rare mixtures. Zero therefore means "no + measurement", and a nonzero value outside 70–100 marks a measurement that produced + no in-band value. +- **Non-percentage values.** None of the observed codes (the nonzero values outside + 70–100) sets bit 6, which every value in the 70–100 range has by arithmetic, so on + the values seen bit 6 alone separates a percentage candidate from a code. The codes + decompose as bit 7, a three-bit field at bits 3–5 and a small field at bits 0–2; + their meanings are unresolved. +- **Within a window.** The in-band value changes about every five records and spans a + few points, with rare one-record excursions. The window, not the record, is the + independent sample; NOOP's nightly figures use the window's median, taking the + lower of the two middle values when the count is even. +- **No app-side unlock.** This strap never paired with the official app: its event + history holds one BLE_BONDED event. It has no account, and it never received the R22 + sequence: no layout-22 record is among 14,866 archived unmapped records. It + produced in-band values from its first scored night. On this strap and firmware the + byte therefore needs no app-side unlock. This does not establish whether the + official app can disable it. + +An official-app overnight capture of another strap, used by +`Tools/linux-capture/validate_spo2_candidate.py`, shows the same 30-record, 1200 s +structure within its one night. + ### R18 quality-adjacent source-selection bits The packed byte at frame 36 combines several independently observable fields. diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 0ba20c6eca..89df6f04a6 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -69,7 +69,16 @@ every changed row explained; SleepBench / SleepPSG for any stager change. |---|---|---|---|---|---|---| | W03-001 | S3 | Reported | In-bed start from gravity counts evening lead-ins, which drags sleep efficiency down | `SleepStager.swift` (to confirm) | 5/MG sleep audit of 2026-09-24, left open after PR #20 | | | W03-002 | S3 | Reported | Staging misses early-cycle REM on some nights | Stager recipe (to confirm) | Same audit | | +| W03-003 | S2 | Fixed | The 5/MG SpO₂ candidate night (low, dips, mean, evidence count) was resolved per second, but the strap measures byte 82 in 30-record windows whose rolling value blips for single seconds: a one-second blip became the night's low and a dip no window showed, and the evidence count was seconds of one measurement | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `validate_spo2_candidate.py` `night_mean_at_offset` | V0: `testASingleSecondBlipInsideAWindowIsNotADip` fails on the old resolver (1 dip, low 77, in a 95–96 window). V1: oracle re-pinned and matched line for line by an independent Python implementation (17 cases); on all 11 backups the resolver and that implementation agree on every night; package suite, `StrandTests`, both app builds, i18n and hygiene gates pass. V2 (independent reviewer and `/code-review high`): a continuous stream would collapse into one window (fixed: a 60 s window cap, twice the observed window so a clock step cannot split one); the Dips caption claimed seconds below the threshold (fixed: it counts readings, the dip properties are renamed `dipSamples`/`dipSpanSeconds`); nights scored before the window keys showed per-second Low and Dips under per-reading copy (fixed: dashes); the comparison tool valued a window by its mean (fixed: the app's lower median); stale comments and copy (fixed). Left as before: a night whose readings all failed still resolves to nil; the night chart plots raw seconds, labelled as such. V3: 7 stored nights replayed on `main` and on the fix: in-band seconds unchanged on all 7, displayed mean moves on 1 by one point, the low rises on 6, dips fall from 10 to 1 (the one left is a whole window below the threshold) | | +| W03-005 | S4 | Reported | The strap-log SpO₂ candidate line resolves over the one stored session it describes, while the scoring pass resolves over all of the day's detected sessions, so on a day with more than one session its "(shown as …%)" can differ from the screen. Found in W03-003's V2; predates it | `DebugDataDiagnostics.swift` (`nightlySpo2CandidateNight([det], …)`) | Code reading; not yet reproduced | | ## Log - 2026-09-25 — File created from the plan. W03-001 and W03-002 carried over from the 5/MG sleep audit. +- 2026-09-26 — Out-of-phase fix at the owner's request, from the BLE-dossier fact-check: W03-003 and W03-004 on + `review/w03-spo2-windows`. The byte 82 window facts behind them are in `docs/PROTOCOL_SENSORS.md`. W3's own + review passes are not started. +- 2026-09-26 — V2 of W03-003/004 by an independent reviewer and `/code-review high`; every confirmed point fixed on + the same branch, then re-validated on all 11 backups (resolver = independent Python implementation on every + night). W03-005 added from that review. Next: the strap check (a night on the new build, the card showing + "N of M" readings, the strap log's window line). From 5cba9d2eab672b879588ee65479918b9c4d7eb61 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Sat, 26 Sep 2026 11:22:26 +0200 Subject: [PATCH 2/2] fix(spo2): stop saying the strap reports the candidate every second The Blood Oxygen card's footnote and the Settings toggle said the strap sends the byte 82 candidate every second. On the straps measured so far it takes a 30-second reading about every 20 minutes once the band scores the wearer asleep, and some readings fail without a value. Both now say that, scoped to the straps measured, and the card says a reading counts once, by its middle value, so a one-second blip inside it is not a dip. The night chart's caption says each point is one second the strap reported, and when a reading is a dip. WHOOP5_DEEP_DATA.md described per-second runs; it now describes the readings. Translated for the nine catalogue languages. W03-004. --- Strand/Resources/Localizable.xcstrings | 80 +++++++++++++----------- Strand/Screens/SettingsView.swift | 2 +- Strand/Screens/Spo2EstimateCard.swift | 11 ++-- docs/WHOOP5_DEEP_DATA.md | 29 +++++---- docs/review/workstreams/W03-analytics.md | 1 + 5 files changed, 68 insertions(+), 55 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 93bde60e9a..52e7070bd2 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -229300,60 +229300,66 @@ } } }, - "Your WHOOP 5.0/MG sends a strap-computed SpO₂ percentage (the @82 candidate byte) every second — an 8-night independent validation tracked it at corr +0.99 against the WHOOP app, but two nights on the original test device moved the OPPOSITE direction, so device/firmware variance is unresolved. Turning this on surfaces it as \"strap estimate (unverified)\" in the Blood Oxygen tile when no calibrated import exists. It never feeds recovery or illness scoring.": { + "Your WHOOP 5.0/MG reports a strap-computed SpO₂ percentage (the @82 candidate byte) in short readings while it scores you as asleep — one 30-second reading about every 20 minutes on the straps measured so far. An 8-night independent validation tracked it at corr +0.99 against the WHOOP app, but two nights on the original test device moved the OPPOSITE direction, so device/firmware variance is unresolved. Turning this on surfaces it as \"strap estimate (unverified)\" in the Blood Oxygen tile when no calibrated import exists. It never feeds recovery or illness scoring.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Dein WHOOP 5.0/MG sendet jede Sekunde einen vom Band berechneten SpO₂-Wert (das Kandidaten-Byte @82). Eine unabhängige Validierung über 8 Nächte ergab eine Korrelation von +0,99 zur WHOOP-App, doch zwei Nächte auf dem ursprünglichen Testgerät liefen in die ENTGEGENGESETZTE Richtung, sodass die Geräte-/Firmware-Varianz ungeklärt ist. Wenn du dies aktivierst, erscheint der Wert als „Schätzung des Bands (unbestätigt)“ in der Blutsauerstoff-Kachel, sofern kein kalibrierter Import vorliegt. Er fließt nie in Erholungs- oder Krankheitswerte ein." + "value": "Dein WHOOP 5.0/MG meldet einen vom Band berechneten SpO₂-Wert (das Kandidaten-Byte @82) in kurzen Messungen, während es dich als schlafend einstuft – bei den bisher gemessenen Bändern etwa eine 30-Sekunden-Messung alle 20 Minuten. Eine unabhängige Validierung über 8 Nächte ergab eine Korrelation von +0,99 zur WHOOP-App, doch zwei Nächte auf dem ursprünglichen Testgerät liefen in die ENTGEGENGESETZTE Richtung, sodass die Geräte-/Firmware-Varianz ungeklärt ist. Wenn du dies aktivierst, erscheint der Wert als „Schätzung des Bands (unbestätigt)“ in der Blutsauerstoff-Kachel, sofern kein kalibrierter Import vorliegt. Er fließt nie in Erholungs- oder Krankheitswerte ein." } }, - "es": { + "en": { "stringUnit": { "state": "translated", - "value": "Tu WHOOP 5.0/MG envía cada segundo un porcentaje de SpO₂ calculado por la banda (el byte candidato @82). Una validación independiente de 8 noches lo situó en una correlación de +0,99 frente a la app de WHOOP, pero dos noches en el dispositivo de prueba original fueron en dirección OPUESTA, así que la variación entre dispositivos y firmware sigue sin resolverse. Al activarlo, aparece como «estimación de la banda (sin verificar)» en el recuadro de Oxígeno en sangre cuando no hay una importación calibrada. Nunca alimenta la recuperación ni la detección de enfermedad." + "value": "Your WHOOP 5.0/MG reports a strap-computed SpO₂ percentage (the @82 candidate byte) in short readings while it scores you as asleep — one 30-second reading about every 20 minutes on the straps measured so far. An 8-night independent validation tracked it at corr +0.99 against the WHOOP app, but two nights on the original test device moved the OPPOSITE direction, so device/firmware variance is unresolved. Turning this on surfaces it as \"strap estimate (unverified)\" in the Blood Oxygen tile when no calibrated import exists. It never feeds recovery or illness scoring." } }, - "fr": { + "es": { "stringUnit": { "state": "translated", - "value": "Votre WHOOP 5.0/MG envoie chaque seconde un pourcentage de SpO₂ calculé par le bracelet (l'octet candidat @82). Une validation indépendante sur 8 nuits a relevé une corrélation de +0,99 avec l'app WHOOP, mais deux nuits sur l'appareil de test initial ont évolué en sens INVERSE : la variation selon l'appareil et le micrologiciel reste non résolue. En l'activant, la valeur apparaît comme « estimation du bracelet (non vérifiée) » dans la tuile Oxygène sanguin lorsqu'aucun import calibré n'existe. Elle n'alimente jamais la récupération ni la détection de maladie." + "value": "Tu WHOOP 5.0/MG informa de un porcentaje de SpO₂ calculado por la banda (el byte candidato @82) en lecturas breves mientras te considera dormido: una lectura de 30 segundos aproximadamente cada 20 minutos en las bandas medidas hasta ahora. Una validación independiente de 8 noches lo situó en una correlación de +0,99 frente a la app de WHOOP, pero dos noches en el dispositivo de prueba original fueron en dirección OPUESTA, así que la variación entre dispositivos y firmware sigue sin resolverse. Al activarlo, aparece como «estimación de la banda (sin verificar)» en el recuadro de Oxígeno en sangre cuando no hay una importación calibrada. Nunca alimenta la recuperación ni la detección de enfermedad." } }, - "pt-PT": { + "fr": { "stringUnit": { "state": "translated", - "value": "O teu WHOOP 5.0/MG envia a cada segundo uma percentagem de SpO₂ calculada pela banda (o byte candidato @82). Uma validação independente de 8 noites registou uma correlação de +0,99 face à app da WHOOP, mas duas noites no dispositivo de teste original seguiram a direção OPOSTA, pelo que a variação entre dispositivos e firmware continua por resolver. Ao ativar, o valor surge como «estimativa da banda (não verificada)» no cartão de Oxigénio no sangue quando não existe uma importação calibrada. Nunca alimenta a recuperação nem a deteção de doença." + "value": "Votre WHOOP 5.0/MG transmet un pourcentage de SpO₂ calculé par le bracelet (l'octet candidat @82) par courtes mesures pendant qu'il vous considère endormi : une mesure de 30 secondes environ toutes les 20 minutes sur les bracelets mesurés jusqu'ici. Une validation indépendante sur 8 nuits a relevé une corrélation de +0,99 avec l'app WHOOP, mais deux nuits sur l'appareil de test initial ont évolué en sens INVERSE : la variation selon l'appareil et le micrologiciel reste non résolue. En l'activant, la valeur apparaît comme « estimation du bracelet (non vérifiée) » dans la tuile Oxygène sanguin lorsqu'aucun import calibré n'existe. Elle n'alimente jamais la récupération ni la détection de maladie." } }, "it": { "stringUnit": { "state": "translated", - "value": "Il tuo WHOOP 5.0/MG invia ogni secondo una percentuale di SpO₂ calcolata dalla fascia (il byte candidato @82). Una validazione indipendente su 8 notti ha rilevato una correlazione di +0,99 con l'app WHOOP, ma due notti sul dispositivo di test originale sono andate nella direzione OPPOSTA, quindi la varianza tra dispositivi e firmware resta irrisolta. Attivandolo, il valore compare come «stima della fascia (non verificata)» nel riquadro Ossigeno nel sangue quando non esiste un import calibrato. Non alimenta mai il recupero né il rilevamento di malattia." + "value": "Il tuo WHOOP 5.0/MG riporta una percentuale di SpO₂ calcolata dalla fascia (il byte candidato @82) in brevi letture mentre ti considera addormentato: una lettura di 30 secondi circa ogni 20 minuti sulle fasce misurate finora. Una validazione indipendente su 8 notti ha rilevato una correlazione di +0,99 con l'app WHOOP, ma due notti sul dispositivo di test originale sono andate nella direzione OPPOSTA, quindi la varianza tra dispositivi e firmware resta irrisolta. Attivandolo, il valore compare come «stima della fascia (non verificata)» nel riquadro Ossigeno nel sangue quando non esiste un import calibrato. Non alimenta mai il recupero né il rilevamento di malattia." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Twój WHOOP 5.0/MG co sekundę wysyła wyliczony przez opaskę odsetek SpO₂ (bajt kandydujący @82). Niezależna walidacja przez 8 nocy wykazała korelację +0,99 z aplikacją WHOOP, ale dwie noce na pierwotnym urządzeniu testowym poszły w PRZECIWNYM kierunku, więc zmienność między urządzeniami i wersjami firmware pozostaje nierozstrzygnięta. Po włączeniu wartość pojawia się jako „szacunek opaski (niezweryfikowany)” w kafelku Tlen we krwi, gdy nie ma skalibrowanego importu. Nigdy nie zasila regeneracji ani wykrywania choroby." + "value": "Twój WHOOP 5.0/MG podaje wyliczony przez opaskę odsetek SpO₂ (bajt kandydujący @82) w krótkich odczytach, gdy uznaje, że śpisz — na zmierzonych dotąd opaskach jeden 30-sekundowy odczyt mniej więcej co 20 minut. Niezależna walidacja przez 8 nocy wykazała korelację +0,99 z aplikacją WHOOP, ale dwie noce na pierwotnym urządzeniu testowym poszły w PRZECIWNYM kierunku, więc zmienność między urządzeniami i wersjami firmware pozostaje nierozstrzygnięta. Po włączeniu wartość pojawia się jako „szacunek opaski (niezweryfikowany)” w kafelku Tlen we krwi, gdy nie ma skalibrowanego importu. Nigdy nie zasila regeneracji ani wykrywania choroby." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O teu WHOOP 5.0/MG comunica uma percentagem de SpO₂ calculada pela banda (o byte candidato @82) em leituras curtas enquanto te considera a dormir — uma leitura de 30 segundos cerca de 20 em 20 minutos nas bandas medidas até agora. Uma validação independente de 8 noites registou uma correlação de +0,99 face à app da WHOOP, mas duas noites no dispositivo de teste original seguiram a direção OPOSTA, pelo que a variação entre dispositivos e firmware continua por resolver. Ao ativar, o valor surge como «estimativa da banda (não verificada)» no cartão de Oxigénio no sangue quando não existe uma importação calibrada. Nunca alimenta a recuperação nem a deteção de doença." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Ваш WHOOP 5.0/MG каждую секунду отправляет рассчитанный браслетом процент SpO₂ (байт-кандидат @82). Независимая проверка за 8 ночей показала корреляцию +0,99 с приложением WHOOP, но две ночи на исходном тестовом устройстве дали ПРОТИВОПОЛОЖНОЕ направление, поэтому разброс между устройствами и прошивками не выяснен. При включении значение отображается как «оценка браслета (не подтверждено)» в плитке «Кислород в крови», если нет калиброванного импорта. Оно никогда не влияет на восстановление и на выявление болезни." + "value": "Ваш WHOOP 5.0/MG сообщает рассчитанный браслетом процент SpO₂ (байт-кандидат @82) короткими измерениями, пока считает, что вы спите, — на изученных пока браслетах одно 30-секундное измерение примерно каждые 20 минут. Независимая проверка за 8 ночей показала корреляцию +0,99 с приложением WHOOP, но две ночи на исходном тестовом устройстве дали ПРОТИВОПОЛОЖНОЕ направление, поэтому разброс между устройствами и прошивками не выяснен. При включении значение отображается как «оценка браслета (не подтверждено)» в плитке «Кислород в крови», если нет калиброванного импорта. Оно никогда не влияет на восстановление и на выявление болезни." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "你的 WHOOP 5.0/MG 每秒发送一个由手环计算的 SpO₂ 百分比(@82 候选字节)。一项为期 8 晚的独立验证显示其与 WHOOP 应用的相关性为 +0.99,但原始测试设备上有两晚走向相反,因此设备与固件差异尚未厘清。开启后,在没有校准导入时,血氧卡片会将其显示为「手环估算值(未验证)」。它从不参与恢复或疾病评分。" + "value": "你的 WHOOP 5.0/MG 会在判定你入睡期间,以简短读数上报由手环计算的 SpO₂ 百分比(@82 候选字节)——在目前测过的手环上,大约每 20 分钟一次 30 秒读数。一项为期 8 晚的独立验证显示其与 WHOOP 应用的相关性为 +0.99,但原始测试设备上有两晚走向相反,因此设备与固件差异尚未厘清。开启后,在没有校准导入时,血氧卡片会将其显示为「手环估算值(未验证)」。它从不参与恢复或疾病评分。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "你的 WHOOP 5.0/MG 每秒傳送一個由手環計算的 SpO₂ 百分比(@82 候選位元組)。一項為期 8 晚的獨立驗證顯示其與 WHOOP App 的相關性為 +0.99,但原始測試裝置上有兩晚走向相反,因此裝置與韌體差異尚未釐清。開啟後,在沒有校準匯入時,血氧卡片會將其顯示為「手環估算值(未驗證)」。它從不參與恢復或疾病評分。" + "value": "你的 WHOOP 5.0/MG 會在判定你入睡期間,以簡短讀數回報由手環計算的 SpO₂ 百分比(@82 候選位元組)——在目前測過的手環上,大約每 20 分鐘一次 30 秒讀數。一項為期 8 晚的獨立驗證顯示其與 WHOOP App 的相關性為 +0.99,但原始測試裝置上有兩晚走向相反,因此裝置與韌體差異尚未釐清。開啟後,在沒有校準匯入時,血氧卡片會將其顯示為「手環估算值(未驗證)」。它從不參與恢復或疾病評分。" } } } @@ -251705,66 +251711,66 @@ } } }, - "Your strap reports this every second while it scores a night. It is the band's own unverified figure, not a calibrated blood-oxygen measurement, and NOOP never feeds it into recovery or any other score.": { + "On the straps measured so far, the band takes a 30-second reading about every 20 minutes once it scores you as asleep, so a short nap may have none, and some readings fail without a value. Each reading counts once, by its middle value, so a one-second blip inside it is not a dip. It is the band's own unverified figure, not a calibrated blood-oxygen measurement, and NOOP never feeds it into recovery or any other score.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Dein Strap meldet diesen Wert jede Sekunde, während es eine Nacht auswertet. Es ist die eigene, nicht verifizierte Zahl des Armbands, keine kalibrierte Blutsauerstoffmessung — NOOP verwendet sie nie für Erholung oder einen anderen Score." + "value": "Bei den bisher gemessenen Straps nimmt das Armband etwa alle 20 Minuten eine 30-Sekunden-Messung vor, sobald es dich als schlafend einstuft – ein kurzes Nickerchen hat also womöglich keine, und manche Messungen scheitern ohne Wert. Jede Messung zählt einmal, mit ihrem mittleren Wert, daher ist ein einsekündiger Ausreißer darin kein Einbruch. Es ist die eigene, nicht verifizierte Zahl des Armbands, keine kalibrierte Blutsauerstoffmessung — NOOP verwendet sie nie für Erholung oder einen anderen Score." } }, "en": { "stringUnit": { "state": "translated", - "value": "Your strap reports this every second while it scores a night. It is the band's own unverified figure, not a calibrated blood-oxygen measurement, and NOOP never feeds it into recovery or any other score." + "value": "On the straps measured so far, the band takes a 30-second reading about every 20 minutes once it scores you as asleep, so a short nap may have none, and some readings fail without a value. Each reading counts once, by its middle value, so a one-second blip inside it is not a dip. It is the band's own unverified figure, not a calibrated blood-oxygen measurement, and NOOP never feeds it into recovery or any other score." } }, "es": { "stringUnit": { "state": "translated", - "value": "Tu banda informa este valor cada segundo mientras evalúa una noche. Es la cifra propia y sin verificar de la banda, no una medición calibrada de oxígeno en sangre, y NOOP nunca la usa para la recuperación ni para ninguna otra puntuación." + "value": "En las bandas medidas hasta ahora, la banda toma una lectura de 30 segundos aproximadamente cada 20 minutos una vez que te considera dormido, así que una siesta corta puede no tener ninguna, y algunas lecturas fallan sin valor. Cada lectura cuenta una vez, por su valor central, así que un pico de un segundo dentro de ella no es una caída. Es la cifra propia y sin verificar de la banda, no una medición calibrada de oxígeno en sangre, y NOOP nunca la usa para la recuperación ni para ninguna otra puntuación." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Votre bracelet transmet cette valeur chaque seconde pendant l’analyse d’une nuit. C’est le chiffre propre au bracelet, non vérifié, et non une mesure calibrée d’oxygène sanguin : NOOP ne l’utilise jamais pour la récupération ni pour aucun autre score." + "value": "Sur les bracelets mesurés jusqu’ici, le bracelet prend une mesure de 30 secondes environ toutes les 20 minutes dès qu’il vous considère endormi : une courte sieste peut donc n’en avoir aucune, et certaines mesures échouent sans valeur. Chaque mesure compte une fois, par sa valeur médiane, donc un pic d’une seconde à l’intérieur n’est pas une baisse. C’est le chiffre propre au bracelet, non vérifié, et non une mesure calibrée d’oxygène sanguin : NOOP ne l’utilise jamais pour la récupération ni pour aucun autre score." } }, "it": { "stringUnit": { "state": "translated", - "value": "La tua fascia riporta questo valore ogni secondo mentre valuta una notte. È il dato della fascia stessa, non verificato, non una misurazione calibrata dell’ossigeno nel sangue, e NOOP non lo usa mai per il recupero né per alcun altro punteggio." + "value": "Sulle fasce misurate finora, la fascia esegue una lettura di 30 secondi circa ogni 20 minuti quando ti considera addormentato, quindi un breve pisolino può non averne, e alcune letture falliscono senza un valore. Ogni lettura conta una volta, con il suo valore centrale, quindi un picco di un secondo al suo interno non è un calo. È il dato della fascia stessa, non verificato, non una misurazione calibrata dell’ossigeno nel sangue, e NOOP non lo usa mai per il recupero né per alcun altro punteggio." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Twoja opaska podaje tę wartość co sekundę podczas oceniania nocy. To własna, niezweryfikowana liczba opaski, a nie skalibrowany pomiar tlenu we krwi — NOOP nigdy nie używa jej do regeneracji ani żadnego innego wyniku." + "value": "Na zmierzonych dotąd opaskach opaska wykonuje 30-sekundowy odczyt mniej więcej co 20 minut, gdy uzna, że śpisz, więc krótka drzemka może nie mieć żadnego, a niektóre odczyty kończą się bez wartości. Każdy odczyt liczy się raz, według środkowej wartości, więc jednosekundowy skok w jego trakcie nie jest spadkiem. To własna, niezweryfikowana liczba opaski, a nie skalibrowany pomiar tlenu we krwi — NOOP nigdy nie używa jej do regeneracji ani żadnego innego wyniku." } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "A sua pulseira comunica este valor a cada segundo enquanto avalia uma noite. É o número próprio da pulseira, não verificado, e não uma medição calibrada de oxigénio no sangue — o NOOP nunca o usa na recuperação nem em qualquer outra pontuação." + "value": "Nas pulseiras medidas até agora, a pulseira faz uma leitura de 30 segundos cerca de 20 em 20 minutos quando o considera a dormir, por isso uma sesta curta pode não ter nenhuma, e algumas leituras falham sem valor. Cada leitura conta uma vez, pelo seu valor central, por isso um pico de um segundo dentro dela não é uma descida. É o número próprio da pulseira, não verificado, e não uma medição calibrada de oxigénio no sangue — o NOOP nunca o usa na recuperação nem em qualquer outra pontuação." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Ремешок сообщает это значение каждую секунду, пока оценивает ночь. Это собственная непроверенная цифра ремешка, а не калиброванное измерение кислорода в крови, и NOOP никогда не использует её для восстановления или любой другой оценки." + "value": "На изученных пока ремешках ремешок делает 30-секундное измерение примерно каждые 20 минут, когда считает, что вы спите, поэтому у короткого дневного сна их может не быть, а некоторые измерения не дают значения. Каждое измерение учитывается один раз, по медианному значению, поэтому односекундный всплеск внутри него — не провал. Это собственная непроверенная цифра ремешка, а не калиброванное измерение кислорода в крови, и NOOP никогда не использует её для восстановления или любой другой оценки." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "手环在评估夜间睡眠时每秒上报一次该值。这是手环自己的未验证数值,不是校准的血氧测量结果,NOOP 从不将其用于恢复度或任何其他评分。" + "value": "在目前测过的手环上,手环判定你入睡后,大约每 20 分钟进行一次 30 秒读数,因此短暂的小睡可能没有读数,部分读数也会失败而没有数值。每次读数按其中间值只计一次,因此其中一秒的跳变不算下降。这是手环自己的未验证数值,不是校准的血氧测量结果,NOOP 从不将其用于恢复度或任何其他评分。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "手環在評估夜間睡眠時每秒回報一次該值。這是手環自己的未驗證數值,不是校準的血氧測量結果,NOOP 從不將其用於恢復度或任何其他評分。" + "value": "在目前測過的手環上,手環判定你入睡後,大約每 20 分鐘進行一次 30 秒讀數,因此短暫的小睡可能沒有讀數,部分讀數也會失敗而沒有數值。每次讀數按其中間值只計一次,因此其中一秒的跳變不算下降。這是手環自己的未驗證數值,不是校準的血氧測量結果,NOOP 從不將其用於恢復度或任何其他評分。" } } } @@ -252327,66 +252333,66 @@ } } }, - "Each point is one reading. The dashed line is %@, where a dip starts.": { + "Each point is one second the strap reported. A reading is a dip when its middle value is under the dashed line at %@.": { "localizations": { - "en": { + "de": { "stringUnit": { "state": "translated", - "value": "Each point is one reading. The dashed line is %@, where a dip starts." + "value": "Jeder Punkt ist eine vom Strap gemeldete Sekunde. Eine Messung zählt als Einbruch, wenn ihr mittlerer Wert unter der gestrichelten Linie bei %@ liegt." } }, - "de": { + "en": { "stringUnit": { "state": "translated", - "value": "Jeder Punkt ist ein Messwert. Die gestrichelte Linie liegt bei %@, wo ein Abfall beginnt." + "value": "Each point is one second the strap reported. A reading is a dip when its middle value is under the dashed line at %@." } }, "es": { "stringUnit": { "state": "translated", - "value": "Cada punto es una lectura. La línea discontinua está en %@, donde empieza una caída." + "value": "Cada punto es un segundo informado por la banda. Una lectura cuenta como caída cuando su valor central queda por debajo de la línea discontinua en %@." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Chaque point est une mesure. La ligne pointillée est à %@, là où commence une baisse." + "value": "Chaque point est une seconde transmise par le bracelet. Une mesure compte comme une baisse quand sa valeur médiane passe sous la ligne pointillée à %@." } }, "it": { "stringUnit": { "state": "translated", - "value": "Ogni punto è una lettura. La linea tratteggiata è a %@, dove inizia un calo." + "value": "Ogni punto è un secondo riportato dalla fascia. Una lettura conta come calo quando il suo valore centrale è sotto la linea tratteggiata a %@." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Każdy punkt to jeden odczyt. Linia przerywana to %@, od której zaczyna się spadek." + "value": "Każdy punkt to jedna sekunda podana przez opaskę. Odczyt liczy się jako spadek, gdy jego środkowa wartość leży poniżej linii przerywanej na %@." } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Cada ponto é uma leitura. A linha tracejada está em %@, onde começa uma descida." + "value": "Cada ponto é um segundo comunicado pela pulseira. Uma leitura conta como descida quando o seu valor central fica abaixo da linha tracejada em %@." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Каждая точка — одно измерение. Пунктирная линия — %@, с неё начинается провал." + "value": "Каждая точка — одна секунда, переданная ремешком. Измерение считается провалом, если его медианное значение ниже пунктирной линии на %@." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "每个点是一次读数。虚线为 %@,低于它即为一次下降。" + "value": "每个点是手环上报的一秒。当一次读数的中间值低于 %@ 处的虚线时,计为一次下降。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "每個點是一次讀數。虛線為 %@,低於它即為一次下降。" + "value": "每個點是手環回報的一秒。當一次讀數的中間值低於 %@ 處的虛線時,計為一次下降。" } } } diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 08b5b74654..4d4da158bb 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -2419,7 +2419,7 @@ struct SettingsView: View { // the HRV window toggle above (analyzeRecent → refresh). Task { await model.intelligence.analyzeRecent(); await model.repo.refresh() } } - Text("Your WHOOP 5.0/MG sends a strap-computed SpO₂ percentage (the @82 candidate byte) every second — an 8-night independent validation tracked it at corr +0.99 against the WHOOP app, but two nights on the original test device moved the OPPOSITE direction, so device/firmware variance is unresolved. Turning this on surfaces it as \"strap estimate (unverified)\" in the Blood Oxygen tile when no calibrated import exists. It never feeds recovery or illness scoring.") + Text("Your WHOOP 5.0/MG reports a strap-computed SpO₂ percentage (the @82 candidate byte) in short readings while it scores you as asleep — one 30-second reading about every 20 minutes on the straps measured so far. An 8-night independent validation tracked it at corr +0.99 against the WHOOP app, but two nights on the original test device moved the OPPOSITE direction, so device/firmware variance is unresolved. Turning this on surfaces it as \"strap estimate (unverified)\" in the Blood Oxygen tile when no calibrated import exists. It never feeds recovery or illness scoring.") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) diff --git a/Strand/Screens/Spo2EstimateCard.swift b/Strand/Screens/Spo2EstimateCard.swift index eb7c85aaa0..e29df18e75 100644 --- a/Strand/Screens/Spo2EstimateCard.swift +++ b/Strand/Screens/Spo2EstimateCard.swift @@ -80,7 +80,7 @@ struct Spo2EstimateCard: View { if trace.count >= 2 { traceCard } - Text("Your strap reports this every second while it scores a night. It is the band's own unverified figure, not a calibrated blood-oxygen measurement, and NOOP never feeds it into recovery or any other score.") + Text("On the straps measured so far, the band takes a 30-second reading about every 20 minutes once it scores you as asleep, so a short nap may have none, and some readings fail without a value. Each reading counts once, by its middle value, so a one-second blip inside it is not a dip. It is the band's own unverified figure, not a calibrated blood-oxygen measurement, and NOOP never feeds it into recovery or any other score.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -90,9 +90,10 @@ struct Spo2EstimateCard: View { // MARK: - Night trace - /// Every in-band reading of the night, plotted as the strap reported it. The dashed rule is the dip - /// threshold the Low and Dips tiles are cut at, so a dip on the chart and a dip in the count are the - /// same line. Out-of-band seconds were dropped by the reader, never zeroed. + /// Every in-band second of the night, plotted as the strap reported it. The dashed rule is the dip + /// threshold the Low and Dips tiles are cut at; a READING is a dip when its middle value is under it, + /// so a single second below the line is not one. Out-of-band seconds were dropped by the reader, + /// never zeroed. private var traceCard: some View { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.space2) { @@ -108,7 +109,7 @@ struct Spo2EstimateCard: View { dateFormat: { Self.traceTimeFormatter.string(from: $0) }, accessibilityLabel: String(localized: "Blood oxygen strap estimate through the night"), yDomain: traceDomain) - Text("Each point is one reading. The dashed line is \(thresholdLabel), where a dip starts.") + Text("Each point is one second the strap reported. A reading is a dip when its middle value is under the dashed line at \(thresholdLabel).") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) diff --git a/docs/WHOOP5_DEEP_DATA.md b/docs/WHOOP5_DEEP_DATA.md index b81c0975fb..eacd336c70 100644 --- a/docs/WHOOP5_DEEP_DATA.md +++ b/docs/WHOOP5_DEEP_DATA.md @@ -93,19 +93,24 @@ The byte-82 candidate is decoded and persisted per strap-second on every v18 off (`v18AuxSample`, slot `auxByte82`), independently of any display preference. What the default-off Experimental display toggle controls is only whether the client SHOWS it. -While that toggle is on, one scoring pass resolves each night’s in-band (70–100) -readings once (`AnalyticsEngine.nightlySpo2CandidateNight`) and stores the mean -unrounded, together with the night’s minimum, its below-threshold runs, their measured -span and the reading count they rest on. A per-second track is available in the -full-day viewer. Every figure is labelled as an unverified strap estimate. - -None of this changes the validation boundary above. The 90 % cut used to group runs is -a display convention, not a clinical threshold, so a grouped run is not a desaturation -event. Run spans are measured between readings rather than multiplied out of a sample +While that toggle is on, one scoring pass resolves each night’s readings once +(`AnalyticsEngine.nightlySpo2CandidateNight`). The strap measures this byte in +windows, 30 records about every 20 minutes of band SLEEP on the strap measured so far +([byte 82 windows](PROTOCOL_SENSORS.md#byte-82-measurement-windows)), and inside a +window its value is a rolling figure. So the window is the reading: its value is the +median of its in-band (70–100) seconds, and the pass stores the unrounded mean of the +window values, the lowest window value, the windows below the threshold, their +measured span, and how many windows gave a value out of how many the strap attempted. +A per-second track is available in the full-day viewer. Every figure is labelled as an +unverified strap estimate. + +None of this changes the validation boundary above. The 90 % cut is a display +convention, not a clinical threshold, so a window below it is not a desaturation event. +Spans are measured between in-band seconds rather than multiplied out of a sample count, because the R18 cadence of roughly one record per second is an observation and -not a guarantee; a run of one reading therefore reports no duration. The stored figures -never reach `spo2Pct` and never feed recovery, illness or any other score, and the -cross-device contradiction recorded above remains unresolved. +not a guarantee; a window with one in-band second therefore reports no duration. The +stored figures never reach `spo2Pct` and never feed recovery, illness or any other +score, and the cross-device contradiction recorded above remains unresolved. ### Comparison tool diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 89df6f04a6..64763ad509 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -70,6 +70,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-001 | S3 | Reported | In-bed start from gravity counts evening lead-ins, which drags sleep efficiency down | `SleepStager.swift` (to confirm) | 5/MG sleep audit of 2026-09-24, left open after PR #20 | | | W03-002 | S3 | Reported | Staging misses early-cycle REM on some nights | Stager recipe (to confirm) | Same audit | | | W03-003 | S2 | Fixed | The 5/MG SpO₂ candidate night (low, dips, mean, evidence count) was resolved per second, but the strap measures byte 82 in 30-record windows whose rolling value blips for single seconds: a one-second blip became the night's low and a dip no window showed, and the evidence count was seconds of one measurement | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `validate_spo2_candidate.py` `night_mean_at_offset` | V0: `testASingleSecondBlipInsideAWindowIsNotADip` fails on the old resolver (1 dip, low 77, in a 95–96 window). V1: oracle re-pinned and matched line for line by an independent Python implementation (17 cases); on all 11 backups the resolver and that implementation agree on every night; package suite, `StrandTests`, both app builds, i18n and hygiene gates pass. V2 (independent reviewer and `/code-review high`): a continuous stream would collapse into one window (fixed: a 60 s window cap, twice the observed window so a clock step cannot split one); the Dips caption claimed seconds below the threshold (fixed: it counts readings, the dip properties are renamed `dipSamples`/`dipSpanSeconds`); nights scored before the window keys showed per-second Low and Dips under per-reading copy (fixed: dashes); the comparison tool valued a window by its mean (fixed: the app's lower median); stale comments and copy (fixed). Left as before: a night whose readings all failed still resolves to nil; the night chart plots raw seconds, labelled as such. V3: 7 stored nights replayed on `main` and on the fix: in-band seconds unchanged on all 7, displayed mean moves on 1 by one point, the low rises on 6, dips fall from 10 to 1 (the one left is a whole window below the threshold) | | +| W03-004 | S4 | Fixed | The strap-estimate card and the Settings toggle said the strap reports the value every second; it measures a 30-second reading about every 20 minutes of band SLEEP on the straps measured so far, and some readings fail. `docs/WHOOP5_DEEP_DATA.md` described the per-second runs | `Spo2EstimateCard.swift` footnote and trace caption, `SettingsView.swift` `spo2CandidateCard`, `docs/WHOOP5_DEEP_DATA.md` | `docs/PROTOCOL_SENSORS.md` "Byte 82 measurement windows" (175 of 175 windows predicted on one MG) | | | W03-005 | S4 | Reported | The strap-log SpO₂ candidate line resolves over the one stored session it describes, while the scoring pass resolves over all of the day's detected sessions, so on a day with more than one session its "(shown as …%)" can differ from the screen. Found in W03-003's V2; predates it | `DebugDataDiagnostics.swift` (`nightlySpo2CandidateNight([det], …)`) | Code reading; not yet reproduced | | ## Log