Skip to content

feat(oura): active calories from the ring's own MET stream, behind a default-off Experimental toggle (#2242) - #2273

Open
pipiche38 wants to merge 8 commits into
ryanbr:mainfrom
pipiche38:feat/oura-met-active-calories-2242
Open

pipiche38 wants to merge 8 commits into
ryanbr:mainfrom
pipiche38:feat/oura-met-active-calories-2242

Conversation

@pipiche38

Copy link
Copy Markdown

Closes #2242.

What

On an Oura day DailyMetric.activeKcalEst is today computed by the HR-only Keytel path over the ring's sparse banked HR; against Oura's own numbers it correlates at r ≈ −0.1. The ring already streams its own minute-by-minute activity intensity (0x50, MET), which NOOP has only ever kept as a diagnostic JSONL sidecar. This PR persists that series and, behind a default-off Experimental toggle, scores the day's calories from it with Oura's documented rule.

Four layers, one commit each, both platforms:

  1. Pure estimatorCalories.estimateDayEnergyFromMET / estimateDayEnergyFromMet: active = Σ_{MET ≥ 1.5}(MET − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight, resting = the same revised Harris–Benedict the HR path uses over the covered minutes only, coverage reported over the window the caller passes. Missing minutes are unknown — never extrapolated to activity, never banked as rest. Kotlin pinned to Swift by oracle (the real Calories enum compiled standalone over a 68-case spread, stdout pasted verbatim into MetCaloriesOracleTest).
  2. StorageouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts): GRDB v47-oura-met-sample, Room MIGRATION_40_41 (schema 41), same column order, pinned in schema_oracle.json, on the device-scoped delete lists on both sides. epochS is carried per row so a cadence other than 60 s scales rather than skews.
  3. Engine selectionanalyzeDay gains dayMet (+ dayMetNow, caloriesDiag). MET present ⇒ activeKcalEst = MET total when the stream covers ≥ 50 % of the window (today: of the hours elapsed); below that the number is withheld rather than substituted by the HR path. nil/empty keeps the HR path byte-identical for every WHOOP and pure-function caller. One always-on log line per MET day.
  4. Writer + toggle + copyOuraLiveSource fans each anchored 0x50 record out to the table, only while the toggle is on (an OFF install's DB and scores are unchanged). Settings gains "Experimental · Oura Calories" (Oura-paired only), strings in all locales, README Oura table row, OURA_PROTOCOL.md §6.13 update. The toggle joins the day-cache config signature so a flip re-scores every cached day.

Why this formula, and why no fitted constant

Oura's support page says active calories are "the portion that exceeds 1.5 MET". Taken literally, at the textbook MET→kcal definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹ ≈ 0.0175 kcal·kg⁻¹·min⁻¹), it reproduces the Oura app's own active_calories on the export's per-minute MET series with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days (r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the wearer's weight being the only input. The constant was identified by that comparison, not fitted to it — nothing here is calibrated to a vendor number.

Validation (the "tracks a varying input" bar)

  • Decode → day sums: the NOOP 0x50 decode reproduces the export's Σ(MET) on 10/10 clean days within 1–8 % (16 days, 16,839 overlapping minutes).
  • Timestamp semantics settled: a 0x50 record's timestamp is the END of its last sample — every record length n aligns to the export best at exactly −n minutes (85 % exact minute matches, r 0.90; read forward: 23 % / 0.57). The writer stamps sample i at utc − (n − i)·60, so a record straddling midnight lands on the right days. Documented in §6.13.
  • Replay through the estimator (08-13 → 08-26 sidecars, worklog/tools/verify-2242-met-calories.py): on every day without a confirmed workout NOOP's active kcal is the Oura app's figure to 0.8–1.5 % (n = 8, r = 1.000; the residual is the profile weight). The sidecar's ~15–20 % ring-side coverage holes cost nothing on those days — the minutes the ring does not log are rest minutes.
  • The one systematic difference, on purpose: a confirmed workout in the Oura app has its minutes rewritten to the activity type's average MET (two golf rounds read a flat 4.2–4.3 where the wire carries the ring's 2.6–2.9 — "calculated based on average calorie burn rates for that activity type"). That is a label, not the sensor, so on such a day NOOP reads 10–27 % below the Oura app. The Settings caption says so.

Still an estimate of true expenditure (Kristiansson et al. 2023: free-living AEE MAPE 46–90 %), labelled as one.

Verification

  • swift test: StrandAnalytics 2038, WhoopStore 609 (schema oracle + device-scoped-table guard included). macOS Strand build + StrandTests 1945 green; NOOPiOS compiles.
  • Android: compileFullDebugKotlin, testFullDebugUnitTest 6233 (the 8 failures on the dev Mac are French-JVM-locale formatting tests, identical on main); WhoopDatabaseUpgradeTest runs v40 → v41 through the real chain against the committed schema; IntelligenceEngineJacocoBudgetTest green — the Android read lives inside the existing readDaySkinAndWristOff suspension point because a new suspend call in analyzeRecentOnCpu spills ~700 instructions of continuation state.
  • i18n_audit --ci, doc_comment_lint green.
  • Hardware: not yet run with the toggle ON — one night + day on the phone is owed and will be reported on this PR.

@pipiche38
pipiche38 force-pushed the feat/oura-met-active-calories-2242 branch from faba1ec to 7c36b25 Compare September 16, 2026 13:31
@pipiche38
pipiche38 marked this pull request as ready for review September 17, 2026 06:34
@pipiche38
pipiche38 marked this pull request as draft September 17, 2026 06:34
@pipiche38

Copy link
Copy Markdown
Author

Hardware day, Gen 3 on iOS (integration/oura-full@2fa8e0e0 = upstream/main@8576a2dd0 + this branch at 7c36b25d2), toggle ON from 2026-09-16 15:09. Two defects found, both fixed on the branch (458987dc1, fbba66b79); the estimator itself was exact.

What the engine computed. Evening pass: calories 2026-09-16: MET path - coverage 22% (256 samples) below 50% floor, estimate withheld (HR path not substituted on a MET day) — the floor and the no-substitution rule working as designed on a half-filled table. Morning pass, after the wake drain: calories 2026-09-16: MET path - coverage 72% (1035 samples), active 203 kcal, resting 1017 kcal, total 1220 kcal and calories 2026-09-17: MET path - coverage 61% (289 samples), active 1 kcal, resting 284 kcal, total 285 kcal. Checks against the stored rows: Σ_{MET ≥ 1.5}(MET − 1.5) = 184.1 MET-min × 0.0175 × 63 kg = 202.9 → 203 ✓; resting = revised Harris–Benedict (sex unset → male) 1414.5 kcal/d × 1035/1440 = 1016.7 → 1017 ✓. The Oura app for the same day: 187 cal active, 2 057 total; for 09-17 at 08:43: 0 cal (NOOP: 1 kcal).

Defect 1 — a re-served minute counted twice (+8 %). 1,035 rows were stored for 09-16 but they cover only 878 distinct minutes: 157 rows are 3–4 s twins of another row. ts is ring time through the session's own 0x13 anchor, and the anchor is per session, so the same ring record served again under a second session lands a few seconds off its first copy and the (deviceId, ts) key sees a new minute. Here the second session was the Oura app opening on the shared link at 07:52 — the ring re-served the day from the app's older cursor position, the hypnogram half of that replay was correctly discarded by #2097's guard, and the MET half was banked as new minutes. 458987dc1 drops an incoming sample whose interval overlaps a stored one (or one accepted earlier in the same batch; earlier start wins, lower MET on an exact tie) on both platforms, and the estimator skips a sample that starts inside the interval it has already counted, so rows stored before the fix stop inflating coverage. The Kotlin oracle is re-pinned: the 68 existing lines are byte-identical (the rule changes nothing where intervals do not overlap) and three overlap shapes are added. Recomputed from the exported rows with the rule: 875 minutes, 60.8 % coverage, active 183.6 kcal vs the app's 187 (−1.8 %) — inside the band the replay validation predicted for a day with no confirmed workout.

Defect 2 — the row never carried the number. dailyMetric.activeKcalEst in the export read 743.66 for 09-16 and 665.27 for 09-17: Keytel over the wake-to-wake HR window. DayCycleIntelligenceIntegration.compute recomputed Calories.estimateDayCalories for every cycle unconditionally and applying() wrote it over activeKcalEst for every day at or after the first recovered wake day, so on any phone with a day-cycle history the toggle changed a log line and nothing on screen; same shape in PhysiologicalStepCycleEngine + DayCycleIntelligenceIntegration.apply on Android. fbba66b79 makes the fold take the same decision analyzeDay takes, over its own window: MET rows present → estimateDayEnergyFromMET over [onset, min(endExclusive, now)) with the 50 % floor, covered → the MET total, thin → withheld (no entry, no HR substitute), no rows or toggle off → Keytel exactly as before. Tests on both platforms drive a seeded 8-h main sleep with HR across the cycle through the real store (Swift DayCycleRecoveryTests 10/10 under xcodebuild test; Kotlin DayCycleMetCaloriesTest 3/3 under Robolectric + Room) and pin the three outcomes; Android's IntelligenceEngineJacocoBudgetTest stays green because the engine takes the pass's toggle as a Boolean rather than a lambda at the call site.

One visible consequence of the flip, so nobody reads it as a bug. The "Calories" figure is a whole-day total (resting over the covered minutes + active), the same semantic the HR path had. The HR path only ever covered the hours the ring had banked HR — mostly the night — so a Keytel day read ≈ 600–740 kcal; the MET stream covers most of the day, so the same day reads ≈ 1 200 kcal at 72 % coverage. On the first day with the toggle on, today's number therefore jumps against a rolling average still computed the old way (days before the flip have no MET rows and stay Keytel when re-scored), and the two converge only as MET days accumulate in the window. Coverage being honest, not more exertion.

Verification on the new head: StrandAnalytics 2040/0, WhoopStore 611/0; Android testFullDebugUnitTest 6246 with the same 8 pre-existing failures as the branch base (proven in a worktree at 7c36b25d2); Strand (macOS) and NOOPiOS BUILD SUCCEEDED; doc_comment_lint OK. Next: a day on the new head with the toggle ON, checking that the export's activeKcalEst equals the log's total and that the app's active figure is matched within a few percent on a day without a confirmed workout.

@pipiche38
pipiche38 force-pushed the feat/oura-met-active-calories-2242 branch from 4b49531 to 9f29ff9 Compare September 18, 2026 07:27
@pipiche38

Copy link
Copy Markdown
Author

Second hardware day, Gen 3 on iOS (integration/oura-full@cb4f3b64 = upstream/main@1f37c4e2a + this branch at fbba66b79), toggle ON all day, and one more commit — 9f29ff9e4 (the branch is also rebased onto 11.8.0 main, so the conflict is gone).

The two 09-17 fixes hold on device. calories 2026-09-17: MET path - coverage 83% (1197 samples), active 232 kcal, resting 1170 kcal, total 1402 kcal in the log, and the exported row now carries the MET decision, not Keytel: dailyMetric.activeKcalEst 09-17 = 1397.2 (the day-cycle fold's own wake-to-wake window, 0.3 % from the calendar line — by design, it decides over its window), 09-16 = 993.0; the day before the toggle still holds its HR figure (593.8). The store dedupe: 1,197 rows for 09-17 = 1,197 distinct minutes (09-16's 1,035/878 replay twins stay as they were; the estimator skips them).

Against the Oura app, 09-17 (no recorded workout): Oura 247 active cal, 2 161 total, 4 526 steps; NOOP 232 active = −6.1 %. That is wider than 09-16's −1.8 %, and the reason is a second edge of the overlap rule I wrote on Wednesday — 9f29ff9e4.

What the ring does that the rule did not expect. Its minute grid is not fixed: samples land at :02 for an hour or so, then at :01 — the per-record anchor rounds differently, so between two records the grid steps back a second. A real successor then starts 59 s after the minute before it and overlaps it by one second. "A sample whose interval overlaps the one already counted is the same minute" read every such successor as a twin:

  • at the store, droppingOverlaps rejected the phase-step minute on insert — after the Wednesday flash the 09-17 table shows 8 holes (12:19, 13:30, 14:06, 14:12, 14:46, 19:27, 20:38, and one on 09-18), each a 119-s gap between rows that are 60 s apart everywhere else, about one per hour, invisible unless you diff the timestamps;
  • in the estimator, the 6 phase-step minutes stored before the rule (08:20, 09:03, 09:32, 10:06, 11:21, 11:53 — the day's 7.8-MET peak among them) were skipped: replaying the committed rule over the exported rows in Python gives exactly the log's 232.3; the exact rule over the same rows gives 245.7, which is the app's 247 to −0.5 %.

The fix. A re-served twin is 2–5 s off its first copy; the next minute is 55–61 s on; half a period tells them apart. Both platforms, store and estimator, one integer test: two samples are the same minute when |Δ| × 2 < min(epochS) (OuraMetSample.isTwin / OuraMetSampleEntity.isTwin; droppingOverlapsdroppingTwins), and the estimator walk compares against the last counted start rather than its interval end. The 09-16 replay twins still collapse — the overlap-3s-twins and overlap-phone-day oracle lines are byte-identical — and the 59-s successor is kept. Rows the store already dropped are gone from this install (the ring re-serves them only under a replay); rows stored before Wednesday's rule count correctly again with no migration.

Verification: StrandAnalytics 2041/0 (the 57-s "twin" test replaced by the 59-s successor and a full stepping day), WhoopStore 612/0 (a stepping day inserts 120/120); Android MetCaloriesOracleTest regenerated from the Swift twin's stdout — 84 lines, the 76 carried-over lines byte-identical, 4 retired, 8 added — OuraMetSampleTwinTest 3/3, DayCycleMetCaloriesTest 3/3 (Robolectric + Room); macOS + iOS build; doc lint OK. Tomorrow's day on this commit is the check that the table stops growing holes.

@pipiche38
pipiche38 marked this pull request as ready for review September 18, 2026 08:08
@pipiche38
pipiche38 force-pushed the feat/oura-met-active-calories-2242 branch from 9f29ff9 to 41c2fc3 Compare September 19, 2026 13:14
@pipiche38

Copy link
Copy Markdown
Author

The conficts is related to localization. I'll be fixing it whent this PR is reviewed. As othewise I would have to fix the localization every time other PRs touching it would be merged!

pipiche38 and others added 8 commits September 21, 2026 09:12
…e, no fitted constant (ryanbr#2242)

Adds Calories.estimateDayEnergyFromMET (Swift) / estimateDayEnergyFromMet (Kotlin):
active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight over the day's samples
— Oura's support wording ("the portion that exceeds 1.5 MET") at the standard MET→kcal
definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹). Resting is the same revised Harris–Benedict the
HR path uses, scaled by covered seconds; coverage is reported over the window the caller
passes so today's partial day is judged against elapsed hours. Missing minutes are unknown —
never extrapolated to activity, never banked as rest.

The rule was identified, not fitted: on the Oura export's own per-minute MET series it
reproduces active_calories with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days
(r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the only input being the
wearer's weight. The earlier (MET − 1) × k reading was the wrong subtraction absorbing an
intercept — there is no hidden per-account constant.

Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a
68-case spread (four profiles × empty / rest floor / bout / 1.5 threshold edge / 12.7-12.8
decoder boundary / 60 % + 40 % coverage / 120 s cadence / duplicate ts / window edges /
partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim
into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals.

Pure and unwired: no store, no engine selection yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL
…ample, both platforms (ryanbr#2242)

Until now the 0x50 MET stream existed only as the diagnostic JSONL sidecar, so nothing
scored could read it. GRDB v47-oura-met-sample / Room MIGRATION_40_41 (schema 41) add
ouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts), column order
identical on both sides and pinned in schema_oracle.json (both copies). epochS is carried
per row so a cadence other than 60 s scales the estimate rather than skewing it.

Swift: OuraMetStore (idempotent insert, bounded range read, count), the table on
DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity,
DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the
committed v41 Room schema for the upgrade test.

Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin); WhoopStore 609
green, com.noop.data 460 green incl. SchemaOracleTest / WhoopDatabaseUpgradeTest /
the device-scoped-table guards on both sides.

No writer yet — the table is created empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL
…es when supplied (ryanbr#2242)

analyzeDay gains dayMet (the day owner's own per-minute MET samples), dayMetNow (so
today's coverage is judged against elapsed hours, not 24 h) and a caloriesDiag sink.
Rule: MET samples present ⇒ activeKcalEst = estimateDayEnergyFromMET(...).totalKcal when
the stream covers ≥ 50 % of the window; below that the estimate is withheld (nil) rather
than substituted by the HR path, which on a ring day runs over sparse banked HR and does
not track Oura's number (r ≈ −0.1). nil/empty dayMet keeps the HR path byte-identical —
every WHOOP and pure-function caller is unchanged. One always-on log line per MET day.

Both platforms; AnalyticsEngineMetCaloriesTests / AnalyticsEngineMetCaloriesTest pin the
selection, the withhold, the elapsed-hours window and the byte-identical no-MET path.
Nothing supplies dayMet yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL
…r, behind an Experimental toggle (ryanbr#2242)

The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES,
default OFF) gates two things:

  • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one
    row per minute, next to the existing JSONL sidecar call. The record's timestamp is the
    END of its last sample: fitted against Oura's own per-minute export, every record length
    n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57
    read forward), so sample i starts at utc − (n − i)·60 and a record straddling local
    midnight lands its minutes on the right days. Wired in SourceCoordinator to
    store.insertOuraMetSamples / repository.insertOuraMetSamples.
  • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar-
    day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now so
    today's coverage is judged against elapsed hours, and the calories line routed through
    the same per-day diag recorder as the Effort funnel. The toggle joins the day-cache
    config signature on both platforms so a flip re-scores every cached day rather than
    serving stale calories (DayCacheConfigFieldTests / DayCacheConfigFieldTest updated).

Android threads the flag Context-free like spo2CandidateDisplay (analyzeRecent; AppViewModel
×2 + WhoopBleClient callers) but carries it into the pass on a field and reads the MET rows
inside readDaySkinAndWristOff — both for analyzeRecentOnCpu's JaCoCo budget: a new parameter
or a new suspend call in that method costs ~0.3 KB / ~0.7 K instructions of continuation
spill, which the ratchet refuses (measured by bisecting the raw method size per commit).

OFF keeps both the DB and every score byte-identical. Verified: macOS Strand build +
StrandTests (1945 green), NOOPiOS build, Android compile + unit tests (the 8 failures on
this machine are French-locale formatting tests that fail identically on upstream/main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL
…yanbr#2242)

Settings gains an "Experimental · Oura Calories" section (iOS card / Android row), shown
only when an Oura ring is the active/paired device, default OFF, with the caption stating
the method (minutes above 1.5 MET × resting rate), that it is an estimate tracking Oura's
own total to within a constant factor, the < 50 % coverage withhold, and that turning it
on starts storing the ring's MET samples. Flipping it re-scores immediately on both
platforms (the toggle is in the day-cache signature). Strings land in de/es/fr/pt-PT plus
it/pl/ru/zh-Hans/zh-Hant (iOS) and pl/ru/zh (Android) so the i18n ratchets stay green.

Docs: OURA_PROTOCOL.md §6.13 gets the day-sum validation of the 0x50 decode against
Oura's export (10/10 clean days within 1–8 %), the end-of-record timestamp semantics, and
the derived active-calorie formula; README's Oura table gains an "Active calories" row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL
… once (ryanbr#2242)

First hardware day of the toggle (2026-09-17, Gen 3): 157 of the 1,035 ouraMetSample
rows stored for 09-16 were 3–4 s twins of another minute, and the day's active energy
read +8 % (203 kcal against the Oura app's 187). The (deviceId, ts) key was built on
"a re-served record is a no-op insert", which holds only while the anchor holds: `ts`
is ring time through the session's own 0x13 anchor, so the same record served again
under a second session — here an Oura-app replay from the app's older cursor — lands
a few seconds off its first copy and the key sees a new minute.

Two guards, both platforms, byte-identical:

- Store: `insertOuraMetSamples` reads the stored intervals around the batch and drops
  any incoming sample whose interval overlaps a stored one, or one accepted earlier in
  the batch (earlier start wins, lower MET on an exact tie). Pure rule extracted as
  `OuraMetSample.droppingOverlaps` / `OuraMetSampleEntity.droppingOverlaps` so it is
  testable without a database.
- Estimator: `estimateDayEnergyFromMET` skips a sample that starts inside the interval
  it has already counted, so rows stored before this fix stop inflating coverage and
  active energy. The Kotlin twin is re-pinned by the oracle: the 68 existing lines are
  byte-identical to the previous literal (the rule changes nothing where intervals do
  not overlap) and three overlap shapes are added, 80 lines regenerated from the Swift
  twin's stdout.

Tests: StrandAnalytics 2040/0 (2 new), WhoopStore 611/0 (2 new); Android
MetCaloriesOracleTest 80/80, OuraMetSampleOverlapTest 2/2, full JVM suite 6246 with the
same 8 pre-existing failures as the branch base (proven in a worktree at 7c36b25).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCUoRfjiQHTdb5G5bPs8rW
…ead of writing Keytel over it (ryanbr#2242)

On the first hardware day the engine logged the ring's number — `calories 2026-09-16:
MET path - coverage 72% (1035 samples), active 203 kcal, resting 1017 kcal, total
1220 kcal` — and the exported row held 743.66: Keytel over the wake-to-wake HR window.
`DayCycleIntelligenceIntegration.compute` recomputed `Calories.estimateDayCalories` for
every cycle unconditionally and `applying()` wrote that over `activeKcalEst` on every
day at or after the first recovered wake day, so on any phone with a day-cycle history
the toggle changed a log line and nothing the user could see. Same shape on Android
(`PhysiologicalStepCycleEngine` + `DayCycleIntelligenceIntegration.apply`).

The fold now makes the same decision `analyzeDay` makes, over its own window: when the
cycle owner has MET rows, `estimateDayEnergyFromMET` over [onset, min(endExclusive, now))
with the same 50 % coverage floor; covered → the MET total, thin → WITHHELD (no entry,
and the HR figure is not substituted), no rows or toggle off → Keytel as before. Swift
threads a `metReader` closure (nil while the toggle is off, so the HR path is
byte-identical); Kotlin passes the pass's toggle flag and reads `repo.ouraMetSamples`
inside the engine — a Boolean argument at the call site rather than a lambda, to stay
inside `analyzeRecentOnCpu`'s JaCoCo budget (IntelligenceEngineJacocoBudgetTest still
green). A `stepsCycle calories … path=met` trace line names the decision.

Tests, both platforms, on a real in-memory store with a seeded 8-h main sleep and HR
across the cycle: a covered MET cycle carries the MET total onto the row, a 10 %-covered
one is withheld with no Keytel in its place, no reader / empty table is the Keytel path
byte for byte. Swift `DayCycleRecoveryTests` 10/10 (3 new, `xcodebuild test`), Kotlin
`DayCycleMetCaloriesTest` 3/3 (Robolectric + Room). `Strand` (macOS) and `NOOPiOS`
BUILD SUCCEEDED; `doc_comment_lint` OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCUoRfjiQHTdb5G5bPs8rW
…xt minute, not a re-served twin (ryanbr#2242)

Second hardware day of the toggle (2026-09-18, Gen 3): the ring's own minute grid steps
back one second between records — samples land at :02 for an hour, then :01 — so a real
successor starts 59 s after the minute before it and overlaps it by one second. The
overlap rule from the first day ("a sample whose interval overlaps the one already
counted is the same minute") read every such successor as a twin. At the store,
`droppingOverlaps` rejected the phase-step minute on insert — 8 holes on the first day
after the rule landed, one per step, ~once an hour, invisible in the table except as a
119-s gap between rows. In the estimator, the six phase-step minutes that had been stored
BEFORE the rule (the day's 7.8-MET peak among them) were skipped: `active 232 kcal` in the
log against 245.7 by the exact rule over the same rows, −5.5 %, and −6.1 % against the
Oura app's 247 where the fixed rule reads −0.5 %.

A twin is 2–5 s off its first copy (the per-session 0x13 anchor); the next minute is
55–61 s on. Half a period tells them apart. Both platforms, store and estimator, the
same integer test: two samples are the same minute when `|Δ| × 2 < min(epochS)`
(`OuraMetSample.isTwin` / `OuraMetSampleEntity.isTwin`; `droppingOverlaps` becomes
`droppingTwins`), and the estimator walk compares against the last COUNTED START rather
than its interval end. The 3–4-s twins of 09-16's replay still collapse (the
`overlap-3s-twins` and `overlap-phone-day` oracle lines are byte-identical); the 59-s
successor is kept.

Rows already dropped at the store on the first day are gone from that install (the ring
re-serves them only under a replay); rows stored before the rule are counted correctly
again by the estimator without a migration.

Tests: StrandAnalytics 2041/0 (+1 net: the 57-s "twin" test replaced by the 59-s
successor + a full stepping day), WhoopStore 612/0 (+1: the phase-step day inserts 120/120);
Android MetCaloriesOracleTest 84 lines regenerated from the Swift twin's stdout (76
existing lines byte-identical, the 4 retired `overlap-57s-twin` lines removed, 8
phase-step lines added), OuraMetSampleTwinTest 3/3, DayCycleMetCaloriesTest 3/3; doc
lint OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBrA24fNXFBYAeLr65GVe1
@pipiche38
pipiche38 force-pushed the feat/oura-met-active-calories-2242 branch from 41c2fc3 to bffd28a Compare September 21, 2026 07:19
@pipiche38

Copy link
Copy Markdown
Author

@ryanbr this has just been rebase and its ready for your review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant