Repository navigation
feat(oura): overnight readings, PI=255 inclusion, curated upstream fixes - #5
Merged
Merged
Conversation
Count PI=255 samples in nightly diagnostics instead of excluding them. Update the protocol interpretation and add regression coverage using the September 28 capture.
Add date navigation, an interactive chart, and a paginated list alongside nightly averages. Preserve grouped SpO2 samples and expose stored skin temperatures even when the nightly average is unavailable.
- Move the 18 overnight-reading strings from values/oura_overnight.xml into values/strings.xml and translate them for de, es, fr, pl, pt-rPT, ru and zh. The separate file sat outside the i18n audit, which only reads values*/strings.xml, so the card would have shipped English-only. The Settings toggle name is kept in English inside each translation because that toggle's label is not localized. - Draw at most 400 chart points (OuraOvernightReadings.chartIndices): each bucket contributes its min and max, so a brief dip or peak survives. A full night is ~20,000 SpO2 samples; the paginated list and the low / high line still use every reading. Row labels are built on demand instead of for every sample, and the axis endpoints use the full series. - versionCode 542. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…does not restore what it already stored (ryanbr#2455) Reported in ryanbr#2443 by @pipiche38, with the drain traced through the log and the over-count measured against the raw capture. The resume cursor is committed only when a drain ENDS. A link that dropped partway through threw the drain's progress away: the next connect refetched from the old cursor and the ring re-served everything the interrupted drain had already stored. Those copies resolve under the NEXT session's SyncTime anchor, so they land a second or two off the first copy and MISS `rrInterval`'s (deviceId, ts, rrMs, seq) key instead of colliding with it. The beats are stored twice, the night's R-R coverage goes above 1, and the night's HRV is refused as an over-count. On the reported night 1,753 of 4,346 records were served twice. Both teardowns commit now, not just the one the report named. A deliberate `stop()` mid-drain loses the cursor by exactly the same route as an involuntary drop, and both sites already had the ordering the commit needs. Nothing new decides the cursor. `commitInterruptedDrainCursor` calls the existing `commitResumeCursor(drainCompleted: false)`, so the forward-only rule, the anchor-resolution rule and the ryanbr#2097 reboot judge all apply unchanged. It commits only mid-drain and only with banked progress, so a link that drops while merely streaming writes nothing and no cursor rule is asked to judge a drain that stored nothing. It never makes the ryanbr#2097 REBOOT judgement, which is the one part of `commitResumeCursor` an interrupted drain must not reach. That judgement wipes the cursor to 0 and re-pulls the ring's whole history, and it declines to trust continuity when this session never adopted an anchor, which is the ordinary state of a drain cut short before a 0x13 reply resolved. Answering "no evidence" with a full re-pull would double-store everything re-served, which is the defect being fixed here. A genuine reboot still serves pre-resume data on the next drain, so deferring the judgement to a drain that finishes costs nothing. Placement is load-bearing and is what the tests pin: AFTER the hypnogram flush, so a 0x49 burst still assembling banks against the cursor it belongs to, and BEFORE the driver is torn down, because the commit asks the driver to resolve the candidate ring time and a stopped driver has no anchor left. The report raised the first of those as a risk; both teardown sites already flushed immediately above where the commit goes. This NARROWS the window rather than closing the hole, which the reporter's own follow-up shows: on a second ring the duplicates arrived after the night had already drained to bytes_left 0, with no cursor involved. A redrain of a completed night stores the same beats twice by the same route. The durable fix is a dedup that survives an anchor shift, which the stored row cannot express today: it carries ts, rrMs, seq, ord and srcChannel, and no ring time. That stays open on ryanbr#2443, as does the heal for nights already double-stored. A BLE source has no unit-testable surface, so the wiring is pinned against the source the way StressPersonalBaselineSurfaceTest pins its own: both platforms commit at both teardowns, each commit sits after the hypnogram flush and before the driver teardown, and the helper gates on the drain phase and on banked progress rather than committing on every disconnect. Removing either call site reds the count test. Full Android suite 6418 tests, no failures. Refs ryanbr#2424. (cherry picked from commit 066f502)
…ight is readable again (ryanbr#2457) Reported in ryanbr#2443 by @pipiche38 and split out as ryanbr#2456, with both instances measured against raw captures and backups. A ring record is served again whenever history is refetched: after a link drops mid-drain, which ryanbr#2455 now narrows, and also after a night has ALREADY drained, which no resume cursor can prevent. Every connection adopts its own SyncTime anchor, so the second copy resolves to a wall-clock second one or two off the first and misses `rrInterval`'s (deviceId, ts, rrMs, seq) key instead of colliding with it. Both copies are stored, the night's R-R coverage rises above 1, and its HRV is refused as an over-count. One reported night went from coverage 0.99 with HRV shown on Today to 1.22 with the night's HRV gone, retroactively, after a redrain added 6,466 channel-3 beats of which 5,471 were an exact copy stamped one second later. What identifies a copy is the RECORD, not the beat. A banked 0x60 record stamps all of its intervals on one timestamp, so a re-served record reappears as the same ordered run of rrMs values, on the same channel, a second or two later. Single beats are not enough to go on: equal successive beats are physiological and are deliberately kept (ryanbr#163), and on a clean night 2.5% of beats already have an equal neighbour one second away. A whole ordered run repeating is not a coincidence at that rate. Collapsed on READ, not deleted on write, and that is the point of doing it this way: * it needs no migration, on a table holding real health history * it costs nothing when the ring never re-serves, which is the ordinary case * it repairs nights ALREADY double-stored, which a write-side key can never do It does not reclaim the disk those rows occupy, and it is a recognition rather than an identity. The structural fix is still to dedup on a clock the ring owns, which the stored row cannot express today: it carries ts, rrMs, seq, ord and srcChannel, and no ring time. ryanbr#2456 stays open for that. Placed at the per-device read on both platforms, so every SCORING reader agrees rather than one scorer: the damage shows up as a coverage over-count and coverage is computed from this read. Only Oura channels are considered; a WHOOP transport is never touched. Both platforms order these rows ORDER BY ts, ord, rrMs, seq, so the run signature is the same on each side. The raw diagnostic exports deliberately do NOT collapse and still show both copies, on both platforms. That is what a raw export is for, and it is the evidence this was diagnosed from. Two consequences worth stating: a diagnostic export and the app can legitimately disagree on beat counts, and the `hrv diag` line will now print the collapsed count, which is the number actually scored. Twelve cases each side, the same cases, and they RUN: WhoopProtocol has no GRDB dependency so its tests execute on Linux. A record served again at +1s and at +2s collapses; the same run further out is kept; three copies collapse to one and not to two; a flat run of equal beats is kept; short coincidental repeats are kept; WHOOP channels, untagged rows and reordered runs are all kept; order and content of what survives are unchanged; a clean 600-second night is returned identical. Mutation-tested three ways. Dropping the minimum run length to 1 reds six cases, removing the Oura-only guard reds exactly the WHOOP case, and widening the window to nine seconds reds exactly the distant-record case. Android suite 6430 tests, WhoopProtocol 803, no failures. Parity ledger clean. Refs ryanbr#2443, ryanbr#2455. (cherry picked from commit c5cfb21)
…0x60 (ryanbr#2423) An Oura ring measures the same heartbeats on more than one tag and banks every one of them. The scoring read excluded only 0x6E (ryanbr#1071) and kept both remaining beat channels, so a night could be read as roughly two overlapping copies of itself: on one ring 0x60 alone covers 0.91-0.99 of the wall clock over 10 of 11 nights, while 0x60 plus 0x80 reads 1.01-1.31. The ryanbr#1118 coverage gate then refused whichever nights happened to bank more 0x80, HRV went blank, and Charge calibration stalled at "3 of 4 nights" for days, for reasons that had nothing to do with those nights. Neither channel is a duplicate to exclude by name. Which one is complete is a property of the capture, not of the tag, so the read keeps green alone when it holds MORE beats in the requested window than the amplitude family, and the amplitude family alone otherwise, ties included. 0x60 and 0x44 are counted and kept together: one decoder, split for labelling only, so they are never scored against each other. A ring with only one of them keeps it, which is the guarantee the old exclusion was protecting. NULL rows and every non-Oura code pass untouched, so WHOOP 4, WHOOP 5 and legacy rows read exactly as before. An uncorrelated subquery over the same time and suspect predicates as the outer read, evaluated before LIMIT, the same shape as the WHOOP 5 transport selection beside it. Rows are filtered, never deleted, and no migration is involved: the other channel stays on disk as its cross-check. Verified against two real backups over the window the app actually reads, 30 h before to 6 h after each main session. Six refused nights on the reporting ring become five passing and one still refused; four refused nights on a second ring now pass; no night that passed before regresses on either. The nights still refused are 0x60 over-covering on its own, which is record overlap and a separate problem this does not claim to answer. One interaction worth recording, because the two changes were built in parallel and meet here for the first time. ryanbr#2457 collapses a ring record the drain served twice, and it does so AFTER this read's SQL. So the "which channel is fuller" decision is made on beats that still include re-served duplicates, and a redrain inflates channels unequally: the ryanbr#2443 capture shows one channel gaining 22% and another 7.6%. In these captures the margin is far too large to flip, 36,317 beats against 2,006, but the count the choice rests on is not exactly the count that ends up scored, and that is worth knowing before someone reads it as a contradiction. Five new Swift tests: the fuller channel wins, green wins when green is the fuller one, the choice is per requested window, a tie goes to the amplitude family, and 0x60 plus 0x44 count and survive as one. Kotlin runs the production SQL on real SQLite using the Swift fixtures and expected values verbatim, so the two cannot drift into agreeing in prose while differing in fact. Authored by @pipiche38. Refs ryanbr#2424, ryanbr#1071, ryanbr#1118. (cherry picked from commit 401b457)
Reported in ryanbr#2430 by @bartmuskala: the personal stress baseline applied on the Stress detail screen but not on Today, so the two surfaces disagreed about the same day. One foreground resolver per platform now answers the scoring-mode question for both surfaces: `DaytimeStressMode.selected` on Apple, `selectedDaytimeStressMode` on Android. With the lens off it returns immediately and the read is byte-identical to before; with it on it reduces each of the trailing 30 days to two Doubles rather than holding thirty days of raw HR and R-R at once (ryanbr#2107). Resolution sits inside the existing HR-count guard on both platforms, so a day with nothing to score never pays for it. The scoring lens is part of the memo identity on both producers. Foreground Today and the background publishers can call in either order, and without the lens in the key an unchanged HR fingerprint would replay one surface's curve into the other. Android Today no longer seeds its card from the widget snapshot while the personal lens is on. That snapshot is the default-lens curve, so using it as a cold-start seed recreated the reported divergence until the foreground pass finished, or indefinitely with no active strap id. Background publishers deliberately stay on the cheaper default lens on both platforms: a display preference must not turn an unprompted periodic publish into thirty days of raw reads. All four Android callers and all three Apple callers were checked against that rule; AppViewModel's is the widget-snapshot writer and correctly stays default. Source tripwires in StrandTests and the Android suite pin the wiring at each call site, which is the failure this fixes: every one of those sites compiles while silently omitting the preference. Two follow-ups are already identified and are not in this change. The producer memo holds ONE slot, so with the lens on a background publish between two Today ticks makes every tick miss regardless of the HR fingerprint, and Android's Today re-ticks every 15 minutes; a slot per lens restores the fingerprint gate. And the Apple resolver derives each past day by adding days to the start of today without re-anchoring, which in a zone whose clocks shift at midnight leaves all thirty windows an hour out on the transition date, where the Kotlin twin's LocalDate arithmetic is immune. Both toggles default off, so neither reaches a user who has not opted in. Authored by @kavemang. (cherry picked from commit 5298341)
…ple resolver to real local days (ryanbr#2452) Two follow-ups to ryanbr#2450, both found reviewing it and neither in scope for that change. ONE SLOT PER LENS. ryanbr#2450 put the scoring lens into the memo's identity so an unchanged heart-rate fingerprint could not replay one surface's curve into the other. That was right and, with a single slot, self-defeating: three Android callers and one Apple caller ask with the default lens while Today asks with the selected one, so with the toggle on each call evicted the other's entry and every call missed whatever the fingerprint said. Android's Today re-asks every 15 minutes, so the trailing-history read the fingerprint gate exists to avoid was being paid on essentially every tick. Keyed by lens, each surface keeps its own gate. Two entries at most. The Kotlin slots stay VOLATILE, which the single slot already was. Four callers reach that producer, on a BLE connection service, the widget worker, the view model and a Compose effect, so they can be inside it at once; a plain mutable map there would be a data race on the table itself, not merely a lost entry. It is an immutable map republished behind `@Volatile`, so a write copies two references on the rare pass where the fingerprint moved. The Apple memo is `@MainActor`, so a dictionary is already safe there. REAL LOCAL DAYS. The Apple resolver derived each trailing day by adding days to the start of today and never re-anchored. A day-add preserves the TIME of day, so in a zone whose clocks shift at midnight the transition date's start-of-day is 01:00 and every window walked back from it ran 01:00 to 01:00, taking an hour of the next day and dropping its own first hour. Across two years of America/Santiago windows that is 60 of 21900 starting off their day and 118 that are not one local day long. Europe/London, America/New_York, Australia/Sydney and Asia/Tokyo are clean before and after: a day-add from a midnight that EXISTS already yields the next midnight, so this is confined to zones that shift at midnight. The Kotlin twin walks LocalDate and was never affected, which is the point: the resolver ryanbr#2450 made shared should not differ between platforms. The tripwires ryanbr#2450 added asserted the comparison, which is the mechanism this replaces rather than the invariant behind it. Repinned to the keying, with one more holding the volatile publication that the compiler only caught here by accident. Both test-reset helpers drop every slot, not the one field that is gone. The Kotlin one was caught by a local sweep for surviving references; the Apple one was not, because the sweep was run on one platform and not the other, and the app targets do not build on this host. CI caught it. Full Android suite 6414 tests, no failures. Refs ryanbr#2430 (cherry picked from commit 678147d)
…imeline on Android and iOS/macOS (cherry picked from commit 57698ad)
Present stage breakdown and timeline rows as Awake, REM, Light, Deep on Android and Apple. Keep stage values, percentages, and colours attached to their original labels. Refs ryanbr#2534 (cherry picked from commit 0f607ea)
Stacked on @kavemang's change, which put Sleep detail and the Android surfaces into chart-depth order. Apple writes the stage sequence out inline rather than routing through a shared view, and it does so in TWO files: `SleepView` for Sleep detail and `StagesCard` for the hosted card that `LiquidTodayView` renders. Liquid Today is the default Today, so leaving `StagesCard` alone would have made Today and Sleep detail disagree about stage order, which is the exact complaint in ryanbr#2534. Both of its row stacks and the doc line that still described the old sequence are brought across. Android needed nothing further: every screen there goes through `StageBreakdownRows` and `StageTimeline`. The order is written out six times across four files, which is how it diverged. Before today the two Apple files disagreed with each other AND each disagreed internally between its breakdown rows and its timeline rows, so the old "WHOOP order" comment was not being honoured by any single surface. A source tripwire now pins all six and asserts they are identical. It compares the EXTRACTED sequence, not a substring, because a test checking only "awake comes before deep" would have passed the reported state. The tripwire deliberately claims only the six row stacks. Two other places name the stages in a sequence and are left alone, with a scope note in the test saying so rather than implying coverage: `XiaomiBandView` lists REM / Deep / Light / Awake for a Mi Band night, and `Charts.hypnogramSummary` builds the hypnogram screen-reader read-out as deep / REM / light / awake, so a TalkBack user hears a different order from the one they see. Both are worth a look on their own terms. (cherry picked from commit 8a95036)
The hypnogram's screen-reader summary announced deep first while every row stack draws awake first, so a TalkBack user heard a different order from the one on screen. It now speaks awake, REM, light, deep. Pulling on that turned up a second and quieter problem. The read-out fed its OWN stage order into `wholePercentages`, which breaks ties by lower index, so the input order decides which stage receives the spare percentage point. On a night where two stages share a fractional remainder the read-out therefore announced a different number than the row on screen for that same stage. With awake 50, light 205, deep 95 and REM 50, the rows say awake 13 and REM 12 while the read-out said awake 12 and REM 13. Display order and apportionment order are separate concerns, so they are now separate lists: `spoken` matches the rows, and `apportion` stays the order every visible surface already uses. There is a comment saying that merging the two back into one list would look tidier and would silently bring the mismatch back, because that is exactly the tidy-up someone would reach for. `hypnogramSummary` becomes internal so this can be pinned at all; it was private and untested. The tie test compares against `stageSharePercent` itself rather than a hardcoded number, so the two cannot drift apart without it failing. (cherry picked from commit 74a9f42)
Refs ryanbr#2529 (cherry picked from commit 4a827f9)
…latforms (ryanbr#2435) The one-Oura-channel selection (ryanbr#2423) changed what `rr=` counts in the strap log's `Night …: grav=N hr=N rr=N resp=N skin=N` line, from beats BANKED to beats scored, on both platforms: Swift `DebugDataDiagnostics` read `WhoopStore.rrIntervals`, and Kotlin `AndroidDiagnostics` read `rrIntervalsForDevice`, which routes to the same selecting SQL. The two platforms still agreed with each other and both differed from every log filed before. `WhoopStore.rawRrIntervals` is added as the twin of Kotlin `WhoopDao.rawRrIntervals` (`RAW_RR_INTERVALS_SQL`): the 0x6E duplicate and quarantined beats excluded, no scoring selection, the same ORDER BY. Both Night lines point at it. Scoring is untouched, every scoring reader still goes through `rrIntervals`, and the Kotlin function stops being one-sided in the parity ledger. Two consequences are documented where the number is produced rather than left to be discovered. A WHOOP 5's `rr=` does not go back to what it was; it steps UP. The strict single-transport selection predates ryanbr#2423, so a 5/MG's `rr=` was already the scored transport in every older log, and the raw read drops that too. That is the right answer for a line whose job is to say what the strap banked, since a legacy row that cannot be spliced into a scored beat train was still banked, and one number meaning "banked on a ring, scored on a strap" would be worse. It is still a change, on a device this work otherwise leaves alone. The raw read also does not collapse a record the drain served twice, where `rrIntervals` now does (ryanbr#2456). On a redrained night `rr=` counts the duplicates and the `hrv diag` line does not, so the two disagreeing is the clearest signal that a redrain happened at all, rather than a fault. Tested on the same fixture and expected values as the Kotlin `OuraOneChannelReadSqliteTest.theRawExportReadKeepsBothBeatChannels`, so the two reads cannot drift into agreeing in prose while differing in fact. Authored by @pipiche38. Refs ryanbr#2424, ryanbr#2423.
- versionCode 543. - platform_specific disposition for WhoopDao.RAW_RR_INTERVALS_SQL (Room's compile-time query string; the Swift twin builds the same query inline), brought in by the cherry-picked upstream R-R channel fix (401b457). - Twin map refreshed (ratchet errors=0). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 53769e5)
chartTimeTicks is shared. The Deep Timeline passes its pinch-zoom window, but Today's HR card passes the RENDERED extent of its banked buckets rather than a nominal window, so adding tiers below an hour reached Today as well: a morning holding ten minutes of HR drew ten 1-minute dotted gridlines on a small card. Labels are thinned by an overlap skip; the gridlines are not. The sub-hour tiers now sit behind deepZoom, off by default and set only by the surface that actually zooms. That also makes the retained comment true again, the one about thresholds sitting below the nominal Today windows so a window covering slightly less than nominal still lands on its intended interval. Swift keys that on zoomBounds rather than zoomDomain, for the reason OverviewHRChart already records twice: zoomBounds is set once by the zooming call sites, while zoomDomain stays nil until the first pinch, so keying on it would hand a not-yet-zoomed Deep Timeline the static tiers and then switch them under the user (ryanbr#829). The Swift function now returns dates and no longer builds a DateFormatter per call. Every label it formatted was discarded by the caller's map, once per body evaluation, which during a pinch is once per frame. Its doc no longer claims the two platforms read identically: positions are shared, labels are not, because Swift draws them with AxisValueLabel() in the viewer's locale where the Kotlin side formats HH:mm itself. Tests: the three deep-zoom tiers now pass the flag, and both platforms pin the Today guarantee. ChartTimeTicksTests is new, because eight thresholds written twice in two languages drift, and a drift between them is invisible in a build. (cherry picked from commit 1e8ef1e)
…readings Replace the thinned raw-point chart on the overnight SpO2 / skin-temperature card with five-minute intervals: a median line plus each interval's full low-high range drawn unsmoothed behind it, so a brief dip stays visible. Recording gaps stay gaps (no line across an empty interval); the median curve cannot overshoot between two medians. Tap or drag selects an interval and shows its span, median, range and reading count above the plot. SpO2 uses a fixed 85-100 % axis so nights compare at a glance. Every raw reading stays in the paginated list and the nightly statistics; display aggregation never mutates it. Notes and three new strings in all shipped locales. The device UI test that came with this rework is not included: it would be the repo's first androidTest, and the dependency lockfile does not cover the androidTest classpath, so it cannot be built. The layout property it checked (readout laid out above the plot) holds by construction. versionCode 545. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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
Shows the Oura ring's captured overnight SpO2 and skin-temperature readings, and stops excluding PI=255 SpO2 samples.
1. Include PI=255 samples (639e553). PR #4 skipped samples whose perfusion-index byte is
0xFFas "held / no-signal", based on six samples in the 2026-09-27 capture that shared one R (0.766). The 2026-09-28 capture has four PI=255 samples with four distinct R values (0.5829, 0.6310, 0.5941, 0.7169; about 95–98 %), sitting beside normal-PI samples with similar R, which looks like PI saturating on a strong signal. The byte alone no longer justifies exclusion. Every sample now counts, and the nightly line reportspi255=Nfor further study. The raw records are stored, so the change applies retroactively on the next re-score.2. Overnight readings card (d0598f8). On the Oura SpO2 and skin-temperature detail screens:
3. Review fixes (cc01fae).
values/oura_overnight.xml, outside the i18n audit (it only readsvalues*/strings.xml), so the card would have shipped English-only. They are moved intostrings.xmland translated for de, es, fr, pl, pt-rPT, ru and zh. The Settings toggle's name stays in English inside each translation because that label is not localized.chartIndices: each bucket keeps its min and max, so a brief dip or peak survives). The list and the low / high line still use every reading; row labels are built on demand.5. Five-minute median chart (8663a70). Replaces the thinned raw-point chart with five-minute intervals:
Every raw reading stays in the paginated list. Three new strings, in all locales. The accompanying androidTest is omitted: it would be the repo's first, and the lockfile does not cover the androidTest classpath (
kotlin-stdlib-common:1.9.24 … not part of the dependency lock state).4. Curated upstream fixes from upstream main. 12 of the 160 new upstream commits, chosen for an Android + Oura Ring 4 user and cherry-picked with
-x. All applied without conflicts.066f5021: an interrupted drain now banks its resume cursor, so the next connect does not re-serve and re-store what it already stored (upstream issue 2455).c5cfb212: a ring record the drain served twice is collapsed on read (upstream issue 2457).401b4575: HRV is scored from one R-R beat channel per window, not0x80on top of0x60(upstream issue 2423).52983414honours the personal baseline on Today (upstream issue 2450);678147d0gives the daytime-lens memo a slot per lens (upstream issue 2452).57698ad3: Deep Timeline x-axis labels and per-minute zoom ticks, with upstream's two follow-ups.53769e5bmakes its Android test compile (it does not compile on its own).1e8ef1eckeeps the sub-hour ticks off Today's small HR card, behind a deep-zoom flag; without it, Today drew one-minute gridlines.0f607ea0,8a950360and74a9f429align the stage rows and hypnogram read-out with the chart.4a827f90: collapses overlapping cross-source sleep nights.a7373b34: keeps the Night diagnostic line'srr=counting beats as banked, on both platforms. It also supplies the Swift twinReads.rawRrIntervalsthat the R-R channel fix's Kotlin read pairs with.Parity:
RAW_RR_INTERVALS_SQL(Room's compile-time query string; Swift builds the same query inline) gets aplatform_specificdisposition, and the twin map is refreshed (ratchet errors=0).Deliberately not taken:
5de05190(restore the primary-night resting-HR floor) leavesprimarySessionRestingHRreached only from tests on both platforms. That newtest-only-callsitefinding cannot be authorized by a disposition, and deleting the twin pair is a forbidden pair removal. Upstream landed it unchecked and repaired a red main afterwards (069ac57d, stale-base repair). It is deferred to a full upstream merge, where that repair flow applies.analyzeRecentOnCpubytecode budget (56,072 > 55,700) and would need 32 Italian strings, so it is deferred.Type of change
How it was tested
OuraOvernightReadingsTest(8; chart tests now cover interval medians and ranges, kept dips, unbridged gaps, interval hit-testing and recompute-on-change): per-record indices including PI=255, temperatures without the wear gate, edited sleep bounds on late wakes, paging across boundaries, malformed records, and chart thinning (≤ 400 points, ordered, keeps an isolated dip and peak).OuraSpO2RatioTest(6), including the verbatim 04:50 record and itspi255=3log line.RecoveryDriversTestfloat assertions fail).Related issues
Refs #4
🤖 Generated with Claude Code