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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Strand/BLE/PuffinExperiment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 42 additions & 1 deletion Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion Strand/Screens/HealthView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 })
}
}
}

Expand Down
27 changes: 27 additions & 0 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
51 changes: 42 additions & 9 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
Loading
Loading