Repository navigation
feat(oura): store Ring 4 SpO2 (0x8b) verbatim and derive a nightly estimate - #4
Merged
Merged
Conversation
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>
2 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
0x8bspo2_r_pirecords: 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.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 4ORE_06payloads (every one is1 + 3nbytes, R 0.53–0.89).OURA_SPO2_RPIevent 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 firmware0x6Fpercentage.OuraSpO2Nightlytakes 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 asspo2 r-pi night …. Nights without0x8bfall back to the existing0x6Fceiling@100 path. Never written tospo2Pct, never scored; shown only with Settings → "Blood Oxygen: strap estimate" on.0xFFare 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:spo2Samplehas no unit column (StreamPersistencedropsunit), so ther_pi_pcttag was lost on save and derived rows became indistinguishable from0x6F.(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
How it was tested
OuraSpO2RatioTest(5), pinned to verbatim Ring 4 payloads and their real ring-times:StreamPersistence.toBatchat four anchor phases: every record lands in its own stored event row with its kind, and nothing is written tospo2Sample;IntelligenceEngineJacocoBudgetTeststill passes with the newdiagargument.RecoveryDriversTestfloat assertions fail).Related issues
Refs #2
🤖 Generated with Claude Code