Skip to content

fix(oura): SpO2 candidate-tile plumbing + Deep Timeline single-channel fix - #1568

Closed
pipiche38 wants to merge 3 commits into
ryanbr:mainfrom
pipiche38:fix/oura-spo2-candidate-tile
Closed

pipiche38 wants to merge 3 commits into
ryanbr:mainfrom
pipiche38:fix/oura-spo2-candidate-tile

Conversation

@pipiche38

Copy link
Copy Markdown

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:

  1. fix(oura): show single-channel SpO2 in the Deep Timeline instead of an 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, leaves ir = 0) — so the ratio path discarded 100% of a
    ring'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...110 plausibility range that excludes
    the mis-scaled dc_raw perfusion-channel contamination (-1016 … 11,709,098) without clamping
    genuine 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, not
    this 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.

  2. fix(oura): queue 11a — SpO2 candidate-tile plumbing, ceiling@100 (Swift+Kotlin) — the Blood
    Oxygen Key Metrics card read "–" for any Oura-only install: AnalyticsEngine writes spo2Pct: nil unconditionally for every device, and the ring's own 0x6F SpO2 has a documented
    calibration problem (OURA_PROTOCOL.md §6.5) that rules out just promoting it straight into
    spo2Pct. Reuses the existing WHOOP spo2_candidate_82 precedent (WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103) instead of inventing a
    new 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 metricSeries sidecar key,
    never to spo2Pct, never scored (CLAUDE.md's derived-biosignal rule). Adds
    AnalyticsEngine.nightlySpo2CeilingMean (Swift + Kotlin twin): per-sample min(x,100) gated to
    the same 50...110 plausibility range from commit 1 (moved into AnalyticsEngine as the
    canonical constant, Repository.spo2SingleChannelPlausible now derives from it) BEFORE
    averaging, 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-tier
    nights 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 (including LiquidTodayView's Blood
    Oxygen tile, which previously read spo2Pct only with no fallback at all), and the shared
    toggle's copy (now names both devices). 6 new tests each platform.

  3. fix(oura): queue 11a — SpO2 candidate toggle unreachable on an Oura-only install — commit 2's
    toggle copy covers Oura ("Blood Oxygen: strap estimate (WHOOP 5/MG, Oura)"), but the toggle
    stayed nested inside fiveMGCard, a settings section gated to showFiveMGControls
    (selectedWhoopModelRaw == whoop5mg). An install that has never had a real WHOOP 5/MG
    connect — 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_candidate had zero rows across the whole database despite pass-2 scoring
    running 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, shown
    for showFiveMGControls || model.repo.activeDeviceIsOura — visible for a 5/MG or an active Oura
    device, 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
    @AppStorage key, same copy, same re-score-on-flip behavior, just relocated. Checked Android
    first — it does not have this bug (SettingsScreen.kt's toggle already sits outside its own
    showFiveMGControls gate) — iOS/macOS-only fix, one file.

How it was tested

  • swift test — StrandAnalytics 1561/1561, including the 6 new
    Spo2CeilingNightlyTests and 4 new DeepTimelineFacadeTests.
  • xcodebuild -scheme Strand -destination 'platform=macOS' build — green. Required: multiple
    touched files (Repository.swift, SettingsView.swift, TodayView.swift,
    LiquidTodayView.swift) are app-target Swift with no default CI coverage (app-build.yml is
    disabled 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.
  • Android compileFullDebugKotlin — green.
  • Android testFullDebugUnitTest — 2404 tests, 2 failures, both in
    RecoveryDriversTest (driverPointRoundingUsesNearestWithHalfTiesAwayFromZero,
    issue51NegativeHalfTieUsesDefaultArg8WithoutChangingScoreOrDriverFields) — confirmed
    pre-existing on plain upstream/main HEAD (ran the identical test class against a clean
    upstream/main worktree, same 2 failures, unrelated to anything in this PR), not introduced
    here.
  • python3 Tools/i18n_audit.py --ci HEAD — clean, no new hardcoded/un-extracted literals, all
    focus 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, nightlySpo2CeilingMean byte-identical logic on both platforms per
the twin tests. Commit 3 is iOS/macOS-only — checked and confirmed Android never had the bug it
fixes.

pipiche38 and others added 3 commits August 23, 2026 15:41
…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
@pipiche38

Copy link
Copy Markdown
Author

Adding more detail on the SpO2 numbers this PR's ceiling@100 choice is based on, since the PR body
only cites the tally as it stood when commit 2 was written — it's moved since, in a direction worth
knowing about before merge.

Method

Since the ring bonds to one BLE client at a time, a same-night comparison against Oura's own app
needs the ring re-paired to the Oura app after a NOOP capture (confirmed possible — see
OURA_PROTOCOL.md §5.3). Each full-tier data point is: Oura app's own displayed nightly SpO2 %
(screenshot) vs. three candidate transforms of NOOP's decoded 0x6F samples over the same sleep
window — raw wire mean, ceiling@100 (min(x,100) per sample before averaging — this PR's choice),
and an offset(−0.32) + clamp[85,100] fit derived separately in OURA_PROTOCOL.md §6.5.0.1. "Hit"
means the transform's mean rounds to the app's integer %.

Running tally (4 nights)

night app % raw (round) ceiling@100 (round) offset+clamp (round)
08-18/19 98 99.11 (99) miss 98.31 (98) hit 98.11 (98) hit
08-13/14 98 98.08 (98) hit 97.64 (98) hit 97.39 (97) miss
08-21/22 98 99.66 (100) miss 98.48 (98) hit 98.31 (98) hit
08-22/23 96 95.10 (95) miss 94.87 (95) miss 94.97 (95) miss
hit rate 1/4 3/4 2/4

08-22/23 is the night this PR's own toggle-unreachable bug was found on, so the candidate had never
actually been persisted — that row is a hand-reconstruction (same nightlySpo2CeilingMean transform
run directly against spo2Sample, same sleep-session window), not a live read of the shipped
feature. First night all three transforms miss the app together, and the first time the miss is on
the LOW side (candidate under-reads) rather than the high side the first three nights showed
(raw over-reads, ceiling/offset correct it down). One night isn't enough to say anything changed
structurally, but it's real and worth having on record before anyone reads "3/3" as settled.

Why this doesn't change what's being shipped

ceiling@100 is still the best performer of the three (3/4 vs 1/4 raw, 2/4 offset+clamp), and this
PR was never claiming a validated calibration — the toggle labels the value "strap estimate
(unverified)" and it's never written to spo2Pct or scored, exactly per CLAUDE.md's derived-
biosignal rule. The point of shipping the toggle at all is to make this comparison easy to keep
running (once the placement bug in commit 3 is live, the candidate will persist automatically
instead of needing a hand-reconstruction like 08-22/23's). Full detail:
docs/OURA_PROTOCOL.md §6.5.0, and worklog/analysis/2026-08-23-1512-*.txt for the 08-22/23 night
specifically.

ryanbr added a commit that referenced this pull request Aug 24, 2026
…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.
@ryanbr

ryanbr commented Aug 24, 2026

Copy link
Copy Markdown
Owner

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 MERGEABLE / CLEAN, but the green was ~10 hours stale — every check finished at 2026-08-23T14:0x and main had since gained six commits. The conflict was never textual, so nothing flagged it. Merging locally is what found it:

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 your device-conditional SpO2 candidate branch and #1575's trace recorders both add to it. Neither exceeded the budget alone — no single PR's CI can see that. Your 14-line branch is now a call to a private spo2CandidateMean; behaviour is unchanged, same two paths, same null cases.

Your two "pre-existing failures" weren't real. RecoveryDriversTest is 10/10 on main and 10/10 on your own head, and your full suite is 4270/0 — not the 2404/2 you saw. That test count gap is the tell: a stale Gradle/KSP cache, which this repo has a documented history of serving across branches. --no-build-cache --rerun-tasks is worth making a habit here. Nothing was broken and main was fine.

What the PR gets right, since it's worth saying explicitly: the derived-biosignal handling is exactly what CLAUDE.md asks for — default-OFF, metricSeries sidecar, never into spo2Pct, never scored, reusing the spo2_candidate_82 precedent instead of inventing a pattern. Checking Android first on commit 3 and confirming it never had the bug is the right instinct. And the Deep Timeline fix is well-evidenced: 0 rows plotted → 5,562.

ceiling@100 resting on n=3 nights from one subject is precisely why default-OFF and "unverified" is the correct landing. Worth keeping it there until it demonstrably tracks a varying input rather than matching a tally — the PPG→HR withdrawal (#194) is the cautionary case.

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.

@ryanbr ryanbr closed this Aug 24, 2026
ryanbr added a commit that referenced this pull request Aug 24, 2026
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.
pipiche38 pushed a commit to pipiche38/noop that referenced this pull request Aug 24, 2026
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.
pipiche38 added a commit to pipiche38/noop that referenced this pull request Aug 24, 2026
…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).
@pipiche38

Copy link
Copy Markdown
Author

To: @ryanbr

Thank you for the rebase and the writeup — both were the right call, and worth reporting back on
since the device capture you flagged as owed happened last night, plus one correction on the test
failures.

The "stale cache" theory doesn't hold up

Re-checked this directly rather than taking either read on faith: RecoveryDriversTest's two failures
(driverPointRoundingUsesNearestWithHalfTiesAwayFromZero, issue51NegativeHalfTieUsesDefaultArg8...)
reproduce for me on a fully clean checkout of upstream/main — no stash, rm -rf on both
DerivedData and ~/.gradle's build cache, --no-build-cache --rerun-tasks, nothing of mine applied.
Same two tests fail every time. So it isn't a stale Gradle/KSP cache on my end — it's a real,
environment-dependent floating-point discrepancy (this machine vs whatever your CI/box uses), most
likely a half-tie-rounding edge case that resolves differently depending on JVM/architecture. While
verifying tonight's fixes I found it's not isolated to that one test class either —
AiCoachContextTest.computedOnlyMergedDaysGroundTheCoachInRealNumbers and both
StandardHrSensorFormatTest.speedRoundsHalfUp/speedFormatsToOneDecimal fail the same way, same
machine, confirmed pre-existing (they fail identically on integration/oura-full's pre-rebase tip,
before any of tonight's changes). Not something I'm going to chase down tonight, but worth flagging
since "stale cache" would send someone down the wrong path if they hit the same thing.

The device capture, and what it found

First real overnight run with the toggle reachable in Settings landed last night. metricSeries. spo2_candidate now persists correctly end-to-end — the shipped pipeline (toggle read,
device-conditional branch, nightlySpo2CeilingMean, the metricSeries write) runs on real hardware,
not just compiles. Last night's row matched a hand-computed check against the same night's raw
spo2Sample rows exactly.

It also surfaced three real issues, all found, fixed, verified on every platform, and pushed —
opening as three separate small PRs rather than reopening this one:

  1. nightlySpo2CeilingMean/nightlySpo2CandidateMean floor instead of round. sum / kept on two
    Ints truncates toward zero. Last night's precise mean was 97.97 — round-matches the Oura app's
    displayed 98% (extending the §6.5.0 tally to n=5, ceiling@100 now 4/5), but the shipped Int
    division floored it to 97, a spurious miss the transform's own track record doesn't actually have.
    Same bug in the older WHOOP @82 path too (nightlySpo2CandidateMean, predates queue 11a by
    weeks). Fixed both functions, both platforms, via Double division + round — all values here are
    positive so the rounding-rule choice (half-up vs half-away-from-zero) is inert, no
    platform-divergence risk. 6 new tests.

  2. The "Your Cards" drill-down never got the candidate fallback. MetricExplorerView (iOS/macOS)
    and HealthScreen's VitalDetailScreen (Android) both open a generic, catalog-driven detail page —
    the chart + WINDOW/POINTS/LATEST + readings table, reachable by tapping into a card. Every other
    Blood Oxygen surface (the Key Metrics tile, VitalSignsSummary, LiquidTodayView) got the fallback
    wiring in commit 2; this screen never did. Symptom: the Key Metrics tile showed a real number, the
    drill-down for the same metric showed nothing past the last calibrated import — weeks earlier for
    an Oura-only/WHOOP-4.0 install. Fixed both platforms, labelled "strap estimate (unverified)" via a
    new source-token idiom mirroring the existing vo2max-estimator: attribution pattern exactly — no
    new plumbing. 2 new tests.

  3. Skin Temp was never a Key Metrics tile option. Separate finding, same investigation (a user
    report about Skin Temp being on the Deep Timeline but missing from "the Cards" turned out to be
    about a different card grid than the Blood Oxygen issue above — Skin Temp's own DashboardCard
    tile and drill-down both work fine; it just never had a KeyMetric case). Added one, both
    platforms, deliberately not in defaultOrder so no existing layout changes — opt-in via the
    editor only. 4 new tests.

All three verified: xcodebuild -scheme Strand build+test and -scheme NOOPiOS build (app-target
Swift, still not CI-covered) on top of the full integration tree, plus
Android compileFullDebugKotlin + the full testFullDebugUnitTest run (only the pre-existing,
now-confirmed-unrelated failures above). doc_comment_lint.py/i18n_audit.py --ci clean.

@pipiche38
pipiche38 deleted the fix/oura-spo2-candidate-tile branch September 12, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants