diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift index beb44ef2d3..020211fb75 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -52,6 +52,20 @@ enum PuffinExperiment { static var ecgRawDataEnabled: Bool { UserDefaults.standard.bool(forKey: ecgRawDataKey) } + /// Opt-in "SpO₂ strap estimate" display (#103): surfaces the WHOOP 5/MG `spo2_candidate_82` nightly + /// mean in the Blood Oxygen tile/card, labelled "strap estimate (unverified)". The @82 byte is a + /// strap-computed SpO₂ % (70–100 range) that an 8-night independent validation tracked at corr +0.99 + /// against the WHOOP app, but two nights on the original #103 device moved OPPOSITE — device/firmware + /// variance unresolved. Per the derived-biosignal rule (CLAUDE.md), an unvalidated signal ships behind + /// a default-off toggle, never as the default `spo2Pct` and never feeding a downstream gate. + /// + /// Display-only: writes nothing to the strap. The engine computes `nightlySpo2CandidateMean` and + /// writes it to metricSeries as "spo2_candidate" under the "-noop" computed device ID; the UI reads + /// it only while this toggle is ON. Mirrors the Android `NoopPrefs.KEY_SPO2_CANDIDATE_DISPLAY`. + static let spo2CandidateDisplayKey = "noopSpo2CandidateDisplay" + + static var spo2CandidateDisplayEnabled: Bool { UserDefaults.standard.bool(forKey: spo2CandidateDisplayKey) } + /// Opt-in "Continuous HRV capture": hold the dense realtime HR stream armed even with no Live screen /// open, so the strap banks beat-to-beat R-R intervals 24/7 for far better overnight HRV/recovery/ /// sleep (vs the sparse history offload). Uses more battery (continuous HR streaming). Default OFF; diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 67309f7526..16df6e5e6f 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -130,6 +130,11 @@ final class IntelligenceEngine: ObservableObject { /// where `rr` is in scope and replayed through `diagnosticSink` in pass 2 (which is main-actor /// isolated). nil when the night has no in-sleep R-R. let hrvDiag: String? + /// #103: the nightly `spo2_candidate_82` mean for this day, computed off the main actor from the + /// V18AuxSample stream when the SpO₂ candidate display toggle is ON. nil when the toggle is OFF, + /// the night has no in-band @82 readings, or the owner is a WHOOP 4.0 (no v18 aux stream). + /// Written to metricSeries as "spo2_candidate" under the "-noop" device ID in pass 2. + let spo2Candidate: Int? } struct Computed: Identifiable { @@ -509,6 +514,12 @@ final class IntelligenceEngine: ObservableObject { // the 5/MG cumulative @57 series + wrap-aware deltas + dropped deltas, replayed below tagged `.steps`. // The trace recomputes the SAME wrap-aware sum analyzeDay already did, so the steps total is unchanged. let stepsTraceActive = TestCentre.active(.steps) + // #103: read the SpO₂ candidate display toggle ONCE here (off the detached executor, matching the + // other toggle reads above). When ON, each night's `spo2_candidate_82` mean is computed from the + // V18AuxSample stream and written to metricSeries as "spo2_candidate" under the "-noop" device ID, + // so the Blood Oxygen tile can surface it as a "strap estimate (unverified)" fallback. Default OFF + // per the derived-biosignal rule (CLAUDE.md) — the @82 candidate has split cross-device evidence. + let spo2CandidateDisplayOn = PuffinExperiment.spo2CandidateDisplayEnabled let (scanned, skippedDayLines): ([DayScan], [String]) = await Task.detached(priority: .utility) { var out: [DayScan] = [] // Days skipped below (too few HR samples) never get a DayScan, so this diagnostic can't ride @@ -837,10 +848,26 @@ final class IntelligenceEngine: ObservableObject { }.map { $0.bpm } rhrLine = Self.rhrFloorMeanLogLine(day: res.daily.day, floor: floor, inBedBpms: inBedBpms) } + // #103: SpO₂ candidate @82 nightly mean. Only computed when the display toggle is ON. + // Reads the V18AuxSample stream for this night's owner and averages the in-band (70–100) + // @82 readings that fall inside a detected sleep session. nil on a WHOOP 4.0 (no v18 aux + // stream), a night with no in-band readings, or when the toggle is OFF. The mean is + // written to metricSeries as "spo2_candidate" in pass 2, never to `spo2Pct` — the guard + // test `testHistoricalV18OpticalFieldsAreNotNamedPhysiologically` enforces that boundary. + var spo2CandidateMean: Int? = nil + if spo2CandidateDisplayOn { + let auxSamples = (try? await store.v18AuxSamples( + deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] + if !auxSamples.isEmpty { + if let cand = AnalyticsEngine.nightlySpo2CandidateMean(res.sleepSessions, aux: auxSamples) { + spo2CandidateMean = cand.mean + } + } + } out.append(DayScan(result: res, rhrLine: rhrLine, readOwner: owner, hrRows: hr.count, sleepTrace: sleepTrace, stepsTrace: stepsTrace, hrvTrace: hrvTrace, - hrvDiag: hrvDiag)) + hrvDiag: hrvDiag, spo2Candidate: spo2CandidateMean)) } return (out, skippedDayLines) }.value @@ -856,6 +883,8 @@ final class IntelligenceEngine: ObservableObject { // visible in every export. var readOwnerByDay: [String: (owner: String, hrRows: Int)] = [:] var resolvedScoreOwnerByDay: [String: String] = [:] + // #103: SpO₂ candidate @82 nightly mean per day, carried from pass 1 for metricSeries persistence. + var spo2CandidateByDay: [String: Int] = [:] // Back on the main actor: fold the off-actor results into the pass-2 state in the SAME order the // loop produced them. Pure assignment / appends , no further store reads , so this is cheap and the @@ -868,6 +897,11 @@ final class IntelligenceEngine: ObservableObject { nightlyRhrByDay[res.daily.day] = res.daily.restingHr.map(Double.init) nightlyRespByDay[res.daily.day] = res.daily.respRateBpm nightlySkinByDay[res.daily.day] = res.nightlySkinTempC + // #103: carry the SpO₂ candidate @82 nightly mean into pass 2 for metricSeries persistence. + // nil when the toggle is OFF or the night had no in-band @82 readings. + if let cand = scan.spo2Candidate { + spo2CandidateByDay[res.daily.day] = cand + } if let line = scan.rhrLine { diagnosticSink?(line, nil) } // Sleep & Rest test mode (E5): replay this day's gate-trace + Rest lines tagged `.sleep` so they // land under the profile tag in the export. Empty unless the mode is active. @@ -1097,6 +1131,13 @@ final class IntelligenceEngine: ObservableObject { if let rest = AnalyticsEngine.Rest.composite(daily: daily) { restPoints.append(MetricPoint(day: daily.day, key: "sleep_performance", value: rest)) } + // #103: persist the SpO₂ candidate @82 nightly mean to metricSeries as "spo2_candidate" so the + // Blood Oxygen tile can surface it as a "strap estimate (unverified)" fallback when the toggle + // is ON. Written under the "-noop" computed device ID, never to `spo2Pct` — the candidate has + // split cross-device evidence and stays behind the experimental display toggle. + if let cand = spo2CandidateByDay[daily.day] { + restPoints.append(MetricPoint(day: daily.day, key: "spo2_candidate", value: Double(cand))) + } cachedSleep.append(contentsOf: night.cachedSleep) // Persist the detected workouts the pipeline already computes (previously discarded). // Skip any bout overlapping a real imported/manual workout so import+wear users don't diff --git a/Strand/Screens/HealthView.swift b/Strand/Screens/HealthView.swift index 43ab5a21a2..0f9d02955d 100644 --- a/Strand/Screens/HealthView.swift +++ b/Strand/Screens/HealthView.swift @@ -1192,10 +1192,15 @@ private struct VitalsSection: View { return UnitPrefs.resolveTemperature(system: system, override: temperatureRaw) } + // #103: SpO₂ candidate @82 nightly means from metricSeries, loaded when the experimental toggle is ON. + // Empty when the toggle is OFF or no candidate data exists (WHOOP 4.0 has no v18 aux stream). + @State private var spo2CandidateByDay: [String: Double] = [:] + var body: some View { let readings = BodyVitalSigns.readings( sourceRows: repo.vitalMetricRows, - temperatureUnit: temperatureUnit + temperatureUnit: temperatureUnit, + spo2CandidateByDay: spo2CandidateByDay ) VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("Vital Signs", overline: "Latest", trailing: BodyVitalSigns.latestDayLabel(readings)) @@ -1219,6 +1224,18 @@ private struct VitalsSection: View { .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) } + .task(id: PuffinExperiment.spo2CandidateDisplayEnabled) { + // #103: load the SpO₂ candidate @82 nightly means from metricSeries when the toggle is ON. + // The engine writes "spo2_candidate" under the "-noop" computed device ID; `exploreSeries` + // with source "my-whoop" reads it from Layer 2 (computed metricSeries). Empty when the toggle + // is OFF (the engine writes nothing) or on a WHOOP 4.0 (no v18 aux stream). + guard PuffinExperiment.spo2CandidateDisplayEnabled else { + spo2CandidateByDay = [:] + return + } + let pts = await repo.exploreSeries(key: "spo2_candidate", source: "my-whoop", days: 14) + spo2CandidateByDay = Dictionary(pts.map { ($0.day, $0.value) }, uniquingKeysWith: { a, _ in a }) + } } } diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 8a31fb705f..1fbf8bd4c9 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -56,6 +56,11 @@ struct SettingsView: View { /// See [PuffinExperiment.ecgRawDataKey]. @AppStorage(PuffinExperiment.ecgRawDataKey) private var ecgRawDataEnabled = false + /// #103 opt-in: surfaces the WHOOP 5/MG `spo2_candidate_82` nightly mean in the Blood Oxygen tile + /// as a "strap estimate (unverified)" fallback when no calibrated `spo2Pct` exists. Display-only — + /// writes nothing to the strap. See [PuffinExperiment.spo2CandidateDisplayKey]. + @AppStorage(PuffinExperiment.spo2CandidateDisplayKey) private var spo2CandidateDisplayEnabled = false + /// True when the connected strap has positively attested itself a WHOOP MG. The variant is published as /// its label string (`LiveState.whoop5Variant`); "MG" is `Whoop5Variant.mg.label`. nil / not-yet- /// identified / a plain 5.0 is not an MG. @@ -1783,6 +1788,28 @@ struct SettingsView: View { .accessibilityElement(children: .combine) } + // MARK: #103 SpO₂ strap estimate display — surface the @82 candidate as a fallback. + Divider().overlay(StrandPalette.hairline) + + Toggle(isOn: $spo2CandidateDisplayEnabled) { + Text("Blood Oxygen: strap estimate (WHOOP 5/MG)") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .onChangeCompat(of: spo2CandidateDisplayEnabled) { _ in + // Re-score immediately so the @82 candidate is computed and persisted on this + // toggle flip — without this the user waits up to 15 min for the next analyze + // loop, and the Blood Oxygen tile stays blank in the meantime. Same pattern as + // the HRV window toggle above (analyzeRecent → refresh). + Task { await model.intelligence.analyzeRecent(); await model.repo.refresh() } + } + Text("Your WHOOP 5.0/MG sends a strap-computed SpO₂ percentage (the @82 candidate byte) every second. An 8-night independent validation tracked it at corr +0.99 against the WHOOP app, but two nights on the original test device moved the OPPOSITE direction — device/firmware variance is unresolved. Turning this on surfaces the nightly mean in the Blood Oxygen tile as \"strap estimate (unverified)\" when no calibrated import exists. It never feeds recovery or illness scoring. WHOOP 4.0 has no @82 stream, so this does nothing there.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + // MARK: #891 ECG raw-data gate — the second device-config key this app may write, MG-only. Divider().overlay(StrandPalette.hairline) diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 8e569d475d..ab9d9d808e 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -526,9 +526,11 @@ struct TodayView: View { } /// PER-FIELD SpO₂ carry — the twin of `lastVitalsDay` for the field its predicate does NOT check. The - /// on-device engine writes `spo2Pct = nil` (it banks only raw `spo2Red`/`spo2Ir`), so every computed - /// "-noop" row lacks a percentage; only imported rows carry one. A whole-row carry (`lastScoredRecoveryDay` - /// or `lastVitalsDay`) therefore lands on a row with null `spo2Pct` and the Blood Oxygen card reads + /// on-device engine writes `spo2Pct = nil` for WHOOP 5/MG (no raw red/IR in the v18 layout; the + /// `spo2_candidate_82` @82 byte is surfaced behind an experimental toggle, never as `spo2Pct`), and + /// for WHOOP 4.0 the ratio-of-ratios computation may still return nil on too few samples. Only + /// imported rows carry a calibrated percentage. A whole-row carry (`lastScoredRecoveryDay` or + /// `lastVitalsDay`) therefore lands on a row with null `spo2Pct` and the Blood Oxygen card reads /// "No Data" even though an imported row holds a real reading. Resolving SpO₂ independently (the last /// strictly-prior row that HAS it) mirrors the Android `lastSpo2Row`. Only on today; today's key bounds it. private var lastSpo2Day: DailyMetric? { @@ -2259,8 +2261,14 @@ struct TodayView: View { // PER-FIELD carry: today → whole-row vitals carry → the last row that actually HAS a reading // (computed "-noop" rows write spo2Pct = nil), so this card agrees with the Key Metrics tile // (`d?.spo2Pct ?? carriedVital(perField: lastSpo2Day)`). Mirrors the Android dashboardCardValue. - return (d?.spo2Pct ?? lastVitalsDay?.spo2Pct ?? lastSpo2Day?.spo2Pct) - .map { String(format: "%.0f%%", $0) } ?? "—" + // #103: when no calibrated spo2Pct exists AND the experimental toggle is ON, fall back to the + // spo2_candidate @82 sparkline tail (strap estimate, unverified) so the card shows a number. + let calibrated = (d?.spo2Pct ?? lastVitalsDay?.spo2Pct ?? lastSpo2Day?.spo2Pct) + if let v = calibrated { return String(format: "%.0f%%", v) } + if PuffinExperiment.spo2CandidateDisplayEnabled, let tail = sparks["spo2_candidate"]?.last { + return String(format: "%.0f%%", tail) + } + return "—" case .skinTemp: // Stored as a deviation from baseline (°C); show it signed so +/- reads honestly. Same per-field // carry as Blood Oxygen. @@ -3364,12 +3372,31 @@ struct TodayView: View { let spo2 = carriedVital(unit: "SpO₂", today: d?.spo2Pct, prior: { $0.spo2Pct }, perField: lastSpo2Day, format: { String(format: "%.0f%%", $0) }) + // #103: SpO₂ candidate @82 fallback. When spo2Pct is nil (WHOOP 5/MG BLE-only, no import) AND + // the experimental toggle is ON, surface the strap's own @82 nightly mean as a "strap estimate + // (unverified)" so the tile shows a number instead of "—". The candidate has split cross-device + // evidence (corr +0.99 on 8 nights, but 2 nights moved opposite on the original device), so it + // ships behind a default-off toggle and is never written to `spo2Pct` (CLAUDE.md derived- + // biosignal rule). The sparkline switches to the candidate trend when the fallback is active. + // When the toggle is ON but NO candidate data exists (empty @82 stream, WHOOP 4.0, or the + // engine hasn't re-scored yet), show "toggle ON · no @82 data" so the user can tell the + // difference between "toggle off" and "toggle on but no data" — a silent blank reads as broken. + let spo2CandidateOn = PuffinExperiment.spo2CandidateDisplayEnabled + let candidateTail = spo2CandidateOn ? sparks["spo2_candidate"]?.last : nil + let spo2Value = spo2.value == "—" && candidateTail != nil + ? String(format: "%.0f%%", candidateTail!) + : spo2.value + let spo2Caption: String = spo2.value == "—" && candidateTail != nil + ? String(localized: "strap estimate (unverified)") + : (spo2.value == "—" && spo2CandidateOn + ? String(localized: "toggle ON · no @82 data") + : (spo2.caption ?? "")) StatTile( label: "Blood Oxygen", - value: spo2.value, - caption: spo2.caption, - accent: spo2.value == "—" ? StrandPalette.textPrimary : StrandPalette.metricCyan, - sparkline: sparks["spo2"], + value: spo2Value, + caption: spo2Caption, + accent: spo2Value == "—" ? StrandPalette.textPrimary : StrandPalette.metricCyan, + sparkline: spo2.value == "—" && candidateTail != nil ? sparks["spo2_candidate"] : sparks["spo2"], sparkColor: StrandPalette.metricCyan ) case .respiratory: @@ -3780,6 +3807,11 @@ 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: SpO₂ candidate @82 nightly mean (WHOOP 5/MG only). Read via `exploreSeries` so the + // computed "-noop" metricSeries backs the trend. Empty when the toggle is OFF (the engine + // writes nothing) or on a WHOOP 4.0 (no v18 aux stream). 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) // `resp_rate` via `exploreSeries` so a BLE-only WHOOP 5 user's on-device computed // `DailyMetric.respRateBpm` backs the trend (the engine writes the column, not a metricSeries // point). The old `series(… source: "apple-health")` read only Apple Health's metricSeries, @@ -3796,6 +3828,7 @@ struct TodayView: View { sparks["hrv"] = await hrvSpark sparks["rhr"] = await rhrSpark sparks["spo2"] = await spo2Spark + sparks["spo2_candidate"] = await spo2CandidateSpark sparks["resp_rate"] = await respRateSpark sparks["steps"] = await stepsAppleSpark // Steps prefer the strap's own @57 daily total (no metricSeries, it lives on the daily row), diff --git a/Strand/Screens/VitalSignsSummary.swift b/Strand/Screens/VitalSignsSummary.swift index a4124207f9..5f23f80d38 100644 --- a/Strand/Screens/VitalSignsSummary.swift +++ b/Strand/Screens/VitalSignsSummary.swift @@ -115,7 +115,8 @@ enum BodyVitalSigns { static func readings(sourceRows: [SourcedDailyMetric], temperatureUnit: TemperatureUnit, - now: Date = Date()) -> [BodyVitalReading] { + now: Date = Date(), + spo2CandidateByDay: [String: Double] = [:]) -> [BodyVitalReading] { let logicalDay = logicalDayKey(now) // Resolve one metric to a per-day series, taking the FIRST source (by precedence) that carries @@ -147,6 +148,16 @@ enum BodyVitalSigns { let respPoints = points(key: "resp", \.respRateBpm) let spo2Points = points(key: "spo2", \.spo2Pct) + // #103: SpO₂ candidate @82 (WHOOP 5/MG only). When no calibrated spo2Pct exists AND the toggle is + // ON, the candidate mean is passed in from metricSeries as a fallback. It has split cross-device + // evidence and ships behind a default-off toggle, never as `spo2Pct` (CLAUDE.md derived-biosignal + // rule). Built into VitalPoints so the tile + sparkline + `latest()` resolve it the same way. + let spo2CandidateOn = PuffinExperiment.spo2CandidateDisplayEnabled && !spo2CandidateByDay.isEmpty + let spo2CandidatePoints: [VitalPoint] = spo2CandidateOn + ? spo2CandidateByDay.map { (day, value) in + VitalPoint(day: day, value: value, source: .noopComputed) + }.sorted { $0.day < $1.day } + : [] // WHOOP 4.0 raw SpO₂: the (red + IR) / 2 ADC mean per night, present only when both channels // decoded for the day. On-device only, so this resolves to the NOOP-computed row. (#93) let spo2rawPoints = points(key: "spo2raw") { m in @@ -158,7 +169,11 @@ enum BodyVitalSigns { let skinPoints = points(key: "skin", \.skinTempDevC) let respRow = latest(respPoints) - let spo2Row = latest(spo2Points) + // #103: fall back to the spo2_candidate @82 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) + let spo2IsCandidate = spo2Row != nil && latest(spo2Points) == nil let spo2rawRow = latest(spo2rawPoints) let rhrRow = latest(rhrPoints) let hrvRow = latest(hrvPoints) @@ -246,10 +261,14 @@ enum BodyVitalSigns { // neighbouring tile is blank. Note `latest()` here resolves `logicalDay ?? most recent`, // where Android resolves the selected day — so the ROW differs across platforms by design // and the parity contract is the relationship between the two tiles, not the row. - missingCaption: spo2rawRow != nil - ? String(localized: "Raw counts only — needs an import") - : String(localized: "No SpO₂ import or Health value"), - sparkline: trail(spo2Points) + missingCaption: spo2IsCandidate + ? String(localized: "strap estimate (unverified)") + : (PuffinExperiment.spo2CandidateDisplayEnabled && spo2Row == nil + ? String(localized: "toggle ON · no @82 data") + : (spo2rawRow != nil + ? String(localized: "Raw counts only — needs an import") + : String(localized: "No SpO₂ import or Health value"))), + sparkline: spo2IsCandidate ? trail(spo2CandidatePoints) : trail(spo2Points) ), BodyVitalReading( key: "spo2raw", diff --git a/StrandTests/VitalSourceResolutionTests.swift b/StrandTests/VitalSourceResolutionTests.swift index 0e63993420..9155c46771 100644 --- a/StrandTests/VitalSourceResolutionTests.swift +++ b/StrandTests/VitalSourceResolutionTests.swift @@ -129,6 +129,69 @@ final class VitalSourceResolutionTests: XCTestCase { XCTAssertEqual(BodyVitalSigns.latestDayLabel(readings), BodyVitalReading.dayLabel("2026-06-12")) } + // MARK: - #103 SpO₂ candidate @82 fallback + + /// When the toggle is ON and no calibrated `spo2Pct` exists, the Blood O₂ tile falls back to the + /// `spo2_candidate` mean from metricSeries, labelled "strap estimate (unverified)". + func testSpo2CandidateFallsBackWhenNoCalibratedSpo2Pct() { + UserDefaults.standard.set(true, forKey: PuffinExperiment.spo2CandidateDisplayKey) + defer { UserDefaults.standard.set(false, forKey: PuffinExperiment.spo2CandidateDisplayKey) } + + let readings = BodyVitalSigns.readings( + sourceRows: [ + SourcedDailyMetric(metric: daily(day: "2026-06-12", spo2Pct: nil), source: .noopComputed) + ], + temperatureUnit: .celsius, + now: localNoon(day: "2026-06-13"), + spo2CandidateByDay: ["2026-06-12": 96.0] + ) + + let spo2 = readings.first { $0.key == "spo2" } + XCTAssertEqual(spo2?.value, 96.0) + XCTAssertEqual(spo2?.source, .noopComputed) + XCTAssertTrue(spo2?.missingCaption.contains("strap estimate") == true) + } + + /// When the toggle is OFF, the candidate is never surfaced even if data exists. + func testSpo2CandidateNotSurfacedWhenToggleOff() { + UserDefaults.standard.set(false, forKey: PuffinExperiment.spo2CandidateDisplayKey) + + let readings = BodyVitalSigns.readings( + sourceRows: [ + SourcedDailyMetric(metric: daily(day: "2026-06-12", spo2Pct: nil), source: .noopComputed) + ], + temperatureUnit: .celsius, + now: localNoon(day: "2026-06-13"), + spo2CandidateByDay: ["2026-06-12": 96.0] + ) + + let spo2 = readings.first { $0.key == "spo2" } + XCTAssertNil(spo2?.value) + XCTAssertNil(spo2?.source) + } + + /// A calibrated `spo2Pct` always wins over the candidate — the candidate is a fallback, not a + /// replacement. + func testCalibratedSpo2PctWinsOverCandidate() { + UserDefaults.standard.set(true, forKey: PuffinExperiment.spo2CandidateDisplayKey) + defer { UserDefaults.standard.set(false, forKey: PuffinExperiment.spo2CandidateDisplayKey) } + + let readings = BodyVitalSigns.readings( + sourceRows: [ + SourcedDailyMetric(metric: daily(day: "2026-06-12", spo2Pct: 98), source: .whoopImport) + ], + temperatureUnit: .celsius, + now: localNoon(day: "2026-06-13"), + spo2CandidateByDay: ["2026-06-12": 95.0] + ) + + let spo2 = readings.first { $0.key == "spo2" } + XCTAssertEqual(spo2?.value, 98) + XCTAssertEqual(spo2?.source, .whoopImport) + // The calibrated caption, NOT the "strap estimate" one. + XCTAssertFalse(spo2?.missingCaption.contains("strap estimate") == true) + } + // MARK: - Fixtures private func daily( diff --git a/Tools/i18n_audit_baseline.json b/Tools/i18n_audit_baseline.json index 40079fe6a3..103fbf5cab 100644 --- a/Tools/i18n_audit_baseline.json +++ b/Tools/i18n_audit_baseline.json @@ -512,6 +512,10 @@ "android/app/src/main/java/com/noop/ui/SettingsScreen.kt", "Auto · ${profile.hrMaxAuto} bpm (Tanaka)" ], + [ + "android/app/src/main/java/com/noop/ui/SettingsScreen.kt", + "Blood Oxygen: strap estimate" + ], [ "android/app/src/main/java/com/noop/ui/SettingsScreen.kt", "Change photo" @@ -548,6 +552,10 @@ "android/app/src/main/java/com/noop/ui/SettingsScreen.kt", "Sleep chart" ], + [ + "android/app/src/main/java/com/noop/ui/SettingsScreen.kt", + "Surfaces the WHOOP 5/MG strap's nightly SpO₂ estimate (spo2_candidate_82) in the " + ], [ "android/app/src/main/java/com/noop/ui/SettingsScreen.kt", "Wear the strap, tap once, then let it sync and share your strap log." @@ -970,6 +978,10 @@ "Strand/Screens/SettingsView.swift", "%+.0f%%" ], + [ + "Strand/Screens/SettingsView.swift", + "Blood Oxygen: strap estimate (WHOOP 5/MG)" + ], [ "Strand/Screens/SettingsView.swift", "Date of birth, age \\(profile.age) years" @@ -986,6 +998,10 @@ "Strand/Screens/SettingsView.swift", "WHOOP 5.0 / MG · motion → steps" ], + [ + "Strand/Screens/SettingsView.swift", + "Your WHOOP 5.0/MG sends a strap-computed SpO₂ percentage (the @82 candidate byte) every second. An 8-night independent validation tracked it at corr +0.99 against the WHOOP app, but two nights on the original test device moved the OPPOSITE direction — device/firmware variance is unresolved. Turning this on surfaces the nightly mean in the Blood Oxygen tile as \\\"strap estimate (unverified)\\\" when no calibrated import exists. It never feeds recovery or illness scoring. WHOOP 4.0 has no @82 stream, so this does nothing there." + ], [ "Strand/Screens/SleepView.swift", "Removes this recorded sleep and recomputes the day without it. NOOP won't re-detect sleep in this window. You can undo for a few seconds after." @@ -1046,10 +1062,6 @@ "Strand/Screens/WorkoutsView.swift", "Clear filters" ], - [ - "Strand/Screens/WorkoutsView.swift", - "Clear search" - ], [ "Strand/Screens/WorkoutsView.swift", "Delete (\\(chosen.count))" diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index f4daaf17a7..3abf5e2660 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -224,6 +224,11 @@ object IntelligenceEngine { // #141: nightly HRV over DEEP-sleep windows only (WHOOP-style) when true; whole-night mean (the // historical default) when false. The Context-aware caller reads UnitPrefs.hrvWindow and passes it. deepHrvWindow: Boolean = false, + // #103: SpO₂ candidate @82 display toggle. When ON, the nightly `spo2_candidate_82` mean is + // computed from the V18AuxSample stream and persisted as "spo2_candidate" in metricSeries so the + // Blood Oxygen tile can surface it as a "strap estimate (unverified)" fallback. Display-only. + // The Context-aware caller reads NoopPrefs.spo2CandidateDisplay(context) and passes it down. + spo2CandidateDisplay: Boolean = false, ): List = withContext(Dispatchers.Default) { // Serialise the whole pass so overlapping callers never run two rescores in parallel (see // [analyzeGate]). The heavy scoring already ran off the caller's thread via withContext above; the @@ -232,7 +237,7 @@ object IntelligenceEngine { val (out, healed) = analyzeRecentOnCpu(repo, profile, maxDays, importedDeviceId, maxHROverride, nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, baselineEpoch, recoveryEpoch, diag, useExperimentalSleepV2, useMotionAwareWake, sleepTraceSink, recoveryTraceSink, - stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow) + stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow, spo2CandidateDisplay) if (healed == 0) out // #899 heal re-pass: the pass above deleted overlapping duplicate sleep sessions AFTER its days // were scored, and the read-side dedup those days consumed had no bank-recency witness (the fresh @@ -242,7 +247,7 @@ object IntelligenceEngine { else analyzeRecentOnCpu(repo, profile, maxDays, importedDeviceId, maxHROverride, nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, baselineEpoch, recoveryEpoch, diag, useExperimentalSleepV2, useMotionAwareWake, sleepTraceSink, recoveryTraceSink, - stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow).first + stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow, spo2CandidateDisplay).first } } @@ -334,6 +339,10 @@ object IntelligenceEngine { // #141: nightly HRV over DEEP-sleep windows only (WHOOP-style) when true; whole-night default when // false. Threaded into analyzeDay per scored night. deepHrvWindow: Boolean = false, + // #103: SpO₂ candidate @82 display toggle. When ON, the nightly candidate mean is computed and + // persisted as "spo2_candidate" in metricSeries. Default false — the @82 candidate has split + // cross-device evidence and ships behind a default-off toggle (CLAUDE.md derived-biosignal rule). + spo2CandidateDisplay: Boolean = false, // #899 heal re-pass: the second component of the return is how many overlapping duplicate sleep // sessions the heal below deleted this pass. The public wrapper re-runs ONCE when it is non-zero // so the affected days re-score against the cleaned store. @@ -394,6 +403,8 @@ object IntelligenceEngine { // the cheap recovery composite. The raw hr/rr/... lists are freed after each analyzeDay, // keeping memory bounded over a full multi-night offload history. val scoredNights = ArrayList() + // #103: SpO₂ candidate @82 nightly mean per day, carried from pass 1 for metricSeries persistence. + val spo2CandidateByDay = LinkedHashMap() // In-memory nightly values harvested in pass 1, used to seed the pass-2 baseline. // Keyed by day so the union with imported history de-dupes cleanly per UTC day. @@ -724,6 +735,20 @@ object IntelligenceEngine { .map { it.bpm } diag(rhrFloorMeanLogLine(day, rhrFloor, inBedBpms)) } + // #103: SpO₂ candidate @82 nightly mean. Only computed when the display toggle is ON. + // Reads the V18AuxSample stream for this night's owner and averages the in-band (70–100) + // @82 readings that fall inside a detected sleep session. null on a WHOOP 4.0 (no v18 aux + // stream), a night with no in-band readings, or when the toggle is OFF. Persisted to + // metricSeries as "spo2_candidate" in pass 2, never to `spo2Pct`. + if (spo2CandidateDisplay) { + val auxSamples = repo.v18AuxSamples(owner, from, to, STREAM_LIMIT) + if (auxSamples.isNotEmpty()) { + val cand = AnalyticsEngine.nightlySpo2CandidateMean(res.sleepSessions, auxSamples) + if (cand != null) { + spo2CandidateByDay[res.daily.day] = cand.first + } + } + } scoredNights.add(res) resolvedScoreOwnerByDay[res.daily.day] = owner } @@ -888,6 +913,12 @@ object IntelligenceEngine { RestScorer.restFromDaily(daily)?.let { rest -> restRows.add(MetricSeriesRow(deviceId = computedId, day = daily.day, key = "sleep_performance", value = rest)) } + // #103: persist the SpO₂ candidate @82 nightly mean to metricSeries as "spo2_candidate" so the + // Blood Oxygen tile can surface it as a "strap estimate (unverified)" fallback when the toggle + // is ON. Written under the "-noop" computed device ID, never to `spo2Pct`. + spo2CandidateByDay[daily.day]?.let { cand -> + restRows.add(MetricSeriesRow(deviceId = computedId, day = daily.day, key = "spo2_candidate", value = cand.toDouble())) + } out.add( Computed( diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index d434c0cf91..a95bba3ab4 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -2218,6 +2218,9 @@ class WhoopBleClient( if (testCentre.active(com.noop.testcentre.TestDomain.STEPS)) { s -> log(s, com.noop.testcentre.TestDomain.STEPS) } else null, + // #103: SpO₂ candidate @82 display toggle — when ON, the engine computes and + // persists the nightly @82 mean as "spo2_candidate" in metricSeries. + spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(context), ) }.onSuccess { // Advance the shared watermark so the next 15-min tick sees no change and skips (#836). diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index 448ebd6cdb..0ba51ba2a7 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -58,6 +58,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive @@ -633,6 +636,24 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { repository.recentDaysMergedFlow(deviceId) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** + * #103: SpO₂ candidate @82 nightly mean per day, loaded from the "spo2_candidate" metricSeries + * key (written by IntelligenceEngine when the experimental display toggle is ON). Empty when the + * toggle is OFF or no candidate data exists — the Blood O₂ tile then behaves exactly as before. + * Mirrors the iOS HealthView loading `spo2_candidate` from the repository. + */ + val spo2CandidateByDay: StateFlow> = + combine(recentDays, flowOf(NoopPrefs.spo2CandidateDisplay(appContext))) { days, toggleOn -> + if (!toggleOn || days.isEmpty()) emptyMap() + else { + val from = days.first().day + val to = days.last().day + repository.metricSeriesComputedUnion(deviceId, "spo2_candidate", from, to) + .associate { it.day to it.value } + } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + /** * #386 self-heal: a "kick" the app-resume hook sends to wake the 15-min analyze loop early, so an * OEM-killed overnight re-score tick catches up the moment the user opens NOOP instead of showing a @@ -1008,6 +1029,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { else null, // #141: nightly HRV over deep-sleep windows only when the user picked WHOOP-style. deepHrvWindow = UnitPrefs.hrvWindow(appContext) == HrvWindow.DEEP_SLEEP, + // #103: SpO₂ candidate @82 display toggle — when ON, the engine computes and + // persists the nightly @82 mean as "spo2_candidate" in metricSeries. + spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(appContext), ) // analyzeRecent now hops to Dispatchers.Default; a scope cancellation surfaces as a // CancellationException that runCatching would otherwise swallow, breaking the loop's @@ -1570,6 +1594,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { useExperimentalSleepV2 = PuffinExperiment.from(appContext).experimentalSleepV2, // Opt-in motion-aware wake refinement (#364 follow-up) — same flag the 15-min loop reads. useMotionAwareWake = PuffinExperiment.from(appContext).motionAwareWake, + // #103: SpO₂ candidate @82 display toggle — same flag the 15-min loop reads. + spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(appContext), ) }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } } @@ -2302,6 +2328,15 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { } } + /** #103: Flip the SpO₂ candidate display toggle. Persists and immediately re-scores so the @82 + * candidate is computed and persisted on this flip — without this the user waits up to 15 min + * for the next analyze loop, and the Blood Oxygen tile stays blank in the meantime. Mirrors the + * iOS SettingsView `.onChangeCompat` → `analyzeRecent()` pattern. */ + fun setSpo2CandidateDisplay(enabled: Boolean) { + NoopPrefs.setSpo2CandidateDisplay(appContext, enabled) + viewModelScope.launch { rescoreAfterEdit() } + } + /** * Same-day confounder context for [IllnessSignalEngine] from the day's journal — alcohol / hard-or-late * workout / "feeling unwell" suppress an anomaly so a hangover or a late session never reads as illness. diff --git a/android/app/src/main/java/com/noop/ui/HealthScreen.kt b/android/app/src/main/java/com/noop/ui/HealthScreen.kt index aae114cd29..b4ab58351e 100644 --- a/android/app/src/main/java/com/noop/ui/HealthScreen.kt +++ b/android/app/src/main/java/com/noop/ui/HealthScreen.kt @@ -176,7 +176,12 @@ fun HealthScreen( title = uiString(R.string.l10n_health_screen_vital_signs_e7d9e1b1), overline = "Latest readings", trailing = null, - vitals = latestVitals(days, UnitPrefs.temperature(LocalContext.current)), + vitals = latestVitals( + days, + UnitPrefs.temperature(LocalContext.current), + vm.spo2CandidateByDay.value, + NoopPrefs.spo2CandidateDisplay(LocalContext.current), + ), onVitalClick = onVitalClick, captionMode = VitalCaptionMode.AS_OF, ) @@ -1090,13 +1095,17 @@ private fun yearWord(years: Int): String = if (kotlin.math.abs(years) == 1) "yea @Composable fun VitalSignsScreen(vm: AppViewModel, onVitalClick: (String) -> Unit = {}) { val days by vm.recentDays.collectAsStateWithLifecycle() + val spo2CandidateByDay by vm.spo2CandidateByDay.collectAsStateWithLifecycle() var selectedDayOffset by remember { mutableIntStateOf(0) } val selectedDay = remember(selectedDayOffset) { LocalDate.now().minusDays(selectedDayOffset.toLong()) } val selectedDayKey = remember(selectedDay) { selectedDay.toString() } val selectedMetric = remember(days, selectedDayKey) { days.lastOrNull { it.day == selectedDayKey } } val tempUnit = UnitPrefs.temperature(LocalContext.current) - val vitals = remember(selectedMetric, days, tempUnit) { - selectedMetric?.let { vitalsFor(it, days, tempUnit) }.orEmpty() + // Read the toggle here in the composable body, NOT inside remember{} — LocalContext.current is a + // @Composable read and is illegal inside the calculation lambda; pass the resolved value in + key on it. + val spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(LocalContext.current) + val vitals = remember(selectedMetric, days, tempUnit, spo2CandidateByDay, spo2CandidateDisplay) { + selectedMetric?.let { vitalsFor(it, days, tempUnit, spo2CandidateByDay, spo2CandidateDisplay) }.orEmpty() } ScreenScaffold( diff --git a/android/app/src/main/java/com/noop/ui/HealthVitalsLogic.kt b/android/app/src/main/java/com/noop/ui/HealthVitalsLogic.kt index df4bec4ec1..7dd1b46c63 100644 --- a/android/app/src/main/java/com/noop/ui/HealthVitalsLogic.kt +++ b/android/app/src/main/java/com/noop/ui/HealthVitalsLogic.kt @@ -31,6 +31,10 @@ internal data class Vital( /** Trailing values (oldest → newest) for the tile's metric-tinted sparkline trail, matching * Today's Key-Metrics tiles. Presentation-only; defaulted so existing call sites compile. */ val sparkline: List = emptyList(), + /** #103: true when [value] is the spo2_candidate_82 strap estimate (not a calibrated spo2Pct). + * Drives the "Estimate (unverified)" caption so the user knows this is an unverified value. + * Defaulted false so every other vital is unaffected. */ + val isEstimate: Boolean = false, ) { /** Value with its unit appended, or null when no data. */ val formattedValue: String? = value?.let { "${format(it)} $unit" } @@ -46,6 +50,9 @@ internal data class Vital( /** The in-range caption that stands in for a StatePill inside the fixed-height tile. * The wording says which yardstick judged it: your baseline vs typical ranges. */ val stateCaption: String = when { + // #103: when the Blood O₂ tile is showing the spo2_candidate_82 strap estimate (not a + // calibrated spo2Pct), label it "estimate" so the user knows this is an unverified value. + isEstimate && banding.band != VitalBands.Band.NO_DATA -> "Estimate (unverified)" // Raw SpO₂ is a device-dependent ADC, not a clinical value — never claim an in/out-of-range // judgment. Show a plain "uncalibrated" note when a value decoded. (#93) key == "spo2raw" && banding.band != VitalBands.Band.NO_DATA -> "Uncalibrated" @@ -90,11 +97,21 @@ internal enum class VitalCaptionMode { } /** Build the vitals, banded against the user's OWN trailing baseline once 14 trusted - * nights exist (population ranges before that — VitalBands does the deciding). */ + * nights exist (population ranges before that — VitalBands does the deciding). + * + * #103: [spo2CandidateByDay] carries the nightly `spo2_candidate_82` mean (70–100) per day, loaded + * from the "spo2_candidate" metricSeries key when the experimental display toggle is ON. When the + * selected day has no calibrated `spo2Pct` but DOES have a candidate, the Blood O₂ tile falls back to + * the candidate with an "estimate" caption. [spo2ToggleOn] distinguishes "toggle ON, no @82 data" + * from "toggle OFF" so the missingCaption can tell the user which — a silent blank reads as broken. + * Empty map = toggle off or no candidate data → the tile behaves exactly as before. Mirrors the iOS + * `BodyVitalSigns.readings` candidate fallback. */ internal fun vitalsFor( d: DailyMetric?, days: List, tempUnit: TemperatureUnit = TemperatureUnit.CELSIUS, + spo2CandidateByDay: Map = emptyMap(), + spo2ToggleOn: Boolean = false, ): List { val todayKey = d?.day // History strictly before the displayed day, oldest→newest (recentDays is already @@ -190,23 +207,34 @@ internal fun vitalsFor( ), Vital( key = "spo2", label = uiString(R.string.l10n_health_screen_blood_o_9bf5ed9b), unit = "%", + // #103: fall back to the spo2_candidate_82 strap estimate when no calibrated spo2Pct exists + // and the experimental display toggle is ON (candidate map is non-empty). The candidate is + // an unverified strap-computed value (split cross-device evidence), so it ships behind a + // default-off toggle and is labelled "estimate" — never fed into a downstream gate. // The condition is EXACTLY the Raw SpO₂ tile's own value expression (`d?.let(spo2RawMean)`, // below) and must stay that way: the caption's claim is "the tile beside this one is // showing a number", so if the two expressions drift, this says the sensor recorded on a // night where the neighbouring tile is blank. The platforms pick that row differently — // Android per selected day, Apple `logicalDay ?? most recent` — so the ROW is not the // parity contract here; the relationship between the two tiles is. - missingCaption = uiString(spo2MissingCaptionRes(d?.let(spo2RawMean) != null)), - value = d?.spo2Pct, format = { String.format("%.0f", it) }, + missingCaption = if (d?.spo2Pct == null && d?.day?.let { spo2CandidateByDay[it] } != null) + "Estimate (unverified)" + else if (d?.spo2Pct == null && spo2ToggleOn && spo2CandidateByDay.isEmpty()) + "toggle ON · no @82 data" + else uiString(spo2MissingCaptionRes(d?.let(spo2RawMean) != null)), + value = d?.spo2Pct ?: d?.day?.let { spo2CandidateByDay[it] }, + format = { String.format("%.0f", it) }, deltaText = deltaText(d?.spo2Pct, previous { it.spo2Pct }, decimals = 0), readingDay = todayKey, asOfLabel = asOfLabel(todayKey), rangeCaption = spo2RangeCaption, // Population-only on purpose: an absolute <95% floor is meaningful regardless // of personal baseline (no "spo2" MetricCfg exists). - banding = VitalBands.band(d?.spo2Pct, emptyList(), 95.0..100.0, null), + banding = VitalBands.band(d?.spo2Pct ?: d?.day?.let { spo2CandidateByDay[it] }, emptyList(), 95.0..100.0, null), metricColor = Palette.metricCyan, sparkline = trail(d?.spo2Pct) { it.spo2Pct }, + // #103: mark as estimate when the value came from the candidate, not a calibrated spo2Pct. + isEstimate = d?.spo2Pct == null && d?.day?.let { spo2CandidateByDay[it] } != null, ), Vital( // Issue #93: WHOOP 4.0 raw SpO₂ PPG ADC mean (red+IR)/2 per night. NOT a calibrated @@ -269,13 +297,20 @@ internal fun vitalsFor( ) } -internal fun latestVitals(days: List, tempUnit: TemperatureUnit): List { - val emptyByKey = vitalsFor(null, days, tempUnit).associateBy { it.key } +internal fun latestVitals( + days: List, + tempUnit: TemperatureUnit, + spo2CandidateByDay: Map = emptyMap(), + spo2ToggleOn: Boolean = false, +): List { + val emptyByKey = vitalsFor(null, days, tempUnit, spo2CandidateByDay, spo2ToggleOn).associateBy { it.key } return listOf( - latestVital("resp", days, tempUnit, emptyByKey) { it.respRateBpm != null }, - latestVital("spo2", days, tempUnit, emptyByKey) { it.spo2Pct != null }, - latestVital("spo2raw", days, tempUnit, emptyByKey) { it.spo2Red != null && it.spo2Ir != null }, - latestVital("rhr", days, tempUnit, emptyByKey) { it.restingHr != null }, + latestVital("resp", days, tempUnit, emptyByKey, spo2CandidateByDay, spo2ToggleOn) { it.respRateBpm != null }, + latestVital("spo2", days, tempUnit, emptyByKey, spo2CandidateByDay, spo2ToggleOn) { + it.spo2Pct != null || spo2CandidateByDay[it.day] != null + }, + latestVital("spo2raw", days, tempUnit, emptyByKey, spo2CandidateByDay, spo2ToggleOn) { it.spo2Red != null && it.spo2Ir != null }, + latestVital("rhr", days, tempUnit, emptyByKey, spo2CandidateByDay, spo2ToggleOn) { it.restingHr != null }, latestVital("hrv", days, tempUnit, emptyByKey) { it.avgHrv != null }, latestVital("skin", days, tempUnit, emptyByKey) { it.skinTempDevC != null }, ) @@ -286,11 +321,13 @@ private fun latestVital( days: List, tempUnit: TemperatureUnit, emptyByKey: Map, + spo2CandidateByDay: Map = emptyMap(), + spo2ToggleOn: Boolean = false, hasValue: (DailyMetric) -> Boolean, ): Vital { val row = days.asReversed().firstOrNull(hasValue) return row - ?.let { latestRow -> vitalsFor(latestRow, days, tempUnit).firstOrNull { it.key == key } } + ?.let { latestRow -> vitalsFor(latestRow, days, tempUnit, spo2CandidateByDay, spo2ToggleOn).firstOrNull { it.key == key } } ?.copy(asOfLabel = asOfLabel(row.day)) ?: emptyByKey.getValue(key) } diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index 42942c9d31..758582dd68 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -192,6 +192,14 @@ object NoopPrefs { * [com.noop.ble.WhoopBleClient] at every arm site (re-derived at arm time, never cached). */ const val KEY_CONTINUOUS_HRV_OVERNIGHT = "noop.continuousHrvOvernight" + /** #103: "Blood Oxygen: strap estimate" opt-in. When ON, the WHOOP 5/MG `spo2_candidate_82` nightly + * mean is surfaced in the Blood Oxygen tile as a "strap estimate (unverified)" fallback when no + * calibrated `spo2Pct` exists. Display-only — writes nothing to the strap. The @82 candidate has + * split cross-device evidence (corr +0.99 on 8 nights, but 2 nights moved opposite on the original + * device), so it ships behind a default-off toggle per the derived-biosignal rule (CLAUDE.md). + * Mirrors iOS `PuffinExperiment.spo2CandidateDisplayKey`. */ + const val KEY_SPO2_CANDIDATE_DISPLAY = "noop.spo2CandidateDisplay" + /** The calendar day (yyyy-MM-dd) on which the morning-journal nudge was last shown, keeps the * Sleep screen's "Good morning" sheet to at most once per day. */ const val KEY_LAST_JOURNAL_PROMPT = "noop.lastJournalPromptDay" @@ -440,6 +448,15 @@ object NoopPrefs { of(context).edit().putBoolean(KEY_CONTINUOUS_HRV_OVERNIGHT, enabled).apply() } + /** #103: whether the SpO₂ candidate @82 strap estimate is surfaced in the Blood Oxygen tile. + * Default false — the @82 candidate has split cross-device evidence and ships behind a toggle. */ + fun spo2CandidateDisplay(context: Context): Boolean = + of(context).getBoolean(KEY_SPO2_CANDIDATE_DISPLAY, false) + + fun setSpo2CandidateDisplay(context: Context, enabled: Boolean) { + of(context).edit().putBoolean(KEY_SPO2_CANDIDATE_DISPLAY, enabled).apply() + } + /** Whether the strap log is mirrored to logcat. Default false (normal users don't log to adb). */ fun debugLogging(context: Context): Boolean = of(context).getBoolean(KEY_DEBUG_LOGGING, false) diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index ed65bc006c..d4a51c66fb 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -2430,6 +2430,49 @@ fun SettingsScreen( color = Palette.textTertiary, ) + // --- #103: Blood Oxygen strap estimate (spo2_candidate_82) — OFF by default. --- + // The WHOOP 5/MG strap computes a nightly SpO₂ candidate at byte @82 of the V18Aux stream. + // Cross-device evidence is split (corr +0.99 on 8 nights, but 2 nights moved opposite), so + // it ships behind a default-off toggle and is labelled "estimate" in the UI. Display-only: + // never fed into a downstream gate (recovery, illness). Mirrors the iOS toggle. + SettingsRowDivider() + var spo2CandidateDisplay by remember { mutableStateOf(NoopPrefs.spo2CandidateDisplay(context)) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + "Blood Oxygen: strap estimate", + style = NoopType.subhead, + color = Palette.textPrimary, + modifier = Modifier.weight(1f), + ) + Switch( + checked = spo2CandidateDisplay, + onCheckedChange = { + spo2CandidateDisplay = it + vm.setSpo2CandidateDisplay(it) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Palette.surfaceBase, + checkedTrackColor = Palette.accent, + uncheckedThumbColor = Palette.textSecondary, + uncheckedTrackColor = Palette.surfaceInset, + uncheckedBorderColor = Palette.hairline, + ), + ) + } + Text( + "Surfaces the WHOOP 5/MG strap's nightly SpO₂ estimate (spo2_candidate_82) in the " + + "Blood Oxygen tile when no calibrated percentage is available. This is an " + + "UNVERIFIED strap-computed value — it matched a reference device closely on most " + + "nights but moved in the opposite direction on some. Shown as an 'estimate' and " + + "never fed into recovery or illness scoring. Off by default.", + style = NoopType.caption, + color = Palette.textTertiary, + ) + // Diagnostics: dump the decoded per-sample sensor streams (last 24h) to one long-format // CSV so power users / external devs can prototype sleep/activity/VBT algorithms on real // data without a BLE stream (#308/#276/#322). On-device only; plain text, no BLE hex. diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index c08e5bc598..02912ee2c3 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -272,6 +272,7 @@ fun TodayScreen( val today by viewModel.today.collectAsStateWithLifecycle() val alert by viewModel.healthAlert.collectAsStateWithLifecycle() val days by viewModel.recentDays.collectAsStateWithLifecycle() + val spo2CandidateByDay by viewModel.spo2CandidateByDay.collectAsStateWithLifecycle() val live by viewModel.live.collectAsStateWithLifecycle() // The in-flight manual workout (single source of truth, survives an app kill via rehydration), so the // indicator card auto-appears/clears off this alone. Null↔non-null + the start drive the card; the @@ -1543,6 +1544,7 @@ fun TodayScreen( onOpenSleep = onOpenSleep, onOpenCoupled = onOpenCoupled, onCustomise = { showDashboardEditor = true }, + spo2CandidateByDay = spo2CandidateByDay, ) // #656: the persistent journal widget (last-7-days strip + tap-through). Now a // reorderable section like the others — hold-drag or Arrange moves it. Today-only @@ -2997,6 +2999,7 @@ private fun YourCardsSection( onOpenSleep: () -> Unit, onOpenCoupled: () -> Unit, onCustomise: () -> Unit, + spo2CandidateByDay: Map = emptyMap(), ) { Box(modifier = Modifier.fillMaxWidth().staggeredAppear(2)) { Column(verticalArrangement = Arrangement.spacedBy(Metrics.gap)) { @@ -3026,6 +3029,7 @@ private fun YourCardsSection( caloriesForDay = caloriesForDay, hydrationTotalMl = hydrationTotalMl, hydrationGoalMl = hydrationGoalMl, + spo2CandidateByDay = spo2CandidateByDay, ), // The mini liquid vessel's fill — the SAME per-card fraction iOS `liquidCard` uses. fraction = dashboardCardFraction( @@ -3215,6 +3219,7 @@ private fun dashboardCardValue( caloriesForDay: Double?, hydrationTotalMl: Double, hydrationGoalMl: Int, + spo2CandidateByDay: Map = emptyMap(), ): String { fun withUnit(s: String): String = if (s == NO_DATA) NO_DATA else if (card.unit.isEmpty()) s else "$s ${card.unit}" @@ -3232,7 +3237,12 @@ private fun dashboardCardValue( DashboardCard.BLOOD_OXYGEN -> // PER-FIELD carry: the whole-row carries (vd) land on rows whose spo2Pct is null (the engine // writes spo2Pct = null on computed rows), so fall through to the last row that HAS one. - (vd?.spo2Pct ?: spo2Day?.spo2Pct)?.let { String.format(Locale.US, "%.0f%%", it) } ?: NO_DATA + // #103: when no calibrated spo2Pct exists, fall back to the spo2_candidate_82 strap estimate + // (from metricSeries) when the experimental display toggle is ON. Labelled "estimate" in the + // Health vitals screen; here it just fills the card so it's not blank. + (vd?.spo2Pct ?: spo2Day?.spo2Pct)?.let { String.format(Locale.US, "%.0f%%", it) } + ?: (vd?.day ?: day?.day)?.let { spo2CandidateByDay[it] }?.let { String.format(Locale.US, "%.0f%%", it) } + ?: NO_DATA DashboardCard.SKIN_TEMP -> // Stored as a deviation from baseline (°C); show it signed so +/- reads honestly. // Same per-field carry as Blood Oxygen.