Repository navigation
Conversation
…n empty chart The Deep Timeline plotted SpO2 as a red/IR ratio and skipped any sample with ir <= 0. A WHOOP 4.0 v24 banks BOTH optical channels so that works, but an Oura ring reports only ONE channel: OuraStreamMapping stores it in `red` and leaves ir = 0 (an unread channel, never a fabricated second reading). The ratio path therefore discarded 100% of a ring's SpO2 - 18,688 rows on the reporting device - and drew an empty chart with no explanation (the ryanbr#623 "unsupported strap" notice only fires for the 5.0 family, so a ring owner got no notice either). Adds Repository.spo2TimelineValue(red:ir:), a pure decision: two-channel keeps the unchanged unitless ratio proxy (ryanbr#166 - there is still no calibrated %), single-channel plots the reading itself. A real overnight capture (2026-08-01) shows the ring's channel clustering at 95-105, i.e. already a genuine %SpO2, not an ADC awaiting calibration. RANGE GATE, single-channel only: the `unit` tag that separates the ring's true-percentage channel from its different-scale perfusion channel is NOT persisted (spo2Sample stores only red/ir), so rows banked before that split was fixed are distinguishable only by magnitude - the reporting device holds values from -1016 to 11,709,098 beside real ones. Gating to 50...110 keeps every genuine reading and drops the mis-scaled ones, so one legacy outlier cannot flatten the whole y-axis. It is a DISPLAY gate on a metric that is never scored, changes no stored row, and mirrors the idiom already guarding temp (20-45 C) and HR (0-300 bpm) at their decoders. The bounds are deliberately permissive rather than a cosmetic clamp: the channel is uncalibrated and genuinely overshoots 100 (1,948 of 5,562 in-range rows sit at 101-110 and track the rest of the series), so clamping at 100 would silently flatten real data. The two-channel ratio is NOT gated - a ratio has no comparable physiological range - so WHOOP output stays byte-identical. Effect on the reporting device: 0 rows plotted before, 5,562 after, averaging 96.9%. Apple-only: the Deep Timeline has no Android twin (Android surfaces SpO2 through HealthVitalsLogic, which reads the daily rollup, not this per-sample path), so there is no parity change to make. Tested: 4 new DeepTimelineFacadeTests pinning the two-channel ratio (unchanged), the single-channel reading, the legacy-row drop, and that the gate never touches the ratio path; DeepTimelineFacadeTests 13/13; macOS Strand builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LfZdZnmEmYya6AgzYwH9Xh
…ft+Kotlin) Blood Oxygen tile falls back to a device-conditional, opt-in nightly SpO2 estimate when no calibrated spo2Pct exists: WHOOP keeps its existing @82 V18Aux candidate, an Oura owner gets a new ceiling@100 mean of the ring's own 0x6F reading (min(sample,100) applied per-sample before averaging, gated to the existing contamination-exclusion range so a mis-scaled dc_raw row can't drag the mean down). Ceiling@100 is queue 11a's starting transform per OURA_PROTOCOL.md §6.5.0's Oura-app-groundtruth tally (3/3 full-tier nights vs raw's 1/3 and the offset+clamp fit's 2/3). Reuses the WHOOP spo2_candidate_82 precedent exactly: opt-in, default-off, labelled "strap estimate (unverified)", written to a metricSeries sidecar key, never to spo2Pct, never scored. Adds AnalyticsEngine.nightlySpo2CeilingMean (Swift + Kotlin twin, 6 tests each) and wires it into both platforms' IntelligenceEngine, UI candidate-fallback paths, and the shared toggle's copy. LiquidTodayView's Blood Oxygen tile previously read spo2Pct only, with no candidate fallback at all; it now has one via a new optional caption param on its shared tile helper. Verified: swift test (1519/1519), Android com.noop.analytics.* suite, xcodebuild for the macOS app target (no default CI covers app-target Swift), gradlew compileFullDebugKotlin, and the i18n gate (clean except one pre-existing, unrelated pl-locale gap). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBBrN3NH9uQ63XN3tgkvkq
…nly install The toggle (spo2CandidateDisplayEnabled) has covered Oura since 89c8533 (label reads "Blood Oxygen: strap estimate (WHOOP 5/MG, Oura)"), but stayed nested inside fiveMGCard, gated by showFiveMGControls (selectedWhoopModelRaw == whoop5mg). An install that has never had a real WHOOP 5/MG connect — WHOOP 4.0 + Oura paired here — never sees that section at all, so the toggle could never be reached. metricSeries confirmed zero spo2_candidate rows ever written despite pass-2 scoring running daily, and a full screenshot sweep of Settings confirmed the section never renders. Splits the toggle into its own spo2CandidateCard, shown for a 5/MG OR an active Oura device (mirrors rawSensorDiagnosticsCard's existing ryanbr#22 split for controls that outgrew the 5/MG-only card). No logic change: same @AppStorage key, same copy, same re-score-on-flip behavior, just relocated. Checked Android first: it does not have this bug — the Kotlin toggle (SettingsScreen.kt) already sits outside its own showFiveMGControls gate, inside the always-visible Diagnostics card. iOS/macOS-only fix. Verified: Strand (macOS) and NOOPiOS (iOS Simulator) both build clean (xcodebuild ... build — no default CI covers app-target Swift), i18n gate clean except the pre-existing, unrelated pl-locale gap. Related, not fixed here: stressPersonalBaselineEnabled (ryanbr#463) is also still inside fiveMGCard despite not being WHOOP-5/MG-specific — same class of bug, left for a separate change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEX9vyGzsBECX7ECkDL1rg
|
Adding more detail on the SpO2 numbers this PR's ceiling@100 choice is based on, since the PR body MethodSince the ring bonds to one BLE client at a time, a same-night comparison against Oura's own app Running tally (4 nights)
08-22/23 is the night this PR's own toggle-unreachable bug was found on, so the candidate had never Why this doesn't change what's being shippedceiling@100 is still the best performer of the three (3/4 vs 1/4 raw, 2/4 offset+clamp), and this |
…ebase of #1568) @pipiche38's #1568 rebased onto current main, with one integration fix on top. Their three commits are preserved with their authorship. #1568 was green and CLEAN, but the green was ~10 hours stale and the conflict was never textual: merging their branch with main pushed analyzeRecentOnCpu 30 bytes past the JaCoCo budget, because their SpO2 candidate branch and #1575's trace recorders both add to a method already within ~100 bytes of the JVM's 64 KB ceiling. Neither exceeded it alone. Verified on the integrated tree: 4283 Android tests 0 failures, release lint and i18n clean, and app-build green on macOS and iOS — the first compile of the integrated Swift, since their local Xcode builds predated the merge.
|
Merged as #1584 — your three commits, rebased onto current main with one fix on top, with your authorship intact in history. Thank you, this was a thorough piece of work and the write-up made it reviewable. Two things worth passing back. Why it needed a rebase rather than a straight merge. This PR was green and GitHub reported Thirty bytes. Your two "pre-existing failures" weren't real. What the PR gets right, since it's worth saying explicitly: the derived-biosignal handling is exactly what CLAUDE.md asks for — default-OFF,
app-build is green on macOS and iOS for the integrated tree, which is the first time the merged Swift was compiled — your local Xcode builds predated main moving. The device capture with the toggle actually flipped on is still owed, as you said. |
Post-merge parity fix for #1584 (@pipiche38's #1568), found while re-reviewing the merged state. The Oura SpO2 candidate branch resolved "is this owner an Oura device" differently on each platform: Kotlin DeviceBrandCatalog.isOura(owner) -> device-id PREFIX Swift regDevices.first { $0.id == owner }?.brand == "Oura" -> registry BRAND string Not stylistic. The two keys can disagree, and when they do it is an analytics split: a device with an `oura-` id but no registry row, or a row whose brand text differs, was Oura on Android and not on iOS — so Android computed the ceiling@100 candidate for that night and iOS did not. A byte-identical Swift twin already existed and is what Repository.activeDeviceIsOura uses, with a comment saying it is there "rather than an ad-hoc 'oura' literal". Swift now uses it too. The id-prefix key is the safer one, not just the consistent one: Repository.swift:178 records that the registry mints every non-WHOOP id as "<idPrefix>-<uuid>", so the prefix IS the brand key and is always present, while the brand compare needs a registry row to exist AND its text to match. Verified Oura ids really are minted that way before changing anything — SourceCoordinator.swift:453 builds "\(ExperimentalBrand.oura.idPrefix)-\(serial)" — because otherwise this would have silently disabled the Oura path it exists to protect. WHOOP owners unaffected: "my-whoop" and "my-whoop-noop" fail the prefix test exactly as they failed the brand test, so they still take the v18 aux branch. Checked and left alone: skinTempWornToleranceSec keys on the brand string on BOTH platforms (Swift :2363, Kotlin :77), so that pair is symmetric — a different convention from the catalog, not a divergence. It predates #1568. Kotlin untouched; it already used the catalog. Verified: app-build green on macOS and iOS, doc-comments clean. No strings, no schema. Not seen on a device — @pipiche38's owed capture with the toggle flipped on now exercises this resolver too.
Integration fix for @pipiche38's ryanbr#1568, rebased onto current main. Their branch passed CI and so did main, but the MERGE of the two failed: analyzeRecentOnCpu instrumented Code length 61565 exceeds the 61535-byte budget Thirty bytes. `analyzeRecentOnCpu` sits within ~100 bytes of the JVM's 64 KB per-method ceiling, and their device-conditional SpO2 candidate branch plus the trace recorders from ryanbr#1575 both add to it. Neither exceeded the budget alone — only together, which is invisible to either PR's own CI. ryanbr#1568's checks were also ~10 hours stale by then, predating all six commits main had gained, and GitHub reported the branch CLEAN because the conflict is not textual. Their 14-line branch becomes a call to a new private `spo2CandidateMean`. Behaviour is unchanged: same two paths, same `?.first`, same null cases, still gated by the caller's toggle, still never written to `spo2Pct` and never scored. Also worth recording for them: the "2 pre-existing failures in RecoveryDriversTest" in the PR description do not reproduce. That class is 10/10 on main and 10/10 on their own head, and their full suite is 4270/0 rather than the 2404/2 reported — a stale local Gradle/KSP cache, which this repo has a documented history of. Nothing was broken. Integrated tree: 4283 tests, 0 failures. Release lint, doc-comments and i18n clean.
…il screen MetricExplorerView (iOS/macOS) and HealthScreen's VitalDetailScreen (Android) both drill into a generic, catalog-driven detail page - the one with the chart, the WINDOW/POINTS/LATEST header, and the readings table - reachable from "Your Cards". Unlike every other Blood Oxygen surface (the Key Metrics tile, VitalSignsSummary, LiquidTodayView), this screen read only calibrated spo2Pct with no spo2_candidate fallback at all - ryanbr#1568 never touched it. Found 2026-08-24: an install with no calibrated SpO2 source (WHOOP 4.0 + Oura, no 5/MG) showed a real candidate number on the Key Metrics tile but an empty/stale "Your Cards" detail page frozen on the last day a calibrated import existed, weeks earlier. Both platforms now merge in spo2_candidate for any day the calibrated series is missing (calibrated always wins; this only adds days, never overwrites one), gated on the same toggle every other candidate site checks. Rows sourced from the candidate are labelled "strap estimate (unverified)" in the readings table's Source column via a new source-token idiom (spo2CandidateAttributionSource / SPO2_CANDIDATE_ATTRIBUTION_SOURCE), mirroring the existing vo2max-estimator attribution pattern exactly so no new plumbing was needed - just one more case in provenanceDisplayLabel on each platform. Reuses the already-fully-translated spo2_strap_estimate_caption string resource on Android and the existing "strap estimate (unverified)" literal on Swift - no new i18n debt. 2 new tests (one per platform) pin the new source label. Verified: xcodebuild -scheme Strand build (macOS app, app-target Swift - CI doesn't cover this) + xcodebuild test on the touched suite (7/7); Android compileFullDebugKotlin clean, com.noop.ui.* 767/767 minus 3 pre-existing TodayExplainabilityTest failures confirmed present on pristine upstream/main before this change (unrelated, not chased); doc_comment_lint.py and i18n_audit.py --ci both clean (no new literals - the Kotlin string was already translated in all 4 focus locales).
|
To: @ryanbr Thank you for the rebase and the writeup — both were the right call, and worth reporting back on The "stale cache" theory doesn't hold upRe-checked this directly rather than taking either read on faith: The device capture, and what it foundFirst real overnight run with the toggle reachable in Settings landed last night. It also surfaced three real issues, all found, fixed, verified on every platform, and pushed —
All three verified: |
What this PR does
The Blood Oxygen tile and the Deep Timeline both had gaps for an Oura owner. This PR closes both,
plus a placement bug the second one introduced.
Three commits, oldest to newest:
fix(oura): show single-channel SpO2 in the Deep Timeline instead of an empty chart— the DeepTimeline plotted SpO2 as a red/IR ratio and skipped any sample with
ir <= 0. A WHOOP 4.0 v24banks both optical channels so that works, but an Oura ring reports only ONE channel
(
OuraStreamMappingstores it inred, leavesir = 0) — so the ratio path discarded 100% of aring's SpO2 (18,688 rows on the reporting device) and drew an empty chart with no explanation.
Adds
Repository.spo2TimelineValue(red:ir:): two-channel keeps the unchanged ratio proxy,single-channel plots the reading itself, gated to a
50...110plausibility range that excludesthe mis-scaled
dc_rawperfusion-channel contamination (-1016 … 11,709,098) without clampinggenuine overshoot above 100 (the channel is uncalibrated and real values do exceed 100 — see
OURA_PROTOCOL.md§6.5 for why). Apple-only: Android's SpO2 display reads the daily rollup, notthis per-sample path, so there's no parity change to make. Effect on the reporting device: 0 rows
plotted before, 5,562 after, averaging 96.9%. 4 new
DeepTimelineFacadeTests.fix(oura): queue 11a — SpO2 candidate-tile plumbing, ceiling@100 (Swift+Kotlin)— the BloodOxygen Key Metrics card read "–" for any Oura-only install:
AnalyticsEnginewritesspo2Pct: nilunconditionally for every device, and the ring's own0x6FSpO2 has a documentedcalibration problem (
OURA_PROTOCOL.md§6.5) that rules out just promoting it straight intospo2Pct. Reuses the existing WHOOPspo2_candidate_82precedent (WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103) instead of inventing anew pattern: an opt-in, default-OFF toggle surfaces an unvalidated nightly candidate mean as
"strap estimate (unverified)" in the Blood Oxygen tile, written to a
metricSeriessidecar key,never to
spo2Pct, never scored (CLAUDE.md's derived-biosignal rule). AddsAnalyticsEngine.nightlySpo2CeilingMean(Swift + Kotlin twin): per-samplemin(x,100)gated tothe same
50...110plausibility range from commit 1 (moved intoAnalyticsEngineas thecanonical constant,
Repository.spo2SingleChannelPlausiblenow derives from it) BEFOREaveraging, so a contamination row can't drag the mean down. ceiling@100 is this feature's
starting transform per
OURA_PROTOCOL.md§6.5.0's Oura-app-groundtruth tally (3/3 full-tiernights vs raw's 1/3 and an offset+clamp fit's 2/3 — see that doc section for the method and the
running comparison). Wires the device-conditional branch into both platforms'
IntelligenceEngine, both UI candidate-fallback paths (includingLiquidTodayView's BloodOxygen tile, which previously read
spo2Pctonly with no fallback at all), and the sharedtoggle's copy (now names both devices). 6 new tests each platform.
fix(oura): queue 11a — SpO2 candidate toggle unreachable on an Oura-only install— commit 2'stoggle copy covers Oura ("Blood Oxygen: strap estimate (WHOOP 5/MG, Oura)"), but the toggle
stayed nested inside
fiveMGCard, a settings section gated toshowFiveMGControls(
selectedWhoopModelRaw == whoop5mg). An install that has never had a real WHOOP 5/MGconnect — WHOOP 4.0 + Oura paired, this reporter's own install — never sees that section at all,
so the toggle commit 2 just made Oura-aware was unreachable in the UI. Not a config issue:
metricSeries.spo2_candidatehad zero rows across the whole database despite pass-2 scoringrunning daily, and a full screenshot sweep of the "More" settings list confirmed the WHOOP-5/MG
card never renders on this install. Fix: split the toggle into its own
spo2CandidateCard, shownfor
showFiveMGControls || model.repo.activeDeviceIsOura— visible for a 5/MG or an active Ouradevice, hidden for a confident WHOOP-4.0-only owner it can't help either way (mirrors
rawSensorDiagnosticsCard's existing split for the identical reason, iOS: fix restore of WAL-header backups (SQLITE_CANTOPEN) — #18 #22). No logic change: same@AppStoragekey, same copy, same re-score-on-flip behavior, just relocated. Checked Androidfirst — it does not have this bug (
SettingsScreen.kt's toggle already sits outside its ownshowFiveMGControlsgate) — iOS/macOS-only fix, one file.How it was tested
swift test—StrandAnalytics1561/1561, including the 6 newSpo2CeilingNightlyTestsand 4 newDeepTimelineFacadeTests.xcodebuild -scheme Strand -destination 'platform=macOS' build— green. Required: multipletouched files (
Repository.swift,SettingsView.swift,TodayView.swift,LiquidTodayView.swift) are app-target Swift with no default CI coverage (app-build.ymlisdisabled per CLAUDE.md).
xcodebuild -scheme NOOPiOS -destination 'platform=iOS Simulator,name=iPad Air 11-inch (M4)' build— green. Required since these files are shared with iOS.compileFullDebugKotlin— green.testFullDebugUnitTest— 2404 tests, 2 failures, both inRecoveryDriversTest(driverPointRoundingUsesNearestWithHalfTiesAwayFromZero,issue51NegativeHalfTieUsesDefaultArg8WithoutChangingScoreOrDriverFields) — confirmedpre-existing on plain
upstream/mainHEAD (ran the identical test class against a cleanupstream/mainworktree, same 2 failures, unrelated to anything in this PR), not introducedhere.
python3 Tools/i18n_audit.py --ci HEAD— clean, no new hardcoded/un-extracted literals, allfocus locales (de/es/fr/pt-PT) complete.
python3 Tools/doc_comment_lint.py— clean, no new detached doc comments.python3 -m unittest discover -s Tools— 103/103.Not yet done: flashed to a phone with the toggle actually flipped on, to confirm the Blood
Oxygen tile populates end-to-end for an Oura owner on real hardware. UI-only change with no BLE
surface of its own, but that's the real end-to-end validation this unblocks — owed as its own
capture.
Parity
Commit 1 is Apple-only (Android's SpO2 display doesn't go through this per-sample path). Commit 2
is full Swift+Kotlin parity,
nightlySpo2CeilingMeanbyte-identical logic on both platforms perthe twin tests. Commit 3 is iOS/macOS-only — checked and confirmed Android never had the bug it
fixes.