From 5b36f4e10cd12c303328208818c404615d603202 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 10:14:08 +0200 Subject: [PATCH 01/15] =?UTF-8?q?fix(analytics):=20leave=20low-quality=20S?= =?UTF-8?q?pO=E2=82=82=20candidate=20readings=20out=20of=20the=20night=20(?= =?UTF-8?q?W03-007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A byte-82 reading counts only when at least two thirds of its measured seconds carry an in-band value and at least three fifths of those sit within 2 points of its median. Loose-strap readings, mixed with the strap's own codes, no longer become the night's low or its dips. A night whose readings all fail is a night with no reliable reading, not nil. The stored series gain spo2_candidate_windows_low_quality, the marker of a gated night; latest() shows such a night instead of skipping to an older one. Oracle re-captured (28 cases) and matched by an independent Python twin; 108 real sessions across 15 backups agree between the two. --- .../StrandAnalytics/AnalyticsEngine.swift | 6 +- .../StrandAnalytics/Spo2CandidateNight.swift | 246 ++++++++++++++---- .../Spo2CandidateNightStatsTests.swift | 160 +++++++++--- .../Spo2CandidateQualityTests.swift | 83 ++++++ docs/review/workstreams/W03-analytics.md | 2 + 5 files changed, 407 insertions(+), 90 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 1071ca3d2f..049e08d4db 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -1246,8 +1246,10 @@ public enum AnalyticsEngine { /// 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 } - return (mean: night.meanRounded, samples: night.samples) + guard let night = nightlySpo2CandidateNight(sessions, aux: aux), let mean = night.meanRounded else { + return nil + } + return (mean: mean, samples: night.samples) } /// The plausible range for a raw Oura `0x6F` SpO2 sample before the ceiling transform below. diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift index f56631923b..7173a1bdc7 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift @@ -33,6 +33,24 @@ public struct Spo2DesatEvent: Equatable, Sendable { public var spanSeconds: Int { max(0, end - start) } } +/// Why a reading was, or was not, allowed to stand for its 30 seconds (W03-007). +/// +/// Defined by what the strap REPORTED during the reading — how many of its seconds carried a value, and +/// whether those values agree — never by whether the value is low. A reading that is low, steady and well +/// covered stays a low reading: a filter tuned on the outcome would hide exactly the nights it exists for. +public enum Spo2CandidateReadingQuality: Equatable, Sendable { + /// Enough in-band seconds, and most of them agree with the reading's middle value. + case reliable + /// Fewer than `AnalyticsEngine.spo2CandidateMinCoverage` of the reading's measured seconds carried an + /// in-band value; the rest were the strap's own non-percentage codes. + case lowCoverage + /// Enough seconds, but too few of them sit near the middle value: the reading swept across the band + /// instead of settling on a figure. + case unsettled + /// Codes only: the strap measured and produced no in-band second at all. + case noValue +} + /// One `@82` measurement window: a run of nonzero seconds, no two further apart than /// `AnalyticsEngine.spo2CandidateWindowGapSeconds` and spanning less than /// `AnalyticsEngine.spo2CandidateWindowMaxSpanSeconds`. @@ -42,6 +60,8 @@ public struct Spo2DesatEvent: Equatable, Sendable { /// 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 { + /// Every nonzero second of the window, codes included: the seconds the strap spent measuring. + var measuredSeconds = 0 /// The window's in-band seconds, ascending by timestamp. Empty for a window of failure codes only. var inBand: [(ts: Int, value: Int)] = [] @@ -55,6 +75,27 @@ struct Spo2CandidateWindow { let sorted = inBand.map(\.value).sorted() return sorted[(sorted.count - 1) / 2] } + + /// W03-007: whether the value may stand for the reading. Integer arithmetic throughout, so the + /// verdict cannot differ between platforms or depend on how a fraction rounds. + /// + /// Coverage first: when most of a reading's measured seconds are codes, its in-band seconds are the + /// fragments between them, and on the owner's loose-strap nights those fragments carried every false + /// dip. Then agreement: a reading whose seconds are mostly within + /// `spo2CandidateAgreementTolerance` of its median has settled on a figure. That test, rather than a + /// cap on the reading's spread, is what tolerates the settling ramp a clean reading can open with + /// (four seconds climbing into a steady value) while still refusing a reading that sweeps the band. + var quality: Spo2CandidateReadingQuality { + guard let median = value else { return .noValue } + let n = inBand.count + let cover = AnalyticsEngine.spo2CandidateMinCoverage + guard n * cover.denominator >= measuredSeconds * cover.numerator else { return .lowCoverage } + let tol = AnalyticsEngine.spo2CandidateAgreementTolerance + let near = inBand.filter { abs($0.value - median) <= tol }.count + let agree = AnalyticsEngine.spo2CandidateMinAgreement + guard near * agree.denominator >= n * agree.numerator else { return .unsettled } + return .reliable + } } /// Everything one night's in-band `@82` readings support, resolved ONCE. @@ -77,36 +118,53 @@ struct Spo2CandidateWindow { /// `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. +/// +/// RELIABLE READINGS ONLY (W03-007). A reading counts only when the strap reported enough of its seconds +/// as values and those values agree (`Spo2CandidateReadingQuality`). On a loose-strap night most readings +/// come back mixed with the strap's own codes, and their fragments supplied every false dip; they are +/// counted as low quality and left out, so the night states what it measured instead of averaging noise. public struct Spo2CandidateNight: Equatable, Sendable { - /// 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 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. + /// Unrounded mean of the reliable readings' values. Stored unrounded so a sub-1 % night-to-night move + /// survives to the series; every display rounds it at the edge. nil when the strap attempted readings + /// but none was reliable (W03-007): the night is then reported as having no reliable reading rather + /// than averaged from readings that failed the check. + public let mean: Double? + /// The lowest and highest reliable reading values; nil exactly when `mean` is. + public let minimum: Int? + public let maximum: Int? + /// In-band SECONDS inside the reliable readings. Kept for the stored series, whose meaning it has + /// always had; the evidence a night rests on is `windows`, because the seconds inside one reading 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. + /// Reliable readings: the measurements every figure above rests on. public let windows: Int - /// Every window the strap measured in, including those that reported only failure codes. + /// Every reading the strap measured in, failed and low-quality ones included. public let windowsAttempted: Int + /// Readings with an in-band value that failed the quality check, split by reason (W03-007). A reading + /// of codes only is in neither: it had no value to leave out. + public let windowsLowCoverage: Int + public let windowsUnsettled: Int /// The threshold `events` were cut at, carried so a surface can name it instead of assuming one. public let threshold: Int - /// Below-threshold windows, ascending by `start`. + /// Reliable readings below the threshold, ascending by `start`. public let events: [Spo2DesatEvent] - public init(mean: Double, minimum: Int, maximum: Int, samples: Int, windows: Int, - windowsAttempted: Int, threshold: Int, events: [Spo2DesatEvent]) { + public init(mean: Double?, minimum: Int?, maximum: Int?, samples: Int, windows: Int, + windowsAttempted: Int, windowsLowCoverage: Int = 0, windowsUnsettled: Int = 0, + threshold: Int, events: [Spo2DesatEvent]) { self.mean = mean; self.minimum = minimum; self.maximum = maximum self.samples = samples; self.windows = windows; self.windowsAttempted = windowsAttempted + self.windowsLowCoverage = windowsLowCoverage; self.windowsUnsettled = windowsUnsettled 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()) } + public var meanRounded: Int? { mean.map { Int($0.rounded()) } } + + /// Readings that had a value and were left out as low quality. Derived, so it cannot disagree with + /// the two reasons it sums. + public var windowsLowQuality: Int { windowsLowCoverage + windowsUnsettled } /// 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 @@ -153,24 +211,34 @@ extension AnalyticsEngine { /// 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, 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. - /// - /// 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( + /// W03-007: the smallest share of a reading's MEASURED seconds (codes included) that must carry an + /// in-band value, as an exact fraction. Two thirds, so a reading rests on a clear majority of its own + /// seconds. From one MG's 192 in-session readings: on its well-fitted nights a reading reaches this in + /// 80–100 % of cases, and the night's figures barely move anywhere between one half and four fifths — + /// so the value is not doing fine work. No published source gives a coverage rule for this byte; + /// validated wrist oximeters discard 15–50 % of overnight data by their own quality indices. + public static let spo2CandidateMinCoverage = (numerator: 2, denominator: 3) + + /// W03-007: how far, in points, an in-band second may sit from its reading's median and still agree + /// with it. On the same MG, 70 of 74 clean readings (no code, 28+ in-band seconds) never step more + /// than 2 points between consecutive seconds. + public static let spo2CandidateAgreementTolerance = 2 + + /// W03-007: the smallest share of a reading's in-band seconds that must agree with its median, as an + /// exact fraction. Three fifths: loose enough for a clean reading's settling ramp and its genuine + /// few-point swings, strict enough to refuse a reading that sweeps from the 70s to 100 in seconds. + public static let spo2CandidateMinAgreement = (numerator: 3, denominator: 5) + + /// The night's `@82` readings, in timestamp order, over the detected in-bed `sessions`: every window + /// the strap measured in, each carrying its measured seconds, its in-band seconds and its quality. + /// The ONE place readings are cut from the stream, so the night's figures and its trace cannot be cut + /// two ways. + static func spo2CandidateWindows( _ sessions: [SleepSession], aux: [V18AuxSample], - threshold: Int = AnalyticsEngine.spo2CandidateDipThreshold, windowGapSeconds: Int = AnalyticsEngine.spo2CandidateWindowGapSeconds - ) -> Spo2CandidateNight? { - guard !sessions.isEmpty, !aux.isEmpty else { return nil } + ) -> [Spo2CandidateWindow] { + guard !sessions.isEmpty, !aux.isEmpty else { return [] } var measured: [(ts: Int, value: Int)] = [] for a in aux { guard let v = a.auxByte82, v != 0 else { continue } @@ -190,28 +258,64 @@ extension AnalyticsEngine { windowStart = m.ts } lastTs = m.ts + windows[windows.count - 1].measuredSeconds += 1 if spo2CandidateInBand.contains(m.value) { windows[windows.count - 1].inBand.append(m) } } + return windows + } - let valued = windows.compactMap { w in w.value.map { (window: w, value: $0) } } - guard !valued.isEmpty else { return nil } - let values = valued.map(\.value) + /// THE resolver for a night's `@82` candidate readings — mean, range, below-threshold readings and + /// the reading counts — over the detected in-bed `sessions`. nil when the strap measured nothing + /// inside any span. A night whose readings all failed (codes only, or low quality) is NOT nil: it is + /// a night with no reliable reading, which a surface must be able to say (W03-007). + /// + /// 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, and only reliable readings contribute a figure. + /// 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, + windowGapSeconds: Int = AnalyticsEngine.spo2CandidateWindowGapSeconds + ) -> Spo2CandidateNight? { + let windows = spo2CandidateWindows(sessions, aux: aux, windowGapSeconds: windowGapSeconds) + guard !windows.isEmpty else { return nil } + let quality = windows.map(\.quality) + // `value` is non-nil for every reliable window: `quality` returns `.noValue` first otherwise. + let reliable = zip(windows, quality).filter { $0.1 == .reliable } + .map { (window: $0.0, value: $0.0.value!) } + let values = reliable.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. + let events = reliable.filter { $0.value < threshold }.map { v in + // A reliable 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, + return Spo2CandidateNight(mean: values.isEmpty ? nil : Double(values.reduce(0, +)) / Double(values.count), + minimum: values.min(), maximum: values.max(), + samples: reliable.reduce(0) { $0 + $1.window.inBand.count }, + windows: reliable.count, windowsAttempted: windows.count, + windowsLowCoverage: quality.filter { $0 == .lowCoverage }.count, + windowsUnsettled: quality.filter { $0 == .unsettled }.count, threshold: threshold, events: events) } + + /// The in-band seconds of the night's RELIABLE readings, ascending by timestamp: what a chart of the + /// night may plot beside the figures `nightlySpo2CandidateNight` states. Cut by the same windows and + /// the same quality verdict, so the chart cannot draw a low the figures left out (W03-007). + public static func spo2CandidateReliableSeconds( + _ sessions: [SleepSession], + aux: [V18AuxSample] + ) -> [(ts: Int, value: Int)] { + spo2CandidateWindows(sessions, aux: aux).filter { $0.quality == .reliable }.flatMap(\.inBand) + } } // MARK: - The stored series (#103) @@ -230,10 +334,15 @@ extension AnalyticsEngine { /// 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. +/// +/// QUALITY-GATED SINCE W03-007. A night that also carries `lowQualityKey` was resolved over reliable +/// readings only, and may legitimately have NO mean: the strap attempted readings and none passed. Such a +/// night is still a night (its counts say so), and it is the newest night a surface must show, not skip. public enum Spo2CandidateSeries { - /// The night's UNROUNDED mean of its window values. Every display rounds it at the edge. + /// The night's UNROUNDED mean of its reliable readings' values. Every display rounds it at the edge. + /// Absent on a quality-gated night with no reliable reading. public static let meanKey = "spo2_candidate" - /// The lowest window value of the night. + /// The lowest reliable reading value of the night. Absent exactly when the mean is. public static let minimumKey = "spo2_candidate_min" /// How many below-threshold READINGS (windows) the night held. 0 is written, not omitted. public static let dipsKey = "spo2_candidate_dips" @@ -241,19 +350,24 @@ public enum Spo2CandidateSeries { /// 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 SECONDS inside the night's windows. + /// In-band SECONDS inside the night's reliable readings. public static let samplesKey = "spo2_candidate_samples" - /// Windows with an in-band value: the measurements the night's figures rest on (W03-003). + /// Reliable readings: the measurements the night's figures rest on (W03-003). Before W03-007 this + /// counted every reading with an in-band value; a night carrying `lowQualityKey` counts reliable ones. public static let windowsKey = "spo2_candidate_windows" /// Every window the strap measured in, failed ones included. public static let windowsAttemptedKey = "spo2_candidate_windows_attempted" + /// Readings that had a value and were left out as low quality (W03-007). 0 is written, not omitted: + /// its presence is the marker that the night was quality-gated. + public static let lowQualityKey = "spo2_candidate_windows_low_quality" /// 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 /// the mean it does have rather than blanking the whole card. public struct Night: Equatable, Sendable { public let day: String - public let mean: Double + /// nil only on a quality-gated night with no reliable reading (`hasNoReliableReading`). + public let mean: Double? public let minimum: Int? public let dips: Int? public let dipSeconds: Int? @@ -262,17 +376,25 @@ public enum Spo2CandidateSeries { /// 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? + /// Readings left out as low quality. nil on a night scored before W03-007, which was not gated. + public let windowsLowQuality: Int? - public init(day: String, mean: Double, minimum: Int?, dips: Int?, - dipSeconds: Int?, samples: Int?, windows: Int? = nil, windowsAttempted: Int? = nil) { + public init(day: String, mean: Double?, minimum: Int?, dips: Int?, + dipSeconds: Int?, samples: Int?, windows: Int? = nil, windowsAttempted: Int? = nil, + windowsLowQuality: 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 + self.windowsLowQuality = windowsLowQuality } /// The displayed mean — the same rounding `Spo2CandidateNight.meanRounded` applies, so the card /// and the vital tile round one stored number the same way instead of two. - public var meanRounded: Int { Int(mean.rounded()) } + public var meanRounded: Int? { mean.map { Int($0.rounded()) } } + + /// True for a quality-gated night on which the strap attempted readings and none was reliable: + /// the night to say "no reliable reading" about, rather than show a number for. + public var hasNoReliableReading: Bool { mean == nil } /// True when the night is known to have dipped. nil `dips` is UNKNOWN, not zero: a pre-keys night /// must not be captioned "no dips" on the strength of a row that was never written. @@ -281,10 +403,13 @@ public enum Spo2CandidateSeries { /// THE funnel every candidate-stats surface resolves a night through. /// - /// The mean decides which night exists at all — it is the one key that has always been written, so a - /// night present in any companion series but not in the mean is a half-written night and is skipped - /// rather than shown with a blank headline. `latest` picks the highest day key present, which is a - /// plain lexicographic max because the keys are `YYYY-MM-DD`. + /// A night exists when it has a mean, or when it is a quality-gated night with no reliable reading: + /// `lowQualityKey` and `windowsAttemptedKey` present, and `windowsKey` 0 (W03-007). Any other night + /// present only in a companion series is a half-written night and is skipped rather than shown with a + /// blank headline. On a gated night with no reliable reading a mean row is IGNORED even if present: + /// the writer deletes it, and a delete that failed must not bring back an average the resolver + /// refused to state. `latest` picks the highest day key present, which is a plain lexicographic max + /// because the keys are `YYYY-MM-DD`. /// /// Passing the dictionaries in (rather than each surface reading its own) is the point: the mean, the /// minimum and the dip count on one card must describe ONE night, and a surface that resolved each @@ -296,11 +421,18 @@ public enum Spo2CandidateSeries { dipSeconds: [String: Double] = [:], samples: [String: Double] = [:], windows: [String: Double] = [:], - windowsAttempted: [String: Double] = [:]) -> Night? { - guard let day = mean.keys.max(), let m = mean[day] else { return nil } + windowsAttempted: [String: Double] = [:], + lowQuality: [String: Double] = [:]) -> Night? { + func noReliableReading(_ d: String) -> Bool { + lowQuality[d] != nil && windowsAttempted[d] != nil && windows[d].map { $0.rounded() == 0 } == true + } + let days = Set(mean.keys).union(lowQuality.keys.filter(noReliableReading)) + guard let day = days.max() 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), - windows: int(windows), windowsAttempted: int(windowsAttempted)) + let empty = noReliableReading(day) + return Night(day: day, mean: empty ? nil : mean[day], minimum: empty ? nil : int(minimum), + dips: int(dips), dipSeconds: int(dipSeconds), samples: int(samples), + windows: int(windows), windowsAttempted: int(windowsAttempted), + windowsLowQuality: int(lowQuality)) } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift index 784d021e70..8eadc2b982 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift @@ -10,7 +10,9 @@ import WhoopProtocol /// 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 +/// and pinned verbatim. Re-captured for W03-007's reading-quality gate (28 cases, matched line for line +/// by a new independent Python implementation); the pre-existing lines that moved are the ones whose +/// windows are mostly codes or whose seconds disagree. The named tests after it say what each line is FOR, so a diff explains itself /// instead of only failing. final class Spo2CandidateNightStatsTests: XCTestCase { @@ -19,18 +21,24 @@ final class Spo2CandidateNightStatsTests: XCTestCase { } private func aux(_ ts: Int, _ v: Int?) -> V18AuxSample { V18AuxSample(ts: ts, auxByte82: v) } - /// One case's whole result as a single line — the format the oracle was captured in. + /// One case's whole result as a single line — the format the oracle was captured in. `lq` is the + /// low-quality readings as coverage+unsettled (W03-007); `-` is a figure the night does not state. private func line(_ name: String, _ sessions: [SleepSession], _ samples: [V18AuxSample]) -> String { guard let n = AnalyticsEngine.nightlySpo2CandidateNight(sessions, aux: samples) else { return "\(name) -> nil" } 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) " - + "windows=\(n.windows)/\(n.windowsAttempted) thr=\(n.threshold) " - + "below=\(n.dipSamples) secs=\(n.dipSpanSeconds) " - + "nadir=\(n.nadir.map(String.init) ?? "-") events=[\(ev)]" + let mean: String = n.mean.map { String(format: "%.6f", $0) } ?? "-" + let rounded: String = n.meanRounded.map(String.init) ?? "-" + let low: String = n.minimum.map(String.init) ?? "-" + let high: String = n.maximum.map(String.init) ?? "-" + let nadir: String = n.nadir.map(String.init) ?? "-" + var s = "\(name) -> mean=\(mean) rounded=\(rounded) min=\(low) max=\(high)" + s += " samples=\(n.samples) windows=\(n.windows)/\(n.windowsAttempted)" + s += " lq=\(n.windowsLowCoverage)+\(n.windowsUnsettled) thr=\(n.threshold)" + s += " below=\(n.dipSamples) secs=\(n.dipSpanSeconds) nadir=\(nadir) events=[\(ev)]" + return s } /// A 30-second window starting at `start`, one value per second from `values` (cycled). @@ -70,25 +78,58 @@ final class Spo2CandidateNightStatsTests: XCTestCase { 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]))) + out.append(line("window-straddles-session-end-steady", [sess(1000, 15)], window(1000, [88, 89]))) + // W03-007: reading quality. A third of the seconds codes still covers; half does not; a reading + // that sweeps the band is unsettled; a settling ramp into a steady value is reliable; a night of + // failed readings is a night with no reliable reading, not nil; and both exact boundaries pass. + out.append(line("third-codes-covers", [sess(0, 600)], window(0, [32, 94, 95]))) + out.append(line("half-codes-low-coverage", [sess(0, 600)], window(0, [160, 74]))) + out.append(line("sweep-unsettled", [sess(0, 600)], + window(0, [72, 75, 80, 85, 90, 95, 98, 100, 100, 99]))) + out.append(line("settling-ramp", [sess(0, 600)], + window(0, [83, 86, 90, 94] + Array(repeating: 95, count: 26)))) + out.append(line("no-reliable-reading", [sess(0, 3 * 1200)], + window(0, [160, 74]) + window(1200, [8, 40]) + window(2400, [32, 128, 73]))) + out.append(line("coverage-exactly-two-thirds", [sess(0, 600)], window(0, [128, 95, 95]))) + out.append(line("coverage-just-under", [sess(0, 600)], + (0..<29).map { aux($0, $0 < 10 ? 128 : 95) })) + out.append(line("agreement-exactly-three-fifths", [sess(0, 600)], + window(0, [95, 95, 95, 80, 99]))) + out.append(line("agreement-just-under", [sess(0, 600)], + (0..<29).map { aux($0, $0 % 29 < 17 ? 95 : ($0 % 2 == 0 ? 80 : 99)) })) + out.append(line("mixed-night", [sess(0, 4 * 1200)], + window(0, [95, 96]) + window(1200, [160, 74]) + window(2400, [87, 88, 87]) + + window(3600, [72, 75, 80, 85, 90, 95, 98, 100, 100, 99]))) let expected = """ - 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 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] + flat-96 -> mean=96.000000 rounded=96 min=96 max=96 samples=10 windows=1/1 lq=0+0 thr=90 below=0 secs=0 nadir=- events=[] + all-bytes-contiguous -> mean=- rounded=- min=- max=- samples=0 windows=0/5 lq=1+0 thr=90 below=0 secs=0 nadir=- events=[] + all-bytes-per-window -> mean=85.000000 rounded=85 min=70 max=100 samples=31 windows=31/255 lq=0+0 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 lq=0+0 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 lq=0+0 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 lq=0+0 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 lq=0+0 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 lq=0+0 thr=90 below=7 secs=6 nadir=88 events=[1000-1006/n88/s7/sp6] + out-of-band-only -> mean=- rounded=- min=- max=- samples=0 windows=0/1 lq=0+0 thr=90 below=0 secs=0 nadir=- events=[] + two-sessions -> mean=90.000000 rounded=90 min=88 max=92 samples=2 windows=2/2 lq=0+0 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 lq=0+0 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 lq=0+0 thr=90 below=0 secs=0 nadir=- events=[] + all-below -> mean=83.000000 rounded=83 min=83 max=83 samples=4 windows=1/1 lq=0+0 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 lq=0+0 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 lq=0+0 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=1 windows=1/2 lq=0+1 thr=90 below=1 secs=0 nadir=85 events=[1061-1061/n85/s1/sp0] + window-straddles-session-end -> mean=- rounded=- min=- max=- samples=0 windows=0/1 lq=0+1 thr=90 below=0 secs=0 nadir=- events=[] + window-straddles-session-end-steady -> mean=88.000000 rounded=88 min=88 max=88 samples=16 windows=1/1 lq=0+0 thr=90 below=16 secs=15 nadir=88 events=[1000-1015/n88/s16/sp15] + third-codes-covers -> mean=94.000000 rounded=94 min=94 max=94 samples=20 windows=1/1 lq=0+0 thr=90 below=0 secs=0 nadir=- events=[] + half-codes-low-coverage -> mean=- rounded=- min=- max=- samples=0 windows=0/1 lq=1+0 thr=90 below=0 secs=0 nadir=- events=[] + sweep-unsettled -> mean=- rounded=- min=- max=- samples=0 windows=0/1 lq=0+1 thr=90 below=0 secs=0 nadir=- events=[] + settling-ramp -> mean=95.000000 rounded=95 min=95 max=95 samples=30 windows=1/1 lq=0+0 thr=90 below=0 secs=0 nadir=- events=[] + no-reliable-reading -> mean=- rounded=- min=- max=- samples=0 windows=0/3 lq=2+0 thr=90 below=0 secs=0 nadir=- events=[] + coverage-exactly-two-thirds -> mean=95.000000 rounded=95 min=95 max=95 samples=20 windows=1/1 lq=0+0 thr=90 below=0 secs=0 nadir=- events=[] + coverage-just-under -> mean=- rounded=- min=- max=- samples=0 windows=0/1 lq=1+0 thr=90 below=0 secs=0 nadir=- events=[] + agreement-exactly-three-fifths -> mean=95.000000 rounded=95 min=95 max=95 samples=30 windows=1/1 lq=0+0 thr=90 below=0 secs=0 nadir=- events=[] + agreement-just-under -> mean=- rounded=- min=- max=- samples=0 windows=0/1 lq=0+1 thr=90 below=0 secs=0 nadir=- events=[] + mixed-night -> mean=91.000000 rounded=91 min=87 max=95 samples=60 windows=2/4 lq=1+1 thr=90 below=30 secs=29 nadir=87 events=[2400-2429/n87/s30/sp29] """ XCTAssertEqual(out.joined(separator: "\n"), expected) } @@ -116,7 +157,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { func testMeanKeepsSubPercentPrecisionAndRoundsOnlyForDisplay() { let n = AnalyticsEngine.nightlySpo2CandidateNight( [sess(1000, 3000)], aux: [aux(1000, 96), aux(2200, 97), aux(3400, 97)]) - XCTAssertEqual(n!.mean, 96.66666666666667, accuracy: 1e-12) + XCTAssertEqual(n!.mean!, 96.66666666666667, accuracy: 1e-12) XCTAssertEqual(n?.meanRounded, 97) } @@ -192,16 +233,25 @@ final class Spo2CandidateNightStatsTests: XCTestCase { } /// 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. + /// nothing but codes still counts as attempted. Zero is the strap not measuring at all. The first + /// window is ONE reading of 21 measured seconds with two values in it — too few to stand for it + /// (W03-007), so it is low quality rather than two one-second readings of 95 and 96. 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") + XCTAssertEqual(n?.windowsLowCoverage, 1) + XCTAssertEqual(n?.windows, 0) + XCTAssertNil(n?.mean, "attempted, with nothing reliable: a night with no reliable reading, not nil") + } + + /// Two in-band seconds in two seconds measured is full coverage, and the value is the lower of the + /// two middle readings: always one the strap reported. + func testAnEvenCountTakesTheLowerMiddleReading() { + let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 600)], aux: [aux(1000, 96), aux(1001, 95)]) + XCTAssertEqual(n?.minimum, 95) } /// A window whose only in-band second is below the threshold is a dip with a span of ZERO seconds, not @@ -210,7 +260,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// `dipSamples` and `nadir`. func testASingleReadingDipWindowReportsNoDurationButIsStillCounted() { let n = AnalyticsEngine.nightlySpo2CandidateNight( - [sess(1000, 1800)], aux: [aux(1000, 8), aux(1001, 88), aux(1002, 8), aux(2200, 96)]) + [sess(1000, 1800)], aux: [aux(1001, 88), aux(2200, 96)]) XCTAssertEqual(n?.events.count, 1) XCTAssertEqual(n?.dipSpanSeconds, 0) XCTAssertEqual(n?.dipSamples, 1) @@ -326,7 +376,7 @@ final class Spo2CandidateSeriesTests: XCTestCase { /// `meanRounded` does, so the card and the vital tile cannot show two different numbers for one night. func testMeanPrecisionSurvivesAndRoundsLikeTheResolver() { let n = Spo2CandidateSeries.latest(mean: ["2026-09-22": 96.61]) - XCTAssertEqual(n!.mean, 96.61, accuracy: 1e-12) + XCTAssertEqual(n!.mean!, 96.61, accuracy: 1e-12) XCTAssertEqual(n?.meanRounded, 97) } @@ -341,6 +391,54 @@ final class Spo2CandidateSeriesTests: XCTestCase { XCTAssertEqual(Spo2CandidateSeries.samplesKey, "spo2_candidate_samples") XCTAssertEqual(Spo2CandidateSeries.windowsKey, "spo2_candidate_windows") XCTAssertEqual(Spo2CandidateSeries.windowsAttemptedKey, "spo2_candidate_windows_attempted") + XCTAssertEqual(Spo2CandidateSeries.lowQualityKey, "spo2_candidate_windows_low_quality") + } + + /// W03-007: a quality-gated night on which nothing was reliable is still the newest night. Skipping + /// it would put the previous night's average on the card as if it were last night's. + func testANightWithNoReliableReadingIsTheNewestNightNotSkipped() { + let n = Spo2CandidateSeries.latest( + mean: ["2026-09-29": 95.2], + dips: ["2026-09-29": 0, "2026-09-30": 0], + windows: ["2026-09-29": 20, "2026-09-30": 0], + windowsAttempted: ["2026-09-29": 22, "2026-09-30": 26], + lowQuality: ["2026-09-29": 1, "2026-09-30": 24]) + XCTAssertEqual(n?.day, "2026-09-30") + XCTAssertTrue(n?.hasNoReliableReading == true) + XCTAssertNil(n?.meanRounded) + XCTAssertEqual(n?.windowsLowQuality, 24) + XCTAssertEqual(n?.windowsAttempted, 26) + } + + /// A delete that failed must not bring back an average the resolver refused to state: on a gated + /// night with no reliable reading a leftover mean or minimum row is ignored. + func testALeftoverMeanOnANightWithNoReliableReadingIsIgnored() { + let n = Spo2CandidateSeries.latest( + mean: ["2026-09-30": 91.2], minimum: ["2026-09-30": 73], + windows: ["2026-09-30": 0], windowsAttempted: ["2026-09-30": 26], + lowQuality: ["2026-09-30": 26]) + XCTAssertNil(n?.mean) + XCTAssertNil(n?.minimum) + } + + /// Only a GATED night may exist without a mean. A night with a zero window count and no low-quality + /// row is a half-written night from before W03-007, and stays hidden as it always was. + func testOnlyAGatedNightMayExistWithoutAMean() { + XCTAssertNil(Spo2CandidateSeries.latest(mean: [:], windows: ["2026-09-30": 0], + windowsAttempted: ["2026-09-30": 26])) + XCTAssertNil(Spo2CandidateSeries.latest(mean: [:], windows: ["2026-09-30": 3], + windowsAttempted: ["2026-09-30": 26], + lowQuality: ["2026-09-30": 2]), + "reliable readings without a mean row is half-written, not a night to show") + } + + /// A night scored before W03-007 carries no low-quality count, and reads as ungated rather than as + /// "none left out". + func testAPreGateNightReportsNoLowQualityCount() { + let n = Spo2CandidateSeries.latest(mean: ["2026-09-28": 95.8], windows: ["2026-09-28": 25], + windowsAttempted: ["2026-09-28": 26]) + XCTAssertNil(n?.windowsLowQuality) + XCTAssertFalse(n!.hasNoReliableReading) } /// The window counts resolve from the same night as every other figure (W03-003). diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift new file mode 100644 index 0000000000..d22bad6281 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift @@ -0,0 +1,83 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// W03-007 — a reading is only as good as the seconds behind it. +/// +/// On a night the strap sat loose, byte 82's 30-second readings come back mixed with the strap's own +/// non-percentage codes, and the few in-band seconds left between them are fragments that jump. Taken at +/// face value those readings became the night's low and its dips. These cases are synthetic, shaped like +/// the owner's 2026-09-29 and 2026-09-30 nights (no values copied from them). +final class Spo2CandidateQualityTests: XCTestCase { + + private func sess(_ s: Int, _ d: Int) -> SleepSession { + SleepSession(start: s, end: s + d, efficiency: 0.9, stages: [], restingHR: 50, avgHRV: 60) + } + private func aux(_ ts: Int, _ v: Int?) -> V18AuxSample { V18AuxSample(ts: ts, auxByte82: v) } + /// One reading: `values[i]` at `start + i`, 30 seconds for the strap's real window shape. + private func reading(_ start: Int, _ values: [Int]) -> [V18AuxSample] { + values.enumerated().map { aux(start + $0.offset, $0.element) } + } + + /// A clean reading, as a well-fitted strap reports one. + private let clean = [95, 95, 96, 96, 96, 95, 95, 95, 96, 96, 96, 96, 95, 95, 95, + 96, 96, 96, 95, 95, 95, 96, 96, 96, 96, 95, 95, 95, 96, 96] + /// Half codes: a steady low run broken up by the codes a loose strap interleaves. + private let fragmented = [32, 32, 32, 160, 74, 74, 72, 74, 74, 75, 74, 74, 73, 73, 71, + 160, 160, 160, 74, 32, 32, 32, 75, 128, 128, 128, 72, 73, 128, 128] + /// Enough seconds, but they sweep across the band instead of agreeing on a value. + private let sweep = [128, 1, 128, 128, 128, 128, 128, 128, 84, 83, 88, 32, 83, 32, 75, + 72, 71, 72, 81, 90, 96, 99, 100, 100, 99, 100, 100, 100, 100, 100] + + /// V0: the loose-strap night. Two of its readings are low-quality; neither may become a dip or the + /// night's low, and the average rests on the clean readings alone. + func testLowQualityReadingsAreNotDipsAndDoNotMoveTheNight() { + let aux = reading(0, clean) + reading(1200, fragmented) + reading(2400, sweep) + reading(3600, clean) + let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(0, 5000)], aux: aux) + XCTAssertEqual(n?.events.count, 0) + XCTAssertEqual(n?.minimum, 96) + XCTAssertEqual(n?.windows, 2) + XCTAssertEqual(n?.windowsAttempted, 4) + XCTAssertEqual(n?.windowsLowCoverage, 1) + XCTAssertEqual(n?.windowsUnsettled, 1) + } + + /// The gate judges what the strap reported, never whether the value is low. A steady, well-covered + /// low reading stays a low reading: a filter tuned on the outcome would hide the nights it is for. + func testASteadyWellCoveredLowReadingStaysALowReading() { + let steadyLow = (0..<30).map { 86 + $0 % 3 } + let n = AnalyticsEngine.nightlySpo2CandidateNight( + [sess(0, 3000)], aux: reading(0, clean) + reading(1200, steadyLow)) + XCTAssertEqual(n?.events.count, 1) + XCTAssertEqual(n?.minimum, 87) + XCTAssertEqual(n?.windowsLowQuality, 0) + } + + /// A night on which the strap attempted readings and none was reliable is NOT nil: it is a night + /// with no reliable reading, which is exactly what a surface needs to say. + func testANightOfFailedReadingsIsStatedNotDropped() { + let n = AnalyticsEngine.nightlySpo2CandidateNight( + [sess(0, 3000)], aux: reading(0, fragmented) + reading(1200, sweep)) + XCTAssertNotNil(n) + XCTAssertNil(n?.mean) + XCTAssertNil(n?.minimum) + XCTAssertEqual(n?.windowsAttempted, 2) + XCTAssertEqual(n?.windowsLowQuality, 2) + XCTAssertTrue(n!.events.isEmpty) + XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([sess(0, 3000)], + aux: reading(0, fragmented)), + "the legacy integer mean has no answer either") + } + + /// The chart's seconds and the night's figures are cut by the same windows and the same verdict: the + /// trace holds exactly the reliable readings' in-band seconds, so it cannot draw a low the tiles left + /// out. + func testTheTraceHoldsOnlyTheReliableReadingsSeconds() { + let aux = reading(0, clean) + reading(1200, fragmented) + reading(2400, sweep) + let trace = AnalyticsEngine.spo2CandidateReliableSeconds([sess(0, 3000)], aux: aux) + let night = AnalyticsEngine.nightlySpo2CandidateNight([sess(0, 3000)], aux: aux) + XCTAssertEqual(trace.count, night?.samples) + XCTAssertEqual(trace.map(\.ts), Array(0..<30)) + XCTAssertEqual(trace.map(\.value).min(), 95) + } +} diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index c215f1d9ea..dfc0d66732 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -73,6 +73,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | 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 | | | W03-006 | S3 | Reported | The V3 stager can stage almost no REM on a night where V2 stages a normal amount. On the newest export one night gets under a fifth of V2's REM, while on the other 8 stored nights V3's REM is 0.60–1.36× V2's. No stage truth exists for these nights, so it is unadjudicated | `SleepStager.stageWindow` (V3) | Replay harness over the 2026-09-27 export: V3 reproduces all 9 stored nights byte for byte, so the stored figure is what V3 computes; the strap log's REM funnel (a V1 replay) lands between the two. The night's cardiac inputs are atypical for this wearer. Next: V3's REM probabilities across that night, and a SleepPSG check on nights with atypical cardiac inputs | | +| W03-007 | S3 | Reported | The SpO₂ candidate took every byte-82 reading at face value. On a night the strap sits loose, most 30-second readings come back mixed with the strap's own non-percentage codes, and the in-band fragments between them jump or settle low; each such reading's median became the night's low and a "dip", so a fit problem read as desaturation. Published wrist-oximetry validations discard 15–50 % of overnight data by their own quality indices, and the FDA's January 2025 draft guidance asks for an indicator of signal inadequacy; NOOP had neither | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `IntelligenceEngine.swift` (series writer); `DebugDataDiagnostics.swift` (strap-log line) | Found from the owner's 2026-09-30 export. Across 192 in-session readings of one MG, readings mixed with codes 32/128/160 were 17–60 % of each of the first six nights and 81–83 % of the last two; every low reading on those two nights but one was such a reading, and the readings with no code agreed with the earlier nights. Status words 77/79 and the HR-valid bit do not separate codes from values, so byte 82's own codes are the only strap-side quality signal. Fix shape: judge each reading by what the strap reported during it, never by whether it is low: at least two thirds of its measured seconds in band, and at least three fifths of those within 2 points of its median. A steady, well-covered low reading stays a low reading. Thresholds are from this one strap with no reference oximeter, and the night's figures barely move between one half and four fifths coverage. V0: `testLowQualityReadingsAreNotDipsAndDoNotMoveTheNight` fails on the old resolver (one dip, night low 74). V1: oracle re-captured (28 cases) and matched line for line by an independent Python implementation; on 108 sessions across all 15 backups the Swift resolver and that implementation agree on every figure; package suite 2,200 tests | | ## Log @@ -85,3 +86,4 @@ every changed row explained; SleepBench / SleepPSG for any stager change. 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). - 2026-09-27 — W03-003 checked on the first night scored on the build (V3 row note). W03-005 is not reproducible on this export: its one two-session day's second session holds no byte 82 window, so both resolutions agree. W03-006 recorded from the replay harness. +- 2026-09-30 — W03-007 from the owner's 2026-09-30 export (two nights of low SpO₂ candidate readings). Owner decision the same day: a night whose readings all fail the check shows "no reliable reading" rather than the previous night or an average of failed readings. From d6b06fffb3daa589b52d626fe6a41303dd8834b9 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 10:36:42 +0200 Subject: [PATCH 02/15] =?UTF-8?q?fix(analytics):=20caption=20the=20SpO?= =?UTF-8?q?=E2=82=82=20reading=20count=20from=20one=20resolver=20(W03-007?= =?UTF-8?q?=20V2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spo2CandidateSeries.Night.readingsNote keeps a reading left out as low quality apart from a reading of codes only, so a codes-only night no longer reads "passed the quality check" beside "none passed". Adds boundary tests at the 30-second shape and narrows the trace helper's claim to identical inputs. --- .../StrandAnalytics/Spo2CandidateNight.swift | 30 +++++++++++++++++-- .../Spo2CandidateNightStatsTests.swift | 21 +++++++++++++ .../Spo2CandidateQualityTests.swift | 15 ++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift index 7173a1bdc7..48ce6c062d 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift @@ -308,8 +308,11 @@ extension AnalyticsEngine { } /// The in-band seconds of the night's RELIABLE readings, ascending by timestamp: what a chart of the - /// night may plot beside the figures `nightlySpo2CandidateNight` states. Cut by the same windows and - /// the same quality verdict, so the chart cannot draw a low the figures left out (W03-007). + /// night may plot beside the figures `nightlySpo2CandidateNight` states. Given the same sessions and + /// samples it is cut by the same windows and the same verdict, so the chart cannot draw a low the + /// figures left out (W03-007). A caller that passes different sessions (a chart of one session where + /// the figures cover the day's) can see a reading straddling the session edge cut, and judged, + /// differently. public static func spo2CandidateReliableSeconds( _ sessions: [SleepSession], aux: [V18AuxSample] @@ -396,6 +399,29 @@ public enum Spo2CandidateSeries { /// the night to say "no reliable reading" about, rather than show a number for. public var hasNoReliableReading: Bool { mean == nil } + /// What the reading count ("12 of 26") can honestly be captioned with. A reading the check left + /// out and a reading of codes only are different facts: "passed the quality check" is true only + /// when every attempted reading was reliable, and "left out as low quality" counts only readings + /// that had a value to leave out (W03-007). + public enum ReadingsNote: Equatable, Sendable { + /// Scored before W03-007: the count is every reading with a value. + case ungated + /// This many readings with a value failed the check. + case leftOut(Int) + /// None failed the check, but some readings were codes only: the count is every reading + /// with a value. + case someWithoutValue + /// Every attempted reading was reliable. + case allPassed + } + + public var readingsNote: ReadingsNote { + guard let left = windowsLowQuality else { return .ungated } + if left > 0 { return .leftOut(left) } + if let w = windows, let a = windowsAttempted, w < a { return .someWithoutValue } + return .allPassed + } + /// True when the night is known to have dipped. nil `dips` is UNKNOWN, not zero: a pre-keys night /// must not be captioned "no dips" on the strength of a row that was never written. public var dipped: Bool? { dips.map { $0 > 0 } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift index 8eadc2b982..3f4582bd77 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift @@ -432,6 +432,27 @@ final class Spo2CandidateSeriesTests: XCTestCase { "reliable readings without a mean row is half-written, not a night to show") } + /// The reading count's caption never claims more than the counts support. A night of codes-only + /// readings has no low-quality reading, and "passed the quality check" under "0 of 26" would + /// contradict the notice beside it; nor may "12 of 26" read as all 26 passing. + func testTheReadingsNoteClaimsAPassOnlyWhenEveryReadingPassed() { + func note(windows: Double, attempted: Double, lowQuality: Double?) -> Spo2CandidateSeries.Night.ReadingsNote? { + Spo2CandidateSeries.latest(mean: windows > 0 ? ["2026-09-30": 95] : [:], + windows: ["2026-09-30": windows], + windowsAttempted: ["2026-09-30": attempted], + lowQuality: lowQuality.map { ["2026-09-30": $0] } ?? [:])?.readingsNote + } + XCTAssertEqual(note(windows: 0, attempted: 26, lowQuality: 0), .someWithoutValue, + "a codes-only night is a night with no reliable reading, and nothing passed") + XCTAssertTrue(Spo2CandidateSeries.latest(mean: [:], windows: ["2026-09-30": 0], + windowsAttempted: ["2026-09-30": 26], + lowQuality: ["2026-09-30": 0])!.hasNoReliableReading) + XCTAssertEqual(note(windows: 12, attempted: 26, lowQuality: 0), .someWithoutValue) + XCTAssertEqual(note(windows: 12, attempted: 26, lowQuality: 9), .leftOut(9)) + XCTAssertEqual(note(windows: 26, attempted: 26, lowQuality: 0), .allPassed) + XCTAssertEqual(note(windows: 26, attempted: 26, lowQuality: nil), .ungated) + } + /// A night scored before W03-007 carries no low-quality count, and reads as ungated rather than as /// "none left out". func testAPreGateNightReportsNoLowQualityCount() { diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift index d22bad6281..0adedb8e09 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift @@ -53,6 +53,21 @@ final class Spo2CandidateQualityTests: XCTestCase { XCTAssertEqual(n?.windowsLowQuality, 0) } + /// The two gates at the strap's real 30-second shape, at and one second past each boundary: 20 of + /// 30 in band passes and 19 fails; 18 of 30 agreeing seconds passes and 17 fails. + func testTheGatesAtTheirBoundariesOnAThirtySecondReading() { + func quality(_ values: [Int]) -> (low: Int, windows: Int)? { + AnalyticsEngine.nightlySpo2CandidateNight([sess(0, 600)], aux: reading(0, values)) + .map { ($0.windowsLowQuality, $0.windows) } + } + let codes = { (n: Int) in Array(repeating: 128, count: n) } + XCTAssertEqual(quality(codes(10) + Array(repeating: 95, count: 20))?.windows, 1) + XCTAssertEqual(quality(codes(11) + Array(repeating: 95, count: 19))?.low, 1) + let spread = { (agree: Int) in Array(repeating: 95, count: agree) + (0..<(30 - agree)).map { $0 % 2 == 0 ? 80 : 99 } } + XCTAssertEqual(quality(spread(18))?.windows, 1) + XCTAssertEqual(quality(spread(17))?.low, 1) + } + /// A night on which the strap attempted readings and none was reliable is NOT nil: it is a night /// with no reliable reading, which is exactly what a surface needs to say. func testANightOfFailedReadingsIsStatedNotDropped() { From eb18013a4c38841e70c414b07279ec68e8869e87 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 10:36:42 +0200 Subject: [PATCH 03/15] =?UTF-8?q?fix(app):=20show=20SpO=E2=82=82=20candida?= =?UTF-8?q?te=20nights=20through=20the=20quality=20gate=20(W03-007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The series writer stores the low-quality count and deletes a gated night's stale mean and minimum only after a persist that landed. The strap-estimate card says "no reliable reading" when every reading failed, names readings left out, calls them "Low readings" and plots only reliable readings. The Today Blood Oxygen tile resolves its night through the same funnel instead of the sparkline's last value. The strap-log line names what it left out. Translations for all nine locales. --- Strand/Data/IntelligenceEngine.swift | 44 +- Strand/Data/Repository.swift | 23 + Strand/Resources/Localizable.xcstrings | 640 ++++++++++++++++------- Strand/Screens/HealthView.swift | 22 +- Strand/Screens/Spo2EstimateCard.swift | 61 ++- Strand/Screens/TodayView.swift | 37 +- Strand/System/DebugDataDiagnostics.swift | 33 +- 7 files changed, 622 insertions(+), 238 deletions(-) diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 43ff9390c0..3e7200a8c3 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -223,8 +223,9 @@ final class IntelligenceEngine: ObservableObject { let hrvDiag: String? /// #103/queue-11a: this night's whole SpO₂ candidate result — mean, range and below-threshold /// dips — computed off the main actor when the SpO₂ candidate display toggle is ON from the - /// `spo2_candidate_82` V18Aux byte. nil when the toggle is OFF or the night has no in-band - /// reading. Written to metricSeries under the "-noop" device ID in pass 2. + /// `spo2_candidate_82` V18Aux byte. nil when the toggle is OFF or the strap measured nothing in + /// the night; a night whose readings all failed the quality check is non-nil with no mean + /// (W03-007). Written to metricSeries under the "-noop" device ID in pass 2. /// /// Carries the whole `Spo2CandidateNight` rather than the mean it used to: the dips are shown /// beside that mean now, and re-resolving them in pass 2 would mean two loops over one night's @@ -1679,8 +1680,9 @@ final class IntelligenceEngine: ObservableObject { // #103/queue-11a: the night's SpO₂ candidate result — the in-band (70–100) // `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. + // display toggle is ON; nil when there is no v18 aux stream, no nonzero byte-82 second, + // or the toggle is OFF. A night whose readings all failed the quality check is non-nil + // with no mean (W03-007). // // Resolved ONCE, here, for everything pass 2 writes: the mean, the minimum, the dip count, // the dip span and the window counts are all read off this single value rather than @@ -2034,6 +2036,10 @@ final class IntelligenceEngine: ObservableObject { // Rest composite (0–100) per computed night, persisted as the `sleep_performance` metric // series so the dashboard's Rest score reflects the new composite, not raw efficiency. var restPoints: [MetricPoint] = [] + // W03-007: days whose SpO₂ candidate night had readings but none reliable. Their mean and minimum + // rows are deleted after the persist, so a night that re-scores to "no reliable reading" cannot + // keep showing the average an earlier pass stored. + var spo2NoReliableReadingDays: [String] = [] // User-corrected sleep windows override the detected sleep when scoring a day's sleep aggregates, // so Rest + recovery honor the edit , not just the Sleep tab's session view. An edited block // substitutes its detected twin (matched by the stable detected startTs) before totals recompute. @@ -2286,11 +2292,22 @@ final class IntelligenceEngine: ObservableObject { // 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 // field nobody notices is missing (the `V18AuxSlot.decoderKey` lesson, one layer up). + // + // W03-007: the figures rest on RELIABLE readings only, and a night whose readings all failed + // the quality check has no mean and no minimum. Its counts are still written, with the + // low-quality key as the marker of a gated night, and a mean or minimum an earlier pass + // stored for that day is deleted after the persist below (`spo2NoReliableReadingDays`). if let cand = spo2CandidateByDay[daily.day] { - restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.meanKey, - value: cand.mean)) - restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.minimumKey, - value: Double(cand.minimum))) + if let mean = cand.mean, let minimum = cand.minimum { + restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.meanKey, + value: mean)) + restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.minimumKey, + value: Double(minimum))) + } else { + spo2NoReliableReadingDays.append(daily.day) + } + restPoints.append(MetricPoint(day: daily.day, key: Spo2CandidateSeries.lowQualityKey, + value: Double(cand.windowsLowQuality))) 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 @@ -2535,7 +2552,7 @@ final class IntelligenceEngine: ObservableObject { } markerSources = sourceIds } - try? await store.persistComputedScores( + let persistedScores = (try? await store.persistComputedScores( dailyMetrics: persistedDailies, metricPoints: restPoints, provenance: Array(provenanceByCell.values), @@ -2545,7 +2562,7 @@ final class IntelligenceEngine: ObservableObject { replaceMetricKeys: markerKeys, additionalMetricPoints: markerPoints, replaceMetricSourceIds: markerSources - ) + )) != nil // Now evict only the STALE computed rows in the window , those a prior (e.g. UTC-keyed) run left // behind that the current local-keyed run no longer produces. Read the window, diff against the @@ -2568,6 +2585,13 @@ final class IntelligenceEngine: ObservableObject { _ = try? await store.deleteMetricSeriesPoint(deviceId: computedId, day: day, key: "sleep_performance") } + // Only after a persist that landed: deleting the mean and minimum when the gated night's + // counts were never written would leave a half-written night, which the funnel skips. + for day in spo2NoReliableReadingDays where persistedScores { + for key in [Spo2CandidateSeries.meanKey, Spo2CandidateSeries.minimumKey] { + _ = try? await store.deleteMetricSeriesPoint(deviceId: computedId, day: day, key: key) + } + } } markPostLoopPhase("persist") // ── Fitness Age (Phase 2) , weekly, keyed to the week's Saturday ──────────────────────────── diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 58ba30ef83..2aff21d4a7 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -1969,6 +1969,29 @@ final class Repository: ObservableObject { return spo2SingleChannelPlausible.contains(v) ? v : nil } + /// The SpO₂ candidate seconds a chart of the night `[from, to]` may plot beside the strap-estimate + /// card's figures: the in-band seconds of the RELIABLE readings only, cut by the same windows and the + /// same quality verdict the figures were resolved with (`AnalyticsEngine.spo2CandidateReliableSeconds`, + /// W03-007). The Deep Timeline keeps plotting every in-band second through `timelineSeries`; this is + /// the card's chart, and it must not draw a low its own tiles left out as low quality. + /// + /// Reads the same worn-timeline union as `timelineSeries`, first source winning a shared second, so + /// the chart covers the same seconds it covered before the gate. + func spo2CandidateReliableTrace(from: Int, to: Int) async -> [TrendPoint] { + guard to > from, let store = await ensureStore() else { return [] } + var seen = Set() + var aux: [V18AuxSample] = [] + for id in rawPhysiologyReadIds(store: store) { + let rows = (try? await store.v18AuxSamples(deviceId: id, from: from, to: to, limit: 200_000)) ?? [] + for row in rows where seen.insert(row.ts).inserted { aux.append(row) } + } + let night = [SleepSession(start: from, end: to, efficiency: 0, stages: [], restingHR: nil, avgHRV: nil)] + return await Task.detached(priority: .utility) { + AnalyticsEngine.spo2CandidateReliableSeconds(night, aux: aux) + .map { Self.timelinePoint($0.ts, Double($0.value)) } + }.value + } + /// Deep-Timeline read facade. Returns ~`targetPoints` points for `metric` over `[from, to]` from /// `source` (defaults to the user's own strap), choosing raw seconds vs coarse buckets adaptively so /// the chart never draws ~86k points (the #575 day-scale risk). HR rides the existing COALESCE reads diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 52e7070bd2..80a8837832 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -10696,6 +10696,70 @@ } } }, + "%lld left out as low quality": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld wegen geringer Qualität ausgelassen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld left out as low quality" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%lld descartadas por baja calidad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld écartées pour qualité insuffisante" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%lld escluse per bassa qualità" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%lld pominięto z powodu niskiej jakości" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "%lld excluídas por baixa qualidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld исключено из-за низкого качества" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 次因质量低被排除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 次因品質低被排除" + } + } + } + }, "%lld logged caffeines are past your bedtime cutoff. They may still be on board and keep you up. Just a timing heads-up.": { "localizations": { "de": { @@ -76426,6 +76490,70 @@ } } }, + "Each point is one second of a reading that passed the quality check. A reading counts as low when its middle value is under the dashed line at %@.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Jeder Punkt ist eine Sekunde einer Messung, die die Qualitätsprüfung bestanden hat. Eine Messung zählt als niedrig, wenn ihr mittlerer Wert unter der gestrichelten Linie bei %@ liegt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Each point is one second of a reading that passed the quality check. A reading counts as low when its middle value is under the dashed line at %@." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cada punto es un segundo de una lectura que superó el control de calidad. Una lectura cuenta como baja cuando su valor central queda por debajo de la línea discontinua en %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chaque point est une seconde d’une mesure qui a passé le contrôle de qualité. Une mesure compte comme basse quand sa valeur médiane passe sous la ligne pointillée à %@." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ogni punto è un secondo di una lettura che ha superato il controllo di qualità. Una lettura conta come bassa quando il suo valore centrale è sotto la linea tratteggiata a %@." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Każdy punkt to jedna sekunda odczytu, który przeszedł kontrolę jakości. Odczyt liczy się jako niski, gdy jego środkowa wartość leży poniżej linii przerywanej na %@." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Cada ponto é um segundo de uma leitura que passou o controlo de qualidade. Uma leitura conta como baixa quando o seu valor central fica abaixo da linha tracejada em %@." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Каждая точка — одна секунда измерения, прошедшего проверку качества. Измерение считается низким, если его медианное значение ниже пунктирной линии на %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每个点是通过质量检查的读数中的一秒。当一次读数的中间值低于 %@ 处的虚线时,计为低读数。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每個點是通過品質檢查的讀數中的一秒。當一次讀數的中間值低於 %@ 處的虛線時,計為低讀數。" + } + } + } + }, "Each score follows a published method, computed on your device. We name the method family so you can read up on it, and we never claim to reproduce another company's number exactly.": { "localizations": { "de": { @@ -119473,6 +119601,70 @@ } } }, + "Low readings": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Niedrige Messungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Low readings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lecturas bajas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mesures basses" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Letture basse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Niskie odczyty" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Leituras baixas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Низкие измерения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "低读数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "低讀數" + } + } + } + }, "Low refresh": { "localizations": { "de": { @@ -137306,6 +137498,70 @@ } } }, + "None of this night's readings passed the quality check. This can happen when the strap sits loose or has shifted.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Messung dieser Nacht hat die Qualitätsprüfung bestanden. Das kann passieren, wenn der Strap locker sitzt oder verrutscht ist." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "None of this night's readings passed the quality check. This can happen when the strap sits loose or has shifted." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ninguna lectura de esta noche superó el control de calidad. Puede pasar cuando la banda está suelta o se ha desplazado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune mesure de cette nuit n’a passé le contrôle de qualité. Cela peut arriver quand le bracelet est lâche ou a glissé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna lettura di questa notte ha superato il controllo di qualità. Può succedere quando la fascia è allentata o si è spostata." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Żaden odczyt z tej nocy nie przeszedł kontroli jakości. Może się tak zdarzyć, gdy opaska jest luźna lub się przesunęła." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma leitura desta noite passou o controlo de qualidade. Pode acontecer quando a pulseira está folgada ou se deslocou." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ни одно измерение этой ночи не прошло проверку качества. Так бывает, когда ремешок сидит свободно или сместился." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这一晚没有任何读数通过质量检查。手环过松或移位时可能出现这种情况。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這一晚沒有任何讀數通過品質檢查。手環過鬆或移位時可能出現這種情況。" + } + } + } + }, "None — silence, start to finish": { "localizations": { "de": { @@ -143014,6 +143270,70 @@ } } }, + "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. A reading counts only when most of its seconds carry a value and those values agree; the rest are left out as low quality. Each reading counts once, by its middle value, so a one-second blip inside it is not a low reading. 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": "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. Eine Messung zählt nur, wenn die meisten ihrer Sekunden einen Wert tragen und diese Werte übereinstimmen; die übrigen werden wegen geringer Qualität ausgelassen. Jede Messung zählt einmal, mit ihrem mittleren Wert, daher ist ein einsekündiger Ausreißer darin keine niedrige Messung. 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": "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. A reading counts only when most of its seconds carry a value and those values agree; the rest are left out as low quality. Each reading counts once, by its middle value, so a one-second blip inside it is not a low reading. 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": "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. Una lectura solo cuenta cuando la mayoría de sus segundos tienen un valor y esos valores coinciden; las demás se descartan por baja calidad. Cada lectura cuenta una vez, por su valor central, así que un pico de un segundo dentro de ella no es una lectura baja. 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": "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. Une mesure ne compte que si la plupart de ses secondes portent une valeur et que ces valeurs concordent ; les autres sont écartées pour qualité insuffisante. Chaque mesure compte une fois, par sa valeur médiane, donc un pic d’une seconde à l’intérieur n’est pas une mesure basse. 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": "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. Una lettura conta solo quando la maggior parte dei suoi secondi ha un valore e quei valori concordano; le altre vengono escluse per bassa qualità. Ogni lettura conta una volta, con il suo valore centrale, quindi un picco di un secondo al suo interno non è una lettura bassa. È 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": "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. Odczyt liczy się tylko wtedy, gdy większość jego sekund ma wartość i te wartości są zgodne; pozostałe są pomijane z powodu niskiej jakości. Każdy odczyt liczy się raz, według środkowej wartości, więc jednosekundowy skok w jego trakcie nie jest niskim odczytem. 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": "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. Uma leitura só conta quando a maioria dos seus segundos tem um valor e esses valores concordam; as restantes são excluídas por baixa qualidade. Cada leitura conta uma vez, pelo seu valor central, por isso um pico de um segundo dentro dela não é uma leitura baixa. É 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": "На изученных пока ремешках ремешок делает 30-секундное измерение примерно каждые 20 минут, когда считает, что вы спите, поэтому у короткого дневного сна их может не быть. Измерение учитывается, только если у большинства его секунд есть значение и эти значения согласуются; остальные исключаются из-за низкого качества. Каждое измерение учитывается один раз, по медианному значению, поэтому односекундный всплеск внутри него — не низкое измерение. Это собственная непроверенная цифра ремешка, а не калиброванное измерение кислорода в крови, и NOOP никогда не использует её для восстановления или любой другой оценки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在目前测过的手环上,手环判定你入睡后,大约每 20 分钟进行一次 30 秒读数,因此短暂的小睡可能没有读数。只有当一次读数的大部分秒数都有数值且这些数值一致时,该读数才会计入;其余读数因质量低被排除。每次读数按其中间值只计一次,因此其中一秒的跳变不算低读数。这是手环自己的未验证数值,不是校准的血氧测量结果,NOOP 从不将其用于恢复度或任何其他评分。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在目前測過的手環上,手環判定你入睡後,大約每 20 分鐘進行一次 30 秒讀數,因此短暫的小睡可能沒有讀數。只有當一次讀數的大部分秒數都有數值且這些數值一致時,該讀數才會計入;其餘讀數因品質低被排除。每次讀數按其中間值只計一次,因此其中一秒的跳變不算低讀數。這是手環自己的未驗證數值,不是校準的血氧測量結果,NOOP 從不將其用於恢復度或任何其他評分。" + } + } + } + }, "On wrist": { "localizations": { "de": { @@ -241154,6 +241474,70 @@ } } }, + "no reliable reading": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "keine verlässliche Messung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "no reliable reading" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "sin lectura fiable" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "aucune mesure fiable" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "nessuna lettura affidabile" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "brak wiarygodnego odczytu" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "sem leitura fiável" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "нет надёжных измерений" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无可靠读数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無可靠讀數" + } + } + } + }, "no session yet": { "localizations": { "de": { @@ -242275,6 +242659,70 @@ } } }, + "passed the quality check": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "bestanden die Qualitätsprüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "passed the quality check" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "superaron el control de calidad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "ont passé le contrôle de qualité" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "hanno superato il controllo di qualità" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "przeszły kontrolę jakości" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "passaram o controlo de qualidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "прошли проверку качества" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过质量检查" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通過品質檢查" + } + } + } + }, "peak %@ · %@": { "localizations": { "de": { @@ -251711,70 +252159,6 @@ } } }, - "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": "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": "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": "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": "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": "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": "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": "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": "На изученных пока ремешках ремешок делает 30-секундное измерение примерно каждые 20 минут, когда считает, что вы спите, поэтому у короткого дневного сна их может не быть, а некоторые измерения не дают значения. Каждое измерение учитывается один раз, по медианному значению, поэтому односекундный всплеск внутри него — не провал. Это собственная непроверенная цифра ремешка, а не калиброванное измерение кислорода в крови, и NOOP никогда не использует её для восстановления или любой другой оценки." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在目前测过的手环上,手环判定你入睡后,大约每 20 分钟进行一次 30 秒读数,因此短暂的小睡可能没有读数,部分读数也会失败而没有数值。每次读数按其中间值只计一次,因此其中一秒的跳变不算下降。这是手环自己的未验证数值,不是校准的血氧测量结果,NOOP 从不将其用于恢复度或任何其他评分。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "在目前測過的手環上,手環判定你入睡後,大約每 20 分鐘進行一次 30 秒讀數,因此短暫的小睡可能沒有讀數,部分讀數也會失敗而沒有數值。每次讀數按其中間值只計一次,因此其中一秒的跳變不算下降。這是手環自己的未驗證數值,不是校準的血氧測量結果,NOOP 從不將其用於恢復度或任何其他評分。" - } - } - } - }, "dipped below %@": { "localizations": { "de": { @@ -252095,70 +252479,6 @@ } } }, - "Dips": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Einbrüche" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Dips" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Caídas" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Baisses" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Cali" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Spadki" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Quedas" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Снижения" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "下降次数" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "下降次數" - } - } - } - }, "Better than target": { "localizations": { "de": { @@ -252333,70 +252653,6 @@ } } }, - "Each point is one second the strap reported. A reading is a dip when its middle value is under the dashed line at %@.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "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." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "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 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 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 è 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 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 é 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": "Каждая точка — одна секунда, переданная ремешком. Измерение считается провалом, если его медианное значение ниже пунктирной линии на %@." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "每个点是手环上报的一秒。当一次读数的中间值低于 %@ 处的虚线时,计为一次下降。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "每個點是手環回報的一秒。當一次讀數的中間值低於 %@ 處的虛線時,計為一次下降。" - } - } - } - }, "Blood oxygen strap estimate through the night": { "localizations": { "de": { diff --git a/Strand/Screens/HealthView.swift b/Strand/Screens/HealthView.swift index 0adaabfd65..53dba8bbcd 100644 --- a/Strand/Screens/HealthView.swift +++ b/Strand/Screens/HealthView.swift @@ -1333,13 +1333,18 @@ private struct VitalsSection: View { // #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. + // mean beside an older night's low. Empty whenever the mean is, except that a quality-gated night + // with no reliable reading (W03-007) has counts and no mean. @State private var spo2CandidateMinByDay: [String: Double] = [:] @State private var spo2CandidateDipsByDay: [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. + // W03-007: readings left out as low quality; its presence marks a quality-gated night, which may + // carry no mean at all when none of its readings was reliable. + @State private var spo2CandidateLowQualityByDay: [String: Double] = [:] + /// The resolved night's per-second in-band readings from its reliable readings only (W03-007), for + /// the card's chart. Keyed to that night's day. @State private var spo2NightTrace: [TrendPoint] = [] var body: some View { @@ -1399,6 +1404,10 @@ private struct VitalsSection: View { // the owner has no in-band reading for its device. guard PuffinExperiment.spo2CandidateDisplayEnabled else { spo2CandidateByDay = [:] + // W03-007: a gated night with no reliable reading resolves without a mean, so clearing + // the mean alone no longer hides the card; the series that can stand in for it go too. + spo2CandidateLowQualityByDay = [:] + spo2CandidateWindowsAttemptedByDay = [:] return } let pts = await repo.exploreSeries(key: Spo2CandidateSeries.meanKey, source: "my-whoop", days: 14) @@ -1412,6 +1421,7 @@ private struct VitalsSection: View { spo2CandidateWindowsByDay = await Self.loadCandidateSeries(repo, Spo2CandidateSeries.windowsKey) spo2CandidateWindowsAttemptedByDay = await Self.loadCandidateSeries( repo, Spo2CandidateSeries.windowsAttemptedKey) + spo2CandidateLowQualityByDay = await Self.loadCandidateSeries(repo, Spo2CandidateSeries.lowQualityKey) } } @@ -1436,9 +1446,8 @@ private struct VitalsSection: View { .filter { fmt.string(from: Date(timeIntervalSince1970: TimeInterval($0.endTs))) == day } .max { ($0.endTs - $0.effectiveStartTs) < ($1.endTs - $1.effectiveStartTs) } guard let session, session.endTs > session.effectiveStartTs else { return [] } - let series = await repo.timelineSeries(metric: .spo2Candidate, from: session.effectiveStartTs, - to: session.endTs, targetPoints: 100_000) - return series.points + // W03-007: only the reliable readings' seconds, so the chart cannot draw a low the tiles left out. + return await repo.spo2CandidateReliableTrace(from: session.effectiveStartTs, to: session.endTs) } /// The night the strap-estimate card describes, resolved through the ONE funnel (pure + CI-tested in @@ -1449,7 +1458,8 @@ private struct VitalsSection: View { minimum: spo2CandidateMinByDay, dips: spo2CandidateDipsByDay, windows: spo2CandidateWindowsByDay, - windowsAttempted: spo2CandidateWindowsAttemptedByDay) + windowsAttempted: spo2CandidateWindowsAttemptedByDay, + lowQuality: spo2CandidateLowQualityByDay) } /// 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 e29df18e75..ccafca0a3b 100644 --- a/Strand/Screens/Spo2EstimateCard.swift +++ b/Strand/Screens/Spo2EstimateCard.swift @@ -55,11 +55,16 @@ struct Spo2EstimateCard: View { alignment: .leading, spacing: NoopMetrics.gap ) { + // W03-007: a night whose readings all failed the quality check has no average. It + // says so rather than borrowing the previous night's number or averaging noise. StatTile(label: "Average", - value: "\(night.meanRounded)%", - caption: String(localized: "strap estimate (unverified)"), + value: night.meanRounded.map { "\($0)%" } ?? "—", + caption: night.hasNoReliableReading + ? String(localized: "no reliable reading") + : String(localized: "strap estimate (unverified)"), accent: StrandPalette.metricCyan, - sparkline: Self.spark(meanTrend), + // No trend under "—": the trail would end at an older night's average. + sparkline: night.hasNoReliableReading ? nil : 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 @@ -68,19 +73,27 @@ struct Spo2EstimateCard: View { value: Self.perReading(night) ? night.minimum.map { "\($0)%" } ?? "—" : "—", caption: Self.perReading(night) ? lowCaption(night) : "", accent: StrandPalette.metricCyan) - StatTile(label: "Dips", - value: Self.perReading(night) ? night.dips.map(String.init) ?? "—" : "—", - caption: Self.perReading(night) ? dipsCaption(night) : "", + // "Low readings", not "Dips": one 30-second reading about every 20 minutes cannot + // show a desaturation event, only a reading whose value was low (W03-007). + StatTile(label: "Low readings", + value: Self.showsLowReadings(night) ? night.dips.map(String.init) ?? "—" : "—", + caption: Self.showsLowReadings(night) ? dipsCaption(night) : "", accent: StrandPalette.metricCyan) StatTile(label: "Readings", value: readingsValue(night), caption: readingsCaption(night), accent: StrandPalette.textPrimary) } + if night.hasNoReliableReading { + Text("None of this night's readings passed the quality check. This can happen when the strap sits loose or has shifted.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } if trace.count >= 2 { traceCard } - 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.") + 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. A reading counts only when most of its seconds carry a value and those values agree; the rest are left out as low quality. Each reading counts once, by its middle value, so a one-second blip inside it is not a low reading. 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,10 +103,11 @@ struct Spo2EstimateCard: View { // MARK: - Night trace - /// 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. + /// The in-band seconds of the night's reliable readings, plotted as the strap reported them; a + /// low-quality reading is left out here exactly as it is left out of the tiles (W03-007). The dashed + /// rule is the threshold the Low and Low readings tiles are cut at; a READING is low 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) { @@ -109,7 +123,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 second the strap reported. A reading is a dip when its middle value is under the dashed line at \(thresholdLabel).") + Text("Each point is one second of a reading that passed the quality check. A reading counts as low when its middle value is under the dashed line at \(thresholdLabel).") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -141,8 +155,19 @@ struct Spo2EstimateCard: View { return String(localized: "\(windows) of \(attempted)") } + /// Before W03-007 the count was every reading with a value. On a gated night it is the reliable + /// readings, and the caption says how many were left out, so "12 of 26" is never read as 14 failures + /// the strap could not measure at all. + /// Resolved by `Spo2CandidateSeries.Night.readingsNote`, which keeps a reading left out as low + /// quality apart from a reading of codes only, so "passed the quality check" appears only when every + /// attempted reading passed. private func readingsCaption(_ night: Spo2CandidateSeries.Night) -> String { - night.windows == nil ? "" : String(localized: "readings with a value") + guard night.windows != nil else { return "" } + switch night.readingsNote { + case .ungated, .someWithoutValue: return String(localized: "readings with a value") + case .leftOut(let left): return String(localized: "\(left) left out as low quality") + case .allPassed: return String(localized: "passed the quality check") + } } /// The threshold as a display string, interpolated into the captions below as `%@`. @@ -164,8 +189,8 @@ struct Spo2EstimateCard: View { : String(localized: "stayed above \(thresholdLabel)") } - /// 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 + /// The Low readings tile's caption. A low reading 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 { @@ -178,6 +203,12 @@ struct Spo2EstimateCard: View { /// shipped has no window count, and its low and dips were resolved per second. static func perReading(_ night: Spo2CandidateSeries.Night) -> Bool { night.windows != nil } + /// The Low readings tile states a count only for a night that has reliable readings: on a night with + /// none, "0 — none below 90%" would claim a finding the night cannot support. + static func showsLowReadings(_ night: Spo2CandidateSeries.Night) -> Bool { + perReading(night) && !night.hasNoReliableReading + } + /// "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/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index cbec0395c0..497497aee6 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -314,6 +314,10 @@ struct TodayView: View { // 14-day sparkline series, keyed by metric key. Loaded once in .task. @State private var sparks: [String: [Double]] = [:] + /// W03-007: the newest scored SpO₂ candidate night, resolved through `Spo2CandidateSeries.latest` — + /// the funnel the Health card uses — so the Blood Oxygen fallback cannot show an older night's + /// average while the newest night had no reliable reading. nil when the toggle is OFF. + @State private var spo2CandidateNight: Spo2CandidateSeries.Night? @State private var workouts: [WorkoutRow] = [] /// #1694: a tapped Latest-Workouts tile. Wrapped so `.sheet(item:)` drives presentation, mirroring /// WorkoutsView's own detail target — the feed was read-only, so the only route to a session's @@ -2785,7 +2789,8 @@ struct TodayView: View { // IntelligenceEngine) so the card shows a strap-estimate (unverified) number instead of "—". let calibrated = (d?.spo2Pct ?? lastVitalsDay?.spo2Pct ?? lastSpo2Day?.spo2Pct) if let v = calibrated { return String(format: "%.0f%%", locale: AppLanguage.activeLocale, v) } - if PuffinExperiment.spo2CandidateDisplayEnabled, let tail = sparks["spo2_candidate"]?.last { + // W03-007: the newest night's own mean, never an older one's when that night had none. + if PuffinExperiment.spo2CandidateDisplayEnabled, let tail = spo2CandidateNight?.mean { return String(format: "%.0f%%", locale: AppLanguage.activeLocale, tail) } return "—" @@ -4078,15 +4083,21 @@ struct TodayView: View { // the user can tell "toggle off" apart from "toggle on but no data" — a silent blank reads as // broken. let spo2CandidateOn = PuffinExperiment.spo2CandidateDisplayEnabled - let candidateTail = spo2CandidateOn ? sparks["spo2_candidate"]?.last : nil + // W03-007: the tail is the newest night's own mean, resolved through the Health card's + // funnel. When that night's readings all failed the quality check it has no mean, and the + // tile says so rather than showing the previous night's figure undated. + let candidateTail = spo2CandidateOn ? spo2CandidateNight?.mean : nil + let candidateUnreliable = spo2CandidateOn && spo2CandidateNight?.hasNoReliableReading == true let spo2Value = spo2.value == "—" && candidateTail != nil ? String(format: "%.0f%%", locale: AppLanguage.activeLocale, candidateTail!) : spo2.value let spo2Caption: String = spo2.value == "—" && candidateTail != nil ? String(localized: "strap estimate (unverified)") - : (spo2.value == "—" && spo2CandidateOn - ? String(localized: "toggle ON · no estimate yet") - : (spo2.caption ?? "")) + : (spo2.value == "—" && candidateUnreliable + ? String(localized: "no reliable reading") + : (spo2.value == "—" && spo2CandidateOn + ? String(localized: "toggle ON · no estimate yet") + : (spo2.caption ?? ""))) StatTile( label: "Blood Oxygen", value: spo2Value, @@ -4691,6 +4702,7 @@ struct TodayView: View { sparks["rhr"] = await rhrSpark sparks["spo2"] = await spo2Spark sparks["spo2_candidate"] = await spo2CandidateSpark + spo2CandidateNight = await loadSpo2CandidateNight() sparks["skin_temp"] = await skinTempSpark sparks["resp_rate"] = await respRateSpark sparks["steps"] = await stepsAppleSpark @@ -5148,6 +5160,21 @@ struct TodayView: View { /// falls back to `dailyColumn` so the strap's own nightly respiratory rate fills the trend. /// Mirrors the Android `rememberTrendWindow` which builds the resp spark from /// `DailyMetric.respRateBpm` directly. Used for `resp_rate` (parity fix). + /// The newest SpO₂ candidate night within the sparkline's 14 days, through the one funnel (W03-007). + /// A night whose readings all failed the quality check is the newest night, so the tile says so + /// instead of falling back to the previous night's average. + private func loadSpo2CandidateNight() async -> Spo2CandidateSeries.Night? { + guard PuffinExperiment.spo2CandidateDisplayEnabled else { return nil } + func series(_ key: String) async -> [String: Double] { + let pts = trailingWindow(await repo.exploreSeries(key: key, source: "my-whoop", days: 15), days: 14) + return Dictionary(pts.map { ($0.day, $0.value) }, uniquingKeysWith: { a, _ in a }) + } + return Spo2CandidateSeries.latest(mean: await series(Spo2CandidateSeries.meanKey), + windows: await series(Spo2CandidateSeries.windowsKey), + windowsAttempted: await series(Spo2CandidateSeries.windowsAttemptedKey), + lowQuality: await series(Spo2CandidateSeries.lowQualityKey)) + } + private func sparkValuesExplore(_ key: String, source: String, window: Int) async -> [Double] { let all = await repo.exploreSeries(key: key, source: source, days: window + 1) guard !all.isEmpty else { return [] } diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift index 05ab3dd737..769dea861e 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -356,17 +356,30 @@ enum DebugDataDiagnostics { // // 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 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)%) 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).") + // + // W03-007: the figures rest on reliable readings only, so the line names how many were left + // out and why, and a night with none says so instead of printing a mean it does not have. + let leftOut = "\(cand.windowsLowQuality) left out as low quality " + + "(\(cand.windowsLowCoverage) too few in-band seconds, \(cand.windowsUnsettled) unsettled)" + if let mean = cand.mean, let meanRounded = cand.meanRounded, + let low = cand.minimum, let high = cand.maximum { + let dips = cand.events.isEmpty + ? "no low readings below \(cand.threshold)%" + : "\(cand.events.count) low reading(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", mean))% " + + "(shown as \(meanRounded)%) over \(cand.windows) reliable of " + + "\(cand.windowsAttempted) readings, \(leftOut); \(cand.samples) in-band seconds, " + + "reading range \(low)-\(high)%, \(dips) " + + "— UNVERIFIED, compare against the WHOOP app's figure for this night (#103).") + } else { + let codesOnly = cand.windowsAttempted - cand.windows - cand.windowsLowQuality + lines.append("SpO₂ candidate @82 (5/MG): no reliable reading this night — " + + "\(cand.windowsAttempted) readings attempted, \(leftOut)" + + (codesOnly > 0 ? ", \(codesOnly) codes only." : ".")) + } } else { - lines.append("SpO₂ candidate @82 (5/MG): no in-band readings inside this night's span.") + lines.append("SpO₂ candidate @82 (5/MG): no nonzero byte-82 second among the aux rows read for this night's span.") } return lines } From 41250a8cbafe7da117f547ab5fedfe3afcffd664 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 10:36:42 +0200 Subject: [PATCH 04/15] docs: record the byte-82 code observations and W03-007's review (W03-008) --- .../linux-capture/validate_spo2_candidate.py | 7 +++++++ docs/PROTOCOL_SENSORS.md | 21 ++++++++++++++++++- docs/review/workstreams/W03-analytics.md | 4 +++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Tools/linux-capture/validate_spo2_candidate.py b/Tools/linux-capture/validate_spo2_candidate.py index 750721fa47..80c7a254e0 100644 --- a/Tools/linux-capture/validate_spo2_candidate.py +++ b/Tools/linux-capture/validate_spo2_candidate.py @@ -685,6 +685,13 @@ def night_mean_at_offset( 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. + + One difference remains, by design. At byte 82 the app also leaves out low-quality windows (W03-007): + windows where fewer than two thirds of the records carried an in-band value rather than one of the + strap's non-percentage codes, and windows whose values do not settle near their median. This + scanner drops out-of-band values before it windows, because at an arbitrary offset those values + mean nothing, so it applies neither rule. Its byte-82 figure therefore matches the app's only on a + night where no window failed either rule. """ duty_cycled = is_duty_cycled(duty) per_window: Dict[int, List[int]] = {} diff --git a/docs/PROTOCOL_SENSORS.md b/docs/PROTOCOL_SENSORS.md index 6e575fb5e9..8767012bd6 100644 --- a/docs/PROTOCOL_SENSORS.md +++ b/docs/PROTOCOL_SENSORS.md @@ -350,7 +350,8 @@ means; the value remains an unvalidated candidate. - **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. + lower of the two middle values when the count is even, over the windows that pass + the quality rule below. - **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 @@ -358,6 +359,24 @@ means; the value remains an unvalidated candidate. byte therefore needs no app-side unlock. This does not establish whether the official app can disable it. +**Codes inside valued windows, same strap, 192 in-session windows over eight nights +(owner export 2026-09-30).** Again what the byte does, not what the codes mean. + +- **Mixing.** Codes 32, 128 and 160 appear interleaved with in-band values inside one + window, in runs of one to ten records. Windows holding them were 17–60 % of each of + the first six nights and 81–83 % of the last two. +- **Shape.** Windows without codes change by at most 2 points between records in 70 of + 74 cases. Windows with many codes hold short in-band runs between them that jump + (one crossed 70–100 within ten records) or sit well below the night's code-free + windows. +- **No other marker.** Neither the status-word pair at 77/79 nor bit 7 of byte 36 + separates code records from in-band records inside a window, so the byte's own codes + are the only per-record marker observed. +- **Use.** NOOP counts a window only when at least two thirds of its nonzero records + are in band and at least three fifths of those lie within 2 points of the window's + median (W03-007). The thresholds come from this one strap without a reference + oximeter; they judge how the window was reported, not its value. + 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. diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index dfc0d66732..8a3dbf6bd2 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -73,7 +73,8 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | 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 | | | W03-006 | S3 | Reported | The V3 stager can stage almost no REM on a night where V2 stages a normal amount. On the newest export one night gets under a fifth of V2's REM, while on the other 8 stored nights V3's REM is 0.60–1.36× V2's. No stage truth exists for these nights, so it is unadjudicated | `SleepStager.stageWindow` (V3) | Replay harness over the 2026-09-27 export: V3 reproduces all 9 stored nights byte for byte, so the stored figure is what V3 computes; the strap log's REM funnel (a V1 replay) lands between the two. The night's cardiac inputs are atypical for this wearer. Next: V3's REM probabilities across that night, and a SleepPSG check on nights with atypical cardiac inputs | | -| W03-007 | S3 | Reported | The SpO₂ candidate took every byte-82 reading at face value. On a night the strap sits loose, most 30-second readings come back mixed with the strap's own non-percentage codes, and the in-band fragments between them jump or settle low; each such reading's median became the night's low and a "dip", so a fit problem read as desaturation. Published wrist-oximetry validations discard 15–50 % of overnight data by their own quality indices, and the FDA's January 2025 draft guidance asks for an indicator of signal inadequacy; NOOP had neither | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `IntelligenceEngine.swift` (series writer); `DebugDataDiagnostics.swift` (strap-log line) | Found from the owner's 2026-09-30 export. Across 192 in-session readings of one MG, readings mixed with codes 32/128/160 were 17–60 % of each of the first six nights and 81–83 % of the last two; every low reading on those two nights but one was such a reading, and the readings with no code agreed with the earlier nights. Status words 77/79 and the HR-valid bit do not separate codes from values, so byte 82's own codes are the only strap-side quality signal. Fix shape: judge each reading by what the strap reported during it, never by whether it is low: at least two thirds of its measured seconds in band, and at least three fifths of those within 2 points of its median. A steady, well-covered low reading stays a low reading. Thresholds are from this one strap with no reference oximeter, and the night's figures barely move between one half and four fifths coverage. V0: `testLowQualityReadingsAreNotDipsAndDoNotMoveTheNight` fails on the old resolver (one dip, night low 74). V1: oracle re-captured (28 cases) and matched line for line by an independent Python implementation; on 108 sessions across all 15 backups the Swift resolver and that implementation agree on every figure; package suite 2,200 tests | | +| W03-007 | S3 | Fixed | The SpO₂ candidate took every byte-82 reading at face value. On a night the strap sits loose, most 30-second readings come back mixed with the strap's own non-percentage codes, and the in-band fragments between them jump or settle low; each such reading's median became the night's low and a "dip", so a fit problem read as desaturation. Published wrist-oximetry validations discard 15–50 % of overnight data by their own quality indices, and the FDA's January 2025 draft guidance asks for an indicator of signal inadequacy; NOOP had neither | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `IntelligenceEngine.swift` (series writer); `DebugDataDiagnostics.swift` (strap-log line) | Found from the owner's 2026-09-30 export. Across 192 in-session readings of one MG, readings mixed with codes 32/128/160 were 17–60 % of each of the first six nights and 81–83 % of the last two; every low reading on those two nights but one was such a reading, and the readings with no code agreed with the earlier nights. Status words 77/79 and the HR-valid bit do not separate codes from values, so byte 82's own codes are the only strap-side quality signal. Fix shape: judge each reading by what the strap reported during it, never by whether it is low: at least two thirds of its measured seconds in band, and at least three fifths of those within 2 points of its median. A steady, well-covered low reading stays a low reading. Thresholds are from this one strap with no reference oximeter, and the night's figures barely move between one half and four fifths coverage. V0: `testLowQualityReadingsAreNotDipsAndDoNotMoveTheNight` fails on the old resolver (one dip, night low 74). V1: oracle re-captured (28 cases) and matched line for line by an independent Python implementation; on 108 sessions across all 15 backups the Swift resolver and that implementation agree on every figure; package suite 2,200 tests Fixed: the resolver judges each reading (coverage, then agreement) and states a night whose readings all fail as having no reliable reading, as the owner chose; the series gain `spo2_candidate_windows_low_quality` and a gated night's stale mean and minimum are deleted after a persist that landed; `Spo2CandidateSeries.latest` shows that night instead of an older one, and the Today Blood Oxygen tile now resolves its night through the same funnel rather than the sparkline's last value; the card says "no reliable reading", names readings left out, calls them "Low readings" (one 30-second reading about every 20 minutes cannot show a desaturation event) and plots only reliable readings; the strap-log line names what it left out. V2 (independent subagent): one S2 (the reading-count caption claimed a pass on a codes-only night) and nine smaller findings, all fixed on the branch except W03-008. Package suite 2,209, `StrandTests` 2,125, both app builds (NOOPiOS without the watch app), i18n and hygiene gates pass. Still due: a night on the strap with the build | | +| W03-008 | S4 | Reported | The SpO₂ card's night chart resolves reliable readings over the longest session of the day and the worn-timeline union of strap ids, while the figures beside it resolve over all the day's sessions for the scoring owner. A reading that straddles a session edge, or a second strap's rows, can be cut and judged differently, so the chart can show or omit a reading the tiles did not | `Repository.swift` `spo2CandidateReliableTrace`; `IntelligenceEngine.swift` (the resolver call); `HealthView.swift` `loadNightTrace` | Found by W03-007's V2 by reading; not reproduced, and not yet looked for in the owner's data (one strap, so only the session-edge case applies). Fix shape: resolve the trace from the same sessions and owner as the figures, or store the reliable readings' spans with the night | | ## Log @@ -87,3 +88,4 @@ every changed row explained; SleepBench / SleepPSG for any stager change. "N of M" readings, the strap log's window line). - 2026-09-27 — W03-003 checked on the first night scored on the build (V3 row note). W03-005 is not reproducible on this export: its one two-session day's second session holds no byte 82 window, so both resolutions agree. W03-006 recorded from the replay harness. - 2026-09-30 — W03-007 from the owner's 2026-09-30 export (two nights of low SpO₂ candidate readings). Owner decision the same day: a night whose readings all fail the check shows "no reliable reading" rather than the previous night or an average of failed readings. +- 2026-09-30 — W03-007 app side and V2 on `review/w03-spo2-quality`: the independent review found one S2 and nine smaller issues, fixed on the branch; W03-008 recorded from it. From 1f1c88633b712a916999ed0906e55d40fe1825c4 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 10:39:10 +0200 Subject: [PATCH 05/15] docs(review): W03-007's board entry and V2 tally; German "Readings" reads Messungen The shared "Readings" label read "Lesungen" (readings of a text) beside "Messungen" in the captions under it. --- Strand/Resources/Localizable.xcstrings | 2 +- docs/PROTOCOL_SENSORS.md | 9 +++++---- docs/review/README.md | 7 +++++++ docs/review/workstreams/W03-analytics.md | 4 ++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 80a8837832..76d6bed5dc 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -160264,7 +160264,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Lesungen" + "value": "Messungen" } }, "es": { diff --git a/docs/PROTOCOL_SENSORS.md b/docs/PROTOCOL_SENSORS.md index 8767012bd6..2f5704006c 100644 --- a/docs/PROTOCOL_SENSORS.md +++ b/docs/PROTOCOL_SENSORS.md @@ -372,10 +372,11 @@ means; the value remains an unvalidated candidate. - **No other marker.** Neither the status-word pair at 77/79 nor bit 7 of byte 36 separates code records from in-band records inside a window, so the byte's own codes are the only per-record marker observed. -- **Use.** NOOP counts a window only when at least two thirds of its nonzero records - are in band and at least three fifths of those lie within 2 points of the window's - median (W03-007). The thresholds come from this one strap without a reference - oximeter; they judge how the window was reported, not its value. + +NOOP counts a window only when at least two thirds of its nonzero records are in band +and at least three fifths of those lie within 2 points of the window's median +(W03-007). The thresholds come from this one strap without a reference oximeter; they +judge how the window was reported, not its value. An official-app overnight capture of another strap, used by `Tools/linux-capture/validate_spo2_candidate.py`, shows the same 30-record, 1200 s diff --git a/docs/review/README.md b/docs/review/README.md index 0ceee53129..91a3d1e544 100644 --- a/docs/review/README.md +++ b/docs/review/README.md @@ -173,6 +173,13 @@ item as it lands. migrations, 37 tables and about 5.33 M rows, identical. Re-run on the 2026-09-27 evening export from 11.9.13: 50 → 50 migrations, 37 tables and about 5.53 M rows, identical. Re-run on the 2026-09-28 export from 11.9.13: 50 → 50 migrations, 37 tables and about 5.85 M rows, identical. +- [ ] W3 out of phase, `review/w03-spo2-quality` (off `main`), from the owner's 2026-09-30 export: W03-007 + (the SpO₂ candidate leaves low-quality byte-82 readings out of the night and states a night with no + reliable reading as such, the owner's choice). V2 (independent subagent) found one S2, four S3 and + eight S4; all fixed on the branch except W03-008 (recorded). Package suite, `StrandTests`, both builds + (NOOPiOS without the watch app), i18n and hygiene gates pass. Then a strap run: a night on the build + whose card reads "N of M" with the readings left out named, and its strap-log line; ideally one night + with the strap deliberately loose, to see "no reliable reading". - [ ] Still open from Phase 0: AD-4's target (warning-clean vs Swift 6 mode), now that the counts are in. Needed by Phase 3. diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 8a3dbf6bd2..79f16b38fa 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -73,7 +73,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | 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 | | | W03-006 | S3 | Reported | The V3 stager can stage almost no REM on a night where V2 stages a normal amount. On the newest export one night gets under a fifth of V2's REM, while on the other 8 stored nights V3's REM is 0.60–1.36× V2's. No stage truth exists for these nights, so it is unadjudicated | `SleepStager.stageWindow` (V3) | Replay harness over the 2026-09-27 export: V3 reproduces all 9 stored nights byte for byte, so the stored figure is what V3 computes; the strap log's REM funnel (a V1 replay) lands between the two. The night's cardiac inputs are atypical for this wearer. Next: V3's REM probabilities across that night, and a SleepPSG check on nights with atypical cardiac inputs | | -| W03-007 | S3 | Fixed | The SpO₂ candidate took every byte-82 reading at face value. On a night the strap sits loose, most 30-second readings come back mixed with the strap's own non-percentage codes, and the in-band fragments between them jump or settle low; each such reading's median became the night's low and a "dip", so a fit problem read as desaturation. Published wrist-oximetry validations discard 15–50 % of overnight data by their own quality indices, and the FDA's January 2025 draft guidance asks for an indicator of signal inadequacy; NOOP had neither | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `IntelligenceEngine.swift` (series writer); `DebugDataDiagnostics.swift` (strap-log line) | Found from the owner's 2026-09-30 export. Across 192 in-session readings of one MG, readings mixed with codes 32/128/160 were 17–60 % of each of the first six nights and 81–83 % of the last two; every low reading on those two nights but one was such a reading, and the readings with no code agreed with the earlier nights. Status words 77/79 and the HR-valid bit do not separate codes from values, so byte 82's own codes are the only strap-side quality signal. Fix shape: judge each reading by what the strap reported during it, never by whether it is low: at least two thirds of its measured seconds in band, and at least three fifths of those within 2 points of its median. A steady, well-covered low reading stays a low reading. Thresholds are from this one strap with no reference oximeter, and the night's figures barely move between one half and four fifths coverage. V0: `testLowQualityReadingsAreNotDipsAndDoNotMoveTheNight` fails on the old resolver (one dip, night low 74). V1: oracle re-captured (28 cases) and matched line for line by an independent Python implementation; on 108 sessions across all 15 backups the Swift resolver and that implementation agree on every figure; package suite 2,200 tests Fixed: the resolver judges each reading (coverage, then agreement) and states a night whose readings all fail as having no reliable reading, as the owner chose; the series gain `spo2_candidate_windows_low_quality` and a gated night's stale mean and minimum are deleted after a persist that landed; `Spo2CandidateSeries.latest` shows that night instead of an older one, and the Today Blood Oxygen tile now resolves its night through the same funnel rather than the sparkline's last value; the card says "no reliable reading", names readings left out, calls them "Low readings" (one 30-second reading about every 20 minutes cannot show a desaturation event) and plots only reliable readings; the strap-log line names what it left out. V2 (independent subagent): one S2 (the reading-count caption claimed a pass on a codes-only night) and nine smaller findings, all fixed on the branch except W03-008. Package suite 2,209, `StrandTests` 2,125, both app builds (NOOPiOS without the watch app), i18n and hygiene gates pass. Still due: a night on the strap with the build | | +| W03-007 | S3 | Fixed | The SpO₂ candidate took every byte-82 reading at face value. On a night the strap sits loose, most 30-second readings come back mixed with the strap's own non-percentage codes, and the in-band fragments between them jump or settle low; each such reading's median became the night's low and a "dip", so a fit problem read as desaturation. Published wrist-oximetry validations discard 15–50 % of overnight data by their own quality indices, and the FDA's January 2025 draft guidance asks for an indicator of signal inadequacy; NOOP had neither | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `IntelligenceEngine.swift` (series writer); `DebugDataDiagnostics.swift` (strap-log line) | Found from the owner's 2026-09-30 export. Across 192 in-session readings of one MG, readings mixed with codes 32/128/160 were 17–60 % of each of the first six nights and 81–83 % of the last two; every low reading on those two nights but one was such a reading, and the readings with no code agreed with the earlier nights. Status words 77/79 and the HR-valid bit do not separate codes from values, so byte 82's own codes are the only strap-side quality signal. Fix shape: judge each reading by what the strap reported during it, never by whether it is low: at least two thirds of its measured seconds in band, and at least three fifths of those within 2 points of its median. A steady, well-covered low reading stays a low reading. Thresholds are from this one strap with no reference oximeter, and the night's figures barely move between one half and four fifths coverage. V0: `testLowQualityReadingsAreNotDipsAndDoNotMoveTheNight` fails on the old resolver (one dip, night low 74). V1: oracle re-captured (28 cases) and matched line for line by an independent Python implementation; on 108 sessions across all 15 backups the Swift resolver and that implementation agree on every figure; package suite 2,200 tests Fixed: the resolver judges each reading (coverage, then agreement) and states a night whose readings all fail as having no reliable reading, as the owner chose; the series gain `spo2_candidate_windows_low_quality` and a gated night's stale mean and minimum are deleted after a persist that landed; `Spo2CandidateSeries.latest` shows that night instead of an older one, and the Today Blood Oxygen tile now resolves its night through the same funnel rather than the sparkline's last value; the card says "no reliable reading", names readings left out, calls them "Low readings" (one 30-second reading about every 20 minutes cannot show a desaturation event) and plots only reliable readings; the strap-log line names what it left out. V2 (independent subagent): one S2 (the reading-count caption claimed a pass on a codes-only night), four S3 and eight S4; eleven fixed on the branch (the last is the German "Readings" label, which read "Lesungen" beside "Messungen"), one recorded as W03-008. Two edits landed after V2 and are build- and suite-verified only: the Today tile's funnel and the persist guard on the stale-row delete. Package suite 2,209, `StrandTests` 2,125, both app builds (NOOPiOS without the watch app), i18n and hygiene gates pass. Still due: a night on the strap with the build | | | W03-008 | S4 | Reported | The SpO₂ card's night chart resolves reliable readings over the longest session of the day and the worn-timeline union of strap ids, while the figures beside it resolve over all the day's sessions for the scoring owner. A reading that straddles a session edge, or a second strap's rows, can be cut and judged differently, so the chart can show or omit a reading the tiles did not | `Repository.swift` `spo2CandidateReliableTrace`; `IntelligenceEngine.swift` (the resolver call); `HealthView.swift` `loadNightTrace` | Found by W03-007's V2 by reading; not reproduced, and not yet looked for in the owner's data (one strap, so only the session-edge case applies). Fix shape: resolve the trace from the same sessions and owner as the figures, or store the reliable readings' spans with the night | | ## Log @@ -88,4 +88,4 @@ every changed row explained; SleepBench / SleepPSG for any stager change. "N of M" readings, the strap log's window line). - 2026-09-27 — W03-003 checked on the first night scored on the build (V3 row note). W03-005 is not reproducible on this export: its one two-session day's second session holds no byte 82 window, so both resolutions agree. W03-006 recorded from the replay harness. - 2026-09-30 — W03-007 from the owner's 2026-09-30 export (two nights of low SpO₂ candidate readings). Owner decision the same day: a night whose readings all fail the check shows "no reliable reading" rather than the previous night or an average of failed readings. -- 2026-09-30 — W03-007 app side and V2 on `review/w03-spo2-quality`: the independent review found one S2 and nine smaller issues, fixed on the branch; W03-008 recorded from it. +- 2026-09-30 — W03-007 app side and V2 on `review/w03-spo2-quality`: the independent review found one S2, four S3 and eight S4; eleven fixed on the branch, W03-008 recorded from it. From f52ab4a31d07748373ed0c7c86816e5f350f1c95 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 10:56:11 +0200 Subject: [PATCH 06/15] =?UTF-8?q?docs(review):=20record=20the=20code=20rev?= =?UTF-8?q?iew=20of=20W03-007=20(W03-009=20=E2=80=A6=20W03-022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/review/workstreams/W03-analytics.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 79f16b38fa..357ee9eca2 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -75,6 +75,20 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-006 | S3 | Reported | The V3 stager can stage almost no REM on a night where V2 stages a normal amount. On the newest export one night gets under a fifth of V2's REM, while on the other 8 stored nights V3's REM is 0.60–1.36× V2's. No stage truth exists for these nights, so it is unadjudicated | `SleepStager.stageWindow` (V3) | Replay harness over the 2026-09-27 export: V3 reproduces all 9 stored nights byte for byte, so the stored figure is what V3 computes; the strap log's REM funnel (a V1 replay) lands between the two. The night's cardiac inputs are atypical for this wearer. Next: V3's REM probabilities across that night, and a SleepPSG check on nights with atypical cardiac inputs | | | W03-007 | S3 | Fixed | The SpO₂ candidate took every byte-82 reading at face value. On a night the strap sits loose, most 30-second readings come back mixed with the strap's own non-percentage codes, and the in-band fragments between them jump or settle low; each such reading's median became the night's low and a "dip", so a fit problem read as desaturation. Published wrist-oximetry validations discard 15–50 % of overnight data by their own quality indices, and the FDA's January 2025 draft guidance asks for an indicator of signal inadequacy; NOOP had neither | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `IntelligenceEngine.swift` (series writer); `DebugDataDiagnostics.swift` (strap-log line) | Found from the owner's 2026-09-30 export. Across 192 in-session readings of one MG, readings mixed with codes 32/128/160 were 17–60 % of each of the first six nights and 81–83 % of the last two; every low reading on those two nights but one was such a reading, and the readings with no code agreed with the earlier nights. Status words 77/79 and the HR-valid bit do not separate codes from values, so byte 82's own codes are the only strap-side quality signal. Fix shape: judge each reading by what the strap reported during it, never by whether it is low: at least two thirds of its measured seconds in band, and at least three fifths of those within 2 points of its median. A steady, well-covered low reading stays a low reading. Thresholds are from this one strap with no reference oximeter, and the night's figures barely move between one half and four fifths coverage. V0: `testLowQualityReadingsAreNotDipsAndDoNotMoveTheNight` fails on the old resolver (one dip, night low 74). V1: oracle re-captured (28 cases) and matched line for line by an independent Python implementation; on 108 sessions across all 15 backups the Swift resolver and that implementation agree on every figure; package suite 2,200 tests Fixed: the resolver judges each reading (coverage, then agreement) and states a night whose readings all fail as having no reliable reading, as the owner chose; the series gain `spo2_candidate_windows_low_quality` and a gated night's stale mean and minimum are deleted after a persist that landed; `Spo2CandidateSeries.latest` shows that night instead of an older one, and the Today Blood Oxygen tile now resolves its night through the same funnel rather than the sparkline's last value; the card says "no reliable reading", names readings left out, calls them "Low readings" (one 30-second reading about every 20 minutes cannot show a desaturation event) and plots only reliable readings; the strap-log line names what it left out. V2 (independent subagent): one S2 (the reading-count caption claimed a pass on a codes-only night), four S3 and eight S4; eleven fixed on the branch (the last is the German "Readings" label, which read "Lesungen" beside "Messungen"), one recorded as W03-008. Two edits landed after V2 and are build- and suite-verified only: the Today tile's funnel and the persist guard on the stale-row delete. Package suite 2,209, `StrandTests` 2,125, both app builds (NOOPiOS without the watch app), i18n and hygiene gates pass. Still due: a night on the strap with the build | | | W03-008 | S4 | Reported | The SpO₂ card's night chart resolves reliable readings over the longest session of the day and the worn-timeline union of strap ids, while the figures beside it resolve over all the day's sessions for the scoring owner. A reading that straddles a session edge, or a second strap's rows, can be cut and judged differently, so the chart can show or omit a reading the tiles did not | `Repository.swift` `spo2CandidateReliableTrace`; `IntelligenceEngine.swift` (the resolver call); `HealthView.swift` `loadNightTrace` | Found by W03-007's V2 by reading; not reproduced, and not yet looked for in the owner's data (one strap, so only the session-edge case applies). Fix shape: resolve the trace from the same sessions and owner as the figures, or store the reliable readings' spans with the night | | +| W03-009 | S3 | Reported | The Health screen's Blood Oxygen tile resolves the candidate from the mean series with its own carry, not through `Spo2CandidateSeries.latest`: when the newest night has no reliable reading it shows an older night's mean, and when every recent night is gated it says "toggle ON · no estimate yet" above a card that says "no reliable reading" | `VitalSignsSummary.swift` (the spo2 candidate row) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading: the tile builds `spo2CandidatePoints` from `spo2CandidateByDay` (the mean series), which a gated night lacks. | | +| W03-010 | S3 | Reported | A night of codes-only readings became a "no reliable reading" night whose notice says none of its readings passed the quality check and names a loose strap, though those readings had no value to check and nothing attributes them to fit | `Spo2EstimateCard.swift` (the notice) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading: codes-only readings are `.noValue`, not low quality, yet the notice text does not distinguish them. | | +| W03-011 | S3 | Reported | A leftover mean on a gated night reaches every reader that skips the funnel: the stale-row delete runs per day under the canonical computed id only, while `exploreSeries` also unions the active strap's computed sibling, and a failed delete leaves the row | `IntelligenceEngine.swift` (the stale-row delete); `Repository.exploreSeries` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading `computedReadIds`. Fix shape chosen: make every surface read through one funnel that ignores a mean on a gated night, rather than widen a delete across ids (W02-005 showed how a delete over the shared canonical id loses data). | | +| W03-012 | S4 | Reported | Today formats the candidate mean with `%.0f`, which rounds halves to even, while the card uses `meanRounded`: a 96.5 night shows 96 % on Today and 97 % on Health | `TodayView.swift` (Blood Oxygen tile and card) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: run standalone, `String(format: "%.0f", 96.5)` prints 96 and `Int(96.5.rounded())` 97; 94.5 gives 94 and 95. | | +| W03-013 | S3 | Reported | Both quality gates are ratios with no floor, so a reading of one in-band second always passes, and a fragment is exactly what the gate is for | `Spo2CandidateNight.swift` `Spo2CandidateWindow.quality` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: the re-captured oracle's `all-bytes-per-window` line judges 31 one-second readings reliable. On the owner's 15 backups 302 of 303 distinct readings have 30 measured seconds and every reliable one has 20 or more in-band seconds; the one short reading is cut by a session edge. | | +| W03-014 | S4 | Reported | `spo2CandidateReliableTrace` merges up to 200k rows per strap id on the main actor, and dedups whole rows before reading byte 82, so a first strap's row without a byte-82 value hides a second strap's in-band value at the same second | `Repository.swift` `spo2CandidateReliableTrace` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the old per-point merge kept the first in-band value per second. | | +| W03-015 | S4 | Reported | The Liquid Today Blood Oxygen tile shows a bare "—" on a gated night where the classic Today tile says "no reliable reading" | `LiquidTodayView.swift` (Blood Oxygen tile) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | +| W03-016 | S4 | Reported | `loadSpo2CandidateNight` went in between `sparkValuesExplore` and its doc block, so the doc now documents the wrong function | `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the placement AGENTS.md warns about. | | +| W03-017 | S4 | Reported | Today's candidate night costs four sequential series reads after the concurrent block, one of them the mean the sparkline already read | `TodayView.swift` `loadSpo2CandidateNight` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | +| W03-018 | S4 | Reported | Loading the candidate series and resolving the night is written twice, in HealthView and TodayView, with different inputs and windows, so a key added to one can miss the other | `HealthView.swift`; `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | +| W03-019 | S4 | Reported | `nightlySpo2CandidateMean`'s doc says it returns nil only when no in-band reading fell inside any span; it now also returns nil when none is reliable | `AnalyticsEngine.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | +| W03-020 | S4 | Reported | The Low tile still captions a low night "dipped below 90%" beside the tile this change renamed from Dips to Low readings | `Spo2EstimateCard.swift` `lowCaption` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | +| W03-021 | S4 | Reported | "%lld left out as low quality" has no plural variants, so a count of 1 is ungrammatical in es, fr, it and pt-PT, and ru and pl need several forms | `Localizable.xcstrings` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading the catalog entry. | | +| W03-022 | S4 | Reported | W03-007's fix commits did not carry the tracker updates that describe them; the entries came in later commits | branch history | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). Confirmed. The branch is not pushed; the history is left as it is and every later fix carries its own row update. | | ## Log From 447512b1dcfd9b7e6c3d7c0a0ec6c67ed1583bc1 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 11:06:54 +0200 Subject: [PATCH 07/15] =?UTF-8?q?fix(analytics):=20judge=20no=20SpO?= =?UTF-8?q?=E2=82=82=20reading=20on=20fewer=20than=20ten=20in-band=20secon?= =?UTF-8?q?ds=20(W03-013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both quality gates are ratios, and one in-band second of one measured passes both, so a reading a session edge cuts to a fragment could still become the night's low. A reading now needs ten in-band seconds (a third of the strap's reading) before the ratios judge it. No real reading moves: on 15 backups every reliable reading has twenty or more. --- .../StrandAnalytics/AnalyticsEngine.swift | 10 ++- .../StrandAnalytics/Spo2CandidateNight.swift | 23 +++++-- .../Spo2CandidateNightStatsTests.swift | 69 ++++++++++++------- .../Spo2CandidateNightlyTests.swift | 30 ++++---- .../Spo2CandidateQualityTests.swift | 12 ++++ docs/PROTOCOL_SENSORS.md | 6 +- docs/review/workstreams/W03-analytics.md | 2 +- 7 files changed, 104 insertions(+), 48 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 049e08d4db..937ba4157d 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -1244,9 +1244,13 @@ public enum AnalyticsEngine { /// 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), let mean = night.meanRounded else { + public static func nightlySpo2CandidateMean( + _ sessions: [SleepSession], + aux: [V18AuxSample], + minimumSeconds: Int = AnalyticsEngine.spo2CandidateMinReadingSeconds + ) -> (mean: Int, samples: Int)? { + guard let night = nightlySpo2CandidateNight(sessions, aux: aux, minimumSeconds: minimumSeconds), + let mean = night.meanRounded else { return nil } return (mean: mean, samples: night.samples) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift index 48ce6c062d..da2972ce5e 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift @@ -42,7 +42,8 @@ public enum Spo2CandidateReadingQuality: Equatable, Sendable { /// Enough in-band seconds, and most of them agree with the reading's middle value. case reliable /// Fewer than `AnalyticsEngine.spo2CandidateMinCoverage` of the reading's measured seconds carried an - /// in-band value; the rest were the strap's own non-percentage codes. + /// in-band value (the rest were the strap's own non-percentage codes), or fewer in-band seconds than + /// the floor (`AnalyticsEngine.spo2CandidateMinReadingSeconds`) to judge at all. case lowCoverage /// Enough seconds, but too few of them sit near the middle value: the reading swept across the band /// instead of settling on a figure. @@ -85,9 +86,14 @@ struct Spo2CandidateWindow { /// `spo2CandidateAgreementTolerance` of its median has settled on a figure. That test, rather than a /// cap on the reading's spread, is what tolerates the settling ramp a clean reading can open with /// (four seconds climbing into a steady value) while still refusing a reading that sweeps the band. - var quality: Spo2CandidateReadingQuality { + /// + /// Both tests are ratios, and a ratio cannot judge a fragment: one in-band second of one measured is + /// full coverage and full agreement. `minimumSeconds` is the floor below which a reading is too short + /// to stand for anything (W03-013). + func quality(minimumSeconds: Int = AnalyticsEngine.spo2CandidateMinReadingSeconds) -> Spo2CandidateReadingQuality { guard let median = value else { return .noValue } let n = inBand.count + guard n >= minimumSeconds else { return .lowCoverage } let cover = AnalyticsEngine.spo2CandidateMinCoverage guard n * cover.denominator >= measuredSeconds * cover.numerator else { return .lowCoverage } let tol = AnalyticsEngine.spo2CandidateAgreementTolerance @@ -219,6 +225,12 @@ extension AnalyticsEngine { /// validated wrist oximeters discard 15–50 % of overnight data by their own quality indices. public static let spo2CandidateMinCoverage = (numerator: 2, denominator: 3) + /// W03-013: the fewest in-band seconds a reading needs before the two ratios can judge it. Ten, a + /// third of the strap's 30-record reading. On the owner's 15 backups 302 of 303 distinct readings + /// have 30 measured seconds and every reliable one has 20 or more in-band seconds, so the floor moves + /// no real reading; it exists for a reading a session edge cuts to a few seconds. + public static let spo2CandidateMinReadingSeconds = 10 + /// W03-007: how far, in points, an in-band second may sit from its reading's median and still agree /// with it. On the same MG, 70 of 74 clean readings (no code, 28+ in-band seconds) never step more /// than 2 points between consecutive seconds. @@ -282,11 +294,12 @@ extension AnalyticsEngine { _ sessions: [SleepSession], aux: [V18AuxSample], threshold: Int = AnalyticsEngine.spo2CandidateDipThreshold, - windowGapSeconds: Int = AnalyticsEngine.spo2CandidateWindowGapSeconds + windowGapSeconds: Int = AnalyticsEngine.spo2CandidateWindowGapSeconds, + minimumSeconds: Int = AnalyticsEngine.spo2CandidateMinReadingSeconds ) -> Spo2CandidateNight? { let windows = spo2CandidateWindows(sessions, aux: aux, windowGapSeconds: windowGapSeconds) guard !windows.isEmpty else { return nil } - let quality = windows.map(\.quality) + let quality = windows.map { $0.quality(minimumSeconds: minimumSeconds) } // `value` is non-nil for every reliable window: `quality` returns `.noValue` first otherwise. let reliable = zip(windows, quality).filter { $0.1 == .reliable } .map { (window: $0.0, value: $0.0.value!) } @@ -317,7 +330,7 @@ extension AnalyticsEngine { _ sessions: [SleepSession], aux: [V18AuxSample] ) -> [(ts: Int, value: Int)] { - spo2CandidateWindows(sessions, aux: aux).filter { $0.quality == .reliable }.flatMap(\.inBand) + spo2CandidateWindows(sessions, aux: aux).filter { $0.quality() == .reliable }.flatMap(\.inBand) } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift index 3f4582bd77..7747df54f9 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift @@ -10,7 +10,7 @@ import WhoopProtocol /// 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. Re-captured for W03-007's reading-quality gate (28 cases, matched line for line +/// and pinned verbatim. Re-captured for W03-007's reading-quality gate (28 cases, 31 with W03-013's floor, matched line for line /// by a new independent Python implementation); the pre-existing lines that moved are the ones whose /// windows are mostly codes or whose seconds disagree. The named tests after it say what each line is FOR, so a diff explains itself /// instead of only failing. @@ -21,10 +21,20 @@ final class Spo2CandidateNightStatsTests: XCTestCase { } private func aux(_ ts: Int, _ v: Int?) -> V18AuxSample { V18AuxSample(ts: ts, auxByte82: v) } + /// The resolver with the in-band floor lowered to one second (W03-013). The cases in this file pin + /// windowing, medians, thresholds and bounds on short synthetic readings, which the default floor of + /// ten seconds would turn into low-quality readings; the floor itself is pinned at its default by the + /// oracle's `floor-*` lines and `Spo2CandidateQualityTests`. + private func resolve(_ sessions: [SleepSession], aux: [V18AuxSample], + threshold: Int = AnalyticsEngine.spo2CandidateDipThreshold) -> Spo2CandidateNight? { + AnalyticsEngine.nightlySpo2CandidateNight(sessions, aux: aux, threshold: threshold, minimumSeconds: 1) + } + /// One case's whole result as a single line — the format the oracle was captured in. `lq` is the /// low-quality readings as coverage+unsettled (W03-007); `-` is a figure the night does not state. - private func line(_ name: String, _ sessions: [SleepSession], _ samples: [V18AuxSample]) -> String { - guard let n = AnalyticsEngine.nightlySpo2CandidateNight(sessions, aux: samples) else { + private func line(_ name: String, _ sessions: [SleepSession], _ samples: [V18AuxSample], + floor: Int = 1) -> String { + guard let n = AnalyticsEngine.nightlySpo2CandidateNight(sessions, aux: samples, minimumSeconds: floor) else { return "\(name) -> nil" } let ev = n.events.map { "\($0.start)-\($0.end)/n\($0.nadir)/s\($0.samples)/sp\($0.spanSeconds)" } @@ -97,6 +107,13 @@ final class Spo2CandidateNightStatsTests: XCTestCase { window(0, [95, 95, 95, 80, 99]))) out.append(line("agreement-just-under", [sess(0, 600)], (0..<29).map { aux($0, $0 % 29 < 17 ? 95 : ($0 % 2 == 0 ? 80 : 99)) })) + // W03-013: the in-band floor at its default of ten seconds. Nine seconds of an otherwise clean + // reading is too short to judge; ten is enough; a session edge that cuts a real reading to three + // seconds leaves a fragment, not a reading. + out.append(line("floor-nine-seconds", [sess(0, 600)], (0..<9).map { aux($0, 95) }, floor: 10)) + out.append(line("floor-ten-seconds", [sess(0, 600)], (0..<10).map { aux($0, 95) }, floor: 10)) + out.append(line("floor-edge-fragment", [sess(0, 1202)], + window(0, [95, 96]) + window(1200, [74, 75]), floor: 10)) out.append(line("mixed-night", [sess(0, 4 * 1200)], window(0, [95, 96]) + window(1200, [160, 74]) + window(2400, [87, 88, 87]) + window(3600, [72, 75, 80, 85, 90, 95, 98, 100, 100, 99]))) @@ -129,6 +146,9 @@ final class Spo2CandidateNightStatsTests: XCTestCase { coverage-just-under -> mean=- rounded=- min=- max=- samples=0 windows=0/1 lq=1+0 thr=90 below=0 secs=0 nadir=- events=[] agreement-exactly-three-fifths -> mean=95.000000 rounded=95 min=95 max=95 samples=30 windows=1/1 lq=0+0 thr=90 below=0 secs=0 nadir=- events=[] agreement-just-under -> mean=- rounded=- min=- max=- samples=0 windows=0/1 lq=0+1 thr=90 below=0 secs=0 nadir=- events=[] + floor-nine-seconds -> mean=- rounded=- min=- max=- samples=0 windows=0/1 lq=1+0 thr=90 below=0 secs=0 nadir=- events=[] + floor-ten-seconds -> mean=95.000000 rounded=95 min=95 max=95 samples=10 windows=1/1 lq=0+0 thr=90 below=0 secs=0 nadir=- events=[] + floor-edge-fragment -> mean=95.000000 rounded=95 min=95 max=95 samples=30 windows=1/2 lq=1+0 thr=90 below=0 secs=0 nadir=- events=[] mixed-night -> mean=91.000000 rounded=91 min=87 max=95 samples=60 windows=2/4 lq=1+1 thr=90 below=30 secs=29 nadir=87 events=[2400-2429/n87/s30/sp29] """ XCTAssertEqual(out.joined(separator: "\n"), expected) @@ -142,7 +162,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// 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( + let n = resolve( [sess(0, 256 * 1200)], aux: (0..<256).map { aux($0 * 1200, $0) }) XCTAssertEqual(n?.samples, 31) XCTAssertEqual(n?.windows, 31) @@ -155,7 +175,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// 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( + let n = resolve( [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) @@ -166,7 +186,8 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// 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)] + // Twelve seconds, so the reading clears the default in-band floor both functions apply. + let samples = (0..<12).map { aux(1000 + $0, [96, 95, 77, 95][$0 % 4]) } let legacy = AnalyticsEngine.nightlySpo2CandidateMean(sessions, aux: samples) let night = AnalyticsEngine.nightlySpo2CandidateNight(sessions, aux: samples) XCTAssertEqual(legacy?.mean, night?.meanRounded) @@ -182,14 +203,14 @@ final class Spo2CandidateNightStatsTests: XCTestCase { + [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)) + XCTAssertEqual(resolve([sess(1000, 1800)], aux: ordered), + resolve([sess(1000, 1800)], aux: shuffled)) } /// 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( + let n = resolve( [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) @@ -203,9 +224,9 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// one second more makes two. func testTheWindowGapBudgetIsInclusive() { let gap = AnalyticsEngine.spo2CandidateWindowGapSeconds - let joined = AnalyticsEngine.nightlySpo2CandidateNight( + let joined = resolve( [sess(1000, 600)], aux: [aux(1000, 95), aux(1000 + gap, 95)]) - let split = AnalyticsEngine.nightlySpo2CandidateNight( + let split = resolve( [sess(1000, 600)], aux: [aux(1000, 95), aux(1001 + gap, 95)]) XCTAssertEqual(joined?.windows, 1) XCTAssertEqual(split?.windows, 2) @@ -216,7 +237,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// 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) + let n = resolve([sess(0, 8 * 3600)], aux: night) XCTAssertEqual(n?.minimum, 85) XCTAssertEqual(n?.events.count, 10) XCTAssertEqual(n?.windows, 480) @@ -226,7 +247,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// 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) + let n = resolve([sess(1000, 600)], aux: stretched) XCTAssertEqual(n?.windowsAttempted, 1) XCTAssertEqual(n?.events.count, 0) XCTAssertEqual(n?.minimum, 96) @@ -239,7 +260,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { 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)], + let n = resolve([sess(1000, 1800)], aux: window1 + window2 + [aux(3000, 0)]) XCTAssertEqual(n?.windowsAttempted, 2) XCTAssertEqual(n?.windowsLowCoverage, 1) @@ -250,7 +271,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// Two in-band seconds in two seconds measured is full coverage, and the value is the lower of the /// two middle readings: always one the strap reported. func testAnEvenCountTakesTheLowerMiddleReading() { - let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 600)], aux: [aux(1000, 96), aux(1001, 95)]) + let n = resolve([sess(1000, 600)], aux: [aux(1000, 96), aux(1001, 95)]) XCTAssertEqual(n?.minimum, 95) } @@ -259,7 +280,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// `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( + let n = resolve( [sess(1000, 1800)], aux: [aux(1001, 88), aux(2200, 96)]) XCTAssertEqual(n?.events.count, 1) XCTAssertEqual(n?.dipSpanSeconds, 0) @@ -271,7 +292,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// 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) + let n = resolve([sess(1000, 600)], aux: window) XCTAssertEqual(n?.events.count, 1) XCTAssertEqual(n?.nadir, 89) XCTAssertEqual(n?.dipSamples, 30) @@ -281,7 +302,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// `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( + let n = resolve( [sess(1000, 1800)], aux: [aux(1000, 93), aux(2200, 98)]) XCTAssertNil(n?.nadir) XCTAssertEqual(n?.minimum, 93) @@ -292,8 +313,8 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// assuming the default. func testThresholdIsCarriedAndHonoured() { 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(resolve([sess(1000, 1800)], aux: samples)?.threshold, 90) + let strict = resolve([sess(1000, 1800)], aux: samples, threshold: 95) XCTAssertEqual(strict?.threshold, 95) XCTAssertEqual(strict?.dipSamples, 1) XCTAssertEqual(strict?.nadir, 93) @@ -304,7 +325,7 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// 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) + let n = resolve([sess(1000, 600)], aux: window) XCTAssertEqual(n?.events.count, 0) XCTAssertEqual(n?.minimum, 95) XCTAssertEqual(n?.windows, 1) @@ -314,11 +335,11 @@ final class Spo2CandidateNightStatsTests: XCTestCase { /// 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() { - let n = AnalyticsEngine.nightlySpo2CandidateNight( + let n = resolve( [sess(1000, 100)], aux: [aux(999, 80), aux(1000, 91), aux(1100, 89), aux(1101, 70)]) XCTAssertEqual(n?.samples, 2) - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateNight([sess(1000, 100)], aux: [aux(5000, 95)])) - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateNight([], aux: [aux(1000, 95)])) + XCTAssertNil(resolve([sess(1000, 100)], aux: [aux(5000, 95)])) + XCTAssertNil(resolve([], aux: [aux(1000, 95)])) } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightlyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightlyTests.swift index 583b9db6d4..45534c449a 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightlyTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightlyTests.swift @@ -18,8 +18,14 @@ final class Spo2CandidateNightlyTests: XCTestCase { private func aux(_ ts: Int, _ v: Int?) -> V18AuxSample { V18AuxSample(ts: ts, auxByte82: v) } + /// The mean with the in-band floor lowered to one second (W03-013): these cases pin the gate on + /// single readings, which the default ten-second floor would turn into low-quality readings. + private func mean(_ sessions: [SleepSession], aux: [V18AuxSample]) -> (mean: Int, samples: Int)? { + AnalyticsEngine.nightlySpo2CandidateMean(sessions, aux: aux, minimumSeconds: 1) + } + func testMeanAndSampleCountOverTheSession() { - let r = AnalyticsEngine.nightlySpo2CandidateMean( + let r = mean( [session(1000, 600)], aux: [aux(1100, 94), aux(1200, 96), aux(1300, 95)]) XCTAssertEqual(r?.mean, 95) XCTAssertEqual(r?.samples, 3) @@ -29,14 +35,14 @@ final class Spo2CandidateNightlyTests: XCTestCase { /// and a volunteer's correlation built on the first would be worthless. func testSampleCountTravelsWithTheMean() { let many = (0..<50).map { aux(1000 + $0, 93) } - XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateMean([session(900, 600)], aux: many)?.samples, 50) + XCTAssertEqual(mean([session(900, 600)], aux: many)?.samples, 50) } /// Out-of-band values are DIAGNOSTIC CODES and SATURATION SENTINELS, not low blood oxygen. Averaging /// them in would produce a number that is not a percentage of anything — the single most damaging /// thing this helper could do, because it would look plausible. func testSubSeventyAndSentinelValuesAreExcluded() { - let r = AnalyticsEngine.nightlySpo2CandidateMean( + let r = mean( [session(1000, 600)], aux: [aux(1100, 94), aux(1150, 3), aux(1200, 0), aux(1250, 0x80), aux(1300, 96)]) XCTAssertEqual(r?.mean, 95, "only the two in-band readings may count") @@ -44,22 +50,22 @@ final class Spo2CandidateNightlyTests: XCTestCase { } func testDaytimeSamplesOutsideTheSessionAreExcluded() { - let r = AnalyticsEngine.nightlySpo2CandidateMean( + let r = mean( [session(1000, 600)], aux: [aux(500, 99), aux(1100, 94), aux(5000, 88)]) XCTAssertEqual(r?.samples, 1) XCTAssertEqual(r?.mean, 94) } func testNilWhenNothingInBandFallsInsideASession() { - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: [aux(1100, 5)])) - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: [aux(9999, 95)])) - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([], aux: [aux(1100, 95)])) - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean([session(1000, 600)], aux: [])) + XCTAssertNil(mean([session(1000, 600)], aux: [aux(1100, 5)])) + XCTAssertNil(mean([session(1000, 600)], aux: [aux(9999, 95)])) + XCTAssertNil(mean([], aux: [aux(1100, 95)])) + XCTAssertNil(mean([session(1000, 600)], aux: [])) } /// A record with no @82 at all is not a zero reading. func testMissingBytesAreSkippedNotCountedAsZero() { - let r = AnalyticsEngine.nightlySpo2CandidateMean( + let r = mean( [session(1000, 600)], aux: [aux(1100, nil), aux(1200, 96)]) XCTAssertEqual(r?.mean, 96) XCTAssertEqual(r?.samples, 1) @@ -69,16 +75,16 @@ final class Spo2CandidateNightlyTests: XCTestCase { /// helper shape. `sum / kept` on two `Int`s truncates toward zero; three samples averaging /// 97.666... must round to 98, not floor to 97. func testMeanRoundsRatherThanFloors() { - let r = AnalyticsEngine.nightlySpo2CandidateMean( + let r = mean( [session(1000, 600)], aux: [aux(1100, 98), aux(1200, 98), aux(1300, 97)]) XCTAssertEqual(r?.mean, 98, "97.666... must round to 98, not floor to 97") } /// The boundaries are inclusive, matching the decoder's own `70...100` emit gate exactly. func testBandBoundariesMatchTheDecoderGate() { - XCTAssertEqual(AnalyticsEngine.nightlySpo2CandidateMean( + XCTAssertEqual(mean( [session(1000, 600)], aux: [aux(1100, 70), aux(1200, 100)])?.samples, 2) - XCTAssertNil(AnalyticsEngine.nightlySpo2CandidateMean( + XCTAssertNil(mean( [session(1000, 600)], aux: [aux(1100, 69), aux(1200, 101)])) } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift index 0adedb8e09..31018ebf17 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateQualityTests.swift @@ -68,6 +68,18 @@ final class Spo2CandidateQualityTests: XCTestCase { XCTAssertEqual(quality(spread(17))?.low, 1) } + /// W03-013: a ratio cannot judge a fragment. A reading cut to a few seconds by a session edge passes + /// both ratios trivially (one in-band second of one measured is full coverage and full agreement), + /// so it needs a floor; below it, it is low quality, never the night's low. + func testAFewSecondFragmentIsNotAReliableReading() { + let fragment = [aux(1190, 74)] + let n = AnalyticsEngine.nightlySpo2CandidateNight([sess(0, 1190)], aux: reading(0, clean) + fragment) + XCTAssertEqual(n?.windows, 1) + XCTAssertEqual(n?.windowsLowCoverage, 1) + XCTAssertEqual(n?.minimum, 96) + XCTAssertTrue(n!.events.isEmpty) + } + /// A night on which the strap attempted readings and none was reliable is NOT nil: it is a night /// with no reliable reading, which is exactly what a surface needs to say. func testANightOfFailedReadingsIsStatedNotDropped() { diff --git a/docs/PROTOCOL_SENSORS.md b/docs/PROTOCOL_SENSORS.md index 2f5704006c..92af0ca634 100644 --- a/docs/PROTOCOL_SENSORS.md +++ b/docs/PROTOCOL_SENSORS.md @@ -373,9 +373,9 @@ means; the value remains an unvalidated candidate. separates code records from in-band records inside a window, so the byte's own codes are the only per-record marker observed. -NOOP counts a window only when at least two thirds of its nonzero records are in band -and at least three fifths of those lie within 2 points of the window's median -(W03-007). The thresholds come from this one strap without a reference oximeter; they +NOOP counts a window only when it holds at least ten in-band records, at least two thirds +of its nonzero records are in band, and at least three fifths of those lie within 2 +points of the window's median (W03-007, W03-013). The thresholds come from this one strap without a reference oximeter; they judge how the window was reported, not its value. An official-app overnight capture of another strap, used by diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 357ee9eca2..0d040c72f8 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -79,7 +79,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-010 | S3 | Reported | A night of codes-only readings became a "no reliable reading" night whose notice says none of its readings passed the quality check and names a loose strap, though those readings had no value to check and nothing attributes them to fit | `Spo2EstimateCard.swift` (the notice) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading: codes-only readings are `.noValue`, not low quality, yet the notice text does not distinguish them. | | | W03-011 | S3 | Reported | A leftover mean on a gated night reaches every reader that skips the funnel: the stale-row delete runs per day under the canonical computed id only, while `exploreSeries` also unions the active strap's computed sibling, and a failed delete leaves the row | `IntelligenceEngine.swift` (the stale-row delete); `Repository.exploreSeries` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading `computedReadIds`. Fix shape chosen: make every surface read through one funnel that ignores a mean on a gated night, rather than widen a delete across ids (W02-005 showed how a delete over the shared canonical id loses data). | | | W03-012 | S4 | Reported | Today formats the candidate mean with `%.0f`, which rounds halves to even, while the card uses `meanRounded`: a 96.5 night shows 96 % on Today and 97 % on Health | `TodayView.swift` (Blood Oxygen tile and card) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: run standalone, `String(format: "%.0f", 96.5)` prints 96 and `Int(96.5.rounded())` 97; 94.5 gives 94 and 95. | | -| W03-013 | S3 | Reported | Both quality gates are ratios with no floor, so a reading of one in-band second always passes, and a fragment is exactly what the gate is for | `Spo2CandidateNight.swift` `Spo2CandidateWindow.quality` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: the re-captured oracle's `all-bytes-per-window` line judges 31 one-second readings reliable. On the owner's 15 backups 302 of 303 distinct readings have 30 measured seconds and every reliable one has 20 or more in-band seconds; the one short reading is cut by a session edge. | | +| W03-013 | S3 | Fixed | Both quality gates are ratios with no floor, so a reading of one in-band second always passes, and a fragment is exactly what the gate is for | `Spo2CandidateNight.swift` `Spo2CandidateWindow.quality` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: the re-captured oracle's `all-bytes-per-window` line judges 31 one-second readings reliable. On the owner's 15 backups 302 of 303 distinct readings have 30 measured seconds and every reliable one has 20 or more in-band seconds; the one short reading is cut by a session edge. Fixed: a reading needs at least ten in-band seconds (`spo2CandidateMinReadingSeconds`, a third of the strap's reading) before the ratios judge it; below that it is low coverage. V0: `testAFewSecondFragmentIsNotAReliableReading` fails on the ratios alone (night low 74 from a one-second fragment). V1: the oracle keeps its 28 lines at a floor of one and adds three at the default (31, matched line for line by the independent Python twin); on 108 sessions across 15 backups the twin agrees and every figure is byte-identical to before the floor. Tests that pin windowing on short synthetic readings run at a floor of one through a helper | | | W03-014 | S4 | Reported | `spo2CandidateReliableTrace` merges up to 200k rows per strap id on the main actor, and dedups whole rows before reading byte 82, so a first strap's row without a byte-82 value hides a second strap's in-band value at the same second | `Repository.swift` `spo2CandidateReliableTrace` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the old per-point merge kept the first in-band value per second. | | | W03-015 | S4 | Reported | The Liquid Today Blood Oxygen tile shows a bare "—" on a gated night where the classic Today tile says "no reliable reading" | `LiquidTodayView.swift` (Blood Oxygen tile) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | | W03-016 | S4 | Reported | `loadSpo2CandidateNight` went in between `sparkValuesExplore` and its doc block, so the doc now documents the wrong function | `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the placement AGENTS.md warns about. | | From 542a70524ae0a382a756c09d33abd017ea3e608f Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 11:13:26 +0200 Subject: [PATCH 08/15] =?UTF-8?q?fix(app):=20resolve=20every=20SpO?= =?UTF-8?q?=E2=82=82=20candidate=20surface=20through=20one=20read=20(W03-0?= =?UTF-8?q?09,=20W03-011,=20W03-012,=20W03-015=20=E2=80=A6=20W03-018)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spo2CandidateSeries.Read holds every candidate series and answers the newest night, a given day's night and the nightly means, leaving out a mean left on a night with no reliable reading. Repository loads it once; the Health card and tile, classic and Liquid Today and the Metric Explorer resolve through it. The Health tile stops carrying an older mean past a newest night with no reliable reading; Today and Liquid round with meanRounded; Liquid names a night with no reliable reading; Today drops its second mean read and a misplaced doc block. --- .../StrandAnalytics/Spo2CandidateNight.swift | 84 ++++++++++++++++--- .../Spo2CandidateNightStatsTests.swift | 32 +++++++ Strand/Data/Repository.swift | 24 ++++++ Strand/Liquid/LiquidTodayView.swift | 39 +++++---- Strand/Screens/HealthView.swift | 63 +++----------- Strand/Screens/MetricExplorerView.swift | 4 +- Strand/Screens/TodayView.swift | 58 ++++++------- Strand/Screens/VitalSignsSummary.swift | 18 +++- StrandTests/VitalSourceResolutionTests.swift | 28 +++++++ docs/review/workstreams/W03-analytics.md | 14 ++-- 10 files changed, 242 insertions(+), 122 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift index da2972ce5e..9b528aa25d 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift @@ -446,9 +446,9 @@ public enum Spo2CandidateSeries { /// `lowQualityKey` and `windowsAttemptedKey` present, and `windowsKey` 0 (W03-007). Any other night /// present only in a companion series is a half-written night and is skipped rather than shown with a /// blank headline. On a gated night with no reliable reading a mean row is IGNORED even if present: - /// the writer deletes it, and a delete that failed must not bring back an average the resolver - /// refused to state. `latest` picks the highest day key present, which is a plain lexicographic max - /// because the keys are `YYYY-MM-DD`. + /// the writer deletes it, and a delete that failed, or a mean left under another computed id the + /// reader unions, must not bring back an average the resolver refused to state. `latest` picks the + /// highest day key present, which is a plain lexicographic max because the keys are `YYYY-MM-DD`. /// /// Passing the dictionaries in (rather than each surface reading its own) is the point: the mean, the /// minimum and the dip count on one card must describe ONE night, and a surface that resolved each @@ -462,16 +462,74 @@ public enum Spo2CandidateSeries { windows: [String: Double] = [:], windowsAttempted: [String: Double] = [:], lowQuality: [String: Double] = [:]) -> Night? { - func noReliableReading(_ d: String) -> Bool { - lowQuality[d] != nil && windowsAttempted[d] != nil && windows[d].map { $0.rounded() == 0 } == true + Read(mean: mean, minimum: minimum, dips: dips, dipSeconds: dipSeconds, samples: samples, + windows: windows, windowsAttempted: windowsAttempted, lowQuality: lowQuality).latest + } + + /// Every candidate series as one value, loaded once by a surface and resolved through the rules above + /// (W03-011, W03-018). Each surface used to load the keys it cared about and resolve the night its own + /// way, and the ones that read only the mean kept showing an older night's average beside a card that + /// said the newest night had no reliable reading. A surface now holds one `Read` and asks it for the + /// night (`latest`, `night(on:)`) or for the means (`meanByDay`). + public struct Read: Equatable, Sendable { + public var mean: [String: Double] + public var minimum: [String: Double] + public var dips: [String: Double] + public var dipSeconds: [String: Double] + public var samples: [String: Double] + public var windows: [String: Double] + public var windowsAttempted: [String: Double] + public var lowQuality: [String: Double] + + /// Every key a `Read` is built from, for the loader. Spelled from the constants above. + public static let keys = [meanKey, minimumKey, dipsKey, dipSecondsKey, samplesKey, windowsKey, + windowsAttemptedKey, lowQualityKey] + + public init(mean: [String: Double] = [:], minimum: [String: Double] = [:], + dips: [String: Double] = [:], dipSeconds: [String: Double] = [:], + samples: [String: Double] = [:], windows: [String: Double] = [:], + windowsAttempted: [String: Double] = [:], lowQuality: [String: Double] = [:]) { + self.mean = mean; self.minimum = minimum; self.dips = dips; self.dipSeconds = dipSeconds + self.samples = samples; self.windows = windows; self.windowsAttempted = windowsAttempted + self.lowQuality = lowQuality + } + + /// Built from a loader's `key → (day → value)` map, keyed by `keys`. + public init(byKey: [String: [String: Double]]) { + self.init(mean: byKey[meanKey] ?? [:], minimum: byKey[minimumKey] ?? [:], + dips: byKey[dipsKey] ?? [:], dipSeconds: byKey[dipSecondsKey] ?? [:], + samples: byKey[samplesKey] ?? [:], windows: byKey[windowsKey] ?? [:], + windowsAttempted: byKey[windowsAttemptedKey] ?? [:], + lowQuality: byKey[lowQualityKey] ?? [:]) + } + + /// A quality-gated night on which nothing was reliable. + private func noReliableReading(_ day: String) -> Bool { + lowQuality[day] != nil && windowsAttempted[day] != nil + && windows[day].map { $0.rounded() == 0 } == true } - let days = Set(mean.keys).union(lowQuality.keys.filter(noReliableReading)) - guard let day = days.max() else { return nil } - func int(_ d: [String: Double]) -> Int? { d[day].map { Int($0.rounded()) } } - let empty = noReliableReading(day) - return Night(day: day, mean: empty ? nil : mean[day], minimum: empty ? nil : int(minimum), - dips: int(dips), dipSeconds: int(dipSeconds), samples: int(samples), - windows: int(windows), windowsAttempted: int(windowsAttempted), - windowsLowQuality: int(lowQuality)) + + /// The days that are nights at all (see `latest(mean:…)`). + private var nightDays: Set { + Set(mean.keys).union(lowQuality.keys.filter(noReliableReading)) + } + + /// The newest night. + public var latest: Night? { nightDays.max().flatMap(night(on:)) } + + /// The night of `day`, or nil when `day` is not a night. + public func night(on day: String) -> Night? { + guard nightDays.contains(day) else { return nil } + func int(_ d: [String: Double]) -> Int? { d[day].map { Int($0.rounded()) } } + let empty = noReliableReading(day) + return Night(day: day, mean: empty ? nil : mean[day], minimum: empty ? nil : int(minimum), + dips: int(dips), dipSeconds: int(dipSeconds), samples: int(samples), + windows: int(windows), windowsAttempted: int(windowsAttempted), + windowsLowQuality: int(lowQuality)) + } + + /// Nightly means by day, with the mean of any night that has no reliable reading left out: what a + /// trend, a sparkline or a per-day fallback may plot. + public var meanByDay: [String: Double] { mean.filter { !noReliableReading($0.key) } } } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift index 7747df54f9..2989969e85 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift @@ -474,6 +474,38 @@ final class Spo2CandidateSeriesTests: XCTestCase { XCTAssertEqual(note(windows: 26, attempted: 26, lowQuality: nil), .ungated) } + /// W03-011/W03-018: one loaded `Read` answers every surface. A per-day lookup and a trend both leave + /// out a mean left over on a night with no reliable reading — the mean a failed delete, or a mean under + /// another computed id the reader unions, would otherwise put back on screen. + func testOneReadAnswersEveryReaderAndDropsALeftoverMean() { + let read = Spo2CandidateSeries.Read( + mean: ["2026-09-28": 95.8, "2026-09-29": 93.6, "2026-09-30": 91.2], + minimum: ["2026-09-30": 73], + windows: ["2026-09-29": 10, "2026-09-30": 0], + windowsAttempted: ["2026-09-29": 26, "2026-09-30": 29], + lowQuality: ["2026-09-29": 13, "2026-09-30": 29]) + XCTAssertEqual(read.meanByDay, ["2026-09-28": 95.8, "2026-09-29": 93.6]) + XCTAssertTrue(read.night(on: "2026-09-30")!.hasNoReliableReading) + XCTAssertNil(read.night(on: "2026-09-30")?.minimum) + XCTAssertEqual(read.night(on: "2026-09-29")?.meanRounded, 94) + XCTAssertNil(read.night(on: "2026-09-27"), "a day with no series is not a night") + XCTAssertEqual(read.latest, read.night(on: "2026-09-30")) + XCTAssertEqual(Spo2CandidateSeries.latest(mean: read.mean, minimum: read.minimum, windows: read.windows, + windowsAttempted: read.windowsAttempted, + lowQuality: read.lowQuality), read.latest) + } + + /// The loader's key list is every key the writer banks, spelled from the same constants. + func testTheReadLoadsEveryStoredKey() { + XCTAssertEqual(Set(Spo2CandidateSeries.Read.keys), + [Spo2CandidateSeries.meanKey, Spo2CandidateSeries.minimumKey, Spo2CandidateSeries.dipsKey, + Spo2CandidateSeries.dipSecondsKey, Spo2CandidateSeries.samplesKey, + Spo2CandidateSeries.windowsKey, Spo2CandidateSeries.windowsAttemptedKey, + Spo2CandidateSeries.lowQualityKey]) + let read = Spo2CandidateSeries.Read(byKey: [Spo2CandidateSeries.lowQualityKey: ["2026-09-30": 2]]) + XCTAssertEqual(read.lowQuality, ["2026-09-30": 2]) + } + /// A night scored before W03-007 carries no low-quality count, and reads as ungated rather than as /// "none left out". func testAPreGateNightReportsNoLowQualityCount() { diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 2aff21d4a7..1551db522c 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -1969,6 +1969,30 @@ final class Repository: ObservableObject { return spo2SingleChannelPlausible.contains(v) ? v : nil } + /// Every SpO₂ candidate series, read once for a surface and resolved through + /// `Spo2CandidateSeries.Read` (W03-011, W03-018). The Health card and tile, both Today screens and the + /// Metric Explorer all read the candidate through this, so none of them can show a night, or a mean, + /// that another surface's rules would refuse. The keys load concurrently. `days` is `exploreSeries`' + /// lookback, defaulting to the same full-history window. + func spo2CandidateSeries(days: Int = 4000) async -> Spo2CandidateSeries.Read { + func load(_ key: String) async -> [String: Double] { + let pts = await exploreSeries(key: key, source: "my-whoop", days: days) + return Dictionary(pts.map { ($0.day, $0.value) }, uniquingKeysWith: { a, _ in a }) + } + async let mean = load(Spo2CandidateSeries.meanKey) + async let minimum = load(Spo2CandidateSeries.minimumKey) + async let dips = load(Spo2CandidateSeries.dipsKey) + async let dipSeconds = load(Spo2CandidateSeries.dipSecondsKey) + async let samples = load(Spo2CandidateSeries.samplesKey) + async let windows = load(Spo2CandidateSeries.windowsKey) + async let attempted = load(Spo2CandidateSeries.windowsAttemptedKey) + async let lowQuality = load(Spo2CandidateSeries.lowQualityKey) + return Spo2CandidateSeries.Read(mean: await mean, minimum: await minimum, dips: await dips, + dipSeconds: await dipSeconds, samples: await samples, + windows: await windows, windowsAttempted: await attempted, + lowQuality: await lowQuality) + } + /// The SpO₂ candidate seconds a chart of the night `[from, to]` may plot beside the strap-estimate /// card's figures: the in-band seconds of the RELIABLE readings only, cut by the same windows and the /// same quality verdict the figures were resolved with (`AnalyticsEngine.spo2CandidateReliableSeconds`, diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 4791434a91..6e6322d18b 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -58,12 +58,14 @@ struct LiquidTodayView: View { @State private var fitnessAge: Double? // exploreSeries("fitness_age").last @State private var vo2max: Double? // exploreSeries("vo2max_est").last (#1391) @State private var vitality: Double? // exploreSeries("vitality").last - // Queue 11a: day-keyed "spo2_candidate" metricSeries (WHOOP `spo2_candidate_82` or Oura - // ceiling@100 `0x6F`, device-conditional — see `IntelligenceEngine`). Empty when the + // Queue 11a: the "spo2_candidate" metricSeries and its companions (WHOOP `spo2_candidate_82` or + // Oura ceiling@100 `0x6F`, device-conditional — see `IntelligenceEngine`). Empty when the // experimental toggle is OFF (the engine writes nothing) or the owner has no in-band reading. - // Read unconditionally like the classic TodayView's `spo2CandidateSpark` — always empty when - // the toggle is off, so no separate gate is needed at fetch time. - @State private var spo2CandidateByDay: [String: Double] = [:] + // Read unconditionally like the classic TodayView — always empty when the toggle is off, so no + // separate gate is needed at fetch time. W03-015: through the one read the Health card and classic + // Today use, so the Blood Oxygen tile and card resolve a day's night by the same rules: a night + // with no reliable reading says so rather than showing a bare "—", and its leftover mean never shows. + @State private var spo2Candidate = Spo2CandidateSeries.Read() @State private var stepsEst: Double? // steps_est, day-keyed to the selected day (fallback) @State private var importedStepsDay: Int? // Apple Health steps for the selected day (middle tier) @State private var importedActiveKcalDay: Double? // #616: Apple Health active energy for the day (calorie fallback) @@ -998,10 +1000,11 @@ struct LiquidTodayView: View { // copied; see below for why the two are not interchangeable. let spo2Real = displayDay?.spo2Pct ?? vitalsDay?.spo2Pct let spo2CandidateOn = PuffinExperiment.spo2CandidateDisplayEnabled - let spo2Candidate = spo2Real == nil && spo2CandidateOn - ? spo2CandidateByDay[cachedDisplayDay?.day ?? selectedDayKey] + let candidateNight = spo2Real == nil && spo2CandidateOn + ? spo2Candidate.night(on: cachedDisplayDay?.day ?? selectedDayKey) : nil - let spo2 = spo2Real ?? spo2Candidate + let spo2CandidateValue = candidateNight?.meanRounded.map(Double.init) + let spo2 = spo2Real ?? spo2CandidateValue // ALWAYS routes to "spo2", never "spo2_candidate". The Key Metrics tile switches that string, // but there it is a SPARKLINE SERIES key (ktile feeds it to windowedSpark; navigation goes // through its separate detailMetric argument). Here the string is a NAVIGATION route resolved @@ -1015,7 +1018,9 @@ struct LiquidTodayView: View { // is the DEFAULT Today screen on iOS 26. The subtitle is the slot this card has. cardLink(.metric("spo2"), title: card.title, - sub: spo2Candidate != nil ? String(localized: "strap estimate (unverified)") : card.subtitle, + sub: spo2CandidateValue != nil ? String(localized: "strap estimate (unverified)") + : (candidateNight?.hasNoReliableReading == true + ? String(localized: "no reliable reading") : card.subtitle), // Em dash, not the en dash the stub used: the classic Blood Oxygen card and // skinTempCardValue both return "—", so the stub's "–" would have left the two // adjacent cards printing different glyphs for the same "no reading" state. @@ -1374,12 +1379,14 @@ struct LiquidTodayView: View { // toggle is ON — same gating as the classic tile, never as the default. let spo2Real = displayDay?.spo2Pct ?? vitalsDay?.spo2Pct let spo2CandidateOn = PuffinExperiment.spo2CandidateDisplayEnabled - let spo2CandidateValue = spo2Real == nil && spo2CandidateOn - ? spo2CandidateByDay[cachedDisplayDay?.day ?? selectedDayKey] + let candidateNight = spo2Real == nil && spo2CandidateOn + ? spo2Candidate.night(on: cachedDisplayDay?.day ?? selectedDayKey) : nil + let spo2CandidateValue = candidateNight?.meanRounded.map(Double.init) let spo2 = spo2Real ?? spo2CandidateValue ktile(String(localized: "Blood Oxygen"), icon: keyMetricIcon(metric), intText(spo2), "%", StrandPalette.metricCyan, fracOver(spo2, 100), key: spo2CandidateValue != nil ? "spo2_candidate" : "spo2", - caption: spo2CandidateValue != nil ? String(localized: "strap estimate (unverified)") : nil) + caption: spo2CandidateValue != nil ? String(localized: "strap estimate (unverified)") + : (candidateNight?.hasNoReliableReading == true ? String(localized: "no reliable reading") : nil)) case .respiratory: let resp = displayDay?.respRateBpm ?? vitalsDay?.respRateBpm ?? respDay?.respRateBpm ktile(String(localized: "Respiratory"), icon: keyMetricIcon(metric), resp.map { String(format: "%.1f", locale: AppLanguage.activeLocale, $0) } ?? "—", "rpm", StrandPalette.accent, fracOver(resp, 24), key: "resp_rate") @@ -1666,8 +1673,8 @@ struct LiquidTodayView: View { async let vo2A = repo.exploreSeries(key: "vo2max_est", source: "my-whoop") async let vitA = repo.exploreSeries(key: "vitality", source: "my-whoop") async let stepsA = repo.exploreSeries(key: "steps_est", source: "my-whoop") - // Queue 11a: SpO₂ candidate fallback (see `spo2CandidateByDay`'s declaration). - async let spo2CandA = repo.exploreSeries(key: "spo2_candidate", source: "my-whoop") + // Queue 11a: SpO₂ candidate fallback (see `spo2Candidate`'s declaration). + async let spo2CandA = repo.spo2CandidateSeries() async let weightA = repo.series(key: "weight", source: "apple-health", days: 91) async let appleA = repo.appleDailyRows() async let hrA = repo.hrBuckets(from: from, to: to, bucketSeconds: 300) @@ -1716,8 +1723,8 @@ struct LiquidTodayView: View { let appleRowsForSpark = await appleA // Queue 11a: SpO₂ candidate fallback — day-keyed for the tile's value lookup, windowed for its // detailed-mode sparkline below (same shape as `restByDay`/`kSparks["spo2"]` above). - let spo2CandSeries = await spo2CandA - spo2CandidateByDay = Dictionary(spo2CandSeries.map { ($0.day, $0.value) }, uniquingKeysWith: { _, last in last }) + spo2Candidate = await spo2CandA + let spo2CandSeries = spo2Candidate.meanByDay.sorted { $0.key < $1.key }.map { (day: $0.key, value: $0.value) } var winImportedKcal: [String: Double] = [:] for r in appleRowsForSpark where r.day >= sparkCutoff && r.day <= selectedDayKey { if let k = r.activeKcal { winImportedKcal[r.day] = max(winImportedKcal[r.day] ?? 0, k) } diff --git a/Strand/Screens/HealthView.swift b/Strand/Screens/HealthView.swift index 53dba8bbcd..72c7f46675 100644 --- a/Strand/Screens/HealthView.swift +++ b/Strand/Screens/HealthView.swift @@ -1325,24 +1325,13 @@ private struct VitalsSection: View { return UnitPrefs.resolveTemperature(system: system, override: temperatureRaw) } - // #103/queue-11a: SpO₂ candidate nightly means from metricSeries — WHOOP `spo2_candidate_82`, or an - // Oura owner's ceiling@100 `0x6F` mean (device-conditional, see IntelligenceEngine) — loaded when - // 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 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, except that a quality-gated night - // with no reliable reading (W03-007) has counts and no mean. - @State private var spo2CandidateMinByDay: [String: Double] = [:] - @State private var spo2CandidateDipsByDay: [String: Double] = [:] - // W03-003: the night's measurement windows, valued and attempted. - @State private var spo2CandidateWindowsByDay: [String: Double] = [:] - @State private var spo2CandidateWindowsAttemptedByDay: [String: Double] = [:] - // W03-007: readings left out as low quality; its presence marks a quality-gated night, which may - // carry no mean at all when none of its readings was reliable. - @State private var spo2CandidateLowQualityByDay: [String: Double] = [:] + // #103/queue-11a: every SpO₂ candidate series — WHOOP `spo2_candidate_82`, or an Oura owner's + // ceiling@100 `0x6F` mean (device-conditional, see IntelligenceEngine) — loaded ONCE when the + // experimental toggle is ON and resolved through `Spo2CandidateSeries.Read` (W03-011, W03-018): the + // vital tile, the strap-estimate card and its trend all ask this one value, so none of them can show + // a night or a mean the others' rules would refuse. Empty when the toggle is OFF. + @State private var spo2Candidate = Spo2CandidateSeries.Read() /// The resolved night's per-second in-band readings from its reliable readings only (W03-007), for /// the card's chart. Keyed to that night's day. @State private var spo2NightTrace: [TrendPoint] = [] @@ -1351,7 +1340,8 @@ private struct VitalsSection: View { let readings = BodyVitalSigns.readings( sourceRows: repo.vitalMetricRows, temperatureUnit: temperatureUnit, - spo2CandidateByDay: spo2CandidateByDay, + spo2CandidateByDay: spo2Candidate.meanByDay, + spo2CandidateNight: spo2CandidateNight, hrvOverCountByDay: hrvOverCountByDay, skinTempPreferred: SkinTempDisplay.Kind(rawValue: skinTempDisplayRaw) ?? .absolute // #1846 ) @@ -1403,35 +1393,13 @@ private struct VitalsSection: View { // an Oura ring's own computed id. Empty when the toggle is OFF (the engine writes nothing) or // the owner has no in-band reading for its device. guard PuffinExperiment.spo2CandidateDisplayEnabled else { - spo2CandidateByDay = [:] - // W03-007: a gated night with no reliable reading resolves without a mean, so clearing - // the mean alone no longer hides the card; the series that can stand in for it go too. - spo2CandidateLowQualityByDay = [:] - spo2CandidateWindowsAttemptedByDay = [:] + spo2Candidate = Spo2CandidateSeries.Read() return } - 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 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) - spo2CandidateWindowsByDay = await Self.loadCandidateSeries(repo, Spo2CandidateSeries.windowsKey) - spo2CandidateWindowsAttemptedByDay = await Self.loadCandidateSeries( - repo, Spo2CandidateSeries.windowsAttemptedKey) - spo2CandidateLowQualityByDay = await Self.loadCandidateSeries(repo, Spo2CandidateSeries.lowQualityKey) + spo2Candidate = await repo.spo2CandidateSeries(days: 14) } } - /// One candidate companion series as day → value. Same window and source as the mean, so the card's - /// figures cannot come from a different span than its headline. - private static func loadCandidateSeries(_ repo: Repository, _ key: String) async -> [String: Double] { - let pts = await repo.exploreSeries(key: key, source: "my-whoop", days: 14) - return Dictionary(pts.map { ($0.day, $0.value) }, uniquingKeysWith: { a, _ in a }) - } - /// The per-second in-band readings of the sleep session that `day` was scored from — the same night /// the card's figures describe, so the chart cannot plot a different night than its tiles. The /// session is the longest one waking on `day` (the day key is the wake day); raw seconds are read @@ -1453,19 +1421,12 @@ private struct VitalsSection: View { /// The night the strap-estimate card describes, resolved through the ONE funnel (pure + CI-tested in /// `StrandAnalytics`). nil when the toggle is OFF or nothing has been scored — the card then renders /// nothing rather than an empty frame that would read as "the strap sent nothing". - private var spo2CandidateNight: Spo2CandidateSeries.Night? { - Spo2CandidateSeries.latest(mean: spo2CandidateByDay, - minimum: spo2CandidateMinByDay, - dips: spo2CandidateDipsByDay, - windows: spo2CandidateWindowsByDay, - windowsAttempted: spo2CandidateWindowsAttemptedByDay, - lowQuality: spo2CandidateLowQualityByDay) - } + private var spo2CandidateNight: Spo2CandidateSeries.Night? { spo2Candidate.latest } /// The nightly means oldest → newest for the card's sparkline. Sorted by day KEY (`YYYY-MM-DD` sorts /// lexicographically), not by insertion, so the trail reads left-to-right in time. private var spo2CandidateMeanTrend: [Double] { - spo2CandidateByDay.sorted { $0.key < $1.key }.map(\.value) + spo2Candidate.meanByDay.sorted { $0.key < $1.key }.map(\.value) } } diff --git a/Strand/Screens/MetricExplorerView.swift b/Strand/Screens/MetricExplorerView.swift index bfd40c2731..e3dc78a5d5 100644 --- a/Strand/Screens/MetricExplorerView.swift +++ b/Strand/Screens/MetricExplorerView.swift @@ -1104,7 +1104,9 @@ struct MetricDetailView: View { // so a `spo2` descriptor under another source is a different metric that happens to share a key, // and only the WHOOP partition has a candidate series behind it. if metric.key == "spo2", metric.source == "my-whoop", PuffinExperiment.spo2CandidateDisplayEnabled { - let candidateSeries = await repo.exploreSeries(key: "spo2_candidate", source: metric.source) + // W03-011: through the shared read, so a night with no reliable reading adds no leftover mean. + let candidateSeries = await repo.spo2CandidateSeries().meanByDay.sorted { $0.key < $1.key } + .map { (day: $0.key, value: $0.value) } if !candidateSeries.isEmpty { // `uniquingKeysWith`, matching the `sourceByDay` build above — NOT // `uniqueKeysWithValues`, which TRAPS on a duplicate day. `exploreSeries` collapses by day diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 497497aee6..8f7c40ff9a 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -314,10 +314,16 @@ struct TodayView: View { // 14-day sparkline series, keyed by metric key. Loaded once in .task. @State private var sparks: [String: [Double]] = [:] - /// W03-007: the newest scored SpO₂ candidate night, resolved through `Spo2CandidateSeries.latest` — - /// the funnel the Health card uses — so the Blood Oxygen fallback cannot show an older night's - /// average while the newest night had no reliable reading. nil when the toggle is OFF. - @State private var spo2CandidateNight: Spo2CandidateSeries.Night? + /// W03-007/W03-018: every SpO₂ candidate series, loaded once through `repo.spo2CandidateSeries` — + /// the read the Health card uses — so the Blood Oxygen fallback resolves the same night the card does + /// and cannot show an older night's average while the newest night had no reliable reading. + @State private var spo2Candidate = Spo2CandidateSeries.Read() + /// The newest candidate night inside the sparkline's trailing 14 days; nil when the toggle is OFF. + private var spo2CandidateNight: Spo2CandidateSeries.Night? { + guard PuffinExperiment.spo2CandidateDisplayEnabled, let night = spo2Candidate.latest else { return nil } + let cutoff = Repository.localDayKey(Calendar.current.date(byAdding: .day, value: -13, to: Date()) ?? Date()) + return night.day >= cutoff ? night : nil + } @State private var workouts: [WorkoutRow] = [] /// #1694: a tapped Latest-Workouts tile. Wrapped so `.sheet(item:)` drives presentation, mirroring /// WorkoutsView's own detail target — the feed was read-only, so the only route to a session's @@ -2789,9 +2795,10 @@ struct TodayView: View { // IntelligenceEngine) so the card shows a strap-estimate (unverified) number instead of "—". let calibrated = (d?.spo2Pct ?? lastVitalsDay?.spo2Pct ?? lastSpo2Day?.spo2Pct) if let v = calibrated { return String(format: "%.0f%%", locale: AppLanguage.activeLocale, v) } - // W03-007: the newest night's own mean, never an older one's when that night had none. - if PuffinExperiment.spo2CandidateDisplayEnabled, let tail = spo2CandidateNight?.mean { - return String(format: "%.0f%%", locale: AppLanguage.activeLocale, tail) + // W03-007: the newest night's own mean, never an older one's when that night had none, rounded + // by `meanRounded` like the Health card (W03-012: `%.0f` rounds a half to even). + if PuffinExperiment.spo2CandidateDisplayEnabled, let tail = spo2CandidateNight?.meanRounded { + return "\(tail)%" } return "—" case .skinTemp: @@ -4086,10 +4093,10 @@ struct TodayView: View { // W03-007: the tail is the newest night's own mean, resolved through the Health card's // funnel. When that night's readings all failed the quality check it has no mean, and the // tile says so rather than showing the previous night's figure undated. - let candidateTail = spo2CandidateOn ? spo2CandidateNight?.mean : nil + let candidateTail = spo2CandidateOn ? spo2CandidateNight?.meanRounded : nil let candidateUnreliable = spo2CandidateOn && spo2CandidateNight?.hasNoReliableReading == true let spo2Value = spo2.value == "—" && candidateTail != nil - ? String(format: "%.0f%%", locale: AppLanguage.activeLocale, candidateTail!) + ? "\(candidateTail!)%" : spo2.value let spo2Caption: String = spo2.value == "—" && candidateTail != nil ? String(localized: "strap estimate (unverified)") @@ -4674,13 +4681,12 @@ struct TodayView: View { async let hrvSpark = sparkValues("hrv", source: "my-whoop", window: 14) async let rhrSpark = sparkValues("rhr", source: "my-whoop", window: 14) async let spo2Spark = sparkValues("spo2", source: "my-whoop", window: 14) - // #103/queue-11a: SpO₂ candidate nightly mean — WHOOP `spo2_candidate_82` (see - // IntelligenceEngine). Read via `exploreSeries` so the computed "-noop" metricSeries backs the - // trend; "my-whoop" here is the generic "active strap" sentinel `exploreSeries` resolves through - // `computedReadIds`, not a WHOOP-only filter. Empty when the - // toggle is OFF (the engine writes nothing) or the owner has no in-band reading. Used as a - // fallback for the Blood Oxygen tile when `spo2Pct` is nil, labelled "strap estimate (unverified)". - async let spo2CandidateSpark = sparkValuesExplore("spo2_candidate", source: "my-whoop", window: 14) + // #103/queue-11a: every SpO₂ candidate series — WHOOP `spo2_candidate_82` (see + // IntelligenceEngine) — through the one read the Health card uses (W03-018), concurrently with the + // other sparks. Its means back the trend and its newest night the Blood Oxygen fallback when + // `spo2Pct` is nil, labelled "strap estimate (unverified)". Empty when the toggle is OFF (the + // engine writes nothing) or the owner has no in-band reading. + async let spo2CandidateRead = repo.spo2CandidateSeries(days: 15) // Added 2026-08-24 (queue 11c follow-up) for the new Skin Temp Key Metrics tile. `exploreSeries` // so a BLE-only strap's computed `DailyMetric.skinTempDevC` column backs the trend, same as // `resp_rate` above — the engine writes the column, not a metricSeries point. @@ -4701,8 +4707,9 @@ struct TodayView: View { sparks["hrv"] = await hrvSpark sparks["rhr"] = await rhrSpark sparks["spo2"] = await spo2Spark - sparks["spo2_candidate"] = await spo2CandidateSpark - spo2CandidateNight = await loadSpo2CandidateNight() + spo2Candidate = await spo2CandidateRead + sparks["spo2_candidate"] = trailingWindow(spo2Candidate.meanByDay.sorted { $0.key < $1.key } + .map { (day: $0.key, value: $0.value) }, days: 14).map(\.value) sparks["skin_temp"] = await skinTempSpark sparks["resp_rate"] = await respRateSpark sparks["steps"] = await stepsAppleSpark @@ -5160,21 +5167,6 @@ struct TodayView: View { /// falls back to `dailyColumn` so the strap's own nightly respiratory rate fills the trend. /// Mirrors the Android `rememberTrendWindow` which builds the resp spark from /// `DailyMetric.respRateBpm` directly. Used for `resp_rate` (parity fix). - /// The newest SpO₂ candidate night within the sparkline's 14 days, through the one funnel (W03-007). - /// A night whose readings all failed the quality check is the newest night, so the tile says so - /// instead of falling back to the previous night's average. - private func loadSpo2CandidateNight() async -> Spo2CandidateSeries.Night? { - guard PuffinExperiment.spo2CandidateDisplayEnabled else { return nil } - func series(_ key: String) async -> [String: Double] { - let pts = trailingWindow(await repo.exploreSeries(key: key, source: "my-whoop", days: 15), days: 14) - return Dictionary(pts.map { ($0.day, $0.value) }, uniquingKeysWith: { a, _ in a }) - } - return Spo2CandidateSeries.latest(mean: await series(Spo2CandidateSeries.meanKey), - windows: await series(Spo2CandidateSeries.windowsKey), - windowsAttempted: await series(Spo2CandidateSeries.windowsAttemptedKey), - lowQuality: await series(Spo2CandidateSeries.lowQualityKey)) - } - private func sparkValuesExplore(_ key: String, source: String, window: Int) async -> [Double] { let all = await repo.exploreSeries(key: key, source: source, days: window + 1) guard !all.isEmpty else { return [] } diff --git a/Strand/Screens/VitalSignsSummary.swift b/Strand/Screens/VitalSignsSummary.swift index fd3014f4b7..199e8cff49 100644 --- a/Strand/Screens/VitalSignsSummary.swift +++ b/Strand/Screens/VitalSignsSummary.swift @@ -133,6 +133,10 @@ enum BodyVitalSigns { temperatureUnit: TemperatureUnit, now: Date = Date(), spo2CandidateByDay: [String: Double] = [:], + // W03-009: the newest candidate night, resolved through `Spo2CandidateSeries.Read` + // like the card beside this tile, so a newest night with no reliable reading stops + // the carry instead of letting an older night's mean stand in for it. + spo2CandidateNight: Spo2CandidateSeries.Night? = nil, hrvOverCountByDay: [String: Double] = [:], // #1846: the Settings lead-with choice, so this tile agrees with Today and the // detail screen. A setting that reaches two of three surfaces is worse than none. @@ -205,7 +209,17 @@ enum BodyVitalSigns { // #103/queue-11a: fall back to the spo2_candidate mean when no calibrated spo2Pct exists. The // candidate is labelled "strap estimate (unverified)" in the tile caption so it is never read as // a calibrated blood-oxygen percentage. - let spo2Row = latest(spo2Points) ?? latest(spo2CandidatePoints) + // W03-009: a newest candidate night with no reliable reading, inside the same carry bound, is the + // candidate's answer: "no reliable reading", not an older night's mean carried past it, and not + // "no estimate yet" for a night that was scored. + let carriedCandidate = latest(spo2CandidatePoints) + let candidateUnreliable = PuffinExperiment.spo2CandidateDisplayEnabled + && spo2CandidateNight.map { night in + night.hasNoReliableReading + && night.day >= (carriedCandidate?.day ?? "") + && Baselines.freshestCarried([(day: night.day, value: night)], todayKey: logicalDay) != nil + } == true + let spo2Row = latest(spo2Points) ?? (candidateUnreliable ? nil : carriedCandidate) let spo2IsCandidate = spo2Row != nil && latest(spo2Points) == nil let spo2rawRow = latest(spo2rawPoints) let rhrRow = latest(rhrPoints) @@ -346,6 +360,8 @@ enum BodyVitalSigns { // and the parity contract is the relationship between the two tiles, not the row. missingCaption: spo2IsCandidate ? String(localized: "strap estimate (unverified)") + : candidateUnreliable && spo2Row == nil + ? String(localized: "no reliable reading") : (PuffinExperiment.spo2CandidateDisplayEnabled && spo2Row == nil ? String(localized: "toggle ON · no estimate yet") : (spo2rawRow != nil diff --git a/StrandTests/VitalSourceResolutionTests.swift b/StrandTests/VitalSourceResolutionTests.swift index 350eb2f62f..c50e3236c1 100644 --- a/StrandTests/VitalSourceResolutionTests.swift +++ b/StrandTests/VitalSourceResolutionTests.swift @@ -1,4 +1,5 @@ import XCTest +import StrandAnalytics import WhoopStore @testable import Strand @@ -178,6 +179,33 @@ final class VitalSourceResolutionTests: XCTestCase { XCTAssertTrue(spo2?.missingCaption.contains("strap estimate") == true) } + /// W03-009: the newest candidate night had no reliable reading. The tile says so, as the card beside + /// it does, instead of carrying the previous night's mean past it or claiming no estimate exists. + func testANewestNightWithNoReliableReadingStopsTheCandidateCarry() { + UserDefaults.standard.set(true, forKey: PuffinExperiment.spo2CandidateDisplayKey) + defer { UserDefaults.standard.set(false, forKey: PuffinExperiment.spo2CandidateDisplayKey) } + let read = Spo2CandidateSeries.Read( + mean: ["2026-06-11": 95.0], + windows: ["2026-06-11": 20, "2026-06-12": 0], + windowsAttempted: ["2026-06-11": 22, "2026-06-12": 26], + lowQuality: ["2026-06-11": 1, "2026-06-12": 20]) + + func tile(_ night: Spo2CandidateSeries.Night?) -> BodyVitalReading? { + BodyVitalSigns.readings( + sourceRows: [SourcedDailyMetric(metric: daily(day: "2026-06-12", spo2Pct: nil), source: .noopComputed)], + temperatureUnit: .celsius, + now: localNoon(day: "2026-06-13"), + spo2CandidateByDay: read.meanByDay, + spo2CandidateNight: night + ).first { $0.key == "spo2" } + } + let gated = tile(read.latest) + XCTAssertNil(gated?.value, "the 11th's 95 % must not stand in for the 12th") + XCTAssertEqual(gated?.missingCaption, String(localized: "no reliable reading")) + // Without the resolved night the tile falls back to the old carry: the regression this pins. + XCTAssertEqual(tile(nil)?.value, 95.0) + } + /// When the toggle is OFF, the candidate is never surfaced even if data exists. func testSpo2CandidateNotSurfacedWhenToggleOff() { UserDefaults.standard.set(false, forKey: PuffinExperiment.spo2CandidateDisplayKey) diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 0d040c72f8..abc724a775 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -75,16 +75,16 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-006 | S3 | Reported | The V3 stager can stage almost no REM on a night where V2 stages a normal amount. On the newest export one night gets under a fifth of V2's REM, while on the other 8 stored nights V3's REM is 0.60–1.36× V2's. No stage truth exists for these nights, so it is unadjudicated | `SleepStager.stageWindow` (V3) | Replay harness over the 2026-09-27 export: V3 reproduces all 9 stored nights byte for byte, so the stored figure is what V3 computes; the strap log's REM funnel (a V1 replay) lands between the two. The night's cardiac inputs are atypical for this wearer. Next: V3's REM probabilities across that night, and a SleepPSG check on nights with atypical cardiac inputs | | | W03-007 | S3 | Fixed | The SpO₂ candidate took every byte-82 reading at face value. On a night the strap sits loose, most 30-second readings come back mixed with the strap's own non-percentage codes, and the in-band fragments between them jump or settle low; each such reading's median became the night's low and a "dip", so a fit problem read as desaturation. Published wrist-oximetry validations discard 15–50 % of overnight data by their own quality indices, and the FDA's January 2025 draft guidance asks for an indicator of signal inadequacy; NOOP had neither | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `IntelligenceEngine.swift` (series writer); `DebugDataDiagnostics.swift` (strap-log line) | Found from the owner's 2026-09-30 export. Across 192 in-session readings of one MG, readings mixed with codes 32/128/160 were 17–60 % of each of the first six nights and 81–83 % of the last two; every low reading on those two nights but one was such a reading, and the readings with no code agreed with the earlier nights. Status words 77/79 and the HR-valid bit do not separate codes from values, so byte 82's own codes are the only strap-side quality signal. Fix shape: judge each reading by what the strap reported during it, never by whether it is low: at least two thirds of its measured seconds in band, and at least three fifths of those within 2 points of its median. A steady, well-covered low reading stays a low reading. Thresholds are from this one strap with no reference oximeter, and the night's figures barely move between one half and four fifths coverage. V0: `testLowQualityReadingsAreNotDipsAndDoNotMoveTheNight` fails on the old resolver (one dip, night low 74). V1: oracle re-captured (28 cases) and matched line for line by an independent Python implementation; on 108 sessions across all 15 backups the Swift resolver and that implementation agree on every figure; package suite 2,200 tests Fixed: the resolver judges each reading (coverage, then agreement) and states a night whose readings all fail as having no reliable reading, as the owner chose; the series gain `spo2_candidate_windows_low_quality` and a gated night's stale mean and minimum are deleted after a persist that landed; `Spo2CandidateSeries.latest` shows that night instead of an older one, and the Today Blood Oxygen tile now resolves its night through the same funnel rather than the sparkline's last value; the card says "no reliable reading", names readings left out, calls them "Low readings" (one 30-second reading about every 20 minutes cannot show a desaturation event) and plots only reliable readings; the strap-log line names what it left out. V2 (independent subagent): one S2 (the reading-count caption claimed a pass on a codes-only night), four S3 and eight S4; eleven fixed on the branch (the last is the German "Readings" label, which read "Lesungen" beside "Messungen"), one recorded as W03-008. Two edits landed after V2 and are build- and suite-verified only: the Today tile's funnel and the persist guard on the stale-row delete. Package suite 2,209, `StrandTests` 2,125, both app builds (NOOPiOS without the watch app), i18n and hygiene gates pass. Still due: a night on the strap with the build | | | W03-008 | S4 | Reported | The SpO₂ card's night chart resolves reliable readings over the longest session of the day and the worn-timeline union of strap ids, while the figures beside it resolve over all the day's sessions for the scoring owner. A reading that straddles a session edge, or a second strap's rows, can be cut and judged differently, so the chart can show or omit a reading the tiles did not | `Repository.swift` `spo2CandidateReliableTrace`; `IntelligenceEngine.swift` (the resolver call); `HealthView.swift` `loadNightTrace` | Found by W03-007's V2 by reading; not reproduced, and not yet looked for in the owner's data (one strap, so only the session-edge case applies). Fix shape: resolve the trace from the same sessions and owner as the figures, or store the reliable readings' spans with the night | | -| W03-009 | S3 | Reported | The Health screen's Blood Oxygen tile resolves the candidate from the mean series with its own carry, not through `Spo2CandidateSeries.latest`: when the newest night has no reliable reading it shows an older night's mean, and when every recent night is gated it says "toggle ON · no estimate yet" above a card that says "no reliable reading" | `VitalSignsSummary.swift` (the spo2 candidate row) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading: the tile builds `spo2CandidatePoints` from `spo2CandidateByDay` (the mean series), which a gated night lacks. | | +| W03-009 | S3 | Fixed | The Health screen's Blood Oxygen tile resolves the candidate from the mean series with its own carry, not through `Spo2CandidateSeries.latest`: when the newest night has no reliable reading it shows an older night's mean, and when every recent night is gated it says "toggle ON · no estimate yet" above a card that says "no reliable reading" | `VitalSignsSummary.swift` (the spo2 candidate row) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading: the tile builds `spo2CandidatePoints` from `spo2CandidateByDay` (the mean series), which a gated night lacks. Fixed: the tile takes the resolved night, and a newest night with no reliable reading inside the carry bound ends the carry with the caption "no reliable reading". V0/V1: `testANewestNightWithNoReliableReadingStopsTheCandidateCarry` asserts the tile shows the older mean without the night (the old behaviour) and nothing with it. | | | W03-010 | S3 | Reported | A night of codes-only readings became a "no reliable reading" night whose notice says none of its readings passed the quality check and names a loose strap, though those readings had no value to check and nothing attributes them to fit | `Spo2EstimateCard.swift` (the notice) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading: codes-only readings are `.noValue`, not low quality, yet the notice text does not distinguish them. | | -| W03-011 | S3 | Reported | A leftover mean on a gated night reaches every reader that skips the funnel: the stale-row delete runs per day under the canonical computed id only, while `exploreSeries` also unions the active strap's computed sibling, and a failed delete leaves the row | `IntelligenceEngine.swift` (the stale-row delete); `Repository.exploreSeries` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading `computedReadIds`. Fix shape chosen: make every surface read through one funnel that ignores a mean on a gated night, rather than widen a delete across ids (W02-005 showed how a delete over the shared canonical id loses data). | | -| W03-012 | S4 | Reported | Today formats the candidate mean with `%.0f`, which rounds halves to even, while the card uses `meanRounded`: a 96.5 night shows 96 % on Today and 97 % on Health | `TodayView.swift` (Blood Oxygen tile and card) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: run standalone, `String(format: "%.0f", 96.5)` prints 96 and `Int(96.5.rounded())` 97; 94.5 gives 94 and 95. | | +| W03-011 | S3 | Fixed | A leftover mean on a gated night reaches every reader that skips the funnel: the stale-row delete runs per day under the canonical computed id only, while `exploreSeries` also unions the active strap's computed sibling, and a failed delete leaves the row | `IntelligenceEngine.swift` (the stale-row delete); `Repository.exploreSeries` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading `computedReadIds`. Fix shape chosen: make every surface read through one funnel that ignores a mean on a gated night, rather than widen a delete across ids (W02-005 showed how a delete over the shared canonical id loses data). Fixed: on the read side, as the row's fix shape says: `Read.meanByDay` and `night(on:)` leave out a mean on a night with no reliable reading, and every surface reads through them (W03-018), so neither a failed delete nor a mean under another computed id reaches a screen. The per-day delete stays as housekeeping. | | +| W03-012 | S4 | Fixed | Today formats the candidate mean with `%.0f`, which rounds halves to even, while the card uses `meanRounded`: a 96.5 night shows 96 % on Today and 97 % on Health | `TodayView.swift` (Blood Oxygen tile and card) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: run standalone, `String(format: "%.0f", 96.5)` prints 96 and `Int(96.5.rounded())` 97; 94.5 gives 94 and 95. Fixed: Today's tile and card format `meanRounded`, and so does Liquid Today's card, which used `%.0f` too. | | | W03-013 | S3 | Fixed | Both quality gates are ratios with no floor, so a reading of one in-band second always passes, and a fragment is exactly what the gate is for | `Spo2CandidateNight.swift` `Spo2CandidateWindow.quality` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: the re-captured oracle's `all-bytes-per-window` line judges 31 one-second readings reliable. On the owner's 15 backups 302 of 303 distinct readings have 30 measured seconds and every reliable one has 20 or more in-band seconds; the one short reading is cut by a session edge. Fixed: a reading needs at least ten in-band seconds (`spo2CandidateMinReadingSeconds`, a third of the strap's reading) before the ratios judge it; below that it is low coverage. V0: `testAFewSecondFragmentIsNotAReliableReading` fails on the ratios alone (night low 74 from a one-second fragment). V1: the oracle keeps its 28 lines at a floor of one and adds three at the default (31, matched line for line by the independent Python twin); on 108 sessions across 15 backups the twin agrees and every figure is byte-identical to before the floor. Tests that pin windowing on short synthetic readings run at a floor of one through a helper | | | W03-014 | S4 | Reported | `spo2CandidateReliableTrace` merges up to 200k rows per strap id on the main actor, and dedups whole rows before reading byte 82, so a first strap's row without a byte-82 value hides a second strap's in-band value at the same second | `Repository.swift` `spo2CandidateReliableTrace` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the old per-point merge kept the first in-band value per second. | | -| W03-015 | S4 | Reported | The Liquid Today Blood Oxygen tile shows a bare "—" on a gated night where the classic Today tile says "no reliable reading" | `LiquidTodayView.swift` (Blood Oxygen tile) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | -| W03-016 | S4 | Reported | `loadSpo2CandidateNight` went in between `sparkValuesExplore` and its doc block, so the doc now documents the wrong function | `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the placement AGENTS.md warns about. | | -| W03-017 | S4 | Reported | Today's candidate night costs four sequential series reads after the concurrent block, one of them the mean the sparkline already read | `TodayView.swift` `loadSpo2CandidateNight` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | -| W03-018 | S4 | Reported | Loading the candidate series and resolving the night is written twice, in HealthView and TodayView, with different inputs and windows, so a key added to one can miss the other | `HealthView.swift`; `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | +| W03-015 | S4 | Fixed | The Liquid Today Blood Oxygen tile shows a bare "—" on a gated night where the classic Today tile says "no reliable reading" | `LiquidTodayView.swift` (Blood Oxygen tile) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: the Liquid tile and card resolve the day's night through `Read.night(on:)`; a night with no reliable reading is captioned "no reliable reading". | | +| W03-016 | S4 | Fixed | `loadSpo2CandidateNight` went in between `sparkValuesExplore` and its doc block, so the doc now documents the wrong function | `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the placement AGENTS.md warns about. Fixed: `loadSpo2CandidateNight` is gone (W03-018), and `sparkValuesExplore` has its doc block back. | | +| W03-017 | S4 | Fixed | Today's candidate night costs four sequential series reads after the concurrent block, one of them the mean the sparkline already read | `TodayView.swift` `loadSpo2CandidateNight` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: Today loads the one read as an `async let` beside the other sparks and builds the candidate sparkline from its means; the second mean read is gone, and the read's keys load concurrently. | | +| W03-018 | S4 | Fixed | Loading the candidate series and resolving the night is written twice, in HealthView and TodayView, with different inputs and windows, so a key added to one can miss the other | `HealthView.swift`; `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: one read for every surface: `Spo2CandidateSeries.Read` (package, tested) holds every candidate series and resolves `latest`, `night(on:)` and `meanByDay`; `Repository.spo2CandidateSeries` loads its keys concurrently. The Health card and tile, classic and Liquid Today and the Metric Explorer all resolve through it. `testOneReadAnswersEveryReaderAndDropsALeftoverMean`, `testTheReadLoadsEveryStoredKey`. | | | W03-019 | S4 | Reported | `nightlySpo2CandidateMean`'s doc says it returns nil only when no in-band reading fell inside any span; it now also returns nil when none is reliable | `AnalyticsEngine.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | | W03-020 | S4 | Reported | The Low tile still captions a low night "dipped below 90%" beside the tile this change renamed from Dips to Low readings | `Spo2EstimateCard.swift` `lowCaption` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | | W03-021 | S4 | Reported | "%lld left out as low quality" has no plural variants, so a count of 1 is ungrammatical in es, fr, it and pt-PT, and ru and pl need several forms | `Localizable.xcstrings` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading the catalog entry. | | From a585b9584ee3063ab242a11ed5d10589f11f9981 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 11:14:43 +0200 Subject: [PATCH 09/15] =?UTF-8?q?fix(app):=20do=20not=20blame=20the=20stra?= =?UTF-8?q?p's=20fit=20for=20a=20night=20of=20codes-only=20SpO=E2=82=82=20?= =?UTF-8?q?readings=20(W03-010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A night with no reliable reading now carries why: readings that had values and failed the quality check, or readings that were codes only. Only the first keeps the loose-strap note; the second says that none of the night's readings produced a value. --- .../StrandAnalytics/Spo2CandidateNight.swift | 16 +++++ .../Spo2CandidateNightStatsTests.swift | 15 +++++ Strand/Resources/Localizable.xcstrings | 64 +++++++++++++++++++ Strand/Screens/Spo2EstimateCard.swift | 8 ++- docs/review/workstreams/W03-analytics.md | 2 +- 5 files changed, 102 insertions(+), 3 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift index 9b528aa25d..c2a3ccb4a7 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2CandidateNight.swift @@ -428,6 +428,22 @@ public enum Spo2CandidateSeries { case allPassed } + /// Why a night has no reliable reading, when it has none (W03-010). The two causes are different + /// facts and a surface must not blame one on the other: readings that had values and failed the + /// quality check, against readings that were the strap's codes only and never had a value to check. + public enum NoReliableReadingReason: Equatable, Sendable { + /// At least one reading had in-band values and was left out as low quality. + case lowQuality + /// Every reading was codes only. + case noValue + } + + /// nil when the night has a reliable reading. + public var noReliableReadingReason: NoReliableReadingReason? { + guard hasNoReliableReading else { return nil } + return (windowsLowQuality ?? 0) > 0 ? .lowQuality : .noValue + } + public var readingsNote: ReadingsNote { guard let left = windowsLowQuality else { return .ungated } if left > 0 { return .leftOut(left) } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift index 2989969e85..4c3f75ecd7 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift @@ -474,6 +474,21 @@ final class Spo2CandidateSeriesTests: XCTestCase { XCTAssertEqual(note(windows: 26, attempted: 26, lowQuality: nil), .ungated) } + /// W03-010: a night of codes-only readings had nothing to fail the quality check with, and a surface + /// must not tell its wearer the readings failed a check (or blame the fit) on its strength. + func testTheReasonANightHasNoReliableReadingKeepsItsTwoCausesApart() { + let read = Spo2CandidateSeries.Read( + windows: ["2026-09-29": 0, "2026-09-30": 0], + windowsAttempted: ["2026-09-29": 26, "2026-09-30": 26], + lowQuality: ["2026-09-29": 20, "2026-09-30": 0]) + XCTAssertEqual(read.night(on: "2026-09-29")?.noReliableReadingReason, .lowQuality) + XCTAssertEqual(read.night(on: "2026-09-30")?.noReliableReadingReason, .noValue) + let reliable = Spo2CandidateSeries.latest(mean: ["2026-09-30": 95], windows: ["2026-09-30": 20], + windowsAttempted: ["2026-09-30": 26], + lowQuality: ["2026-09-30": 6]) + XCTAssertNil(reliable?.noReliableReadingReason) + } + /// W03-011/W03-018: one loaded `Read` answers every surface. A per-day lookup and a trend both leave /// out a mean left over on a night with no reliable reading — the mean a failed delete, or a mean under /// another computed id the reader unions, would otherwise put back on screen. diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 76d6bed5dc..c41465d904 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -137562,6 +137562,70 @@ } } }, + "None of this night's readings produced a value.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Messung dieser Nacht hat einen Wert geliefert." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "None of this night's readings produced a value." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ninguna lectura de esta noche dio un valor." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune mesure de cette nuit n’a donné de valeur." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna lettura di questa notte ha prodotto un valore." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Żaden odczyt z tej nocy nie dał wartości." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma leitura desta noite produziu um valor." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ни одно измерение этой ночи не дало значения." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这一晚没有任何读数得出数值。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這一晚沒有任何讀數得出數值。" + } + } + } + }, "None — silence, start to finish": { "localizations": { "de": { diff --git a/Strand/Screens/Spo2EstimateCard.swift b/Strand/Screens/Spo2EstimateCard.swift index ccafca0a3b..ff05898094 100644 --- a/Strand/Screens/Spo2EstimateCard.swift +++ b/Strand/Screens/Spo2EstimateCard.swift @@ -84,8 +84,12 @@ struct Spo2EstimateCard: View { caption: readingsCaption(night), accent: StrandPalette.textPrimary) } - if night.hasNoReliableReading { - Text("None of this night's readings passed the quality check. This can happen when the strap sits loose or has shifted.") + // W03-010: the two causes are different facts. Only readings that had values and failed + // the check say anything about fit; a night of codes only had nothing to check. + if let reason = night.noReliableReadingReason { + Text(reason == .lowQuality + ? String(localized: "None of this night's readings passed the quality check. This can happen when the strap sits loose or has shifted.") + : String(localized: "None of this night's readings produced a value.")) .font(StrandFont.caption) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index abc724a775..062f9a72b3 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -76,7 +76,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-007 | S3 | Fixed | The SpO₂ candidate took every byte-82 reading at face value. On a night the strap sits loose, most 30-second readings come back mixed with the strap's own non-percentage codes, and the in-band fragments between them jump or settle low; each such reading's median became the night's low and a "dip", so a fit problem read as desaturation. Published wrist-oximetry validations discard 15–50 % of overnight data by their own quality indices, and the FDA's January 2025 draft guidance asks for an indicator of signal inadequacy; NOOP had neither | `Spo2CandidateNight.swift` `nightlySpo2CandidateNight`; `Spo2EstimateCard.swift`; `IntelligenceEngine.swift` (series writer); `DebugDataDiagnostics.swift` (strap-log line) | Found from the owner's 2026-09-30 export. Across 192 in-session readings of one MG, readings mixed with codes 32/128/160 were 17–60 % of each of the first six nights and 81–83 % of the last two; every low reading on those two nights but one was such a reading, and the readings with no code agreed with the earlier nights. Status words 77/79 and the HR-valid bit do not separate codes from values, so byte 82's own codes are the only strap-side quality signal. Fix shape: judge each reading by what the strap reported during it, never by whether it is low: at least two thirds of its measured seconds in band, and at least three fifths of those within 2 points of its median. A steady, well-covered low reading stays a low reading. Thresholds are from this one strap with no reference oximeter, and the night's figures barely move between one half and four fifths coverage. V0: `testLowQualityReadingsAreNotDipsAndDoNotMoveTheNight` fails on the old resolver (one dip, night low 74). V1: oracle re-captured (28 cases) and matched line for line by an independent Python implementation; on 108 sessions across all 15 backups the Swift resolver and that implementation agree on every figure; package suite 2,200 tests Fixed: the resolver judges each reading (coverage, then agreement) and states a night whose readings all fail as having no reliable reading, as the owner chose; the series gain `spo2_candidate_windows_low_quality` and a gated night's stale mean and minimum are deleted after a persist that landed; `Spo2CandidateSeries.latest` shows that night instead of an older one, and the Today Blood Oxygen tile now resolves its night through the same funnel rather than the sparkline's last value; the card says "no reliable reading", names readings left out, calls them "Low readings" (one 30-second reading about every 20 minutes cannot show a desaturation event) and plots only reliable readings; the strap-log line names what it left out. V2 (independent subagent): one S2 (the reading-count caption claimed a pass on a codes-only night), four S3 and eight S4; eleven fixed on the branch (the last is the German "Readings" label, which read "Lesungen" beside "Messungen"), one recorded as W03-008. Two edits landed after V2 and are build- and suite-verified only: the Today tile's funnel and the persist guard on the stale-row delete. Package suite 2,209, `StrandTests` 2,125, both app builds (NOOPiOS without the watch app), i18n and hygiene gates pass. Still due: a night on the strap with the build | | | W03-008 | S4 | Reported | The SpO₂ card's night chart resolves reliable readings over the longest session of the day and the worn-timeline union of strap ids, while the figures beside it resolve over all the day's sessions for the scoring owner. A reading that straddles a session edge, or a second strap's rows, can be cut and judged differently, so the chart can show or omit a reading the tiles did not | `Repository.swift` `spo2CandidateReliableTrace`; `IntelligenceEngine.swift` (the resolver call); `HealthView.swift` `loadNightTrace` | Found by W03-007's V2 by reading; not reproduced, and not yet looked for in the owner's data (one strap, so only the session-edge case applies). Fix shape: resolve the trace from the same sessions and owner as the figures, or store the reliable readings' spans with the night | | | W03-009 | S3 | Fixed | The Health screen's Blood Oxygen tile resolves the candidate from the mean series with its own carry, not through `Spo2CandidateSeries.latest`: when the newest night has no reliable reading it shows an older night's mean, and when every recent night is gated it says "toggle ON · no estimate yet" above a card that says "no reliable reading" | `VitalSignsSummary.swift` (the spo2 candidate row) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading: the tile builds `spo2CandidatePoints` from `spo2CandidateByDay` (the mean series), which a gated night lacks. Fixed: the tile takes the resolved night, and a newest night with no reliable reading inside the carry bound ends the carry with the caption "no reliable reading". V0/V1: `testANewestNightWithNoReliableReadingStopsTheCandidateCarry` asserts the tile shows the older mean without the night (the old behaviour) and nothing with it. | | -| W03-010 | S3 | Reported | A night of codes-only readings became a "no reliable reading" night whose notice says none of its readings passed the quality check and names a loose strap, though those readings had no value to check and nothing attributes them to fit | `Spo2EstimateCard.swift` (the notice) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading: codes-only readings are `.noValue`, not low quality, yet the notice text does not distinguish them. | | +| W03-010 | S3 | Fixed | A night of codes-only readings became a "no reliable reading" night whose notice says none of its readings passed the quality check and names a loose strap, though those readings had no value to check and nothing attributes them to fit | `Spo2EstimateCard.swift` (the notice) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading: codes-only readings are `.noValue`, not low quality, yet the notice text does not distinguish them. Fixed: the card's notice reads `Night.noReliableReadingReason` (package, tested): readings that had values and failed the check keep the fit note; a night of codes-only readings says only that none produced a value. `testTheReasonANightHasNoReliableReadingKeepsItsTwoCausesApart`. The Today and Health tiles keep "no reliable reading", which is true of both. | | | W03-011 | S3 | Fixed | A leftover mean on a gated night reaches every reader that skips the funnel: the stale-row delete runs per day under the canonical computed id only, while `exploreSeries` also unions the active strap's computed sibling, and a failed delete leaves the row | `IntelligenceEngine.swift` (the stale-row delete); `Repository.exploreSeries` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading `computedReadIds`. Fix shape chosen: make every surface read through one funnel that ignores a mean on a gated night, rather than widen a delete across ids (W02-005 showed how a delete over the shared canonical id loses data). Fixed: on the read side, as the row's fix shape says: `Read.meanByDay` and `night(on:)` leave out a mean on a night with no reliable reading, and every surface reads through them (W03-018), so neither a failed delete nor a mean under another computed id reaches a screen. The per-day delete stays as housekeeping. | | | W03-012 | S4 | Fixed | Today formats the candidate mean with `%.0f`, which rounds halves to even, while the card uses `meanRounded`: a 96.5 night shows 96 % on Today and 97 % on Health | `TodayView.swift` (Blood Oxygen tile and card) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: run standalone, `String(format: "%.0f", 96.5)` prints 96 and `Int(96.5.rounded())` 97; 94.5 gives 94 and 95. Fixed: Today's tile and card format `meanRounded`, and so does Liquid Today's card, which used `%.0f` too. | | | W03-013 | S3 | Fixed | Both quality gates are ratios with no floor, so a reading of one in-band second always passes, and a fragment is exactly what the gate is for | `Spo2CandidateNight.swift` `Spo2CandidateWindow.quality` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: the re-captured oracle's `all-bytes-per-window` line judges 31 one-second readings reliable. On the owner's 15 backups 302 of 303 distinct readings have 30 measured seconds and every reliable one has 20 or more in-band seconds; the one short reading is cut by a session edge. Fixed: a reading needs at least ten in-band seconds (`spo2CandidateMinReadingSeconds`, a third of the strap's reading) before the ratios judge it; below that it is low coverage. V0: `testAFewSecondFragmentIsNotAReliableReading` fails on the ratios alone (night low 74 from a one-second fragment). V1: the oracle keeps its 28 lines at a floor of one and adds three at the default (31, matched line for line by the independent Python twin); on 108 sessions across 15 backups the twin agrees and every figure is byte-identical to before the floor. Tests that pin windowing on short synthetic readings run at a floor of one through a helper | | From 3768198fab3074455f09580d46fb0535ca9f17f1 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 11:16:54 +0200 Subject: [PATCH 10/15] =?UTF-8?q?fix(app):=20merge=20the=20SpO=E2=82=82=20?= =?UTF-8?q?chart's=20strap=20rows=20by=20value,=20off=20the=20main=20actor?= =?UTF-8?q?=20(W03-014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart's merge gave each second to the first strap id with any row there, so a row without a byte-82 value could hide another strap's reading, and it ran on the main actor. A second now goes to the first id that reported a nonzero byte, and the merge and resolve run in a detached task. --- Strand/Data/Repository.swift | 25 +++++++++++++++---- .../Spo2CandidateTraceMergeTests.swift | 25 +++++++++++++++++++ docs/review/workstreams/W03-analytics.md | 2 +- 3 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 StrandTests/Spo2CandidateTraceMergeTests.swift diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 1551db522c..704ddcd8b0 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -2003,19 +2003,34 @@ final class Repository: ObservableObject { /// the chart covers the same seconds it covered before the gate. func spo2CandidateReliableTrace(from: Int, to: Int) async -> [TrendPoint] { guard to > from, let store = await ensureStore() else { return [] } - var seen = Set() - var aux: [V18AuxSample] = [] + var perId: [[V18AuxSample]] = [] for id in rawPhysiologyReadIds(store: store) { - let rows = (try? await store.v18AuxSamples(deviceId: id, from: from, to: to, limit: 200_000)) ?? [] - for row in rows where seen.insert(row.ts).inserted { aux.append(row) } + perId.append((try? await store.v18AuxSamples(deviceId: id, from: from, to: to, limit: 200_000)) ?? []) } let night = [SleepSession(start: from, end: to, efficiency: 0, stages: [], restingHR: nil, avgHRV: nil)] + // W03-014: the merge and the resolve both run off the main actor, like `dedupSortDownsampleRaw`. return await Task.detached(priority: .utility) { - AnalyticsEngine.spo2CandidateReliableSeconds(night, aux: aux) + AnalyticsEngine.spo2CandidateReliableSeconds(night, aux: Self.mergeSpo2CandidateAux(perId)) .map { Self.timelinePoint($0.ts, Double($0.value)) } }.value } + /// Merges the per-id aux rows of one span, the first id to report a NONZERO byte 82 at a second + /// claiming it (W03-014). A row without a byte-82 value (nil or 0, the strap not measuring) claims + /// nothing, so it cannot hide another strap's value at the same second, which is how the per-point + /// merge this replaced behaved. Codes are nonzero and do claim their second: the resolver needs them to + /// judge the reading they sit in. + nonisolated static func mergeSpo2CandidateAux(_ perId: [[V18AuxSample]]) -> [V18AuxSample] { + var seen = Set() + var merged: [V18AuxSample] = [] + for rows in perId { + for row in rows where (row.auxByte82 ?? 0) != 0 && seen.insert(row.ts).inserted { + merged.append(row) + } + } + return merged + } + /// Deep-Timeline read facade. Returns ~`targetPoints` points for `metric` over `[from, to]` from /// `source` (defaults to the user's own strap), choosing raw seconds vs coarse buckets adaptively so /// the chart never draws ~86k points (the #575 day-scale risk). HR rides the existing COALESCE reads diff --git a/StrandTests/Spo2CandidateTraceMergeTests.swift b/StrandTests/Spo2CandidateTraceMergeTests.swift new file mode 100644 index 0000000000..f363bcf2a6 --- /dev/null +++ b/StrandTests/Spo2CandidateTraceMergeTests.swift @@ -0,0 +1,25 @@ +import XCTest +import WhoopProtocol +@testable import Strand + +/// W03-014 — the SpO₂ card's night chart merges byte-82 rows from every strap id it reads. A second +/// belongs to the first id that reported a value there, never to a row that reported nothing. +final class Spo2CandidateTraceMergeTests: XCTestCase { + + private func aux(_ ts: Int, _ v: Int?) -> V18AuxSample { V18AuxSample(ts: ts, auxByte82: v) } + + /// A first strap's row with no byte-82 value (nil, or 0 for "not measuring") must not hide a second + /// strap's reading at the same second. + func testARowWithoutAValueDoesNotClaimItsSecond() { + let merged = Repository.mergeSpo2CandidateAux([[aux(10, nil), aux(11, 0)], [aux(10, 95), aux(11, 96)]]) + XCTAssertEqual(merged.map(\.ts), [10, 11]) + XCTAssertEqual(merged.map(\.auxByte82), [95, 96]) + } + + /// The first id to report a value keeps its second, and a code claims it too: the resolver needs a + /// reading's codes to judge it. + func testTheFirstIdWithAValueOrACodeKeepsTheSecond() { + let merged = Repository.mergeSpo2CandidateAux([[aux(10, 94), aux(11, 128)], [aux(10, 99), aux(11, 97)]]) + XCTAssertEqual(merged.map(\.auxByte82), [94, 128]) + } +} diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 062f9a72b3..0087879733 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -80,7 +80,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-011 | S3 | Fixed | A leftover mean on a gated night reaches every reader that skips the funnel: the stale-row delete runs per day under the canonical computed id only, while `exploreSeries` also unions the active strap's computed sibling, and a failed delete leaves the row | `IntelligenceEngine.swift` (the stale-row delete); `Repository.exploreSeries` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading `computedReadIds`. Fix shape chosen: make every surface read through one funnel that ignores a mean on a gated night, rather than widen a delete across ids (W02-005 showed how a delete over the shared canonical id loses data). Fixed: on the read side, as the row's fix shape says: `Read.meanByDay` and `night(on:)` leave out a mean on a night with no reliable reading, and every surface reads through them (W03-018), so neither a failed delete nor a mean under another computed id reaches a screen. The per-day delete stays as housekeeping. | | | W03-012 | S4 | Fixed | Today formats the candidate mean with `%.0f`, which rounds halves to even, while the card uses `meanRounded`: a 96.5 night shows 96 % on Today and 97 % on Health | `TodayView.swift` (Blood Oxygen tile and card) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: run standalone, `String(format: "%.0f", 96.5)` prints 96 and `Int(96.5.rounded())` 97; 94.5 gives 94 and 95. Fixed: Today's tile and card format `meanRounded`, and so does Liquid Today's card, which used `%.0f` too. | | | W03-013 | S3 | Fixed | Both quality gates are ratios with no floor, so a reading of one in-band second always passes, and a fragment is exactly what the gate is for | `Spo2CandidateNight.swift` `Spo2CandidateWindow.quality` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0: the re-captured oracle's `all-bytes-per-window` line judges 31 one-second readings reliable. On the owner's 15 backups 302 of 303 distinct readings have 30 measured seconds and every reliable one has 20 or more in-band seconds; the one short reading is cut by a session edge. Fixed: a reading needs at least ten in-band seconds (`spo2CandidateMinReadingSeconds`, a third of the strap's reading) before the ratios judge it; below that it is low coverage. V0: `testAFewSecondFragmentIsNotAReliableReading` fails on the ratios alone (night low 74 from a one-second fragment). V1: the oracle keeps its 28 lines at a floor of one and adds three at the default (31, matched line for line by the independent Python twin); on 108 sessions across 15 backups the twin agrees and every figure is byte-identical to before the floor. Tests that pin windowing on short synthetic readings run at a floor of one through a helper | | -| W03-014 | S4 | Reported | `spo2CandidateReliableTrace` merges up to 200k rows per strap id on the main actor, and dedups whole rows before reading byte 82, so a first strap's row without a byte-82 value hides a second strap's in-band value at the same second | `Repository.swift` `spo2CandidateReliableTrace` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the old per-point merge kept the first in-band value per second. | | +| W03-014 | S4 | Fixed | `spo2CandidateReliableTrace` merges up to 200k rows per strap id on the main actor, and dedups whole rows before reading byte 82, so a first strap's row without a byte-82 value hides a second strap's in-band value at the same second | `Repository.swift` `spo2CandidateReliableTrace` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the old per-point merge kept the first in-band value per second. Fixed: `Repository.mergeSpo2CandidateAux` (pure) gives a second to the first id with a nonzero byte 82, so a row without a value cannot hide another strap's reading, and the merge and resolve run in a detached utility task. V1: `Spo2CandidateTraceMergeTests` (2); with the value check removed (the row-level merge) `testARowWithoutAValueDoesNotClaimItsSecond` fails. | | | W03-015 | S4 | Fixed | The Liquid Today Blood Oxygen tile shows a bare "—" on a gated night where the classic Today tile says "no reliable reading" | `LiquidTodayView.swift` (Blood Oxygen tile) | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: the Liquid tile and card resolve the day's night through `Read.night(on:)`; a night with no reliable reading is captioned "no reliable reading". | | | W03-016 | S4 | Fixed | `loadSpo2CandidateNight` went in between `sparkValuesExplore` and its doc block, so the doc now documents the wrong function | `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the placement AGENTS.md warns about. Fixed: `loadSpo2CandidateNight` is gone (W03-018), and `sparkValuesExplore` has its doc block back. | | | W03-017 | S4 | Fixed | Today's candidate night costs four sequential series reads after the concurrent block, one of them the mean the sparkline already read | `TodayView.swift` `loadSpo2CandidateNight` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: Today loads the one read as an `async let` beside the other sparks and builds the candidate sparkline from its means; the second mean read is gone, and the read's keys load concurrently. | | From 3d96fcde7a526468bd02215ffbba741ec9edb703 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 11:17:31 +0200 Subject: [PATCH 11/15] =?UTF-8?q?docs(analytics):=20say=20what=20a=20nil?= =?UTF-8?q?=20SpO=E2=82=82=20candidate=20mean=20means=20now=20(W03-019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/StrandAnalytics/AnalyticsEngine.swift | 6 ++++-- docs/review/workstreams/W03-analytics.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 937ba4157d..88810053f1 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -1208,8 +1208,10 @@ public enum AnalyticsEngine { } /// Nightly gated mean of the 5/MG SpO2 **candidate** byte (`@82`) over the detected in-bed - /// `sessions`, with the sample count it rests on — or nil when no in-band reading fell inside any - /// span. (#112, tracking #103.) + /// `sessions`, with the sample count it rests on — or nil when the night has no reliable reading: + /// no in-band reading fell inside any span, or every reading with one failed the quality check + /// (W03-007, W03-019). nil therefore does not mean the strap reported nothing; the full + /// `nightlySpo2CandidateNight` result says which. (#112, tracking #103.) /// /// WHY THIS EXISTS. The candidate is decoded and stored but deliberately never scored: `@82` looks /// like a strap-computed SpO2 %, and on one independent 8-night check it tracked the WHOOP app almost diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 0087879733..4931b8e0a4 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -85,7 +85,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-016 | S4 | Fixed | `loadSpo2CandidateNight` went in between `sparkValuesExplore` and its doc block, so the doc now documents the wrong function | `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading; the placement AGENTS.md warns about. Fixed: `loadSpo2CandidateNight` is gone (W03-018), and `sparkValuesExplore` has its doc block back. | | | W03-017 | S4 | Fixed | Today's candidate night costs four sequential series reads after the concurrent block, one of them the mean the sparkline already read | `TodayView.swift` `loadSpo2CandidateNight` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: Today loads the one read as an `async let` beside the other sparks and builds the candidate sparkline from its means; the second mean read is gone, and the read's keys load concurrently. | | | W03-018 | S4 | Fixed | Loading the candidate series and resolving the night is written twice, in HealthView and TodayView, with different inputs and windows, so a key added to one can miss the other | `HealthView.swift`; `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: one read for every surface: `Spo2CandidateSeries.Read` (package, tested) holds every candidate series and resolves `latest`, `night(on:)` and `meanByDay`; `Repository.spo2CandidateSeries` loads its keys concurrently. The Health card and tile, classic and Liquid Today and the Metric Explorer all resolve through it. `testOneReadAnswersEveryReaderAndDropsALeftoverMean`, `testTheReadLoadsEveryStoredKey`. | | -| W03-019 | S4 | Reported | `nightlySpo2CandidateMean`'s doc says it returns nil only when no in-band reading fell inside any span; it now also returns nil when none is reliable | `AnalyticsEngine.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | +| W03-019 | S4 | Fixed | `nightlySpo2CandidateMean`'s doc says it returns nil only when no in-band reading fell inside any span; it now also returns nil when none is reliable | `AnalyticsEngine.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: the doc says nil means no reliable reading, names both causes, and points at the full result to tell them apart. Documentation only. | | | W03-020 | S4 | Reported | The Low tile still captions a low night "dipped below 90%" beside the tile this change renamed from Dips to Low readings | `Spo2EstimateCard.swift` `lowCaption` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | | W03-021 | S4 | Reported | "%lld left out as low quality" has no plural variants, so a count of 1 is ungrammatical in es, fr, it and pt-PT, and ru and pl need several forms | `Localizable.xcstrings` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading the catalog entry. | | | W03-022 | S4 | Reported | W03-007's fix commits did not carry the tracker updates that describe them; the entries came in later commits | branch history | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). Confirmed. The branch is not pushed; the history is left as it is and every later fix carries its own row update. | | From 57e6e097709d5cc5f5cc3bbc9d1fc376aea870fe Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 11:18:48 +0200 Subject: [PATCH 12/15] =?UTF-8?q?fix(app):=20caption=20the=20SpO=E2=82=82?= =?UTF-8?q?=20Low=20tile=20without=20"dipped"=20(W03-020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Strand/Resources/Localizable.xcstrings | 256 +++++++++++------------ Strand/Screens/Spo2EstimateCard.swift | 6 +- docs/review/workstreams/W03-analytics.md | 2 +- 3 files changed, 133 insertions(+), 131 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index c41465d904..fecf5149d1 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -234686,6 +234686,70 @@ } } }, + "all readings at or above %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "alle Messungen bei oder über %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "all readings at or above %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "todas las lecturas en %@ o más" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "toutes les mesures à %@ ou plus" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "tutte le letture a %@ o sopra" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "wszystkie odczyty na poziomie %@ lub wyżej" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "todas as leituras em %@ ou acima" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "все измерения на уровне %@ или выше" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所有读数均不低于 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所有讀數均不低於 %@" + } + } + } + }, "all time": { "localizations": { "de": { @@ -235452,6 +235516,70 @@ } } }, + "below %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "unter %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "below %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "por debajo de %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "sous %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "sotto %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "poniżej %@" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "abaixo de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ниже %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "低于 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "低於 %@" + } + } + } + }, "below a good night, limiting recovery": { "localizations": { "de": { @@ -252223,134 +252351,6 @@ } } }, - "dipped below %@": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "fiel unter %@" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "dipped below %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "bajó de %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "est descendu sous %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "è scesa sotto %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "spadło poniżej %@" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "desceu abaixo de %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "опускалось ниже %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "低于 %@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "低於 %@" - } - } - } - }, - "stayed above %@": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "blieb über %@" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "stayed above %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "se mantuvo por encima de %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "est resté au-dessus de %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "è rimasta sopra %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "utrzymało się powyżej %@" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "manteve-se acima de %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "держалось выше %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "始终高于 %@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "始終高於 %@" - } - } - } - }, "none below %@": { "localizations": { "de": { diff --git a/Strand/Screens/Spo2EstimateCard.swift b/Strand/Screens/Spo2EstimateCard.swift index ff05898094..f6cdb2e833 100644 --- a/Strand/Screens/Spo2EstimateCard.swift +++ b/Strand/Screens/Spo2EstimateCard.swift @@ -188,9 +188,11 @@ struct Spo2EstimateCard: View { guard night.minimum != nil else { return "" } // `dipped == nil` is a night scored before the dip keys shipped: unknown, so claim nothing. guard let dipped = night.dipped else { return "" } + // "below", not "dipped below": one reading about every 20 minutes shows a low reading, not a + // desaturation (W03-020), and "stayed above" claimed a continuity nothing measured. return dipped - ? String(localized: "dipped below \(thresholdLabel)") - : String(localized: "stayed above \(thresholdLabel)") + ? String(localized: "below \(thresholdLabel)") + : String(localized: "all readings at or above \(thresholdLabel)") } /// The Low readings tile's caption. A low reading is a READING whose middle value is under the diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 4931b8e0a4..9d1c1bf975 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -86,7 +86,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-017 | S4 | Fixed | Today's candidate night costs four sequential series reads after the concurrent block, one of them the mean the sparkline already read | `TodayView.swift` `loadSpo2CandidateNight` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: Today loads the one read as an `async let` beside the other sparks and builds the candidate sparkline from its means; the second mean read is gone, and the read's keys load concurrently. | | | W03-018 | S4 | Fixed | Loading the candidate series and resolving the night is written twice, in HealthView and TodayView, with different inputs and windows, so a key added to one can miss the other | `HealthView.swift`; `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: one read for every surface: `Spo2CandidateSeries.Read` (package, tested) holds every candidate series and resolves `latest`, `night(on:)` and `meanByDay`; `Repository.spo2CandidateSeries` loads its keys concurrently. The Health card and tile, classic and Liquid Today and the Metric Explorer all resolve through it. `testOneReadAnswersEveryReaderAndDropsALeftoverMean`, `testTheReadLoadsEveryStoredKey`. | | | W03-019 | S4 | Fixed | `nightlySpo2CandidateMean`'s doc says it returns nil only when no in-band reading fell inside any span; it now also returns nil when none is reliable | `AnalyticsEngine.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: the doc says nil means no reliable reading, names both causes, and points at the full result to tell them apart. Documentation only. | | -| W03-020 | S4 | Reported | The Low tile still captions a low night "dipped below 90%" beside the tile this change renamed from Dips to Low readings | `Spo2EstimateCard.swift` `lowCaption` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. | | +| W03-020 | S4 | Fixed | The Low tile still captions a low night "dipped below 90%" beside the tile this change renamed from Dips to Low readings | `Spo2EstimateCard.swift` `lowCaption` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: the Low tile reads "below 90%" on a night with a low reading and "all readings at or above 90%" otherwise; the old two strings are gone from the catalog, the new ones translated in nine locales. | | | W03-021 | S4 | Reported | "%lld left out as low quality" has no plural variants, so a count of 1 is ungrammatical in es, fr, it and pt-PT, and ru and pl need several forms | `Localizable.xcstrings` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading the catalog entry. | | | W03-022 | S4 | Reported | W03-007's fix commits did not carry the tracker updates that describe them; the entries came in later commits | branch history | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). Confirmed. The branch is not pushed; the history is left as it is and every later fix carries its own row update. | | From 66ecaa2cf8f807c0024d0fe738a6144120ddf1ce Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 11:20:00 +0200 Subject: [PATCH 13/15] =?UTF-8?q?fix(i18n):=20plural=20forms=20for=20the?= =?UTF-8?q?=20SpO=E2=82=82=20readings=20left=20out=20(W03-021)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Strand/Resources/Localizable.xcstrings | 192 +++++++++++++++++++---- docs/review/workstreams/W03-analytics.md | 2 +- 2 files changed, 163 insertions(+), 31 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index fecf5149d1..966d160bd7 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -10699,63 +10699,195 @@ "%lld left out as low quality": { "localizations": { "de": { - "stringUnit": { - "state": "translated", - "value": "%lld wegen geringer Qualität ausgelassen" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld wegen geringer Qualität ausgelassen" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld wegen geringer Qualität ausgelassen" + } + } + } } }, "en": { - "stringUnit": { - "state": "translated", - "value": "%lld left out as low quality" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld left out as low quality" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld left out as low quality" + } + } + } } }, "es": { - "stringUnit": { - "state": "translated", - "value": "%lld descartadas por baja calidad" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld descartada por baja calidad" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld descartadas por baja calidad" + } + } + } } }, "fr": { - "stringUnit": { - "state": "translated", - "value": "%lld écartées pour qualité insuffisante" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld écartée pour qualité insuffisante" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld écartées pour qualité insuffisante" + } + } + } } }, "it": { - "stringUnit": { - "state": "translated", - "value": "%lld escluse per bassa qualità" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld esclusa per bassa qualità" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld escluse per bassa qualità" + } + } + } } }, "pl": { - "stringUnit": { - "state": "translated", - "value": "%lld pominięto z powodu niskiej jakości" + "variations": { + "plural": { + "few": { + "stringUnit": { + "state": "translated", + "value": "%lld odczyty pominięte z powodu niskiej jakości" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "%lld odczytów pominiętych z powodu niskiej jakości" + } + }, + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld odczyt pominięty z powodu niskiej jakości" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld odczytu pominiętego z powodu niskiej jakości" + } + } + } } }, "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "%lld excluídas por baixa qualidade" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld excluída por baixa qualidade" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld excluídas por baixa qualidade" + } + } + } } }, "ru": { - "stringUnit": { - "state": "translated", - "value": "%lld исключено из-за низкого качества" + "variations": { + "plural": { + "few": { + "stringUnit": { + "state": "translated", + "value": "%lld измерения исключены из-за низкого качества" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "%lld измерений исключено из-за низкого качества" + } + }, + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld измерение исключено из-за низкого качества" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld измерения исключено из-за низкого качества" + } + } + } } }, "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%lld 次因质量低被排除" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 个读数因质量低被排除" + } + } + } } }, "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%lld 次因品質低被排除" + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld 個讀數因品質低被排除" + } + } + } } } } diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 9d1c1bf975..3f28f116ee 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -87,7 +87,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-018 | S4 | Fixed | Loading the candidate series and resolving the night is written twice, in HealthView and TodayView, with different inputs and windows, so a key added to one can miss the other | `HealthView.swift`; `TodayView.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: one read for every surface: `Spo2CandidateSeries.Read` (package, tested) holds every candidate series and resolves `latest`, `night(on:)` and `meanByDay`; `Repository.spo2CandidateSeries` loads its keys concurrently. The Health card and tile, classic and Liquid Today and the Metric Explorer all resolve through it. `testOneReadAnswersEveryReaderAndDropsALeftoverMean`, `testTheReadLoadsEveryStoredKey`. | | | W03-019 | S4 | Fixed | `nightlySpo2CandidateMean`'s doc says it returns nil only when no in-band reading fell inside any span; it now also returns nil when none is reliable | `AnalyticsEngine.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: the doc says nil means no reliable reading, names both causes, and points at the full result to tell them apart. Documentation only. | | | W03-020 | S4 | Fixed | The Low tile still captions a low night "dipped below 90%" beside the tile this change renamed from Dips to Low readings | `Spo2EstimateCard.swift` `lowCaption` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: the Low tile reads "below 90%" on a night with a low reading and "all readings at or above 90%" otherwise; the old two strings are gone from the catalog, the new ones translated in nine locales. | | -| W03-021 | S4 | Reported | "%lld left out as low quality" has no plural variants, so a count of 1 is ungrammatical in es, fr, it and pt-PT, and ru and pl need several forms | `Localizable.xcstrings` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading the catalog entry. | | +| W03-021 | S4 | Fixed | "%lld left out as low quality" has no plural variants, so a count of 1 is ungrammatical in es, fr, it and pt-PT, and ru and pl need several forms | `Localizable.xcstrings` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading the catalog entry. Fixed: the entry carries plural variants: one and other in de, es, fr, it and pt-PT (the participle agrees with the reading), one, few, many and other in pl and ru, other in zh; the zh wording counts readings (个读数 / 個讀數) rather than times. The i18n gate reads plural variations. | | | W03-022 | S4 | Reported | W03-007's fix commits did not carry the tracker updates that describe them; the entries came in later commits | branch history | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). Confirmed. The branch is not pushed; the history is left as it is and every later fix carries its own row update. | | ## Log From 9811c103beb65904aff14c81b99597560241d6a5 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 11:25:26 +0200 Subject: [PATCH 14/15] docs(review): W03-022 deferred to the merge; the code review of W03-007 on the board --- docs/review/README.md | 5 +++-- docs/review/workstreams/W03-analytics.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/review/README.md b/docs/review/README.md index 91a3d1e544..a0acf4f2ad 100644 --- a/docs/review/README.md +++ b/docs/review/README.md @@ -176,8 +176,9 @@ item as it lands. - [ ] W3 out of phase, `review/w03-spo2-quality` (off `main`), from the owner's 2026-09-30 export: W03-007 (the SpO₂ candidate leaves low-quality byte-82 readings out of the night and states a night with no reliable reading as such, the owner's choice). V2 (independent subagent) found one S2, four S3 and - eight S4; all fixed on the branch except W03-008 (recorded). Package suite, `StrandTests`, both builds - (NOOPiOS without the watch app), i18n and hygiene gates pass. Then a strap run: a night on the build + eight S4; all fixed on the branch except W03-008 (recorded). A code review (`/code-review xhigh`) then + found W03-009 … W03-022; all are fixed on the same branch except W03-022 (deferred to the merge). + Package suite, `StrandTests`, both builds (NOOPiOS without the watch app), i18n and hygiene gates pass. Then a strap run: a night on the build whose card reads "N of M" with the readings left out named, and its strap-log line; ideally one night with the strap deliberately loose, to see "no reliable reading". - [ ] Still open from Phase 0: AD-4's target (warning-clean vs Swift 6 mode), now that the counts are diff --git a/docs/review/workstreams/W03-analytics.md b/docs/review/workstreams/W03-analytics.md index 3f28f116ee..49d4c648a7 100644 --- a/docs/review/workstreams/W03-analytics.md +++ b/docs/review/workstreams/W03-analytics.md @@ -88,7 +88,7 @@ every changed row explained; SleepBench / SleepPSG for any stager change. | W03-019 | S4 | Fixed | `nightlySpo2CandidateMean`'s doc says it returns nil only when no in-band reading fell inside any span; it now also returns nil when none is reliable | `AnalyticsEngine.swift` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: the doc says nil means no reliable reading, names both causes, and points at the full result to tell them apart. Documentation only. | | | W03-020 | S4 | Fixed | The Low tile still captions a low night "dipped below 90%" beside the tile this change renamed from Dips to Low readings | `Spo2EstimateCard.swift` `lowCaption` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading. Fixed: the Low tile reads "below 90%" on a night with a low reading and "all readings at or above 90%" otherwise; the old two strings are gone from the catalog, the new ones translated in nine locales. | | | W03-021 | S4 | Fixed | "%lld left out as low quality" has no plural variants, so a count of 1 is ungrammatical in es, fr, it and pt-PT, and ru and pl need several forms | `Localizable.xcstrings` | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). V0 by reading the catalog entry. Fixed: the entry carries plural variants: one and other in de, es, fr, it and pt-PT (the participle agrees with the reading), one, few, many and other in pl and ru, other in zh; the zh wording counts readings (个读数 / 個讀數) rather than times. The i18n gate reads plural variations. | | -| W03-022 | S4 | Reported | W03-007's fix commits did not carry the tracker updates that describe them; the entries came in later commits | branch history | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). Confirmed. The branch is not pushed; the history is left as it is and every later fix carries its own row update. | | +| W03-022 | S4 | Deferred | W03-007's fix commits did not carry the tracker updates that describe them; the entries came in later commits | branch history | Found by the code review of W03-007 (2026-09-30, `/code-review xhigh`). Confirmed. The branch is not pushed; the history is left as it is and every later fix carries its own row update. Every fix after the code review (W03-009 … W03-021) carries its own row update in its commit. Rewriting the four earlier commits is left to the owner: the branch is not pushed, so a squash on merge settles it without a history rewrite now. | | ## Log @@ -103,3 +103,4 @@ every changed row explained; SleepBench / SleepPSG for any stager change. - 2026-09-27 — W03-003 checked on the first night scored on the build (V3 row note). W03-005 is not reproducible on this export: its one two-session day's second session holds no byte 82 window, so both resolutions agree. W03-006 recorded from the replay harness. - 2026-09-30 — W03-007 from the owner's 2026-09-30 export (two nights of low SpO₂ candidate readings). Owner decision the same day: a night whose readings all fail the check shows "no reliable reading" rather than the previous night or an average of failed readings. - 2026-09-30 — W03-007 app side and V2 on `review/w03-spo2-quality`: the independent review found one S2, four S3 and eight S4; eleven fixed on the branch, W03-008 recorded from it. +- 2026-09-30 — Code review of W03-007 (`/code-review xhigh`): 14 findings, W03-009 … W03-022, each re-verified (W03-012 and W03-013 by running code, the rest by reading). All fixed on `review/w03-spo2-quality` except W03-022 (deferred to the merge). The shared cause of five of them was surfaces resolving the candidate night on their own; `Spo2CandidateSeries.Read` is now the one read every surface uses. From c646402aa764037f75dba74a754d1104e6d5af54 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Wed, 30 Sep 2026 11:53:23 +0200 Subject: [PATCH 15/15] =?UTF-8?q?test(analytics):=20synthetic=20values=20i?= =?UTF-8?q?n=20the=20SpO=E2=82=82=20series=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Spo2CandidateNightStatsTests.swift | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift index 4c3f75ecd7..69872789a5 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2CandidateNightStatsTests.swift @@ -419,12 +419,12 @@ final class Spo2CandidateSeriesTests: XCTestCase { /// it would put the previous night's average on the card as if it were last night's. func testANightWithNoReliableReadingIsTheNewestNightNotSkipped() { let n = Spo2CandidateSeries.latest( - mean: ["2026-09-29": 95.2], - dips: ["2026-09-29": 0, "2026-09-30": 0], - windows: ["2026-09-29": 20, "2026-09-30": 0], - windowsAttempted: ["2026-09-29": 22, "2026-09-30": 26], - lowQuality: ["2026-09-29": 1, "2026-09-30": 24]) - XCTAssertEqual(n?.day, "2026-09-30") + mean: ["2026-06-11": 96.0], + dips: ["2026-06-11": 0, "2026-06-12": 0], + windows: ["2026-06-11": 20, "2026-06-12": 0], + windowsAttempted: ["2026-06-11": 22, "2026-06-12": 26], + lowQuality: ["2026-06-11": 1, "2026-06-12": 24]) + XCTAssertEqual(n?.day, "2026-06-12") XCTAssertTrue(n?.hasNoReliableReading == true) XCTAssertNil(n?.meanRounded) XCTAssertEqual(n?.windowsLowQuality, 24) @@ -435,9 +435,9 @@ final class Spo2CandidateSeriesTests: XCTestCase { /// night with no reliable reading a leftover mean or minimum row is ignored. func testALeftoverMeanOnANightWithNoReliableReadingIsIgnored() { let n = Spo2CandidateSeries.latest( - mean: ["2026-09-30": 91.2], minimum: ["2026-09-30": 73], - windows: ["2026-09-30": 0], windowsAttempted: ["2026-09-30": 26], - lowQuality: ["2026-09-30": 26]) + mean: ["2026-06-12": 90.0], minimum: ["2026-06-12": 80], + windows: ["2026-06-12": 0], windowsAttempted: ["2026-06-12": 26], + lowQuality: ["2026-06-12": 26]) XCTAssertNil(n?.mean) XCTAssertNil(n?.minimum) } @@ -494,17 +494,17 @@ final class Spo2CandidateSeriesTests: XCTestCase { /// another computed id the reader unions, would otherwise put back on screen. func testOneReadAnswersEveryReaderAndDropsALeftoverMean() { let read = Spo2CandidateSeries.Read( - mean: ["2026-09-28": 95.8, "2026-09-29": 93.6, "2026-09-30": 91.2], - minimum: ["2026-09-30": 73], - windows: ["2026-09-29": 10, "2026-09-30": 0], - windowsAttempted: ["2026-09-29": 26, "2026-09-30": 29], - lowQuality: ["2026-09-29": 13, "2026-09-30": 29]) - XCTAssertEqual(read.meanByDay, ["2026-09-28": 95.8, "2026-09-29": 93.6]) - XCTAssertTrue(read.night(on: "2026-09-30")!.hasNoReliableReading) - XCTAssertNil(read.night(on: "2026-09-30")?.minimum) - XCTAssertEqual(read.night(on: "2026-09-29")?.meanRounded, 94) - XCTAssertNil(read.night(on: "2026-09-27"), "a day with no series is not a night") - XCTAssertEqual(read.latest, read.night(on: "2026-09-30")) + mean: ["2026-06-10": 96.2, "2026-06-11": 94.6, "2026-06-12": 90.0], + minimum: ["2026-06-12": 80], + windows: ["2026-06-11": 10, "2026-06-12": 0], + windowsAttempted: ["2026-06-11": 26, "2026-06-12": 29], + lowQuality: ["2026-06-11": 13, "2026-06-12": 29]) + XCTAssertEqual(read.meanByDay, ["2026-06-10": 96.2, "2026-06-11": 94.6]) + XCTAssertTrue(read.night(on: "2026-06-12")!.hasNoReliableReading) + XCTAssertNil(read.night(on: "2026-06-12")?.minimum) + XCTAssertEqual(read.night(on: "2026-06-11")?.meanRounded, 95) + XCTAssertNil(read.night(on: "2026-06-09"), "a day with no series is not a night") + XCTAssertEqual(read.latest, read.night(on: "2026-06-12")) XCTAssertEqual(Spo2CandidateSeries.latest(mean: read.mean, minimum: read.minimum, windows: read.windows, windowsAttempted: read.windowsAttempted, lowQuality: read.lowQuality), read.latest)