Conversation
faba1ec to
7c36b25
Compare
|
Hardware day, Gen 3 on iOS ( What the engine computed. Evening pass: 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. Defect 2 — the row never carried the number. 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 |
4b49531 to
9f29ff9
Compare
|
Second hardware day, Gen 3 on iOS ( The two 09-17 fixes hold on device. 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 — What the ring does that the rule did not expect. Its minute grid is not fixed: samples land at
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 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 |
9f29ff9 to
41c2fc3
Compare
|
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! |
…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
41c2fc3 to
bffd28a
Compare
|
@ryanbr this has just been rebase and its ready for your review |
Closes #2242.
What
On an Oura day
DailyMetric.activeKcalEstis 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:
Calories.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 realCaloriesenum compiled standalone over a 68-case spread, stdout pasted verbatim intoMetCaloriesOracleTest).ouraMetSample(deviceId, ts, met REAL, state, epochS)keyed(deviceId, ts): GRDBv47-oura-met-sample, RoomMIGRATION_40_41(schema 41), same column order, pinned inschema_oracle.json, on the device-scoped delete lists on both sides.epochSis carried per row so a cadence other than 60 s scales rather than skews.analyzeDaygainsdayMet(+dayMetNow,caloriesDiag). MET present ⇒activeKcalEst = MET totalwhen 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.OuraLiveSourcefans each anchored0x50record 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_calorieson 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)
0x50decode reproduces the export's Σ(MET) on 10/10 clean days within 1–8 % (16 days, 16,839 overlapping minutes).0x50record'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 atutc − (n − i)·60, so a record straddling midnight lands on the right days. Documented in §6.13.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.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). macOSStrandbuild +StrandTests1945 green;NOOPiOScompiles.compileFullDebugKotlin,testFullDebugUnitTest6233 (the 8 failures on the dev Mac are French-JVM-locale formatting tests, identical onmain);WhoopDatabaseUpgradeTestruns v40 → v41 through the real chain against the committed schema;IntelligenceEngineJacocoBudgetTestgreen — the Android read lives inside the existingreadDaySkinAndWristOffsuspension point because a new suspend call inanalyzeRecentOnCpuspills ~700 instructions of continuation state.i18n_audit --ci,doc_comment_lintgreen.