Skip to content

feat(oura): store Ring 4 SpO2 (0x8b) verbatim and derive a nightly estimate - #4

Merged
llmassisted merged 2 commits into
mainfrom
feat/oura-spo2-ratio
Sep 28, 2026
Merged

llmassisted merged 2 commits into
mainfrom
feat/oura-spo2-ratio

Conversation

@llmassisted

@llmassisted llmassisted commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

What this PR does

Stores the Ring 4's SpO2 stream losslessly and turns it into a nightly SpO2 estimate.

After SpO2 was enabled with the Test Centre feature-mode write (PR #2), the ring began emitting 0x8b spo2_r_pi records: 5,236 on the first night, versus 0 before. Build 539 counted and discarded them, and the ring trims its copy after an acknowledged drain.

  • Adds SPO2_R_PI (0x8b) to the tag dictionary and decodes it verbatim: one header byte, then 3-byte samples of u16 big-endian R (raw; R = value ÷ 16384) + u8 perfusion index. The [oura-rs] layout is confirmed on real Ring 4 ORE_06 payloads (every one is 1 + 3n bytes, R 0.53–0.89).
  • Stores one OURA_SPO2_RPI event row per record at the record's anchored time, with the raw R values and PI bytes. Records are ≥ 2.2 s apart, so no two share a key, and the event kind keeps this source apart from the firmware 0x6F percentage.
  • Converts at read time: OuraSpO2Nightly takes every sample from records inside the detected sleep session, applies the cited gen4 quadratic −13.4r² − 5.1r + 105.2 (clamped 85–100, OuraSpO2Ratio), and computes the night's low / median / mean / high. The mean feeds the existing Oura SpO2 estimate (spo2CandidateMean → spo2_candidate); the four figures are logged as spo2 r-pi night …. Nights without 0x8b fall back to the existing 0x6F ceiling@100 path. Never written to spo2Pct, never scored; shown only with Settings → "Blood Oxygen: strap estimate" on.
  • Samples whose PI byte is 0xFF are skipped by the estimate but kept in storage: all 6 of 64 such samples in the capture carried the same R (0.766, ~93 %), the shape of a held / no-signal value. This is inferred, not documented.

Review fixes (second commit). The first version wrote per-second percentage rows into spo2Sample. Review found two real problems, both reproduced:

  1. spo2Sample has no unit column (StreamPersistence drops unit), so the r_pi_pct tag was lost on save and derived rows became indistinguishable from 0x6F.
  2. Records arrive 2.2–4.6 s apart yet carry 4 samples, so one-second spacing is wrong. On the captured records, 5–8 of 58 samples collided on the (deviceId, ts) key and were silently dropped.

Record-level verbatim events fix both, and keep every raw value for re-reading if the timing or calibration is ever pinned.

Android-only. versionCode 541.

Type of change

  • New feature

How it was tested

  • OuraSpO2RatioTest (5), pinned to verbatim Ring 4 payloads and their real ring-times:
    • verbatim decode and malformed lengths;
    • the 8-record 03:57 capture driven through the driver, mapping and StreamPersistence.toBatch at four anchor phases: every record lands in its own stored event row with its kind, and nothing is written to spo2Sample;
    • nightly summary: session filter, held-PI skip, low / median / mean / high;
    • clamp bounds.
  • IntelligenceEngineJacocoBudgetTest still passes with the new diag argument.
  • Full local Android suite: 6,479 / 6,481 pass (the two known macOS/JBR RecoveryDriversTest float assertions fail).
  • i18n audit, doc-comment lint and parity ratchet pass.
  • Hardware: the layout and value range come from the 2026-09-27 capture; the first night stored by this build is still to come.

Related issues

Refs #2

🤖 Generated with Claude Code

llmassisted and others added 2 commits September 27, 2026 13:25
With SpO2 enabled via the Test Centre feature-mode write, a Ring 4 emits
0x8b spo2_r_pi records (5,236 on the first night) that were counted and
discarded, so the night's SpO2 was lost once the ring trimmed its copy.

- Add SPO2_R_PI (0x8b) to the tag dictionary and decode it: header byte, then
  3-byte samples of u16 big-endian R / 16384 + u8 PI ([oura-rs] layout,
  confirmed on the Ring 4 capture). R becomes SpO2 via the cited gen4
  quadratic, clamped 85-100 (OuraSpO2Ratio).
- Samples are one per second and laid backward from the record time like
  0x6F; a saturated PI (0xFF) sample is skipped but keeps its second (all 6
  in the capture held the same R).
- Persist as spo2Sample rows tagged r_pi_pct (new OuraSpO2Channel
  RATIO_PERCENTAGE), which the existing Oura SpO2 estimate path averages.
  Never written to spo2Pct, never scored.
- Tests pinned to verbatim Ring 4 payloads; protocol doc s6.5.1 updated.

versionCode 540.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of the first version found two reproduced problems with writing
derived per-second percentages into spo2Sample:

1. spo2Sample has no unit column (StreamPersistence drops `unit`), so the
   r_pi_pct tag was lost on save and derived rows became indistinguishable
   from the firmware 0x6F percentage.
2. Records arrive 2.2-4.6 s apart yet carry 4 samples, so one-second
   spacing is wrong: on the captured records 5-8 of 58 samples collided on
   the (deviceId, ts) key and were silently dropped.

Now each 0x8b record is stored verbatim as ONE OURA_SPO2_RPI event row
(raw R u16s + PI bytes, record-level ts: >= 2.2 s apart, so no collisions;
the event kind keeps the source). The Oura SpO2 estimate reads those rows,
converts at read time (OuraSpO2Ratio), skips held-PI samples, and logs the
night's low / median / mean / high (OuraSpO2Nightly); nights without 0x8b
fall back to the 0x6F ceiling@100 path. The derived-percentage channel and
its unit tag are removed.

Tests drive the real 03:57 capture through driver, mapping and
StreamPersistence.toBatch at four anchor phases. versionCode 541.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@llmassisted llmassisted changed the title feat(oura): decode Ring 4 SpO2 (0x8b R-ratio) into the SpO2 estimate feat(oura): store Ring 4 SpO2 (0x8b) verbatim and derive a nightly estimate Sep 27, 2026
@llmassisted
llmassisted merged commit 15f41a7 into main Sep 28, 2026
6 checks passed
@github-actions
github-actions Bot deleted the feat/oura-spo2-ratio branch October 12, 2026 12:05
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.

1 participant