From 2691d235b6627f5b3de72d6ee1988c1da482796f Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 10:55:22 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(analytics):=20day=20energy=20from=20a?= =?UTF-8?q?=20MET=20stream=20=E2=80=94=20Oura's=20documented=20rule,=20no?= =?UTF-8?q?=20fitted=20constant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. A re-served minute counts once. The ring's timestamps go through a per-session 0x13 anchor, so a record served again under a second session lands 2–5 s off its first copy, while the ring's own minute grid steps back a second between records (a real successor starts 59–61 s on). Half a period tells them apart: two samples are the same minute when |Δ| × 2 < min(epochS), and the walk compares against the last counted start. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a case spread (84 output lines) (four profiles × empty / rest floor / bout / 1.5 threshold edge / decoder boundary / partial coverage / 120 s cadence / duplicate ts / 3-s twins / 59-s phase steps / 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. Refs #2242 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz --- .../StrandAnalytics/WorkoutDetector.swift | 113 ++++++++++ .../MetCaloriesTests.swift | 179 +++++++++++++++ .../com/noop/analytics/WorkoutDetector.kt | 122 ++++++++++ .../noop/analytics/MetCaloriesOracleTest.kt | 211 ++++++++++++++++++ 4 files changed, 625 insertions(+) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift create mode 100644 android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift index 97aee8e826..ba882fa7d7 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift @@ -853,4 +853,117 @@ public enum Calories { estimateDayEnergy(hrSamples, profile: profile, hrmax: hrmax, restingHR: restingHR).totalKcal } + + // MARK: MET-stream day energy (Oura 0x50, #2242) + + /// One metabolic-equivalent sample from a device that streams its OWN activity intensity — the + /// Oura ring's `0x50` activity record, one value per 60 s (`OURA_PROTOCOL.md` §6.13). `ts` is the + /// unix second the sample's interval STARTS; `secPerSample` is how long it covers (60 on every ring + /// observed; carried per sample rather than assumed so a different cadence scales, not skews). + public struct MetSample: Equatable, Sendable { + public let ts: Int + public let met: Double + public let secPerSample: Int + public init(ts: Int, met: Double, secPerSample: Int = 60) { + self.ts = ts; self.met = met; self.secPerSample = secPerSample + } + } + + /// Whole-day energy from a MET stream. Same split as `DayEnergyEstimate` (so the persisted + /// `totalKcal` keeps its meaning), plus the share of the day the stream actually covered — the + /// caller decides whether that is enough to mint a number (`metMinCoverageFraction`), and the UI + /// can caption it. `coverageFraction` is over the day window the caller passed, so today's + /// partial day is judged against the hours that have elapsed, not against 24 h. + public struct MetEnergyEstimate: Equatable, Sendable { + public let restingKcal: Double + public let activeKcal: Double + public let observedSeconds: Double + public let coverageFraction: Double + + public var totalKcal: Double { restingKcal + activeKcal } + } + + /// Below this MET a minute is rest, not activity, and it is also what an active minute is measured + /// FROM: Oura's documented method counts "the portion that exceeds 1.5 MET" (Oura support, "How Oura + /// Measures Steps & Activity"; Kristiansson et al. 2023 — "AEE starts accumulating at > 1.5 MET"). + public static let metActiveThreshold = 1.5 + /// kcal per kg per MET-minute — the definition of a MET, not a fit: 1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹ + /// (ACSM) at ≈ 5 kcal per litre of O₂ → 0.0175 kcal·kg⁻¹·min⁻¹. Against Oura's own export this exact + /// rule reproduces `active_calories` with r = 1.000 and 0.6 kcal/day RMSE over 75 days (and r 0.9999 + /// / 3 kcal over 396 pre-2025 days), the wearer's weight being the only input — see + /// OURA_PROTOCOL.md §6.13. The constant was IDENTIFIED by that comparison, not fitted to it. + public static let kcalPerKgPerMetMinute = 0.0175 + /// The least of the day the stream must cover before the MET estimate is trusted for the persisted + /// number. Below it the day is mostly unknown — a ring off the finger, a drain that never came — and + /// a half-day sum would read as a low-activity day rather than a missing one. + public static let metMinCoverageFraction = 0.5 + + /// Active + resting energy for one calendar day from the device's own MET stream. + /// + /// `active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 × weightKg × (secPerSample/60)` over the samples + /// inside `[dayStart, dayEnd)` — Oura's documented method (the portion above 1.5 MET) at the + /// standard MET→kcal definition, applied to the ring's own minute-by-minute series. No fitted + /// constant, and it IS Oura's number: against the Oura export it reproduces `active_calories` to + /// 0.6 kcal/day RMSE (r = 1.000). `restingKcal` is the same revised Harris–Benedict BMR the HR path + /// uses (`restingKcalPerS`), over the covered seconds, so `totalKcal` keeps the HR path's meaning. + /// A MET→kcal figure is still an ESTIMATE of true expenditure (free-living MAPE 46–90 % against + /// accelerometry in Kristiansson 2023) — label it so. + /// + /// Coverage: `observedSeconds` is the sum of the covered sample intervals, capped at the day span. + /// A minute counts ONCE: a duplicate `ts` keeps the LOWER MET (the conservative direction), and a + /// sample that starts within HALF a period of the one already counted is that minute again and is + /// dropped — the ring re-serves a record under a fresh per-session `0x13` anchor a few seconds off + /// the first copy (2026-09-17: 157 of 1,035 stored rows were 3–4 s twins of another minute, +8 % on + /// the day), and two rows 3 s apart are one minute, not two. Half a period, not "any overlap": the + /// ring's own minute grid steps by a second between records (the per-record anchor rounds + /// differently — `:02` then `:01`), so a successor can start 59 s after the minute before it and + /// overlap it by one second; judged by overlap, that successor was dropped and the day lost a + /// whole minute per phase step (2026-09-18: six on one day, the day's 7.8-MET peak among them, + /// −5.5 % on active energy). A twin is 2–5 s off; a successor is 55–61 s off; 30 s tells them + /// apart. Missing minutes are UNKNOWN and contribute nothing to either term: never extrapolate a + /// gap to activity, and never bank resting energy for time nobody observed. `restingKcal` therefore + /// scales with coverage exactly as the HR path's does. + public static func estimateDayEnergyFromMET(_ samples: [MetSample], + profile: UserProfile, + dayStart: Int, + dayEnd: Int) -> MetEnergyEstimate { + let daySpan = Double(max(0, dayEnd - dayStart)) + let inDay = samples.filter { $0.ts >= dayStart && $0.ts < dayEnd && $0.secPerSample > 0 } + if inDay.isEmpty || daySpan <= 0 { + return MetEnergyEstimate(restingKcal: 0, activeKcal: 0, observedSeconds: 0, coverageFraction: 0) + } + + let weightKg = profile.weightKg > 0 ? profile.weightKg : 70.0 + let heightCm = profile.heightCm > 0 ? profile.heightCm : 170.0 + let age = profile.age > 0 ? profile.age : 30.0 + let coeffs = resolveCoeffs(profile.sex) + let restingRate = restingKcalPerS(coeffs, weightKg: weightKg, heightCm: heightCm, age: age) + // kcal per excess-MET-minute for THIS wearer (the MET definition scales with body mass). + let kcalPerMetMin = kcalPerKgPerMetMinute * weightKg + + // Ties on ts: ascending MET on a tie keeps the LOWER reading. Twins: a sample that starts less + // than half a period after the previously counted start is the same minute served again (see + // the doc above) — the earlier-starting copy wins and the twin is skipped, so neither coverage + // nor active energy counts a minute twice. Integer test, same expression as the Kotlin twin and + // as `OuraMetSample.isTwin`: `Δ × 2 < min(period, lastPeriod)`. + let ordered = inDay.sorted { $0.ts != $1.ts ? $0.ts < $1.ts : $0.met < $1.met } + var covered = 0.0 + var activeKcal = 0.0 + var lastStart = Int.min + var lastPeriod = 0 + for s in ordered { + if lastPeriod > 0 && (s.ts - lastStart) * 2 < min(s.secPerSample, lastPeriod) { continue } + lastStart = s.ts + lastPeriod = s.secPerSample + let minutes = Double(s.secPerSample) / 60.0 + covered += Double(s.secPerSample) + guard s.met >= metActiveThreshold else { continue } + activeKcal += (s.met - metActiveThreshold) * kcalPerMetMin * minutes + } + let observedSeconds = min(covered, daySpan) + return MetEnergyEstimate(restingKcal: restingRate * observedSeconds, + activeKcal: activeKcal, + observedSeconds: observedSeconds, + coverageFraction: observedSeconds / daySpan) + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift new file mode 100644 index 0000000000..cdbb6edcb7 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift @@ -0,0 +1,179 @@ +import XCTest +@testable import StrandAnalytics + +/// Tests `Calories.estimateDayEnergyFromMET` (#2242) — the whole-day energy estimate from a device's OWN +/// MET stream (the Oura ring's 0x50 record), Oura's documented method with no fitted constant. Pure +/// function; no DB. The Kotlin twin is pinned to this code by oracle (`MetCaloriesOracleTest`, the Swift +/// twin's stdout pasted verbatim); the hand-derived literals here are what stop THIS side drifting. +final class MetCaloriesTests: XCTestCase { + + private typealias M = Calories.MetSample + private let day0 = 1_755_208_800 // 2026-08-15 00:00 Europe/Paris + private var day1: Int { day0 + 86_400 } + /// Revised Harris–Benedict for the default (nonbinary, 70 kg, 170 cm, 30 y) profile: + /// 267.9775 + 11.322·70 + 394.85·1.70 − 5.0035·30. + private let defaultBmr = 1581.6575 + /// kcal per excess-MET-minute for the default 70 kg: 0.0175 × 70. + private let kcalPerMetMin = 1.225 + + private func fullDay(_ met: Double) -> [M] { (0..<1440).map { M(ts: day0 + $0 * 60, met: met) } } + + func testEmptyIsAllZero() { + let r = Calories.estimateDayEnergyFromMET([], profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r, Calories.MetEnergyEstimate(restingKcal: 0, activeKcal: 0, + observedSeconds: 0, coverageFraction: 0)) + } + + func testRestingDayIsBmrOverFullCoverage() { + let r = Calories.estimateDayEnergyFromMET(fullDay(0.9), profile: UserProfile(), + dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.restingKcal, defaultBmr, accuracy: 1e-9) + XCTAssertEqual(r.activeKcal, 0, accuracy: 1e-12) + XCTAssertEqual(r.observedSeconds, 86_400, accuracy: 1e-12) + XCTAssertEqual(r.coverageFraction, 1.0, accuracy: 1e-12) + XCTAssertEqual(r.totalKcal, defaultBmr, accuracy: 1e-9) + } + + func testBoutIsExcessOverThresholdAtTheMetRateForWeight() { + // 30 min at 4.0 MET → (4 − 1.5) × 0.0175 × 70 kg × 30 — Oura's rule at the MET definition. + var day = fullDay(0.9) + for i in 600..<630 { day[i] = M(ts: day0 + i * 60, met: 4.0) } + let r = Calories.estimateDayEnergyFromMET(day, profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.activeKcal, 2.5 * kcalPerMetMin * 30.0, accuracy: 1e-9) + XCTAssertEqual(r.activeKcal, 91.875, accuracy: 1e-9) // the oracle line + XCTAssertEqual(r.restingKcal, defaultBmr, accuracy: 1e-9) + } + + func testThresholdIsTheZeroOfTheActiveTerm() { + // 1.4 is rest; 1.5 is "active" but its excess over the threshold is zero; 1.6 contributes 0.1. + let r = Calories.estimateDayEnergyFromMET([M(ts: day0, met: 1.4), M(ts: day0 + 60, met: 1.5), + M(ts: day0 + 120, met: 1.6)], + profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.activeKcal, 0.1 * kcalPerMetMin, accuracy: 1e-9) + XCTAssertEqual(r.observedSeconds, 180, accuracy: 1e-12) // all three minutes covered + } + + func testActiveScalesWithWeightOnly() { + // Same minutes, twice the weight → twice the active term; resting follows Harris–Benedict instead. + let bout = [M(ts: day0, met: 5.0)] + let a = Calories.estimateDayEnergyFromMET(bout, profile: UserProfile(weightKg: 50), dayStart: day0, dayEnd: day1) + let b = Calories.estimateDayEnergyFromMET(bout, profile: UserProfile(weightKg: 100), dayStart: day0, dayEnd: day1) + XCTAssertEqual(b.activeKcal, 2 * a.activeKcal, accuracy: 1e-9) + XCTAssertEqual(a.activeKcal, 3.5 * 0.0175 * 50.0, accuracy: 1e-9) + } + + func testCoverageIsOverTheDayWindowPassed() { + // 60 % of a 24 h day. + let sixty: [M] = (0..<864).map { M(ts: day0 + $0 * 60, met: 1.0) } + let full = Calories.estimateDayEnergyFromMET(sixty, profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(full.coverageFraction, 0.6, accuracy: 1e-12) + XCTAssertEqual(full.restingKcal, defaultBmr * 0.6, accuracy: 1e-9) + // The same samples against a 6 h elapsed window (today, mid-morning) read as fully covered. + let partial: [M] = (0..<360).map { M(ts: day0 + $0 * 60, met: 1.0) } + let today = Calories.estimateDayEnergyFromMET(partial, profile: UserProfile(), + dayStart: day0, dayEnd: day0 + 6 * 3600) + XCTAssertEqual(today.coverageFraction, 1.0, accuracy: 1e-12) + XCTAssertEqual(today.observedSeconds, 21_600, accuracy: 1e-12) + } + + func testMissingMinutesAreUnknownNotRest() { + // A one-hour hole reduces resting energy by exactly one hour's BMR and adds no activity. + var day = fullDay(1.0) + day.removeSubrange(300..<360) + let r = Calories.estimateDayEnergyFromMET(day, profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.restingKcal, defaultBmr * (1380.0 / 1440.0), accuracy: 1e-9) + XCTAssertEqual(r.activeKcal, 0, accuracy: 1e-12) + XCTAssertEqual(r.coverageFraction, 1380.0 / 1440.0, accuracy: 1e-12) + } + + func testSecPerSampleScalesBothTerms() { + // 720 × 120 s at rest plus one 120 s sample at 3.0 MET = a full day, 2 MET·min × 2 minutes active. + var twoMin: [M] = (0..<720).map { M(ts: day0 + $0 * 120, met: 1.0, secPerSample: 120) } + twoMin[300] = M(ts: day0 + 300 * 120, met: 3.0, secPerSample: 120) + let r = Calories.estimateDayEnergyFromMET(twoMin, profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.observedSeconds, 86_400, accuracy: 1e-12) + XCTAssertEqual(r.activeKcal, 1.5 * kcalPerMetMin * 2.0, accuracy: 1e-9) + } + + func testDuplicateTsCountsOnceAndLowerMetWins() { + let r = Calories.estimateDayEnergyFromMET([M(ts: day0, met: 5.0), M(ts: day0, met: 2.0)], + profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.observedSeconds, 60, accuracy: 1e-12) + XCTAssertEqual(r.activeKcal, 0.5 * kcalPerMetMin, accuracy: 1e-9) + } + + /// 2026-09-17, first hardware day: 157 of 1,035 stored rows were the same minute re-served under a + /// fresh per-session `0x13` anchor, 3–4 s off the first copy, and the day read +8 %. A sample that + /// starts within half a period of the one already counted is that minute again: skipped, the first + /// copy wins. + func testOverlappingReserveIsTheSameMinuteAndCountsOnce() { + let r = Calories.estimateDayEnergyFromMET( + [M(ts: day0, met: 4.0), M(ts: day0 + 3, met: 4.0), M(ts: day0 + 60, met: 0.9), + M(ts: day0 + 64, met: 9.0), M(ts: day0 + 120, met: 4.0)], + profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.observedSeconds, 180) // three minutes, not five + XCTAssertEqual(r.activeKcal, 2 * 2.5 * kcalPerMetMin, accuracy: 1e-9) // the 9.0 twin is dropped + } + + /// 2026-09-18, second hardware day: the ring's minute grid steps back a second between records + /// (`:02` → `:01`), so a real successor starts 59 s after the minute before it and overlaps it by one + /// second. Judged by overlap it was dropped — six minutes on one day, the 7.8-MET peak among them. + /// Twins are 2–5 s off, successors 55–61 s: half a period (30 s) tells them apart, on both sides. + func testPhaseStepSuccessorIsTheNextMinuteNotATwin() { + let r = Calories.estimateDayEnergyFromMET( + [M(ts: day0, met: 1.0), M(ts: day0 + 59, met: 5.0), M(ts: day0 + 120, met: 1.0), + M(ts: day0 + 149, met: 9.0)], // 29 s after minute 2: a twin, dropped + profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.observedSeconds, 180) // minutes 0, 0:59 and 2 — not 2, not 4 + XCTAssertEqual(r.activeKcal, 3.5 * kcalPerMetMin, accuracy: 1e-9) // the 5.0 successor counts, the 9.0 twin does not + } + + /// A whole day on the stepping grid loses nothing: 1440 minutes, one second lost every 30, all counted. + func testPhaseStepDayIsFullyCovered() { + let rows = (0..<1440).map { M(ts: day0 + $0 * 60 - $0 / 30, met: 1.0) } + let r = Calories.estimateDayEnergyFromMET(rows, profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.observedSeconds, 86_400) + XCTAssertEqual(r.coverageFraction, 1.0, accuracy: 1e-12) + } + + func testWindowIsHalfOpenAndOutsideSamplesAreIgnored() { + let r = Calories.estimateDayEnergyFromMET( + [M(ts: day0 - 60, met: 9.0), M(ts: day0, met: 2.0), M(ts: day1 - 60, met: 2.0), M(ts: day1, met: 9.0)], + profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.observedSeconds, 120, accuracy: 1e-12) + XCTAssertEqual(r.activeKcal, 2 * 0.5 * kcalPerMetMin, accuracy: 1e-9) + } + + func testProfileFallbacksMatchTheHrPath() { + // A zeroed profile falls back to 70 kg / 170 cm / 30 y exactly as `estimateDayEnergy` does. + let zeroed = UserProfile(weightKg: 0, heightCm: 0, age: 0, sex: "male") + let r = Calories.estimateDayEnergyFromMET(fullDay(1.0), profile: zeroed, dayStart: day0, dayEnd: day1) + // Explicitly typed, term by term: the untyped literal chain made the CI toolchain's type-checker + // give up ("unable to type-check this expression in reasonable time"). + let weightTerm: Double = 13.397 * 70.0 + let heightTerm: Double = 479.9 * 1.7 + let ageTerm: Double = 5.677 * 30.0 + let maleBmr: Double = 88.362 + weightTerm + heightTerm - ageTerm + XCTAssertEqual(r.restingKcal, maleBmr, accuracy: 1e-9) + } + + func testBadEpochRowsAreDroppedAndZeroLengthDayIsEmpty() { + let bad = Calories.estimateDayEnergyFromMET( + [M(ts: day0, met: 4.0, secPerSample: 0), M(ts: day0 + 60, met: 4.0, secPerSample: -60), + M(ts: day0 + 120, met: 4.0)], + profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(bad.observedSeconds, 60, accuracy: 1e-12) + let zero = Calories.estimateDayEnergyFromMET(fullDay(2.0), profile: UserProfile(), + dayStart: day0, dayEnd: day0) + XCTAssertEqual(zero.coverageFraction, 0, accuracy: 1e-12) + XCTAssertEqual(zero.totalKcal, 0, accuracy: 1e-12) + } + + func testCoverageClampsAtTheDaySpan() { + let r = Calories.estimateDayEnergyFromMET((0..<1500).map { M(ts: day0 + $0 * 60, met: 1.0) }, + profile: UserProfile(), + dayStart: day0, dayEnd: day0 + 1500 * 60 - 3600) + XCTAssertEqual(r.coverageFraction, 1.0, accuracy: 1e-12) + XCTAssertEqual(r.observedSeconds, 86_400, accuracy: 1e-12) + } +} diff --git a/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt b/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt index 773595d889..b5ccc73dcd 100644 --- a/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt +++ b/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt @@ -880,4 +880,126 @@ object Calories { ): Double { return estimateDayEnergy(hrSamples, profile, hrmax, restingHR).totalKcal } + + // ── MET-stream day energy (Oura 0x50, #2242) ───────────────────────────────────────────────── + + /** + * One metabolic-equivalent sample from a device that streams its OWN activity intensity — the Oura + * ring's `0x50` activity record, one value per 60 s (`OURA_PROTOCOL.md` s6.13). [ts] is the unix + * second the sample's interval STARTS; [secPerSample] is how long it covers (60 on every ring + * observed; carried per sample rather than assumed so a different cadence scales, not skews). + * Twin of Swift `Calories.MetSample`. + */ + data class MetSample(val ts: Long, val met: Double, val secPerSample: Int = 60) + + /** + * Whole-day energy from a MET stream. Same split as [DayEnergyEstimate] (so the persisted + * [totalKcal] keeps its meaning), plus the share of the day the stream actually covered — the caller + * decides whether that is enough to mint a number ([MET_MIN_COVERAGE_FRACTION]), and the UI can + * caption it. [coverageFraction] is over the day window the caller passed, so today's partial day is + * judged against the hours that have elapsed, not against 24 h. Twin of Swift `MetEnergyEstimate`. + */ + data class MetEnergyEstimate( + val restingKcal: Double, + val activeKcal: Double, + val observedSeconds: Double, + val coverageFraction: Double, + ) { + val totalKcal: Double get() = restingKcal + activeKcal + } + + /** + * Below this MET a minute is rest, not activity, and it is also what an active minute is measured + * FROM: Oura's documented method counts "the portion that exceeds 1.5 MET" (Oura support, "How Oura + * Measures Steps & Activity"; Kristiansson et al. 2023 — "AEE starts accumulating at > 1.5 MET"). + */ + const val MET_ACTIVE_THRESHOLD = 1.5 + /** + * kcal per kg per MET-minute — the definition of a MET, not a fit: 1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹ + * (ACSM) at ≈ 5 kcal per litre of O₂ → 0.0175 kcal·kg⁻¹·min⁻¹. Against Oura's own export this exact + * rule reproduces `active_calories` with r = 1.000 and 0.6 kcal/day RMSE over 75 days (and r 0.9999 / + * 3 kcal over 396 pre-2025 days), the wearer's weight being the only input — see OURA_PROTOCOL.md + * s6.13. The constant was IDENTIFIED by that comparison, not fitted to it. + */ + const val KCAL_PER_KG_PER_MET_MINUTE = 0.0175 + /** + * The least of the day the stream must cover before the MET estimate is trusted for the persisted + * number. Below it the day is mostly unknown — a ring off the finger, a drain that never came — and + * a half-day sum would read as a low-activity day rather than a missing one. + */ + const val MET_MIN_COVERAGE_FRACTION = 0.5 + + /** + * Active + resting energy for one calendar day from the device's own MET stream. Twin of Swift + * `Calories.estimateDayEnergyFromMET`; byte-identical by oracle (`MetCaloriesOracleTest`). + * + * `active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 × weightKg × (secPerSample/60)` over the samples + * inside `[dayStart, dayEnd)` — Oura's documented method (the portion above 1.5 MET) at the standard + * MET→kcal definition, applied to the ring's own minute-by-minute series. No fitted constant, and it + * IS Oura's number: against the Oura export it reproduces `active_calories` to 0.6 kcal/day RMSE + * (r = 1.000). `restingKcal` is the same revised Harris–Benedict BMR the HR path uses + * ([restingKcalPerS]), over the covered seconds, so `totalKcal` keeps the HR path's meaning. A + * MET→kcal figure is still an ESTIMATE of true expenditure (free-living MAPE 46–90 % against + * accelerometry in Kristiansson 2023) — label it so. + * + * Coverage: [MetEnergyEstimate.observedSeconds] is the sum of the covered sample intervals, capped + * at the day span. A minute counts ONCE: a duplicate `ts` keeps the LOWER MET (the conservative + * direction), and a sample that starts within HALF a period of the one already counted is that + * minute again and is dropped — the ring re-serves a record under a fresh per-session `0x13` anchor + * a few seconds off the first copy (2026-09-17: 157 of 1,035 stored rows were 3–4 s twins of another + * minute, +8 % on the day), and two rows 3 s apart are one minute, not two. Half a period, not "any + * overlap": the ring's own minute grid steps by a second between records (the per-record anchor + * rounds differently — `:02` then `:01`), so a successor can start 59 s after the minute before it + * and overlap it by one second; judged by overlap, that successor was dropped and the day lost a + * whole minute per phase step (2026-09-18: six on one day, the day's 7.8-MET peak among them, + * −5.5 % on active energy). A twin is 2–5 s off; a successor is 55–61 s off; 30 s tells them apart. + * Missing minutes are UNKNOWN and contribute nothing to either term: never extrapolate a gap to + * activity, and never bank resting energy for time nobody observed. `restingKcal` therefore scales + * with coverage exactly as the HR path's does. + */ + fun estimateDayEnergyFromMet( + samples: List, + profile: UserProfile, + dayStart: Long, + dayEnd: Long, + ): MetEnergyEstimate { + val daySpan = maxOf(0L, dayEnd - dayStart).toDouble() + val inDay = samples.filter { it.ts >= dayStart && it.ts < dayEnd && it.secPerSample > 0 } + if (inDay.isEmpty() || daySpan <= 0) return MetEnergyEstimate(0.0, 0.0, 0.0, 0.0) + + val weightKg = if (profile.weightKg > 0) profile.weightKg else 70.0 + val heightCm = if (profile.heightCm > 0) profile.heightCm else 170.0 + val age = if (profile.age > 0) profile.age else 30.0 + val coeffs = resolveCoeffs(profile.sex) + val restingRate = restingKcalPerS(coeffs, weightKg, heightCm, age) + // kcal per excess-MET-minute for THIS wearer (the MET definition scales with body mass). + val kcalPerMetMin = KCAL_PER_KG_PER_MET_MINUTE * weightKg + + // Ties on ts: ascending MET on a tie keeps the LOWER reading. Twins: a sample that starts less + // than half a period after the previously counted start is the same minute served again (see + // the doc above) — the earlier-starting copy wins and the twin is skipped, so neither coverage + // nor active energy counts a minute twice. Integer test, same expression as the Swift twin and + // as `OuraMetSampleEntity.isTwin`: `Δ × 2 < min(period, lastPeriod)`. + val ordered = inDay.sortedWith(compareBy { it.ts }.thenBy { it.met }) + var covered = 0.0 + var activeKcal = 0.0 + var lastStart = Long.MIN_VALUE + var lastPeriod = 0 + for (s in ordered) { + if (lastPeriod > 0 && (s.ts - lastStart) * 2 < minOf(s.secPerSample, lastPeriod)) continue + lastStart = s.ts + lastPeriod = s.secPerSample + val minutes = s.secPerSample.toDouble() / 60.0 + covered += s.secPerSample.toDouble() + if (s.met < MET_ACTIVE_THRESHOLD) continue + activeKcal += (s.met - MET_ACTIVE_THRESHOLD) * kcalPerMetMin * minutes + } + val observedSeconds = minOf(covered, daySpan) + return MetEnergyEstimate( + restingKcal = restingRate * observedSeconds, + activeKcal = activeKcal, + observedSeconds = observedSeconds, + coverageFraction = observedSeconds / daySpan, + ) + } } diff --git a/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt b/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt new file mode 100644 index 0000000000..40234a6319 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt @@ -0,0 +1,211 @@ +package com.noop.analytics + +import com.noop.analytics.Calories.MetSample +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +/** + * Byte-identity oracle for [Calories.estimateDayEnergyFromMet] against its Swift twin + * `Calories.estimateDayEnergyFromMET` (#2242). + * + * [EXPECTED] is the VERBATIM stdout of the Swift twin compiled standalone (`swiftc -O twin.swift + * main.swift`, the real `Calories` enum extracted from `WorkoutDetector.swift`) over the case spread + * rebuilt below, one `%.6f` line per case and profile (84 lines: 17 shapes + the three 2026-09-17 overlap shapes + the + * 2026-09-18 phase-step shape, × 4 profiles). The CLAUDE.md parity rule: verify by oracle, not + * by reading the two implementations side by side. Regenerate the literal from Swift whenever the + * estimator changes on either side — never hand-edit a number here. + * + * `day0` is 2026-08-15 00:00 Europe/Paris (1755208800 UTC); `day1` is the next midnight. + */ +class MetCaloriesOracleTest { + + private val day0 = 1_755_208_800L + private val day1 = day0 + 86_400L + + private val profiles: List> = listOf( + "default" to UserProfile(), + "male-82-181-45" to UserProfile(weightKg = 82.0, heightCm = 181.0, age = 45.0, sex = "male"), + "female-60-165-30" to UserProfile(weightKg = 60.0, heightCm = 165.0, age = 30.0, sex = "female"), + "zeroed-profile" to UserProfile(weightKg = 0.0, heightCm = 0.0, age = 0.0, sex = "male"), + ) + + private fun line(name: String, samples: List, p: UserProfile, s: Long = day0, e: Long = day1): String { + val r = Calories.estimateDayEnergyFromMet(samples, p, s, e) + return String.format( + Locale.ROOT, "%s|%.6f|%.6f|%.6f|%.6f|%.6f", + name, r.restingKcal, r.activeKcal, r.observedSeconds, r.coverageFraction, r.totalKcal, + ) + } + + private fun fullDay(met: Double): MutableList = + (0 until 1440).map { MetSample(day0 + it * 60L, met) }.toMutableList() + + private fun spread(): List { + val out = mutableListOf() + for ((pn, p) in profiles) { + out += line("$pn/empty", emptyList(), p) + out += line("$pn/rest-0.9-all-day", fullDay(0.9), p) + // One 30-min 4.0-MET bout on the rest floor, rest of the day at 0.9. + val bout = fullDay(0.9) + for (i in 600 until 630) bout[i] = MetSample(day0 + i * 60L, 4.0) + out += line("$pn/one-30min-4.0-bout", bout, p) + // Threshold edge: 1.4 is rest, 1.5 is active (one minute each), rest of day absent. + out += line("$pn/threshold-1.4-1.5", listOf(MetSample(day0, 1.4), MetSample(day0 + 60, 1.5)), p) + // Two-slope decoder boundary: 12.7 (byte 0x7f) then 12.8 (byte 0x80). + out += line("$pn/two-slope-12.7-12.8", listOf(MetSample(day0, 12.7), MetSample(day0 + 60, 12.8)), p) + // 60 % coverage: the first 864 minutes only, a 2.5-MET walk from minute 400..460. + var sixty: List = (0 until 864).map { MetSample(day0 + it * 60L, if (it >= 400 && it < 460) 2.5 else 1.0) } + out += line("$pn/coverage-60pct", sixty, p) + // 40 % coverage — below the gate; the estimator still reports it, the caller decides. + sixty = sixty.take(576) + out += line("$pn/coverage-40pct", sixty, p) + // secPerSample 120: 720 samples of 2 min at 1.0, one 3.0-MET sample. + val twoMin = (0 until 720).map { MetSample(day0 + it * 120L, 1.0, 120) }.toMutableList() + twoMin[300] = MetSample(day0 + 300 * 120L, 3.0, 120) + out += line("$pn/secPerSample-120", twoMin, p) + // Duplicate ts: the lower MET wins, and the minute is covered once. + out += line("$pn/dup-ts-lower-wins", listOf(MetSample(day0, 5.0), MetSample(day0, 2.0), MetSample(day0 + 60, 0.9)), p) + // Out-of-window samples are ignored; dayEnd exclusive. + out += line( + "$pn/window-edges", + listOf(MetSample(day0 - 60, 9.0), MetSample(day0, 2.0), MetSample(day1 - 60, 2.0), MetSample(day1, 9.0)), p, + ) + // Today, partial: dayEnd = midnight + 6 h, 6 h of 1.2 MET plus 20 min at 6.0 → coverage 1.0 of the elapsed window. + var partial: List = (0 until 360).map { MetSample(day0 + it * 60L, if (it >= 200 && it < 220) 6.0 else 1.2) } + out += line("$pn/today-partial-6h", partial, p, day0, day0 + 6 * 3600L) + partial = partial.take(180) + out += line("$pn/today-partial-6h-half-covered", partial, p, day0, day0 + 6 * 3600L) + // Coverage over-run: 1500 one-minute samples inside a 24 h day clamp to the span. + out += line("$pn/overrun-clamps", (0 until 1500).map { MetSample(day0 + it * 60L, 1.0) }, p, day0, day0 + 1500 * 60L - 3600L) + // DST day: 23 h span (Europe/Paris 2026-03-29), full coverage. + out += line("$pn/dst-23h", (0 until 1380).map { MetSample(day0 + it * 60L, if (it % 7 == 0) 2.2 else 0.95) }, p, day0, day0 + 82_800L) + // Zero and negative secPerSample rows are dropped; zero-length day. + out += line( + "$pn/bad-epoch-dropped", + listOf(MetSample(day0, 4.0, 0), MetSample(day0 + 60, 4.0, -60), MetSample(day0 + 120, 4.0)), p, + ) + out += line("$pn/zero-length-day", fullDay(2.0), p, day0, day0) + // A realistic day: rest 0.9 at night, 1.1 daytime, three walks 3.4 and a 45-min 7.5 run. + val real = fullDay(0.9) + for (i in 420 until 1380) real[i] = MetSample(day0 + i * 60L, 1.1) + for ((a, b) in listOf(480 to 505, 760 to 790, 1100 to 1140)) for (i in a until b) real[i] = MetSample(day0 + i * 60L, 3.4) + for (i in 1080 until 1125) real[i] = MetSample(day0 + i * 60L, 7.5) + real.subList(300, 360).clear() // a one-hour ring-side hole + out += line("$pn/realistic-day", real, p) + // 2026-09-17: a re-served minute lands 3–4 s off its first copy under a fresh session anchor; the twin is dropped. + out += line( + "$pn/overlap-3s-twins", + listOf(MetSample(day0, 4.0), MetSample(day0 + 3, 4.0), MetSample(day0 + 60, 0.9), MetSample(day0 + 64, 9.0), MetSample(day0 + 120, 4.0)), p, + ) + // 2026-09-18: the ring's grid steps back a second between records, so a 59-s successor is the NEXT minute + // (kept); a 29-s-late copy is a twin (dropped). Half a period decides. + out += line( + "$pn/phase-step-59s-successor", + listOf(MetSample(day0, 1.0), MetSample(day0 + 59, 5.0), MetSample(day0 + 120, 1.0), MetSample(day0 + 149, 9.0)), p, + ) + // A whole day on the stepping grid (one second lost every 30 minutes): every minute counted. + out += line("$pn/phase-step-day", (0 until 1440).map { MetSample(day0 + it * 60L - it / 30, if (it % 90 == 0) 3.0 else 1.0) }, p) + // The phone's shape: a full day where every 7th minute also arrived 4 s late from a second session. + val twins = fullDay(1.1) + for (i in 0 until 1440 step 7) twins += MetSample(day0 + i * 60L + 4, 3.0) + out += line("$pn/overlap-phone-day", twins, p) + } + return out + } + + @Test + fun matchesSwiftTwinByteForByte() { + val actual = spread() + assertEquals(EXPECTED.size, actual.size) + for (i in EXPECTED.indices) assertEquals("case ${i + 1}", EXPECTED[i], actual[i]) + } + + /** The Swift twin's stdout, verbatim. */ + private val EXPECTED = listOf( + "default/empty|0.000000|0.000000|0.000000|0.000000|0.000000", + "default/rest-0.9-all-day|1581.657500|0.000000|86400.000000|1.000000|1581.657500", + "default/one-30min-4.0-bout|1581.657500|91.875000|86400.000000|1.000000|1673.532500", + "default/threshold-1.4-1.5|2.196747|0.000000|120.000000|0.001389|2.196747", + "default/two-slope-12.7-12.8|2.196747|27.562500|120.000000|0.001389|29.759247", + "default/coverage-60pct|948.994500|73.500000|51840.000000|0.600000|1022.494500", + "default/coverage-40pct|632.663000|73.500000|34560.000000|0.400000|706.163000", + "default/secPerSample-120|1581.657500|3.675000|86400.000000|1.000000|1585.332500", + "default/dup-ts-lower-wins|2.196747|0.612500|120.000000|0.001389|2.809247", + "default/window-edges|2.196747|1.225000|120.000000|0.001389|3.421747", + "default/today-partial-6h|395.414375|110.250000|21600.000000|1.000000|505.664375", + "default/today-partial-6h-half-covered|197.707188|0.000000|10800.000000|0.500000|197.707188", + "default/overrun-clamps|1581.657500|0.000000|86400.000000|1.000000|1581.657500", + "default/dst-23h|1515.755104|169.785000|82800.000000|1.000000|1685.540104", + "default/bad-epoch-dropped|1.098373|3.062500|60.000000|0.000694|4.160873", + "default/zero-length-day|0.000000|0.000000|0.000000|0.000000|0.000000", + "default/realistic-day|1515.755104|493.675000|82800.000000|0.958333|2009.430104", + "default/overlap-3s-twins|3.295120|6.125000|180.000000|0.002083|9.420120", + "default/phase-step-59s-successor|3.295120|4.287500|180.000000|0.002083|7.582620", + "default/phase-step-day|1581.657500|29.400000|86400.000000|1.000000|1611.057500", + "default/overlap-phone-day|1581.657500|0.000000|86400.000000|1.000000|1581.657500", + "male-82-181-45/empty|0.000000|0.000000|0.000000|0.000000|0.000000", + "male-82-181-45/rest-0.9-all-day|1800.070000|0.000000|86400.000000|1.000000|1800.070000", + "male-82-181-45/one-30min-4.0-bout|1800.070000|107.625000|86400.000000|1.000000|1907.695000", + "male-82-181-45/threshold-1.4-1.5|2.500097|0.000000|120.000000|0.001389|2.500097", + "male-82-181-45/two-slope-12.7-12.8|2.500097|32.287500|120.000000|0.001389|34.787597", + "male-82-181-45/coverage-60pct|1080.042000|86.100000|51840.000000|0.600000|1166.142000", + "male-82-181-45/coverage-40pct|720.028000|86.100000|34560.000000|0.400000|806.128000", + "male-82-181-45/secPerSample-120|1800.070000|4.305000|86400.000000|1.000000|1804.375000", + "male-82-181-45/dup-ts-lower-wins|2.500097|0.717500|120.000000|0.001389|3.217597", + "male-82-181-45/window-edges|2.500097|1.435000|120.000000|0.001389|3.935097", + "male-82-181-45/today-partial-6h|450.017500|129.150000|21600.000000|1.000000|579.167500", + "male-82-181-45/today-partial-6h-half-covered|225.008750|0.000000|10800.000000|0.500000|225.008750", + "male-82-181-45/overrun-clamps|1800.070000|0.000000|86400.000000|1.000000|1800.070000", + "male-82-181-45/dst-23h|1725.067083|198.891000|82800.000000|1.000000|1923.958083", + "male-82-181-45/bad-epoch-dropped|1.250049|3.587500|60.000000|0.000694|4.837549", + "male-82-181-45/zero-length-day|0.000000|0.000000|0.000000|0.000000|0.000000", + "male-82-181-45/realistic-day|1725.067083|578.305000|82800.000000|0.958333|2303.372083", + "male-82-181-45/overlap-3s-twins|3.750146|7.175000|180.000000|0.002083|10.925146", + "male-82-181-45/phase-step-59s-successor|3.750146|5.022500|180.000000|0.002083|8.772646", + "male-82-181-45/phase-step-day|1800.070000|34.440000|86400.000000|1.000000|1834.510000", + "male-82-181-45/overlap-phone-day|1800.070000|0.000000|86400.000000|1.000000|1800.070000", + "female-60-165-30/empty|0.000000|0.000000|0.000000|0.000000|0.000000", + "female-60-165-30/rest-0.9-all-day|1383.683000|0.000000|86400.000000|1.000000|1383.683000", + "female-60-165-30/one-30min-4.0-bout|1383.683000|78.750000|86400.000000|1.000000|1462.433000", + "female-60-165-30/threshold-1.4-1.5|1.921782|0.000000|120.000000|0.001389|1.921782", + "female-60-165-30/two-slope-12.7-12.8|1.921782|23.625000|120.000000|0.001389|25.546782", + "female-60-165-30/coverage-60pct|830.209800|63.000000|51840.000000|0.600000|893.209800", + "female-60-165-30/coverage-40pct|553.473200|63.000000|34560.000000|0.400000|616.473200", + "female-60-165-30/secPerSample-120|1383.683000|3.150000|86400.000000|1.000000|1386.833000", + "female-60-165-30/dup-ts-lower-wins|1.921782|0.525000|120.000000|0.001389|2.446782", + "female-60-165-30/window-edges|1.921782|1.050000|120.000000|0.001389|2.971782", + "female-60-165-30/today-partial-6h|345.920750|94.500000|21600.000000|1.000000|440.420750", + "female-60-165-30/today-partial-6h-half-covered|172.960375|0.000000|10800.000000|0.500000|172.960375", + "female-60-165-30/overrun-clamps|1383.683000|0.000000|86400.000000|1.000000|1383.683000", + "female-60-165-30/dst-23h|1326.029542|145.530000|82800.000000|1.000000|1471.559542", + "female-60-165-30/bad-epoch-dropped|0.960891|2.625000|60.000000|0.000694|3.585891", + "female-60-165-30/zero-length-day|0.000000|0.000000|0.000000|0.000000|0.000000", + "female-60-165-30/realistic-day|1326.029542|423.150000|82800.000000|0.958333|1749.179542", + "female-60-165-30/overlap-3s-twins|2.882673|5.250000|180.000000|0.002083|8.132673", + "female-60-165-30/phase-step-59s-successor|2.882673|3.675000|180.000000|0.002083|6.557673", + "female-60-165-30/phase-step-day|1383.683000|25.200000|86400.000000|1.000000|1408.883000", + "female-60-165-30/overlap-phone-day|1383.683000|0.000000|86400.000000|1.000000|1383.683000", + "zeroed-profile/empty|0.000000|0.000000|0.000000|0.000000|0.000000", + "zeroed-profile/rest-0.9-all-day|1671.672000|0.000000|86400.000000|1.000000|1671.672000", + "zeroed-profile/one-30min-4.0-bout|1671.672000|91.875000|86400.000000|1.000000|1763.547000", + "zeroed-profile/threshold-1.4-1.5|2.321767|0.000000|120.000000|0.001389|2.321767", + "zeroed-profile/two-slope-12.7-12.8|2.321767|27.562500|120.000000|0.001389|29.884267", + "zeroed-profile/coverage-60pct|1003.003200|73.500000|51840.000000|0.600000|1076.503200", + "zeroed-profile/coverage-40pct|668.668800|73.500000|34560.000000|0.400000|742.168800", + "zeroed-profile/secPerSample-120|1671.672000|3.675000|86400.000000|1.000000|1675.347000", + "zeroed-profile/dup-ts-lower-wins|2.321767|0.612500|120.000000|0.001389|2.934267", + "zeroed-profile/window-edges|2.321767|1.225000|120.000000|0.001389|3.546767", + "zeroed-profile/today-partial-6h|417.918000|110.250000|21600.000000|1.000000|528.168000", + "zeroed-profile/today-partial-6h-half-covered|208.959000|0.000000|10800.000000|0.500000|208.959000", + "zeroed-profile/overrun-clamps|1671.672000|0.000000|86400.000000|1.000000|1671.672000", + "zeroed-profile/dst-23h|1602.019000|169.785000|82800.000000|1.000000|1771.804000", + "zeroed-profile/bad-epoch-dropped|1.160883|3.062500|60.000000|0.000694|4.223383", + "zeroed-profile/zero-length-day|0.000000|0.000000|0.000000|0.000000|0.000000", + "zeroed-profile/realistic-day|1602.019000|493.675000|82800.000000|0.958333|2095.694000", + "zeroed-profile/overlap-3s-twins|3.482650|6.125000|180.000000|0.002083|9.607650", + "zeroed-profile/phase-step-59s-successor|3.482650|4.287500|180.000000|0.002083|7.770150", + "zeroed-profile/phase-step-day|1671.672000|29.400000|86400.000000|1.000000|1701.072000", + "zeroed-profile/overlap-phone-day|1671.672000|0.000000|86400.000000|1.000000|1671.672000", + ) +} From d02ecb872ab5f2776cad75163063df8b1932f5c0 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 10:55:22 +0200 Subject: [PATCH 2/7] =?UTF-8?q?feat(store):=20persist=20the=20Oura=20ring'?= =?UTF-8?q?s=20per-minute=20MET=20series=20=E2=80=94=20ouraMetSample,=20bo?= =?UTF-8?q?th=20platforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Insert drops a re-served twin: the (deviceId, ts) key alone does not catch a record served again under another session anchor (it lands a few seconds off), so the insert reads the stored rows around the batch and drops any incoming sample within half a period of a stored one or of one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). The pure rule is OuraMetSample.droppingTwins / OuraMetSampleEntity.droppingTwins, same integer test as the estimator, testable without a database. A 59-s phase-step successor is kept. Swift: OuraMetStore (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, incl. the twin rule); SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. Refs #2242 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz --- .../Sources/WhoopStore/Database.swift | 26 + .../WhoopStore/DeviceRegistryStore.swift | 3 + .../Sources/WhoopStore/OuraMetStore.swift | 103 + .../WhoopStoreTests/OuraMetStoreTests.swift | 126 + .../Resources/schema_oracle.json | 45 +- .../main/java/com/noop/data/DeviceRegistry.kt | 3 +- .../java/com/noop/data/DeviceRegistryDao.kt | 3 + .../src/main/java/com/noop/data/Entities.kt | 55 + .../src/main/java/com/noop/data/WhoopDao.kt | 15 + .../main/java/com/noop/data/WhoopDatabase.kt | 22 +- .../java/com/noop/data/WhoopRepository.kt | 27 + .../analytics/RegistryDayOwnerSourceTest.kt | 1 + .../noop/ble/SourceCoordinatorAdoptionTest.kt | 1 + .../java/com/noop/data/DeviceRegistryTest.kt | 3 + .../noop/data/OuraMetSampleMigrationTest.kt | 109 + .../com.noop.data.WhoopDatabase/41.json | 2210 +++++++++++++++++ .../app/src/test/resources/schema_oracle.json | 45 +- 17 files changed, 2789 insertions(+), 8 deletions(-) create mode 100644 Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift create mode 100644 Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift create mode 100644 android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt create mode 100644 android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/41.json diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index b03a34e0c2..53efc1743c 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -1091,6 +1091,32 @@ extension WhoopStore { try db.create(index: "idx_liftSet_session_ord", on: "liftSet", columns: ["sessionId", "ord"], options: [.ifNotExists]) } + // v47-oura-met-sample (#2242): the Oura ring's OWN per-minute activity intensity (0x50 MET), + // one row per sample. Until now the series existed only as the diagnostic JSONL sidecar + // (`OuraActivityDump`) — no table on either platform — so nothing scored could read it. The + // MET-derived active-calorie estimate (`Calories.estimateDayEnergyFromMET`) needs the day's + // samples back from the store on every analyze pass, including re-scores of past days after a + // wake drain lands a whole day at once. + // + // • `ts` is the unix second the sample's interval STARTS (anchored ring time of sample i of its + // record); `epochS` is how long it covers — 60 on every ring observed, carried per row so a + // different cadence scales the estimate rather than skewing it. + // • `met` is the decoded value (byte × 0.1 below 0x80, 12.8 + (byte − 128) × 0.2 above — see + // OURA_PROTOCOL.md §6.13); `state` is the record's leading state byte, stored verbatim. + // • (deviceId, ts) primary key: a re-served record is a no-op insert, like every stream table. + // • The writer is gated behind the Experimental toggle, so a user who never turns it on keeps a + // DB byte-identical to today's — the table exists, empty. + // • deviceId-keyed like every stream, hence on `DeviceRegistryStore.deviceScopedTables`. + migrator.registerMigration("v47-oura-met-sample") { db in + try db.create(table: "ouraMetSample", options: [.ifNotExists]) { t in + t.column("deviceId", .text).notNull() + t.column("ts", .integer).notNull() + t.column("met", .double).notNull() + t.column("state", .integer).notNull() + t.column("epochS", .integer).notNull() + t.primaryKey(["deviceId", "ts"]) + } + } return migrator } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift index 63996012c3..6593666f9e 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift @@ -167,6 +167,9 @@ public struct DeviceRegistryStore: Sendable { // privacy defect this list exists to close, and one the deviceId-column guard test could not // catch for a child table keyed only by its parent. "liftExercise", "liftProgram", "liftProgramItem", "liftSession", "liftSet", + // v47-oura-met-sample (#2242): the ring's per-minute MET series is deviceId-keyed like every + // other stream, so forgetting the ring must clear it too. + "ouraMetSample", ] /// Permanently delete every recorded sample/derived row belonging to one device, across all diff --git a/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift new file mode 100644 index 0000000000..b51cf72ea6 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift @@ -0,0 +1,103 @@ +import Foundation +import GRDB + +// MARK: - v47 store: the Oura ring's per-minute MET series (0x50, #2242) +// Same shape as the per-second stream tables: an idempotent `ON CONFLICT DO NOTHING` insert keyed by +// (deviceId, ts) and a bounded range read, all GRDB work via syncWrite/syncRead. The rows feed +// `Calories.estimateDayEnergyFromMET` when the Experimental MET-calories toggle is on; the writer +// (`OuraLiveSource`) only inserts while that toggle is on, so an OFF install never grows this table. + +/// One MET sample as stored. `ts` is the unix second the sample's interval starts; `epochS` how many +/// seconds it covers (60 on every ring observed); `state` the 0x50 record's leading state byte, verbatim. +public struct OuraMetSample: Equatable, Sendable { + public let ts: Int + public let met: Double + public let state: Int + public let epochS: Int + public init(ts: Int, met: Double, state: Int, epochS: Int = 60) { + self.ts = ts; self.met = met; self.state = state; self.epochS = epochS + } + + /// Whether two samples are the SAME minute served twice: their starts are less than half the shorter + /// period apart (`|Δ| × 2 < min(epochS)` — integer, the same expression on both platforms and in + /// `Calories.estimateDayEnergyFromMET`). A re-served record lands 2–5 s off its first copy (the + /// per-session `0x13` anchor); the NEXT minute starts 55–61 s after — the ring's own grid steps by a + /// second between records — so "any overlap" is the wrong test: it read a 59-s successor as a twin + /// and dropped a real minute at the store about once an hour (2026-09-18: 8 holes on the first day + /// after the overlap rule, one per phase step). Half a period tells the two apart. + public static func isTwin(_ a: OuraMetSample, _ b: OuraMetSample) -> Bool { + abs(a.ts - b.ts) * 2 < min(a.epochS, b.epochS) + } + + /// The samples of `incoming` that are a twin (`isTwin`) of neither a row of `existing` nor an + /// earlier-starting sample of `incoming` itself. Pure and order-independent (incoming is sorted by + /// ts, lower MET first on a tie, before the walk) so the insert's dedupe rule is testable without a + /// database. Twin: Kotlin `OuraMetSampleEntity.droppingTwins`. + public static func droppingTwins(_ incoming: [OuraMetSample], existing: [OuraMetSample]) -> [OuraMetSample] { + var kept = existing + var out: [OuraMetSample] = [] + for s in incoming.sorted(by: { $0.ts != $1.ts ? $0.ts < $1.ts : $0.met < $1.met }) { + if kept.contains(where: { isTwin(s, $0) }) { continue } + kept.append(s) + out.append(s) + } + return out + } +} + +extension WhoopStore { + + /// Insert MET samples for a device. Idempotent by MINUTE, not only by (deviceId, ts): a record the ring + /// re-serves across reconnects lands once even when the re-serve carries a different second. + /// + /// Why the key alone is not enough (2026-09-17): `ts` is anchored ring time, and the `0x13` anchor is + /// taken per session, so the same ring record served under two sessions lands 2–5 s apart — the + /// (deviceId, ts) key sees two rows. On the first hardware day 157 of 1,035 rows were such twins + /// (an Oura-app replay re-served the day from the app's older cursor) and the day read +8 %. So + /// an incoming sample that is a twin (`OuraMetSample.isTwin`: starts within half a period) of a + /// stored one — or of one accepted earlier in the same batch — is dropped; the first copy stays. + /// Returns rows actually inserted. + @discardableResult + public func insertOuraMetSamples(_ samples: [OuraMetSample], deviceId: String) async throws -> Int { + if samples.isEmpty { return 0 } + return try syncWrite { db in + let lo = samples.map(\.ts).min()! - samples.map(\.epochS).max()! + let hi = samples.map { $0.ts + $0.epochS }.max()! + let existing = try Row.fetchAll(db, sql: """ + SELECT ts, epochS FROM ouraMetSample WHERE deviceId = ? AND ts >= ? AND ts < ? + """, arguments: [deviceId, lo, hi]) + .map { OuraMetSample(ts: $0["ts"], met: 0, state: 0, epochS: $0["epochS"]) } + let accepted = OuraMetSample.droppingTwins(samples, existing: existing) + let stmt = try db.cachedStatement(sql: """ + INSERT INTO ouraMetSample (deviceId, ts, met, state, epochS) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(deviceId, ts) DO NOTHING + """) + var n = 0 + for s in accepted { + try stmt.execute(arguments: [deviceId, s.ts, s.met, s.state, s.epochS]) + n += db.changesCount + } + return n + } + } + + /// MET samples for a device in `[from, to]` (inclusive, like every stream read), ts ascending. + public func ouraMetSamples(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [OuraMetSample] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT ts, met, state, epochS FROM ouraMetSample + WHERE deviceId = ? AND ts >= ? AND ts <= ? + ORDER BY ts ASC LIMIT ? + """, arguments: [deviceId, from, to, limit]) + .map { OuraMetSample(ts: $0["ts"], met: $0["met"], state: $0["state"], epochS: $0["epochS"]) } + } + } + + /// Row count for a device (diagnostics / tests). + public func ouraMetSampleCount(deviceId: String) async throws -> Int { + try syncRead { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ouraMetSample WHERE deviceId = ?", + arguments: [deviceId]) ?? 0 + } + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift new file mode 100644 index 0000000000..12dca895a2 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift @@ -0,0 +1,126 @@ +import XCTest +import GRDB +@testable import WhoopStore + +/// v47 migration (#2242): the Oura ring's OWN per-minute MET series (0x50), which until now lived only in +/// the diagnostic JSONL sidecar. Proves the table exists, keys by (deviceId, ts), round-trips + dedupes +/// like every stream table, and is cleared by "delete all of this device's data". +final class OuraMetStoreTests: XCTestCase { + func testV47CreatesOuraMetSampleTable() async throws { + let store = try await WhoopStore.inMemory() + let tables = try await store.tableNames() + XCTAssertTrue(tables.contains("ouraMetSample")) + let pk = try await store.primaryKeyColumns("ouraMetSample") + XCTAssertEqual(pk, ["deviceId", "ts"]) + let cols = try await store.columnNamesForTest(table: "ouraMetSample") + XCTAssertEqual(cols, ["deviceId", "ts", "met", "state", "epochS"]) + } + + /// 2026-09-17, first hardware day: `ts` is anchored ring time and the `0x13` anchor is per session, so + /// the same ring record re-served under a second session (an Oura-app replay from the app's older + /// cursor) landed 3–4 s off its first copy and the (deviceId, ts) key kept both — 157 twins in 1,035 + /// rows, +8 % on the day. The insert now judges by start proximity (half a period): the first copy stays. + func testReserveUnderAnotherSessionAnchorIsTheSameMinute() async throws { + let store = try await WhoopStore.inMemory() + let t = 1_755_208_800 + let first = try await store.insertOuraMetSamples( + [OuraMetSample(ts: t, met: 1.1, state: 2), OuraMetSample(ts: t + 60, met: 3.0, state: 2)], + deviceId: "oura-A") + XCTAssertEqual(first, 2) + // The same two minutes served again 4 s later, plus a genuinely new third minute. + let replay = try await store.insertOuraMetSamples( + [OuraMetSample(ts: t + 4, met: 1.1, state: 2), OuraMetSample(ts: t + 64, met: 3.0, state: 2), + OuraMetSample(ts: t + 124, met: 0.9, state: 2)], + deviceId: "oura-A") + XCTAssertEqual(replay, 1, "only the new minute lands; the two 4-s twins are the stored minutes again") + let read = try await store.ouraMetSamples(deviceId: "oura-A", from: t, to: t + 200, limit: 10) + XCTAssertEqual(read.map(\.ts), [t, t + 60, t + 124]) + // Twins INSIDE one batch collapse the same way (earlier start wins, lower MET on an exact tie). + let batch = try await store.insertOuraMetSamples( + [OuraMetSample(ts: t + 300, met: 5.0, state: 2), OuraMetSample(ts: t + 303, met: 2.0, state: 2), + OuraMetSample(ts: t + 300, met: 4.0, state: 2)], + deviceId: "oura-A") + XCTAssertEqual(batch, 1) + let kept = try await store.ouraMetSamples(deviceId: "oura-A", from: t + 300, to: t + 400, limit: 10) + XCTAssertEqual(kept, [OuraMetSample(ts: t + 300, met: 4.0, state: 2)]) + // Another device is its own namespace. + let other = try await store.insertOuraMetSamples([OuraMetSample(ts: t + 4, met: 1.1, state: 2)], + deviceId: "oura-B") + XCTAssertEqual(other, 1) + } + + /// The pure rule behind the insert (twin: Kotlin `OuraMetSampleEntity.droppingTwins`). A twin starts + /// less than half a period after a kept start; a successor 59 s on — the ring's grid stepping back a + /// second (2026-09-18) — is the next minute and stays. + func testDroppingTwinsIsPureAndOrderIndependent() { + let t = 1_000 + let existing = [OuraMetSample(ts: t, met: 1.0, state: 0), OuraMetSample(ts: t + 120, met: 1.0, state: 0)] + let incoming = [OuraMetSample(ts: t + 4, met: 9.0, state: 0), // 4-s twin of the stored t + OuraMetSample(ts: t + 60, met: 2.0, state: 0), // 1-s twin of the accepted t+59 + OuraMetSample(ts: t + 59, met: 5.0, state: 0), // 59 s after t: the next minute, kept + OuraMetSample(ts: t + 149, met: 9.0, state: 0), // 29 s after the stored t+120: twin + OuraMetSample(ts: t + 150, met: 2.0, state: 0), // 30 s after: half a period, kept + OuraMetSample(ts: t + 240, met: 2.0, state: 0), // free minute + OuraMetSample(ts: t + 241, met: 2.0, state: 0)] // 1-s twin of the accepted t+240 + let out = OuraMetSample.droppingTwins(incoming, existing: existing) + XCTAssertEqual(out.map(\.ts), [t + 59, t + 150, t + 240]) + XCTAssertEqual(OuraMetSample.droppingTwins(incoming.reversed(), existing: existing).map(\.ts), [t + 59, t + 150, t + 240]) + XCTAssertEqual(OuraMetSample.droppingTwins([], existing: existing), []) + // Mixed periods: half the SHORTER one decides. + XCTAssertTrue(OuraMetSample.isTwin(OuraMetSample(ts: t, met: 1, state: 0, epochS: 120), OuraMetSample(ts: t + 29, met: 1, state: 0))) + XCTAssertFalse(OuraMetSample.isTwin(OuraMetSample(ts: t, met: 1, state: 0, epochS: 120), OuraMetSample(ts: t + 30, met: 1, state: 0))) + } + + /// The 2026-09-18 hardware shape end to end: a day whose minute grid steps back one second every 30 + /// minutes (`:02` → `:01`) inserts EVERY minute — the phase-step successor is not a twin. + func testPhaseStepSuccessorIsStoredNotDropped() async throws { + let store = try await WhoopStore.inMemory() + let t = 1_755_208_800 + var rows: [OuraMetSample] = [] + for i in 0..<120 { rows.append(OuraMetSample(ts: t + i * 60 - i / 30, met: 1.0, state: 2)) } + let n = try await store.insertOuraMetSamples(rows, deviceId: "oura-A") + XCTAssertEqual(n, 120, "each phase step is a new minute, none of the 120 is lost") + } + + func testInsertRoundTripAndDedup() async throws { + let store = try await WhoopStore.inMemory() + let rows = [ + OuraMetSample(ts: 1_755_208_800, met: 0.9, state: 2), + OuraMetSample(ts: 1_755_208_860, met: 1.5, state: 2), + OuraMetSample(ts: 1_755_208_920, met: 12.8, state: 3), // the 0x80 slope + ] + let first = try await store.insertOuraMetSamples(rows, deviceId: "oura-A") + XCTAssertEqual(first, 3) + // A re-served record (same deviceId, ts) is a no-op — the ring re-serves under churn. + let again = try await store.insertOuraMetSamples(rows, deviceId: "oura-A") + XCTAssertEqual(again, 0) + let none = try await store.insertOuraMetSamples([], deviceId: "oura-A") + XCTAssertEqual(none, 0) + let count = try await store.ouraMetSampleCount(deviceId: "oura-A") + XCTAssertEqual(count, 3) + + let read = try await store.ouraMetSamples(deviceId: "oura-A", from: 1_755_208_800, + to: 1_755_208_920, limit: 10) + XCTAssertEqual(read, rows, "ts ascending, value/state/epochS verbatim") + // Inclusive bounds and a limit, like the other stream reads. + let bounded = try await store.ouraMetSamples(deviceId: "oura-A", from: 1_755_208_860, + to: 1_755_208_919, limit: 10) + XCTAssertEqual(bounded, [rows[1]]) + let limited = try await store.ouraMetSamples(deviceId: "oura-A", from: 0, to: .max, limit: 2) + XCTAssertEqual(limited.count, 2) + // Another device's rows are invisible. + let other = try await store.ouraMetSamples(deviceId: "oura-B", from: 0, to: .max, limit: 10) + XCTAssertTrue(other.isEmpty) + } + + func testDeleteAllDataClearsTheRingsMetRows() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.insertOuraMetSamples([OuraMetSample(ts: 1, met: 1.0, state: 0)], deviceId: "oura-A") + _ = try await store.insertOuraMetSamples([OuraMetSample(ts: 1, met: 1.0, state: 0)], deviceId: "oura-B") + try await store.deleteAllData(deviceId: "oura-A") + let a = try await store.ouraMetSampleCount(deviceId: "oura-A") + let b = try await store.ouraMetSampleCount(deviceId: "oura-B") + XCTAssertEqual(a, 0) + XCTAssertEqual(b, 1) + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index 4ab143d2b4..a200641e2a 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 40, + "roomVersion": 41, "grdbMigrations": [ "v1", "v2", @@ -47,7 +47,8 @@ "v43-coach-messages", "v44-ppg-waveform-base-code", "v45-rr-source-index", - "v46-lift-log" + "v46-lift-log", + "v47-oura-met-sample" ], "divergenceReasons": { "android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -2310,6 +2311,46 @@ "sport" ], "indices": [] + }, + "ouraMetSample": { + "platform": "both", + "columns": [ + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "ts", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "met", + "affinity": "REAL", + "notNull": true, + "default": null + }, + { + "name": "state", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "epochS", + "affinity": "INTEGER", + "notNull": true, + "default": null + } + ], + "primaryKey": [ + "deviceId", + "ts" + ], + "indices": [] } } } diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt index 455cf39430..2d3e6ee2d6 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt @@ -197,7 +197,7 @@ class DeviceRegistry( * The table set is EVERY device-keyed table of [WhoopDatabase]: hrSample, rrInterval, spo2Sample, * skinTempSample, respSample, gravitySample, stepSample, ppgHrSample, ppgWaveformSample, event, battery, dailyMetric, * sleepSession, journal, workout, appleDaily, metricSeries, dayOwnership, sleepStateSample, labMarker, - * liveSession, dismissedWorkout, dismissedSleep. DeviceRegistryTest.deleteDeviceDataCallsEveryDaoDeleteMethod + * liveSession, dismissedWorkout, dismissedSleep, the five lift* tables, ouraMetSample. DeviceRegistryTest.deleteDeviceDataCallsEveryDaoDeleteMethod * guards completeness (fails if a delete*For DAO method isn't wired in here). */ suspend fun deleteDeviceData(id: String) { @@ -233,6 +233,7 @@ class DeviceRegistry( dao.deleteLiftProgramItemsFor(id) dao.deleteLiftSessionsFor(id) dao.deleteLiftSetsFor(id) + dao.deleteOuraMetFor(id) } } diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt index 84d272e850..f847e70fc2 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt @@ -122,6 +122,9 @@ interface DeviceRegistryDao { @Query("DELETE FROM liftProgramItem WHERE deviceId = :deviceId") suspend fun deleteLiftProgramItemsFor(deviceId: String) @Query("DELETE FROM liftSession WHERE deviceId = :deviceId") suspend fun deleteLiftSessionsFor(deviceId: String) @Query("DELETE FROM liftSet WHERE deviceId = :deviceId") suspend fun deleteLiftSetsFor(deviceId: String) + // v47-oura-met-sample (#2242): the ring's per-minute MET series is deviceId-keyed like every other + // stream, so forgetting the ring must clear it too. + @Query("DELETE FROM ouraMetSample WHERE deviceId = :deviceId") suspend fun deleteOuraMetFor(deviceId: String) /** * Delete individual sets by id, as editing a finished session does when a set is removed. The diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt index 50319c32d4..9befa5e5b3 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -272,6 +272,61 @@ data class SleepStateSampleEntity( val rawByte: Int? = null, ) +/** + * The Oura ring's OWN per-minute activity intensity (0x50 MET), one row per sample — Swift WhoopStore + * `ouraMetSample` (v47, #2242). Until this table the series existed only as the diagnostic JSONL sidecar + * ([com.noop.ble.OuraActivityDump]), so nothing scored could read it; the MET-derived active-calorie + * estimate ([com.noop.analytics.Calories.estimateDayEnergyFromMet]) reads the day's rows back from here on + * every analyze pass. [ts] is the unix second the sample's interval STARTS; [epochS] how long it covers (60 + * on every ring observed, carried per row so a different cadence scales the estimate rather than skewing + * it); [met] the decoded value (OURA_PROTOCOL.md s6.13); [state] the record's leading state byte, verbatim. + * Column order IS the Swift column order. The writer is gated behind the Experimental toggle, so an OFF + * install keeps this table empty. + */ +@Entity(tableName = "ouraMetSample", primaryKeys = ["deviceId", "ts"]) +data class OuraMetSampleEntity( + val deviceId: String, + val ts: Long, + val met: Double, + val state: Int, + val epochS: Int, +) { + companion object { + /** + * Whether two samples are the SAME minute served twice: their starts are less than half the shorter + * period apart (`|Δ| × 2 < min(epochS)` — integer, the same expression on both platforms and in + * `Calories.estimateDayEnergyFromMet`). A re-served record lands 2–5 s off its first copy (the + * per-session `0x13` anchor); the NEXT minute starts 55–61 s after — the ring's own grid steps by a + * second between records — so "any overlap" is the wrong test: it read a 59-s successor as a twin + * and dropped a real minute at the store about once an hour (2026-09-18: 8 holes on the first day + * after the overlap rule, one per phase step). Half a period tells the two apart. Twin of Swift + * `OuraMetSample.isTwin`. + */ + fun isTwin(a: OuraMetSampleEntity, b: OuraMetSampleEntity): Boolean = + kotlin.math.abs(a.ts - b.ts) * 2 < minOf(a.epochS, b.epochS) + + /** + * The samples of [incoming] that are a twin ([isTwin]) of neither a row of [existing] nor an + * earlier-starting sample of [incoming] itself. Pure (incoming is sorted by ts, lower MET first on a + * tie, before the walk) so the insert's dedupe rule is testable without a database. Twin of Swift + * `OuraMetSample.droppingTwins`. + */ + fun droppingTwins( + incoming: List, + existing: List, + ): List { + val kept = existing.toMutableList() + val out = mutableListOf() + for (s in incoming.sortedWith(compareBy { it.ts }.thenBy { it.met })) { + if (kept.any { isTwin(s, it) }) continue + kept += s + out += s + } + return out + } + } +} + /** Respiration raw-ADC sample (type-47). Swift `respSample` (v3). PK (deviceId, ts). */ @Entity(tableName = "respSample", primaryKeys = ["deviceId", "ts"]) data class RespSample( diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index 17815abaf8..ffd9c51bbe 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -200,6 +200,11 @@ interface WhoopDao : DeviceRegistryDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertSleepState(rows: List): List + /** The Oura ring's OWN per-minute MET samples (#2242). Idempotent by (deviceId, ts): a record the + * ring re-serves across reconnects lands once. Swift `insertOuraMetSamples`. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOuraMet(rows: List): List + /** Upsert one Live Session (v22). Natural key (deviceId, startTs) — start (endTs null) then end. * The `WHERE excluded.endTs IS NOT NULL OR liveSession.endTs IS NULL` guard makes a start-write * refuse to overwrite an already-ended row: start/end persist as independent, unordered coroutines, @@ -719,6 +724,16 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun sleepStateSamples(deviceId: String, from: Long, to: Long, limit: Int): List + /** The Oura ring's OWN per-minute MET samples (#2242) in [from, to], ascending. Swift `ouraMetSamples`. */ + @Query( + "SELECT * FROM ouraMetSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + + "ORDER BY ts ASC LIMIT :limit" + ) + suspend fun ouraMetSamples(deviceId: String, from: Long, to: Long, limit: Int): List + + @Query("SELECT COUNT(*) FROM ouraMetSample WHERE deviceId = :deviceId") + suspend fun countOuraMetFor(deviceId: String): Int + @Query( "SELECT * FROM respSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + "ORDER BY ts ASC LIMIT :limit" diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index 34bb40c131..b983d444b6 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -58,8 +58,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase LiftProgramItemRow::class, LiftSessionRow::class, LiftSetEntity::class, + OuraMetSampleEntity::class, ], - version = 40, + version = 41, // #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`, // columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export // is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare @@ -79,7 +80,7 @@ abstract class WhoopDatabase : RoomDatabase() { const val DB_NAME = "noop_whoop.db" /** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup * manifest (#1410) so an export states its schema. Bump both together on a migration. */ - const val SCHEMA_VERSION = 40 + const val SCHEMA_VERSION = 41 @Volatile private var instance: WhoopDatabase? = null @@ -1091,6 +1092,21 @@ abstract class WhoopDatabase : RoomDatabase() { override fun migrate(db: SupportSQLiteDatabase) { LIFT_LOG_SQL.forEach(db::execSQL) } } + /** + * v40 -> v41: ADDITIVE, adds the `ouraMetSample` table (#2242) — the Oura ring's own per-minute MET + * series, the Android twin of Swift WhoopStore `v47-oura-met-sample`. See [OuraMetSampleEntity]. + * Room's generated shape for the entity, verbatim (column order = field order), pinned by + * OuraMetSampleMigrationTest. CREATE TABLE only: nothing existing is touched. + */ + internal val OURA_MET_SAMPLE_MIGRATION_SQL: List = listOf( + "CREATE TABLE IF NOT EXISTS `ouraMetSample` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, " + + "`met` REAL NOT NULL, `state` INTEGER NOT NULL, `epochS` INTEGER NOT NULL, " + + "PRIMARY KEY(`deviceId`, `ts`))", + ) + internal val MIGRATION_40_41 = object : Migration(40, 41) { + override fun migrate(db: SupportSQLiteDatabase) { OURA_MET_SAMPLE_MIGRATION_SQL.forEach(db::execSQL) } + } + /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -1117,7 +1133,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, MIGRATION_35_36, - MIGRATION_36_37, MIGRATION_37_38, MIGRATION_38_39, MIGRATION_39_40, + MIGRATION_36_37, MIGRATION_37_38, MIGRATION_38_39, MIGRATION_39_40, MIGRATION_40_41, ) private fun build(appContext: Context): WhoopDatabase = diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 4ecb79e8d1..32caba26da 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1454,6 +1454,33 @@ class WhoopRepository( List = dao.sleepStateSamples(deviceId, from, to, limit).map { SleepStateRow(it.ts, it.state) } + /** + * Insert the Oura ring's own per-minute MET samples (#2242). Idempotent by MINUTE, not only by + * (deviceId, ts): `ts` is anchored ring time under a per-session `0x13` anchor, so the same ring + * record re-served across sessions lands 2-5 s apart and the key alone sees two rows (2026-09-17: + * 157 of 1,035 rows were such twins, +8 % on the day). A sample that is a twin of a stored one + * ([OuraMetSampleEntity.isTwin]: starts within half a period), or of one accepted earlier in the same + * batch, is dropped; the first copy stays. Rows are assumed to + * belong to one device (the writer's batch), read back per device. Returns the rows actually + * inserted. Swift `insertOuraMetSamples`. + */ + suspend fun insertOuraMetSamples(rows: List): Int { + if (rows.isEmpty()) return 0 + var inserted = 0 + for ((deviceId, batch) in rows.groupBy { it.deviceId }) { + val lo = batch.minOf { it.ts } - batch.maxOf { it.epochS } + val hi = batch.maxOf { it.ts + it.epochS } + val existing = dao.ouraMetSamples(deviceId, lo, hi - 1, Int.MAX_VALUE) + val accepted = OuraMetSampleEntity.droppingTwins(batch, existing) + if (accepted.isNotEmpty()) inserted += dao.insertOuraMet(accepted).count { it != -1L } + } + return inserted + } + + /** The ring's MET samples in [from, to], ascending (#2242). Swift `ouraMetSamples`. */ + suspend fun ouraMetSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): + List = dao.ouraMetSamples(deviceId, from, to, limit) + /** * The latest (greatest-ts) non-null @63 activity class over [from, to], read across the active strap ∪ * canonical "my-whoop" union ([importedSourceIds]), for the Steps tile icon (#316 / @63). Kotlin twin of diff --git a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt index bc8d99b125..e5eb5769a8 100644 --- a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt @@ -89,6 +89,7 @@ class RegistryDayOwnerSourceTest { override suspend fun deleteLiftSessionsFor(deviceId: String) {} override suspend fun deleteLiftSetsFor(deviceId: String) {} override suspend fun deleteLiftSets(ids: List) {} + override suspend fun deleteOuraMetFor(deviceId: String) {} // #771 adopt-serial re-key: sample-table re-keys are unmodelled here (no per-table storage in // this fake), same as the delete*For no-ops above. dayOwnership IS modelled ([owners]), so its diff --git a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt index db16b6ab32..26e37410e5 100644 --- a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt +++ b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt @@ -102,6 +102,7 @@ class SourceCoordinatorAdoptionTest { override suspend fun deleteLiftSessionsFor(deviceId: String) {} override suspend fun deleteLiftSetsFor(deviceId: String) {} override suspend fun deleteLiftSets(ids: List) {} + override suspend fun deleteOuraMetFor(deviceId: String) {} override suspend fun deleteDayOwnershipFor(deviceId: String) { owners.entries.removeIf { it.value.deviceId == deviceId } } diff --git a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt index f508a0bc21..e34aa97390 100644 --- a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt +++ b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt @@ -126,6 +126,7 @@ class DeviceRegistryTest { override suspend fun deleteLiftSetsFor(deviceId: String) { deletedTables += "liftSet" to deviceId } // Editing a finished session, not a device wipe: this fake models per-device deletes only. override suspend fun deleteLiftSets(ids: List) {} + override suspend fun deleteOuraMetFor(deviceId: String) { deletedTables += "ouraMetSample" to deviceId } // #771 adopt-serial re-key: sample-table re-keys are unmodelled here (no per-table storage in // this fake), same as the delete*For no-ops above for those tables. dayOwnership IS modelled @@ -335,6 +336,8 @@ class DeviceRegistryTest { // their parents by id rather than by a foreign key, so clearing only the parents would leave // every logged set behind. "liftExercise", "liftProgram", "liftProgramItem", "liftSession", "liftSet", + // v47-oura-met-sample (#2242): the ring's per-minute MET series, deviceId-keyed like every stream. + "ouraMetSample", ) assertEquals(expectedTables, dao.deletedTables.map { it.first }.toSet()) // Every delete was scoped to the requested device, not the seeded my-whoop. diff --git a/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt b/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt new file mode 100644 index 0000000000..ed872235c7 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt @@ -0,0 +1,109 @@ +package com.noop.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the additive v40 -> v41 Room migration (the `ouraMetSample` table, #2242), the Android twin of the + * Swift WhoopStore `v47-oura-met-sample` migration. No Robolectric / Room-testing here, so the migration's + * SQL is exposed as an internal constant ([WhoopDatabase.OURA_MET_SAMPLE_MIGRATION_SQL]) and pinned to + * Room's generated shape for [OuraMetSampleEntity] — column order = field order = the GRDB order + * (deviceId, ts, met, state, epochS), composite PRIMARY KEY (deviceId, ts). SchemaOracleTest holds the + * cross-platform column/affinity pin; this test holds the migration itself. + */ +class OuraMetSampleMigrationTest { + + @Test + fun migration_isAdditive_onlyCreateTable() { + val sql = WhoopDatabase.OURA_MET_SAMPLE_MIGRATION_SQL + assertEquals("one CREATE TABLE statement", 1, sql.size) + for (s in sql) { + val up = s.trimStart().uppercase() + assertTrue("only CREATE TABLE allowed, got: $s", up.startsWith("CREATE TABLE")) + for (banned in listOf("DROP ", "DELETE ", "UPDATE ", "INSERT ", "ALTER ")) { + assertTrue("additive migration must not contain '$banned': $s", !up.contains(banned)) + } + } + } + + @Test + fun migration_createsExactTable() { + assertEquals( + listOf( + "CREATE TABLE IF NOT EXISTS `ouraMetSample` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, " + + "`met` REAL NOT NULL, `state` INTEGER NOT NULL, `epochS` INTEGER NOT NULL, " + + "PRIMARY KEY(`deviceId`, `ts`))", + ), + WhoopDatabase.OURA_MET_SAMPLE_MIGRATION_SQL, + ) + } + + @Test + fun migration_versionPair_is40to41() { + assertEquals(40, WhoopDatabase.MIGRATION_40_41.startVersion) + assertEquals(41, WhoopDatabase.MIGRATION_40_41.endVersion) + assertEquals(41, WhoopDatabase.SCHEMA_VERSION) + } + + /** The entity carries the decoded MET and the raw state byte verbatim; the default cadence is 60 s. */ + @Test + fun entity_shape() { + val e = OuraMetSampleEntity("oura-2H3B", 1_755_208_800L, 12.8, 3, 60) + assertEquals(12.8, e.met, 0.0) + assertEquals(3, e.state) + assertEquals(60, e.epochS) + } +} + +/** + * The insert's minute-level dedupe (#2242, 2026-09-17). `ts` is anchored ring time under a per-session + * `0x13` anchor, so the same ring record re-served under a second session lands 2–5 s off its first copy + * and the (deviceId, ts) key keeps both — 157 twins in 1,035 rows on the first hardware day, +8 % on the + * day. A twin starts less than half a period after a kept start; a successor 59 s on — the ring's grid + * stepping back a second (2026-09-18) — is the next minute and stays. Twin of Swift + * `OuraMetStoreTests.testDroppingTwinsIsPureAndOrderIndependent`. + */ +class OuraMetSampleTwinTest { + private fun s(ts: Long, met: Double = 1.0, epochS: Int = 60) = OuraMetSampleEntity("oura-A", ts, met, 0, epochS) + + @Test + fun droppingTwins_isPureAndOrderIndependent() { + val t = 1_000L + val existing = listOf(s(t), s(t + 120)) + val incoming = listOf( + s(t + 4, 9.0), // 4-s twin of the stored t + s(t + 60, 2.0), // 1-s twin of the accepted t+59 + s(t + 59, 5.0), // 59 s after t: the next minute, kept + s(t + 149, 9.0), // 29 s after the stored t+120: twin + s(t + 150, 2.0), // 30 s after: half a period, kept + s(t + 240, 2.0), // free minute + s(t + 241, 2.0), // 1-s twin of the accepted t+240 + ) + assertEquals(listOf(t + 59, t + 150, t + 240), OuraMetSampleEntity.droppingTwins(incoming, existing).map { it.ts }) + assertEquals( + listOf(t + 59, t + 150, t + 240), + OuraMetSampleEntity.droppingTwins(incoming.reversed(), existing).map { it.ts }, + ) + assertEquals(emptyList(), OuraMetSampleEntity.droppingTwins(emptyList(), existing)) + // Mixed periods: half the SHORTER one decides. + assertEquals(true, OuraMetSampleEntity.isTwin(s(t, epochS = 120), s(t + 29))) + assertEquals(false, OuraMetSampleEntity.isTwin(s(t, epochS = 120), s(t + 30))) + } + + /** Twins inside one batch collapse the same way: earlier start wins, lower MET on an exact tie. */ + @Test + fun droppingTwins_withinOneBatch() { + val t = 1_000L + val out = OuraMetSampleEntity.droppingTwins(listOf(s(t, 5.0), s(t + 3, 2.0), s(t, 4.0)), emptyList()) + assertEquals(listOf(s(t, 4.0)), out) + } + + /** The 2026-09-18 hardware shape: a grid that steps back one second every 30 minutes keeps every minute. */ + @Test + fun droppingTwins_phaseStepSuccessorsAllKept() { + val t = 1_755_208_800L + val rows = (0 until 120).map { s(t + it * 60L - it / 30) } + assertEquals(120, OuraMetSampleEntity.droppingTwins(rows, emptyList()).size) + } +} diff --git a/android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/41.json b/android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/41.json new file mode 100644 index 0000000000..0d81200671 --- /dev/null +++ b/android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/41.json @@ -0,0 +1,2210 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "789b4ab718aca8b7b5bc03f14acbc3ca", + "entities": [ + { + "tableName": "device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `mac` TEXT, `name` TEXT, `firstSeen` INTEGER, `lastSeen` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mac", + "columnName": "mac", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "hrSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rrInterval", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `rrMs` INTEGER NOT NULL, `seq` INTEGER NOT NULL, `synced` INTEGER NOT NULL, `ord` INTEGER, `srcChannel` INTEGER, `tsSuspect` INTEGER, PRIMARY KEY(`deviceId`, `ts`, `rrMs`, `seq`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rrMs", + "columnName": "rrMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seq", + "columnName": "seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ord", + "columnName": "ord", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "srcChannel", + "columnName": "srcChannel", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tsSuspect", + "columnName": "tsSuspect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts", + "rrMs", + "seq" + ] + }, + "indices": [ + { + "name": "rrInterval_source_suspect", + "unique": false, + "columnNames": [ + "srcChannel", + "tsSuspect" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `rrInterval_source_suspect` ON `${TABLE_NAME}` (`srcChannel`, `tsSuspect`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `kind` TEXT NOT NULL, `payloadJSON` TEXT NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`, `kind`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJSON", + "columnName": "payloadJSON", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts", + "kind" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "battery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `soc` REAL, `mv` INTEGER, `charging` INTEGER, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "soc", + "columnName": "soc", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "mv", + "columnName": "mv", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charging", + "columnName": "charging", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spo2Sample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `red` INTEGER NOT NULL, `ir` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ir", + "columnName": "ir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "skinTempSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `raw` INTEGER NOT NULL, `synced` INTEGER NOT NULL, `aux1Raw` INTEGER, `aux2Raw` INTEGER, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aux1Raw", + "columnName": "aux1Raw", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "aux2Raw", + "columnName": "aux2Raw", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "stepSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `counter` INTEGER NOT NULL, `activityClass` INTEGER, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counter", + "columnName": "counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activityClass", + "columnName": "activityClass", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sleepStateSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `state` INTEGER NOT NULL, `rawByte` INTEGER, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rawByte", + "columnName": "rawByte", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "respSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `raw` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "gravitySample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `x` REAL NOT NULL, `y` REAL NOT NULL, `z` REAL NOT NULL, `synced` INTEGER NOT NULL, `dynAccel` REAL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "z", + "columnName": "z", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dynAccel", + "columnName": "dynAccel", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dailyMetric", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `totalSleepMin` REAL, `efficiency` REAL, `deepMin` REAL, `remMin` REAL, `lightMin` REAL, `disturbances` INTEGER, `restingHr` INTEGER, `avgHrv` REAL, `recovery` REAL, `strain` REAL, `exerciseCount` INTEGER, `spo2Pct` REAL, `skinTempDevC` REAL, `respRateBpm` REAL, `steps` INTEGER, `activeKcalEst` REAL, `spo2Red` INTEGER, `spo2Ir` INTEGER, `avgSdnn` REAL, `skinTempC` REAL, `sleepHrOnly` INTEGER, PRIMARY KEY(`deviceId`, `day`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalSleepMin", + "columnName": "totalSleepMin", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "efficiency", + "columnName": "efficiency", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "deepMin", + "columnName": "deepMin", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "remMin", + "columnName": "remMin", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "lightMin", + "columnName": "lightMin", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "disturbances", + "columnName": "disturbances", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "restingHr", + "columnName": "restingHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "avgHrv", + "columnName": "avgHrv", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "recovery", + "columnName": "recovery", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "strain", + "columnName": "strain", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "exerciseCount", + "columnName": "exerciseCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spo2Pct", + "columnName": "spo2Pct", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "skinTempDevC", + "columnName": "skinTempDevC", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "respRateBpm", + "columnName": "respRateBpm", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activeKcalEst", + "columnName": "activeKcalEst", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "spo2Red", + "columnName": "spo2Red", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spo2Ir", + "columnName": "spo2Ir", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "avgSdnn", + "columnName": "avgSdnn", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "skinTempC", + "columnName": "skinTempC", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "sleepHrOnly", + "columnName": "sleepHrOnly", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sleepSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, `efficiency` REAL, `restingHr` INTEGER, `avgHrv` REAL, `stagesJSON` TEXT, `userEdited` INTEGER NOT NULL, `startTsAdjusted` INTEGER, `motionJSON` TEXT, `sleepStateJSON` TEXT, `stagingSparse` INTEGER, PRIMARY KEY(`deviceId`, `startTs`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "efficiency", + "columnName": "efficiency", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "restingHr", + "columnName": "restingHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "avgHrv", + "columnName": "avgHrv", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "stagesJSON", + "columnName": "stagesJSON", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userEdited", + "columnName": "userEdited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startTsAdjusted", + "columnName": "startTsAdjusted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "motionJSON", + "columnName": "motionJSON", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sleepStateJSON", + "columnName": "sleepStateJSON", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "stagingSparse", + "columnName": "stagingSparse", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "metricSeries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`deviceId`, `day`, `key`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day", + "key" + ] + }, + "indices": [ + { + "name": "idx_metricSeries_device_key_day", + "unique": false, + "columnNames": [ + "deviceId", + "key", + "day" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_metricSeries_device_key_day` ON `${TABLE_NAME}` (`deviceId`, `key`, `day`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "scoreInputProvenance", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `key` TEXT NOT NULL, `sourceId` TEXT NOT NULL, PRIMARY KEY(`deviceId`, `day`, `key`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day", + "key" + ] + }, + "indices": [ + { + "name": "idx_scoreInputProvenance_source", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_scoreInputProvenance_source` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "journal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `question` TEXT NOT NULL, `answeredYes` INTEGER NOT NULL, `notes` TEXT, `numericValue` REAL, PRIMARY KEY(`deviceId`, `day`, `question`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "question", + "columnName": "question", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredYes", + "columnName": "answeredYes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numericValue", + "columnName": "numericValue", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day", + "question" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, `sport` TEXT NOT NULL, `source` TEXT NOT NULL, `durationS` REAL, `energyKcal` REAL, `avgHr` INTEGER, `maxHr` INTEGER, `strain` REAL, `distanceM` REAL, `zonesJSON` TEXT, `notes` TEXT, `routePolyline` TEXT, `steps` INTEGER, PRIMARY KEY(`deviceId`, `startTs`, `sport`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sport", + "columnName": "sport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationS", + "columnName": "durationS", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "energyKcal", + "columnName": "energyKcal", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "avgHr", + "columnName": "avgHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxHr", + "columnName": "maxHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "strain", + "columnName": "strain", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "distanceM", + "columnName": "distanceM", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "zonesJSON", + "columnName": "zonesJSON", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "routePolyline", + "columnName": "routePolyline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs", + "sport" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dismissedWorkout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `startTs`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dismissedSleep", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, `managementVisible` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `startTs`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "managementVisible", + "columnName": "managementVisible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "appleDaily", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `steps` INTEGER, `activeKcal` REAL, `basalKcal` REAL, `vo2max` REAL, `avgHr` INTEGER, `maxHr` INTEGER, `walkingHr` INTEGER, `weightKg` REAL, PRIMARY KEY(`deviceId`, `day`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activeKcal", + "columnName": "activeKcal", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "basalKcal", + "columnName": "basalKcal", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "avgHr", + "columnName": "avgHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxHr", + "columnName": "maxHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "walkingHr", + "columnName": "walkingHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weightKg", + "columnName": "weightKg", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ppgHrSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `conf` REAL NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conf", + "columnName": "conf", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pairedDevice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `brand` TEXT NOT NULL, `model` TEXT NOT NULL, `nickname` TEXT, `peripheralId` TEXT, `sourceKind` TEXT NOT NULL, `capabilities` TEXT NOT NULL, `status` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, `lastSeenAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "brand", + "columnName": "brand", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nickname", + "columnName": "nickname", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "peripheralId", + "columnName": "peripheralId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenAt", + "columnName": "lastSeenAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dayOwnership", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `locked` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "labMarker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `markerKey` TEXT NOT NULL, `category` TEXT NOT NULL, `day` TEXT NOT NULL, `takenAt` INTEGER NOT NULL, `value` REAL, `valueText` TEXT, `unit` TEXT NOT NULL, `source` TEXT NOT NULL, `note` TEXT, `referenceText` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markerKey", + "columnName": "markerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "takenAt", + "columnName": "takenAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "valueText", + "columnName": "valueText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referenceText", + "columnName": "referenceText", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_labMarker_natural", + "unique": true, + "columnNames": [ + "deviceId", + "markerKey", + "takenAt", + "source" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `idx_labMarker_natural` ON `${TABLE_NAME}` (`deviceId`, `markerKey`, `takenAt`, `source`)" + }, + { + "name": "idx_labMarker_device_marker_takenAt", + "unique": false, + "columnNames": [ + "deviceId", + "markerKey", + "takenAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_labMarker_device_marker_takenAt` ON `${TABLE_NAME}` (`deviceId`, `markerKey`, `takenAt`)" + }, + { + "name": "idx_labMarker_device_category", + "unique": false, + "columnNames": [ + "deviceId", + "category" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_labMarker_device_category` ON `${TABLE_NAME}` (`deviceId`, `category`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liveSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER, `chargeAtStart` REAL, `floorBpm` REAL NOT NULL, `ceilingBpm` REAL NOT NULL, `inBandSec` REAL NOT NULL, `belowSec` REAL NOT NULL, `aboveSec` REAL NOT NULL, `pushCount` INTEGER NOT NULL, `easeCount` INTEGER NOT NULL, `hrSource` TEXT NOT NULL, PRIMARY KEY(`deviceId`, `startTs`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargeAtStart", + "columnName": "chargeAtStart", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "floorBpm", + "columnName": "floorBpm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "ceilingBpm", + "columnName": "ceilingBpm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "inBandSec", + "columnName": "inBandSec", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "belowSec", + "columnName": "belowSec", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "aboveSec", + "columnName": "aboveSec", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pushCount", + "columnName": "pushCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "easeCount", + "columnName": "easeCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hrSource", + "columnName": "hrSource", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ppgWaveformSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `samples` BLOB NOT NULL, `burstIndex` INTEGER, `baseCode` INTEGER, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "samples", + "columnName": "samples", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "burstIndex", + "columnName": "burstIndex", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "baseCode", + "columnName": "baseCode", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "v18AuxSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `fields` BLOB NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fields", + "columnName": "fields", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "appleStepHour", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `steps` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "coachMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `role` TEXT NOT NULL, `text` TEXT NOT NULL, `provider` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `orderIndex` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "liftExercise", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `name` TEXT NOT NULL, `primaryMuscle` TEXT, `secondaryMuscles` TEXT, `createdAt` INTEGER NOT NULL, `lastUsedTs` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryMuscle", + "columnName": "primaryMuscle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "secondaryMuscles", + "columnName": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsedTs", + "columnName": "lastUsedTs", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftExercise_natural", + "unique": true, + "columnNames": [ + "deviceId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `idx_liftExercise_natural` ON `${TABLE_NAME}` (`deviceId`, `name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liftProgram", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `name` TEXT NOT NULL, `note` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `archived` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archived", + "columnName": "archived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftProgram_device_updatedAt", + "unique": false, + "columnNames": [ + "deviceId", + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftProgram_device_updatedAt` ON `${TABLE_NAME}` (`deviceId`, `updatedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liftProgramItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `programId` TEXT NOT NULL, `ord` INTEGER NOT NULL, `exercise` TEXT NOT NULL, `targetSets` INTEGER, `targetRepsLow` INTEGER, `targetRepsHigh` INTEGER, `targetRpe` REAL, `targetWeightKg` REAL, `restSec` INTEGER, `note` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "programId", + "columnName": "programId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ord", + "columnName": "ord", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exercise", + "columnName": "exercise", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetSets", + "columnName": "targetSets", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetRepsLow", + "columnName": "targetRepsLow", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetRepsHigh", + "columnName": "targetRepsHigh", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetRpe", + "columnName": "targetRpe", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "targetWeightKg", + "columnName": "targetWeightKg", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "restSec", + "columnName": "restSec", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftProgramItem_device", + "unique": false, + "columnNames": [ + "deviceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftProgramItem_device` ON `${TABLE_NAME}` (`deviceId`)" + }, + { + "name": "idx_liftProgramItem_program_ord", + "unique": false, + "columnNames": [ + "programId", + "ord" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftProgramItem_program_ord` ON `${TABLE_NAME}` (`programId`, `ord`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liftSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER, `sport` TEXT NOT NULL, `programId` TEXT, `programName` TEXT, `sessionRpe` REAL, `note` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sport", + "columnName": "sport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "programId", + "columnName": "programId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "programName", + "columnName": "programName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionRpe", + "columnName": "sessionRpe", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftSession_natural", + "unique": true, + "columnNames": [ + "deviceId", + "startTs", + "sport" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `idx_liftSession_natural` ON `${TABLE_NAME}` (`deviceId`, `startTs`, `sport`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liftSet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `sessionId` TEXT NOT NULL, `ord` INTEGER NOT NULL, `exercise` TEXT NOT NULL, `primaryMuscle` TEXT, `secondaryMuscles` TEXT, `setIndex` INTEGER NOT NULL, `weightKg` REAL, `reps` INTEGER, `rpe` REAL, `isWarmup` INTEGER NOT NULL DEFAULT 0, `startTs` INTEGER, `endTs` INTEGER, `restSec` INTEGER, `note` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ord", + "columnName": "ord", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exercise", + "columnName": "exercise", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryMuscle", + "columnName": "primaryMuscle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "secondaryMuscles", + "columnName": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "setIndex", + "columnName": "setIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weightKg", + "columnName": "weightKg", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "reps", + "columnName": "reps", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rpe", + "columnName": "rpe", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "isWarmup", + "columnName": "isWarmup", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "restSec", + "columnName": "restSec", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftSet_device_exercise", + "unique": false, + "columnNames": [ + "deviceId", + "exercise" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftSet_device_exercise` ON `${TABLE_NAME}` (`deviceId`, `exercise`)" + }, + { + "name": "idx_liftSet_session_ord", + "unique": false, + "columnNames": [ + "sessionId", + "ord" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftSet_session_ord` ON `${TABLE_NAME}` (`sessionId`, `ord`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ouraMetSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `met` REAL NOT NULL, `state` INTEGER NOT NULL, `epochS` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "met", + "columnName": "met", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "epochS", + "columnName": "epochS", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '789b4ab718aca8b7b5bc03f14acbc3ca')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index 4ab143d2b4..a200641e2a 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 40, + "roomVersion": 41, "grdbMigrations": [ "v1", "v2", @@ -47,7 +47,8 @@ "v43-coach-messages", "v44-ppg-waveform-base-code", "v45-rr-source-index", - "v46-lift-log" + "v46-lift-log", + "v47-oura-met-sample" ], "divergenceReasons": { "android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -2310,6 +2311,46 @@ "sport" ], "indices": [] + }, + "ouraMetSample": { + "platform": "both", + "columns": [ + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "ts", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "met", + "affinity": "REAL", + "notNull": true, + "default": null + }, + { + "name": "state", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "epochS", + "affinity": "INTEGER", + "notNull": true, + "default": null + } + ], + "primaryKey": [ + "deviceId", + "ts" + ], + "indices": [] } } } From 8cb33aa06b910bbbf522f6279639253ea0eb5977 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 10:55:22 +0200 Subject: [PATCH 3/7] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Refs #2242 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz --- .../StrandAnalytics/AnalyticsEngine.swift | 48 ++++++++- .../AnalyticsEngineMetCaloriesTests.swift | 82 ++++++++++++++++ .../com/noop/analytics/AnalyticsEngine.kt | 42 +++++++- .../AnalyticsEngineMetCaloriesTest.kt | 97 +++++++++++++++++++ 4 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineMetCaloriesTests.swift create mode 100644 android/app/src/test/java/com/noop/analytics/AnalyticsEngineMetCaloriesTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 1c6f4660ff..4ee5a0ae68 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -346,6 +346,28 @@ public enum AnalyticsEngine { dayHr: [HRSample]? = nil, daySteps: [StepSample]? = nil, dayGravity: [GravitySample]? = nil, + // The day owner's OWN per-minute MET series (an Oura ring's 0x50, + // #2242), calendar-day scoped like dayHr. When present and non-empty + // it REPLACES the HR-only Keytel path for `activeKcalEst` + // (`Calories.estimateDayEnergyFromMET`, Oura's documented method); + // when the stream covers less than `Calories.metMinCoverageFraction` + // of the day the estimate is withheld (nil) rather than minted from a + // mostly-unknown day — and the HR path is NOT used as a stand-in, + // since on a ring day it runs over the ring's sparse banked HR and + // does not track Oura's own number (r ≈ −0.1). nil (every WHOOP / + // pure-function caller, and the Experimental toggle OFF) keeps the + // HR path byte-identical. Supplied by IntelligenceEngine only when + // the toggle is on. + dayMet: [Calories.MetSample]? = nil, + // Unix `now` for TODAY so MET coverage is judged against the hours + // that have elapsed, not against 24 h (a 09:00 pass would otherwise + // read 37 % and withhold every morning). nil = the full local day + // (a past day). Only read on the MET path. + dayMetNow: Int? = nil, + // One line per day when the MET path decides `activeKcalEst` + // (taken, or withheld for coverage) — always-on evidence for a + // "my calories changed" report. nil builds nothing. + caloriesDiag: ((String) -> Void)? = nil, // Wear-gated nightly skin-temp mean is harvested here // (baseline-independent); IntelligenceEngine seeds a personal // baseline from these means across nights and re-derives @@ -990,9 +1012,29 @@ public enum AnalyticsEngine { // night-window hr for pure-function callers that don't supply dayHr. Strain keeps the full // window (bounded log). let dayHrFiltered = (dayHr ?? hr).filter { tsInDay($0.ts) } - let activeKcalEst: Double? = dayHrFiltered.isEmpty ? nil : Calories.estimateDayCalories( - dayHrFiltered, profile: profile, hrmax: effMaxHR, - restingHR: restingHRDaily.map(Double.init)) + // #2242: a device that measures its own minute-by-minute intensity (the Oura ring's 0x50 MET) + // decides the day's energy by that stream, not by Keytel over its sparse banked HR. The window is + // the same local day `tsInDay` uses, in real unix seconds; today is cut at `dayMetNow` so coverage + // means "of the hours so far". Below the coverage floor the number is withheld, not substituted. + let activeKcalEst: Double? + if let dayMet, !dayMet.isEmpty { + let metDayStart = dayStartUtc - tzOffsetSeconds + let metDayEnd = min(metDayStart + 86_400, dayMetNow ?? Int.max) + let met = Calories.estimateDayEnergyFromMET(dayMet, profile: profile, + dayStart: metDayStart, dayEnd: metDayEnd) + let coveragePct = Int((met.coverageFraction * 100).rounded()) + if met.coverageFraction >= Calories.metMinCoverageFraction { + activeKcalEst = met.totalKcal + caloriesDiag?("calories \(day): MET path - coverage \(coveragePct)% (\(dayMet.count) samples), active \(Int(met.activeKcal.rounded())) kcal, resting \(Int(met.restingKcal.rounded())) kcal, total \(Int(met.totalKcal.rounded())) kcal") + } else { + activeKcalEst = nil + caloriesDiag?("calories \(day): MET path - coverage \(coveragePct)% (\(dayMet.count) samples) below \(Int((Calories.metMinCoverageFraction * 100).rounded()))% floor, estimate withheld (HR path not substituted on a MET day)") + } + } else { + activeKcalEst = dayHrFiltered.isEmpty ? nil : Calories.estimateDayCalories( + dayHrFiltered, profile: profile, hrmax: effMaxHR, + restingHR: restingHRDaily.map(Double.init)) + } // ── Assemble DailyMetric ────────────────────────────────────────────── let daily = DailyMetric( diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineMetCaloriesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineMetCaloriesTests.swift new file mode 100644 index 0000000000..8eb3a55ffd --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineMetCaloriesTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// `analyzeDay`'s calorie-path selection (#2242): a day that carries the owner's OWN MET series scores +/// `activeKcalEst` by `Calories.estimateDayEnergyFromMET`; below the coverage floor it withholds the number +/// rather than falling back to HR; no MET (every WHOOP / toggle-OFF caller) keeps the HR path byte-identical. +/// The Kotlin twin (`AnalyticsEngineMetCaloriesTest`) mirrors these vectors value-for-value. +final class AnalyticsEngineMetCaloriesTests: XCTestCase { + + private let day = "2026-08-15" + private let off = 7_200 // Europe/Paris in August + private var localMid: Int { AnalyticsEngine.dayStartUtcSeconds(day) - off } + private let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male") + + private func dayHr() -> [HRSample] { + stride(from: localMid, to: localMid + 86_400, by: 10).map { HRSample(ts: $0, bpm: 60 + ($0 / 10) % 40) } + } + /// Full-day MET at 1.0 with a 40-min 5.0-MET bout, plus spill into both neighbour days that must be ignored. + private func fullMet() -> [Calories.MetSample] { + stride(from: localMid - 3_600, to: localMid + 86_400 + 3_600, by: 60).map { ts in + let minute = (ts - localMid) / 60 + let met = (ts < localMid || ts >= localMid + 86_400) ? 9.0 : ((600..<640).contains(minute) ? 5.0 : 1.0) + return Calories.MetSample(ts: ts, met: met) + } + } + + func testMetPathReplacesHrPathWhenCovered() { + var lines: [String] = [] + let res = AnalyticsEngine.analyzeDay(day: day, dayHr: dayHr(), dayMet: fullMet(), + caloriesDiag: { lines.append($0) }, + profile: profile, tzOffsetSeconds: off) + let expected = Calories.estimateDayEnergyFromMET(fullMet(), profile: profile, + dayStart: localMid, dayEnd: localMid + 86_400) + XCTAssertEqual(expected.coverageFraction, 1.0, accuracy: 1e-12) + XCTAssertEqual(res.daily.activeKcalEst ?? -1, expected.totalKcal, accuracy: 1e-9) + // And it is NOT the HR number. + let hrOnly = AnalyticsEngine.analyzeDay(day: day, dayHr: dayHr(), profile: profile, tzOffsetSeconds: off) + XCTAssertNotEqual(hrOnly.daily.activeKcalEst ?? -1, expected.totalKcal, accuracy: 1e-6) + XCTAssertEqual(lines.count, 1) + XCTAssertTrue(lines[0].hasPrefix("calories 2026-08-15: MET path - coverage 100% (1560 samples), active "), lines[0]) + } + + func testBelowCoverageFloorWithholdsRatherThanSubstitutes() { + var lines: [String] = [] + let thin = Array(fullMet().prefix(60 + 400)) // 1 h spill + 400 covered minutes = 28 % of the day + let res = AnalyticsEngine.analyzeDay(day: day, dayHr: dayHr(), dayMet: thin, + caloriesDiag: { lines.append($0) }, + profile: profile, tzOffsetSeconds: off) + XCTAssertNil(res.daily.activeKcalEst) + XCTAssertEqual(lines.count, 1) + XCTAssertTrue(lines[0].contains("coverage 28% (460 samples) below 50% floor, estimate withheld"), lines[0]) + } + + func testTodayIsJudgedAgainstElapsedHours() { + // Same 400 covered minutes, but `now` is 07:00 local: 400/420 min = 95 % of the elapsed window. + var lines: [String] = [] + let thin = Array(fullMet().prefix(60 + 400)) + let res = AnalyticsEngine.analyzeDay(day: day, dayHr: dayHr(), dayMet: thin, dayMetNow: localMid + 7 * 3600, + caloriesDiag: { lines.append($0) }, + profile: profile, tzOffsetSeconds: off) + let expected = Calories.estimateDayEnergyFromMET(thin, profile: profile, + dayStart: localMid, dayEnd: localMid + 7 * 3600) + XCTAssertEqual(res.daily.activeKcalEst ?? -1, expected.totalKcal, accuracy: 1e-9) + XCTAssertTrue(lines[0].contains("coverage 95% (460 samples)"), lines[0]) + } + + func testNoMetKeepsHrPathByteIdentical() { + var lines: [String] = [] + let base = AnalyticsEngine.analyzeDay(day: day, dayHr: dayHr(), profile: profile, tzOffsetSeconds: off) + let nilMet = AnalyticsEngine.analyzeDay(day: day, dayHr: dayHr(), dayMet: nil, + caloriesDiag: { lines.append($0) }, + profile: profile, tzOffsetSeconds: off) + let emptyMet = AnalyticsEngine.analyzeDay(day: day, dayHr: dayHr(), dayMet: [], + caloriesDiag: { lines.append($0) }, + profile: profile, tzOffsetSeconds: off) + XCTAssertEqual(base.daily, nilMet.daily) + XCTAssertEqual(base.daily, emptyMet.daily) + XCTAssertNotNil(base.daily.activeKcalEst) + XCTAssertTrue(lines.isEmpty, "the HR path logs nothing new") + } +} diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 2f08417ad9..3c6e8ad7a8 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -308,6 +308,22 @@ object AnalyticsEngine { dayHr: List? = null, daySteps: List? = null, dayGravity: List? = null, + // The day owner's OWN per-minute MET series (an Oura ring's 0x50, #2242), calendar-day scoped + // like dayHr. When present and non-empty it REPLACES the HR-only Keytel path for `activeKcalEst` + // ([Calories.estimateDayEnergyFromMet], Oura's documented method); when the stream covers less + // than [Calories.MET_MIN_COVERAGE_FRACTION] of the day the estimate is withheld (null) rather + // than minted from a mostly-unknown day — and the HR path is NOT used as a stand-in, since on a + // ring day it runs over the ring's sparse banked HR and does not track Oura's own number + // (r ≈ −0.1). null (every WHOOP / pure-function caller, and the Experimental toggle OFF) keeps + // the HR path byte-identical. Supplied by IntelligenceEngine only when the toggle is on. + dayMet: List? = null, + // Unix `now` for TODAY so MET coverage is judged against the hours that have elapsed, not + // against 24 h (a 09:00 pass would otherwise read 37 % and withhold every morning). null = the + // full local day (a past day). Only read on the MET path. + dayMetNow: Long? = null, + // One line per day when the MET path decides `activeKcalEst` (taken, or withheld for coverage) + // — always-on evidence for a "my calories changed" report. null builds nothing. + caloriesDiag: ((String) -> Unit)? = null, // Wear-gated nightly skin-temp mean is harvested here (baseline-independent); IntelligenceEngine // seeds a personal baseline from these means across nights and re-derives skinTempDevC in pass 2 // (same two-pass shape as avgHrv→recovery). (PR #85) @@ -910,7 +926,31 @@ object AnalyticsEngine { // night-window hr for pure-function callers that don't supply dayHr. Strain keeps the full // window (bounded log). val dayHrFiltered = (dayHr ?: hr).filter { tsInDay(it.ts) } - val activeKcalEst: Double? = if (dayHrFiltered.isEmpty()) { + // #2242: a device that measures its own minute-by-minute intensity (the Oura ring's 0x50 MET) + // decides the day's energy by that stream, not by Keytel over its sparse banked HR. The window is + // the same local day `tsInDay` uses, in real unix seconds; today is cut at `dayMetNow` so coverage + // means "of the hours so far". Below the coverage floor the number is withheld, not substituted. + val activeKcalEst: Double? = if (dayMet != null && dayMet.isNotEmpty()) { + val metDayStart = dayStartUtc - tzOffsetSeconds + val metDayEnd = minOf(metDayStart + 86_400L, dayMetNow ?: Long.MAX_VALUE) + val met = Calories.estimateDayEnergyFromMet(dayMet, profile, metDayStart, metDayEnd) + val coveragePct = Math.round(met.coverageFraction * 100).toInt() + if (met.coverageFraction >= Calories.MET_MIN_COVERAGE_FRACTION) { + caloriesDiag?.invoke( + "calories $day: MET path - coverage $coveragePct% (${dayMet.size} samples), " + + "active ${Math.round(met.activeKcal)} kcal, resting ${Math.round(met.restingKcal)} kcal, " + + "total ${Math.round(met.totalKcal)} kcal", + ) + met.totalKcal + } else { + caloriesDiag?.invoke( + "calories $day: MET path - coverage $coveragePct% (${dayMet.size} samples) below " + + "${Math.round(Calories.MET_MIN_COVERAGE_FRACTION * 100)}% floor, estimate withheld " + + "(HR path not substituted on a MET day)", + ) + null + } + } else if (dayHrFiltered.isEmpty()) { null } else { Calories.estimateDayCalories( diff --git a/android/app/src/test/java/com/noop/analytics/AnalyticsEngineMetCaloriesTest.kt b/android/app/src/test/java/com/noop/analytics/AnalyticsEngineMetCaloriesTest.kt new file mode 100644 index 0000000000..25d78a7d3e --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/AnalyticsEngineMetCaloriesTest.kt @@ -0,0 +1,97 @@ +package com.noop.analytics + +import com.noop.analytics.Calories.MetSample +import com.noop.data.HrSample +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `analyzeDay`'s calorie-path selection (#2242): a day that carries the owner's OWN MET series scores + * `activeKcalEst` by [Calories.estimateDayEnergyFromMet]; below the coverage floor it withholds the number + * rather than falling back to HR; no MET (every WHOOP / toggle-OFF caller) keeps the HR path byte-identical. + * Mirrors the Swift `AnalyticsEngineMetCaloriesTests` vectors value-for-value. + */ +class AnalyticsEngineMetCaloriesTest { + + private val day = "2026-08-15" + private val off = 7_200L // Europe/Paris in August + private val localMid: Long get() = AnalyticsEngine.dayStartUtcSeconds(day) - off + private val profile = UserProfile(weightKg = 75.0, heightCm = 178.0, age = 30.0, sex = "male") + + private fun dayHr(): List = + (localMid until localMid + 86_400 step 10).map { ts -> HrSample(deviceId = "t", ts = ts, bpm = (60 + (ts / 10) % 40).toInt()) } + + /** Full-day MET at 1.0 with a 40-min 5.0-MET bout, plus spill into both neighbour days that must be ignored. */ + private fun fullMet(): List = + (localMid - 3_600 until localMid + 86_400 + 3_600 step 60).map { ts -> + val minute = ((ts - localMid) / 60).toInt() + val met = if (ts < localMid || ts >= localMid + 86_400) 9.0 else if (minute in 600 until 640) 5.0 else 1.0 + MetSample(ts, met) + } + + @Test + fun metPathReplacesHrPathWhenCovered() { + val lines = ArrayList() + val res = AnalyticsEngine.analyzeDay( + day = day, dayHr = dayHr(), dayMet = fullMet(), caloriesDiag = { lines.add(it) }, + profile = profile, tzOffsetSeconds = off, + ) + val expected = Calories.estimateDayEnergyFromMet(fullMet(), profile, localMid, localMid + 86_400) + assertEquals(1.0, expected.coverageFraction, 1e-12) + assertEquals(expected.totalKcal, res.daily.activeKcalEst ?: -1.0, 1e-9) + // And it is NOT the HR number. + val hrOnly = AnalyticsEngine.analyzeDay(day = day, dayHr = dayHr(), profile = profile, tzOffsetSeconds = off) + assertNotEquals(expected.totalKcal, hrOnly.daily.activeKcalEst ?: -1.0, 1e-6) + assertEquals(1, lines.size) + assertTrue(lines[0], lines[0].startsWith("calories 2026-08-15: MET path - coverage 100% (1560 samples), active ")) + } + + @Test + fun belowCoverageFloorWithholdsRatherThanSubstitutes() { + val lines = ArrayList() + val thin = fullMet().take(60 + 400) // 1 h spill + 400 covered minutes = 28 % of the day + val res = AnalyticsEngine.analyzeDay( + day = day, dayHr = dayHr(), dayMet = thin, caloriesDiag = { lines.add(it) }, + profile = profile, tzOffsetSeconds = off, + ) + assertNull(res.daily.activeKcalEst) + assertEquals(1, lines.size) + assertTrue(lines[0], lines[0].contains("coverage 28% (460 samples) below 50% floor, estimate withheld")) + } + + @Test + fun todayIsJudgedAgainstElapsedHours() { + // Same 400 covered minutes, but `now` is 07:00 local: 400/420 min = 95 % of the elapsed window. + val lines = ArrayList() + val thin = fullMet().take(60 + 400) + val res = AnalyticsEngine.analyzeDay( + day = day, dayHr = dayHr(), dayMet = thin, dayMetNow = localMid + 7 * 3600, + caloriesDiag = { lines.add(it) }, profile = profile, tzOffsetSeconds = off, + ) + val expected = Calories.estimateDayEnergyFromMet(thin, profile, localMid, localMid + 7 * 3600) + assertEquals(expected.totalKcal, res.daily.activeKcalEst ?: -1.0, 1e-9) + assertTrue(lines[0], lines[0].contains("coverage 95% (460 samples)")) + } + + @Test + fun noMetKeepsHrPathByteIdentical() { + val lines = ArrayList() + val base = AnalyticsEngine.analyzeDay(day = day, dayHr = dayHr(), profile = profile, tzOffsetSeconds = off) + val nilMet = AnalyticsEngine.analyzeDay( + day = day, dayHr = dayHr(), dayMet = null, caloriesDiag = { lines.add(it) }, + profile = profile, tzOffsetSeconds = off, + ) + val emptyMet = AnalyticsEngine.analyzeDay( + day = day, dayHr = dayHr(), dayMet = emptyList(), caloriesDiag = { lines.add(it) }, + profile = profile, tzOffsetSeconds = off, + ) + assertEquals(base.daily, nilMet.daily) + assertEquals(base.daily, emptyMet.daily) + assertNotNull(base.daily.activeKcalEst) + assertTrue("the HR path logs nothing new", lines.isEmpty()) + } +} From 249c318188a185e3d5df1298b73a720bfdd6bc0e Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 10:55:22 +0200 Subject: [PATCH 4/7] feat(oura): score active calories from the ring's MET records, behind an Experimental toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES, default OFF) gates: • 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. • 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; the calories line goes through the same per-day diag recorder as the Effort funnel. • the day-cycle fold — DayCycleIntelligenceIntegration (and Android's PhysiologicalStepCycleEngine) recomputed Keytel for every cycle and wrote it over activeKcalEst, which would have hidden the MET decision on any phone with a day-cycle history. The fold now takes the same decision analyzeDay takes, over its own window [onset, min(endExclusive, now)): covered → the MET total, thin → withheld with no HR substitute, no rows or toggle off → Keytel byte for byte. A "stepsCycle calories … path=met" trace line names the decision. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day. Android threads the flag Context-free like spo2CandidateDisplay, carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff, and hands the fold a Boolean rather than a lambda — all to stay inside analyzeRecentOnCpu's JaCoCo budget (a new suspend call there spills ~0.7 K instructions of continuation state). Settings gains "Experimental · Oura Calories" (iOS card / Android row), shown only with an Oura ring, caption stating the method, the < 50 % coverage withhold, that Oura re-scores a logged workout's minutes by activity type (NOOP does not, so such a day reads lower), and that turning it on starts storing MET samples. Strings in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/ zh-Hant (iOS) and pl/ru/zh (Android). Docs: OURA_PROTOCOL.md §6.13 (day-sum validation of the 0x50 decode, end-of-record timestamps, the formula); README Oura table "Active calories". OFF keeps both the DB and every score byte-identical. Tests: DayCacheConfigFieldTests / DayCacheConfigFieldTest; DayCycleRecoveryTests (Swift, xcodebuild test) and DayCycleMetCaloriesTest (Kotlin, Robolectric + Room) on a seeded 8-h main sleep: covered cycle carries the MET total, 10 %-covered is withheld, no reader / empty table = Keytel. Refs #2242 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz --- README.md | 1 + Strand/App/AppModel.swift | 11 ++ Strand/BLE/OuraLiveSource.swift | 25 ++++ Strand/BLE/SourceCoordinator.swift | 4 + .../DayCycleIntelligenceIntegration.swift | 37 +++++- Strand/Data/IntelligenceEngine.swift | 28 +++- Strand/Resources/Localizable.xcstrings | 46 ++++++- Strand/Screens/SettingsView.swift | 34 +++++ StrandTests/DayCacheConfigFieldTests.swift | 2 +- StrandTests/DayCycleRecoveryTests.swift | 103 +++++++++++++++ .../DayCycleIntelligenceIntegration.kt | 3 +- .../com/noop/analytics/IntelligenceEngine.kt | 55 +++++++- .../analytics/PhysiologicalStepCycleEngine.kt | 43 +++++- .../main/java/com/noop/ble/OuraLiveSource.kt | 25 ++++ .../java/com/noop/ble/SourceCoordinator.kt | 2 + .../main/java/com/noop/ble/WhoopBleClient.kt | 1 + .../src/main/java/com/noop/ui/AppViewModel.kt | 9 ++ .../src/main/java/com/noop/ui/MainActivity.kt | 14 ++ .../main/java/com/noop/ui/SettingsScreen.kt | 47 +++++++ .../app/src/main/res/values-de/strings.xml | 2 + .../app/src/main/res/values-es/strings.xml | 2 + .../app/src/main/res/values-fr/strings.xml | 2 + .../app/src/main/res/values-pl/strings.xml | 2 + .../src/main/res/values-pt-rPT/strings.xml | 2 + .../app/src/main/res/values-ru/strings.xml | 2 + .../app/src/main/res/values-zh/strings.xml | 2 + android/app/src/main/res/values/strings.xml | 2 + .../noop/analytics/DayCacheConfigFieldTest.kt | 2 +- .../noop/analytics/DayCycleMetCaloriesTest.kt | 124 ++++++++++++++++++ docs/OURA_PROTOCOL.md | 31 +++++ 30 files changed, 649 insertions(+), 14 deletions(-) create mode 100644 android/app/src/test/java/com/noop/analytics/DayCycleMetCaloriesTest.kt diff --git a/README.md b/README.md index 3f523247a5..18466c19f8 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,7 @@ from the official Oura app first, then pairing with NOOP on macOS, works. Docume | **Live heart rate** | 🟡 Partial. Only one of the ring's channels ever arrives near-live, and it is quality-filtered, so it does not tile continuously the way a chest-strap or WHOOP feed does. *Ring 5 ✓* | | **SpO₂** | 🟡 Mostly there. The ring's own overnight SpO₂ percentage is decoded, stored and drawn on the Deep Timeline, and NOOP can switch the ring's SpO₂ sensing on from its own Test Centre (iOS/macOS) — no Oura account needed. A nightly **Blood Oxygen** number (the per-sample ceiling-at-100 mean the wire's positive bias calls for) is available behind the SpO₂ candidate display toggle in Settings, default off; it has round-matched the value the Oura app displays on every paired night measured so far (4 of 4), which is why it is still labelled a candidate rather than promoted to a scored metric. | | **Recovery / strain score on a ring-only day** | 🟡 **Sleep and Strain, not Recovery.** The ring's own hypnogram feeds the scorer (#1183), so a ring-only day gets a Sleep score and a Strain score (from the ring's banked and live HR — light on exercise HR, which stays server-gated, see the last row). **Recovery does not score:** it requires an HRV baseline, and the ring's banked intervals cannot give one (see the HRV row), so the Today screen shows no Recovery on a ring-only day. | +| **Active calories** | 🧪 **Experimental, off by default** (Settings → Experimental · Oura Calories). The ring's own minute-by-minute activity intensity (its MET stream) scored with Oura's documented method — each minute's intensity above 1.5 MET at the standard MET rate for your weight — in place of the heart-rate-only estimate, which on a ring day runs over sparse banked HR and does not track Oura's number. On a day without a logged workout it reproduces the Oura app's own active-calorie figure to about 1 % (8/8 days checked; Oura re-scores a logged workout's minutes by activity type, which NOOP does not, so such a day reads lower). Still an estimate of true expenditure, and a day the ring covered less than half of shows no number rather than a guess. | | **Step count** | 🚧 Estimated only, and not shown as a step count. The ring does **not** transmit a step total NOOP can read; what exists is a research estimate derived from activity intensity, which over-reads badly on very active days. | | **HRV (RMSSD / SDNN) and the Rhythm screen** | ❌ **Not possible from this data.** The ring banks its intervals in records rather than sending true beat-to-beat values, which inflates HRV spread beyond anything physiological. NOOP **refuses** to show a number here rather than showing a plausible-looking wrong one. | | **Respiratory rate** | ❌ **Not possible from this data**, for the same reason — it is derived from beat timing, and the ring's banked intervals carry no breathing signal (shuffling them at random produces the identical answer). The Oura app shows respiration because it computes it from data the ring does not transmit. | diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 21fdc37f34..7a4536e647 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -2139,6 +2139,17 @@ final class AppModel: ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Self.ouraNotifyMaskFullKey) } } + /// #2242 (EXPERIMENTAL, default OFF): estimate active calories from the Oura ring's OWN per-minute MET + /// stream (0x50) with Oura's documented method — minutes above 1.5 MET × RMR — instead of the HR-only + /// Keytel path over the ring's sparse banked HR. Gates BOTH the writer (the ring's MET records are only + /// persisted to `ouraMetSample` while this is on, so an OFF install's DB is byte-identical to today's) + /// and the analyzeDay read. An estimate, not a measurement. No effect without an Oura ring. + static let ouraMetCaloriesKey = "noopOuraMetCalories" + var ouraMetCalories: Bool { + get { UserDefaults.standard.bool(forKey: Self.ouraMetCaloriesKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.ouraMetCaloriesKey) } + } + /// Recompute the v5 skin-temp suite snapshots (cycle phase + body clock) from the current history. /// Called from the analytics pass and when the cycle opt-in flips. Honest-nil throughout: cycle is /// nil unless opted in; circadian is nil unless a usable activity profile exists. diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift index e86fa74ff3..ff82146e07 100644 --- a/Strand/BLE/OuraLiveSource.swift +++ b/Strand/BLE/OuraLiveSource.swift @@ -261,6 +261,13 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// SetNotification is the official app's `ff` instead of `3f` (OURA_PROTOCOL.md s2.3). The next /// connect re-reads it, so switching the toggle off restores the default with nothing left on the ring. private let notifyMaskFull: () -> Bool + /// #2242: persist one anchored 0x50 record's samples as `ouraMetSample` rows (one per minute) — wired at + /// the composition root to `store.insertOuraMetSamples(_:deviceId:)`; default no-op keeps the + /// discovery-only scanner and tests inert. + private let persistMetSamples: ([OuraMetSample]) -> Void + /// #2242 (default OFF): read live per record — the writer above runs only while this is true, so an + /// install that never turns the Experimental toggle on never grows the table. + private let metCalories: () -> Bool private let log: (String) -> Void private let onBattery: (Int) -> Void /// Fired with the ring's TRUE model label ("Oura Ring 3/4/5") once the GetProductInfo hardware id resolves @@ -1324,6 +1331,8 @@ public final class OuraLiveSource: NSObject, ObservableObject { authKey: @escaping () -> Data?, persist: @escaping (Streams) -> Void = { _ in }, persistSleepSession: @escaping (CachedSleepSession) -> Void = { _ in }, + persistMetSamples: @escaping ([OuraMetSample]) -> Void = { _ in }, + metCalories: @escaping () -> Bool = { false }, log: @escaping (String) -> Void = { _ in }, onBattery: @escaping (Int) -> Void = { _ in }, onModel: @escaping (String) -> Void = { _ in }, @@ -1338,6 +1347,8 @@ public final class OuraLiveSource: NSObject, ObservableObject { self.authKey = authKey self.persist = persist self.persistSleepSession = persistSleepSession + self.persistMetSamples = persistMetSamples + self.metCalories = metCalories self.log = log self.onBattery = onBattery self.onModel = onModel @@ -2262,6 +2273,20 @@ public final class OuraLiveSource: NSObject, ObservableObject { activityDump?.record(ringTs: info.ringTimestamp, utc: utc, state: info.state, secPerSample: Int(activityEpochSeconds), met: info.met) } + // #2242: persist the record as one row per minute when the Experimental MET-calories toggle + // is on (anchored records only, same rule as the sidecar; the (deviceId, ts) key absorbs a + // re-serve). 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 from the timestamp), so sample i + // starts at `utc − (n − i) × epoch`. A record straddling local midnight therefore lands its + // minutes on the right days. + if let utc = utc, metCalories(), !info.met.isEmpty { + let epoch = Int(activityEpochSeconds) + let n = info.met.count + persistMetSamples(info.met.enumerated().map { i, met in + OuraMetSample(ts: utc - (n - i) * epoch, met: met, state: info.state, epochS: epoch) + }) + } // Accumulate the MET series by local day for the drain-end estimate, and observe the // per-sample cadence from consecutive record times (both investigation-only, never scored). if let utc = utc { diff --git a/Strand/BLE/SourceCoordinator.swift b/Strand/BLE/SourceCoordinator.swift index 568cf5f10d..b4f0acd8e5 100644 --- a/Strand/BLE/SourceCoordinator.swift +++ b/Strand/BLE/SourceCoordinator.swift @@ -434,6 +434,10 @@ final class SourceCoordinator: ObservableObject { } } }, + persistMetSamples: { [storeHandle] rows in // #2242 + Task { if let store = await storeHandle() { _ = try? await store.insertOuraMetSamples(rows, deviceId: id) } } + }, + metCalories: { UserDefaults.standard.bool(forKey: AppModel.ouraMetCaloriesKey) }, // #2242 log: straplog, onBattery: { [live] pct in live.setBattery(Double(pct)) }, onModel: { [registry] model in registry.setModel(id, model: model) }, // #772: correct a name-guessed gen diff --git a/Strand/Data/DayCycleIntelligenceIntegration.swift b/Strand/Data/DayCycleIntelligenceIntegration.swift index b7a922925a..38eabf4795 100644 --- a/Strand/Data/DayCycleIntelligenceIntegration.swift +++ b/Strand/Data/DayCycleIntelligenceIntegration.swift @@ -46,6 +46,11 @@ import WhoopStore } private static func computedId(_ owner: String) -> String { owner + "-noop" } + /// The cycle owner's own per-minute MET rows in `[from, to]` (#2242) — `nil` while the Experimental + /// MET-calories toggle is off, which is the byte-identical HR path. Injected rather than read from + /// `store` directly so the fold's MET decision can be pinned in a test without a live ring. + typealias MetReader = (_ owner: String, _ from: Int, _ to: Int) async -> [Calories.MetSample] + static func recover(candidates: [(owner: String, priority: Int)], reader: BoundaryRecoveryReader, claimedDays: Set, windowStart: Int, now: Int, offsetSec: Int, habitualMidsleepSec: Int?) async throws -> [PersistedBoundary] { @@ -96,6 +101,7 @@ import WhoopStore mode: DayCycleMode, cache: Cache, profile: UserProfile, maxHROverride: Double?, effortMethod: StrainScorer.Method, recoveryReader: BoundaryRecoveryReader? = nil, + metReader: MetReader? = nil, trace: ((String) -> Void)? = nil) async -> Result { guard mode == .sleepOnset else { return Result(stepsByWakeDay: [:], strainByWakeDay: [:], caloriesByWakeDay: [:], @@ -232,8 +238,10 @@ import WhoopStore key: loadKey, strain: StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR, method: effortMethod, sex: profile.sex), - calories: cycleHR.isEmpty ? nil : Calories.estimateDayCalories( - cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR)) + calories: await cycleCalories( + cycleHR, onset: window.onset, endExclusive: window.endExclusive, day: day, owner: fallback, hrEndInclusive: hrEndInclusive, + now: now, profile: profile, effectiveMaxHR: effectiveMaxHR, restingHR: restingHR, + metReader: metReader, trace: trace)) cache.loads[window.sleepId] = load } if let strain = load.strain { strains[day] = strain } @@ -344,6 +352,31 @@ import WhoopStore sourceIds: Array(Set(candidates.map { computedId($0.owner) })).sorted())) } + /// The cycle's energy, by the SAME decision `AnalyticsEngine.analyzeDay` takes for the calendar day (#2242), + /// over the wake-to-wake window `[onset, min(endExclusive, now))` instead. A device that measures its own + /// minute-by-minute intensity decides it by that stream. This fold used to recompute Keytel over the + /// cycle's HR unconditionally and `applying()` wrote that over the day's `activeKcalEst`, so on any phone + /// with a day-cycle history the MET number never reached the row (2026-09-17: the log said 1220 kcal, the + /// export held 743 — the HR figure). Below the coverage floor the cycle is WITHHELD — `nil`, and the HR + /// figure is not substituted — exactly as on the day path. No reader (toggle off) or no rows = Keytel. + private static func cycleCalories(_ cycleHR: [HRSample], onset: Int, endExclusive: Int, day: String, + owner: String, hrEndInclusive: Int, now: Int, profile: UserProfile, + effectiveMaxHR: Double?, restingHR: Double, metReader: MetReader?, + trace: ((String) -> Void)?) async -> Double? { + let cycleMet = hrEndInclusive >= onset ? await metReader?(owner, onset, hrEndInclusive) ?? [] : [] + if !cycleMet.isEmpty { + let met = Calories.estimateDayEnergyFromMET(cycleMet, profile: profile, + dayStart: onset, dayEnd: min(endExclusive, now)) + let covered = met.coverageFraction >= Calories.metMinCoverageFraction + trace?("stepsCycle calories day=\(day) path=met coverage=\(Int((met.coverageFraction * 100).rounded()))% " + + "active=\(Int(met.activeKcal.rounded())) total=\(Int(met.totalKcal.rounded())) " + + (covered ? "" : "withheld")) + return covered ? met.totalKcal : nil + } + return cycleHR.isEmpty ? nil : Calories.estimateDayCalories( + cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR) + } + static func applying(_ result: Result, to daily: DailyMetric) -> DailyMetric { let established = result.firstWakeDay.map { daily.day >= $0 } ?? false let steps = established ? result.stepsByWakeDay[daily.day] : daily.steps diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 124a96f839..c989440eec 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -103,7 +103,7 @@ final class IntelligenceEngine: ObservableObject { "hrvBaseline", "rhrBaseline", "age", "sex", "stepTicksPerStep", "maxHROverride", "tzOffset", "sleepNeedHours", "sleepConsistency", "habitualMidsleep", "experimentalSleepV2", "motionAwareWake", "deepHrvWindow", "spo2CandidateDisplay", - "effortMethod", "dayCycleMode", + "effortMethod", "dayCycleMode", "ouraMetCalories", ] /// Which config field(s) moved between two signatures, for the `configDropped` tally. @@ -963,6 +963,11 @@ final class IntelligenceEngine: ObservableObject { // into the config signature below rather than the per-day key. let effortMethodGlobal = PuffinExperiment.effortMethod let dayCycleMode = DayCycleMode.persisted(UserDefaults.standard.string(forKey: DayCycleMode.storageKey)) + // #2242: the Experimental MET-calories toggle, read ONCE per pass like the others. When ON, each + // ring day's persisted 0x50 MET rows are read and handed to analyzeDay, which then scores + // `activeKcalEst` by Oura's method instead of the HR path. Global, so it joins the config + // signature below: flipping it must re-score every cached day, not just the next one. + let ouraMetCaloriesOn = UserDefaults.standard.bool(forKey: AppModel.ouraMetCaloriesKey) // Zero the per-day probe counters so the line emitted after the steps phase describes THIS pass // and never accumulates across the back-to-back passes an offload storm is made of. Must precede @@ -1026,6 +1031,7 @@ final class IntelligenceEngine: ObservableObject { // window of days scored by a recipe the user just turned off, with nothing to explain it. "\(effortMethodGlobal)", dayCycleMode.rawValue, + "\(ouraMetCaloriesOn)", // #2242 ].joined(separator: "|") // Drop the whole cache on a config change, then snapshot it into a Sendable `let` for the detached // loop (the engine is @MainActor; the loop can't touch `self`). The loop returns the updated cache @@ -1328,6 +1334,19 @@ final class IntelligenceEngine: ObservableObject { } else { dayGrav = (try? await store.gravitySamples(deviceId: owner, from: dayMid, to: dayEnd, limit: 200_000)) ?? [] } + // #2242: the day owner's OWN per-minute MET series (an Oura ring's persisted 0x50 rows), + // calendar-day scoped like dayHr, read only while the Experimental toggle is on. Same + // `owner` as every other read here — the registry's active id, never a raw address — so a + // WHOOP owner reads an empty table and stays on the HR path. Handed to analyzeDay as nil + // when empty, which is the byte-identical HR path. A past day's rows are re-read on every + // pass, so the wake drain that lands a whole day at once is picked up by the next re-score. + let dayMet: [Calories.MetSample]? + if ouraMetCaloriesOn { + let rows = (try? await store.ouraMetSamples(deviceId: owner, from: dayMid, to: dayEnd, limit: 4_000)) ?? [] + dayMet = rows.isEmpty ? nil : rows.map { Calories.MetSample(ts: $0.ts, met: $0.met, secPerSample: $0.epochS) } + } else { + dayMet = nil + } // CONSUME (#531 / #175): the strap's OWN band sleep_state for the night window as timestamped // (ts, state) samples, so the H7 morning-stillness guard can confirm a borderline re-onset @@ -1435,6 +1454,8 @@ final class IntelligenceEngine: ObservableObject { vendorResp: vendorResp, gravity: grav, steps: steps, dayHr: dayHr, daySteps: daySteps, dayGravity: dayGrav, + dayMet: dayMet, dayMetNow: now, // #2242 + caloriesDiag: { strainDiagLines.append($0) }, // #2242: same per-day recorder skinTemp: skin, skinTempFamily: skinFamily, // #938 skinTempAnchorRaw: skinAnchorRaw, // #938 second capture @@ -2058,6 +2079,11 @@ final class IntelligenceEngine: ObservableObject { profile: up, maxHROverride: maxHR, effortMethod: effortMethodGlobal, + // #2242: the fold makes the same MET-vs-HR energy decision as analyzeDay, over its own window. + metReader: ouraMetCaloriesOn ? { owner, from, to in + let rows = (try? await store.ouraMetSamples(deviceId: owner, from: from, to: to, limit: 4_000)) ?? [] + return rows.map { Calories.MetSample(ts: $0.ts, met: $0.met, secPerSample: $0.epochS) } + } : nil, trace: stepsTraceActive ? { self.diagnosticSink?($0, .steps) } : nil) // #299: `editsByStart` is now built PER DAY inside the scoring loop (scoped to the day each edit // belongs to), NOT window-wide here. sleepEditedDaily folds any edited row that isn't a twin of THIS diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index f48591a1fd..62b1e148a6 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -216017,7 +216017,51 @@ } }, "no time of its own": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "keine eigene Zeit"}}, "es": {"stringUnit": {"state": "translated", "value": "sin hora propia"}}, "fr": {"stringUnit": {"state": "translated", "value": "pas d'heure propre"}}, "it": {"stringUnit": {"state": "translated", "value": "nessun orario proprio"}}, "pl": {"stringUnit": {"state": "translated", "value": "brak własnej godziny"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "sem hora própria"}}, "ru": {"stringUnit": {"state": "translated", "value": "без своего времени"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "没有单独时间"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "沒有單獨時間"}} - } } + } }, + "Experimental · Oura Calories": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Experimentell · Oura-Kalorien"}}, + "es": {"stringUnit": {"state": "translated", "value": "Experimental · Calorías Oura"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Expérimental · Calories Oura"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Experimental · Calorias Oura"}}, + "it": {"stringUnit": {"state": "translated", "value": "Sperimentale · Calorie Oura"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Eksperymentalne · Kalorie Oura"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Экспериментально · Калории Oura"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "实验性 · Oura 卡路里"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "實驗性 · Oura 卡路里"}} + } }, + "Scores your day's calories from the ring's own minute-by-minute activity intensity instead of heart rate alone.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Berechnet die Kalorien des Tages aus der minutengenauen Aktivitätsintensität des Rings statt nur aus der Herzfrequenz."}}, + "es": {"stringUnit": {"state": "translated", "value": "Calcula las calorías del día a partir de la intensidad de actividad minuto a minuto del anillo en lugar de solo la frecuencia cardíaca."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Calcule les calories de la journée à partir de l'intensité d'activité minute par minute de la bague plutôt que de la seule fréquence cardiaque."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Calcula as calorias do dia a partir da intensidade de atividade minuto a minuto do anel em vez de apenas a frequência cardíaca."}}, + "it": {"stringUnit": {"state": "translated", "value": "Calcola le calorie della giornata dall'intensità di attività minuto per minuto dell'anello invece che dalla sola frequenza cardiaca."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Oblicza kalorie dnia na podstawie minutowej intensywności aktywności z pierścienia zamiast samego tętna."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Рассчитывает калории за день по поминутной интенсивности активности кольца, а не только по пульсу."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "根据戒指自身逐分钟的活动强度而非仅凭心率来计算一天的卡路里。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "根據戒指自身逐分鐘的活動強度而非僅憑心率來計算一天的卡路里。"}} + } }, + "Active calories from the ring's MET stream": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Aktive Kalorien aus dem MET-Datenstrom des Rings"}}, + "es": {"stringUnit": {"state": "translated", "value": "Calorías activas a partir del flujo MET del anillo"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Calories actives à partir du flux MET de la bague"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Calorias ativas a partir do fluxo MET do anel"}}, + "it": {"stringUnit": {"state": "translated", "value": "Calorie attive dal flusso MET dell'anello"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Kalorie aktywne ze strumienia MET pierścienia"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Активные калории из потока MET кольца"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "根据戒指的 MET 数据流计算活动卡路里"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "根據戒指的 MET 資料流計算活動卡路里"}} + } }, + "Uses Oura's documented method: each minute's intensity above 1.5 MET, at the standard MET rate for your weight, on top of resting energy for the minutes the ring reported. An estimate, not a measurement — on a day without a logged workout it matches the Oura app's own figure (Oura re-scores a logged workout's minutes by activity type, which NOOP does not), and a day the ring covered less than half of shows no number rather than a guess. Turning this on also starts storing the ring's MET samples on this device; it never feeds recovery or illness scoring. Off by default.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Verwendet die von Oura dokumentierte Methode: die Intensität jeder Minute über 1,5 MET, mit der Standard-MET-Rate für Ihr Gewicht, zusätzlich zur Ruheenergie für die Minuten, die der Ring gemeldet hat. Eine Schätzung, keine Messung – an einem Tag ohne protokolliertes Training entspricht sie dem Wert der Oura-App (Oura bewertet die Minuten eines protokollierten Trainings nach Aktivitätstyp neu, NOOP nicht), und ein Tag, den der Ring weniger als zur Hälfte abgedeckt hat, zeigt keine Zahl statt einer Vermutung. Beim Einschalten werden außerdem die MET-Werte des Rings auf diesem Gerät gespeichert; sie fließen nie in die Erholungs- oder Krankheitsbewertung ein. Standardmäßig aus."}}, + "es": {"stringUnit": {"state": "translated", "value": "Usa el método documentado por Oura: la intensidad de cada minuto por encima de 1,5 MET, a la tasa MET estándar para tu peso, además de la energía en reposo de los minutos que el anillo registró. Es una estimación, no una medición: en un día sin entrenamiento registrado coincide con la cifra de la propia app de Oura (Oura vuelve a puntuar los minutos de un entrenamiento registrado según el tipo de actividad, cosa que NOOP no hace), y un día que el anillo cubrió menos de la mitad no muestra ningún número en lugar de una suposición. Al activarlo también se empiezan a guardar las muestras MET del anillo en este dispositivo; nunca alimenta la puntuación de recuperación ni de enfermedad. Desactivado por defecto."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Utilise la méthode documentée par Oura : l'intensité de chaque minute au-dessus de 1,5 MET, au taux MET standard pour votre poids, en plus de l'énergie de repos des minutes rapportées par la bague. Une estimation, pas une mesure : un jour sans entraînement enregistré, elle correspond au chiffre de l'app Oura elle-même (Oura recalcule les minutes d'un entraînement enregistré selon le type d'activité, ce que NOOP ne fait pas), et un jour couvert à moins de la moitié par la bague n'affiche aucun chiffre plutôt qu'une supposition. L'activer démarre aussi l'enregistrement des échantillons MET de la bague sur cet appareil ; cela n'alimente jamais le score de récupération ni de maladie. Désactivé par défaut."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Usa o método documentado pela Oura: a intensidade de cada minuto acima de 1,5 MET, à taxa MET padrão para o seu peso, além da energia de repouso dos minutos que o anel registou. Uma estimativa, não uma medição — num dia sem treino registado coincide com o valor da própria app Oura (a Oura reavalia os minutos de um treino registado pelo tipo de atividade, o que o NOOP não faz), e um dia que o anel cobriu menos de metade não mostra número em vez de um palpite. Ao ativar, começa também a guardar as amostras MET do anel neste dispositivo; nunca alimenta a pontuação de recuperação nem de doença. Desligado por predefinição."}}, + "it": {"stringUnit": {"state": "translated", "value": "Usa il metodo documentato da Oura: l'intensità di ogni minuto sopra 1,5 MET, al tasso MET standard per il tuo peso, in aggiunta all'energia a riposo dei minuti riportati dall'anello. Una stima, non una misura: in un giorno senza allenamento registrato coincide con il valore dell'app Oura stessa (Oura ricalcola i minuti di un allenamento registrato in base al tipo di attività, cosa che NOOP non fa), e un giorno coperto dall'anello per meno della metà non mostra alcun numero anziché un'ipotesi. Attivandolo si inizia anche a salvare i campioni MET dell'anello su questo dispositivo; non alimenta mai il punteggio di recupero o di malattia. Disattivato per impostazione predefinita."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Używa udokumentowanej metody Oura: intensywność każdej minuty powyżej 1,5 MET, według standardowej stawki MET dla Twojej wagi, dodatkowo do energii spoczynkowej za minuty zgłoszone przez pierścień. To szacunek, nie pomiar — w dniu bez zarejestrowanego treningu zgadza się z wartością samej aplikacji Oura (Oura przelicza minuty zarejestrowanego treningu według typu aktywności, czego NOOP nie robi), a dzień, którego pierścień nie pokrył w co najmniej połowie, nie pokazuje żadnej liczby zamiast zgadywania. Włączenie rozpoczyna też zapisywanie próbek MET pierścienia na tym urządzeniu; nigdy nie zasila oceny regeneracji ani choroby. Domyślnie wyłączone."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Использует задокументированный метод Oura: интенсивность каждой минуты выше 1,5 MET по стандартной ставке MET для вашего веса, в дополнение к энергии покоя за минуты, о которых сообщило кольцо. Это оценка, а не измерение — в день без записанной тренировки она совпадает с показателем самого приложения Oura (Oura пересчитывает минуты записанной тренировки по типу активности, чего NOOP не делает), а день, который кольцо покрыло меньше чем наполовину, не показывает число вместо догадки. Включение также запускает сохранение образцов MET кольца на этом устройстве; они никогда не влияют на оценку восстановления или болезни. По умолчанию выключено."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "采用 Oura 公开的方法:每一分钟高于 1.5 MET 的部分,按您体重对应的标准 MET 速率计算,再加上戒指已报告分钟的静息能量。这是估算而非测量——在没有记录锻炼的日子里,它与 Oura 应用自身的数值一致(Oura 会按活动类型重新计算已记录锻炼的分钟,NOOP 不会);戒指覆盖不足半天的日子将不显示数字,而不是给出猜测。开启后也会开始在本设备上存储戒指的 MET 样本;它永远不会影响恢复或疾病评分。默认关闭。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "採用 Oura 公開的方法:每一分鐘高於 1.5 MET 的部分,按您體重對應的標準 MET 速率計算,再加上戒指已回報分鐘的靜息能量。這是估算而非測量——在沒有記錄運動的日子裡,它與 Oura 應用程式自身的數值一致(Oura 會按活動類型重新計算已記錄運動的分鐘,NOOP 不會);戒指覆蓋不足半天的日子將不顯示數字,而不是給出猜測。開啟後也會開始在本裝置上儲存戒指的 MET 樣本;它永遠不會影響恢復或疾病評分。預設關閉。"}} + } } }, "version": "1.0" } diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index e0f0cdf31f..e6c922a72c 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -55,6 +55,7 @@ struct SettingsView: View { /// as a "strap estimate (unverified)" fallback when no calibrated `spo2Pct` exists. Display-only — /// writes nothing to the strap. See [PuffinExperiment.spo2CandidateDisplayKey]. @AppStorage(PuffinExperiment.spo2CandidateDisplayKey) private var spo2CandidateDisplayEnabled = false + @AppStorage(AppModel.ouraMetCaloriesKey) private var ouraMetCaloriesEnabled = false // #2242 /// #1545 opt-in: score Effort with Banister's exponential TRIMP instead of Edwards' heart-rate zones. /// Default OFF — it re-scores the whole window against a different recipe. See @@ -1837,6 +1838,7 @@ struct SettingsView: View { // WHOOP 5/MG protocol research now lives in Test Centre. Everyday Settings no longer carries // a second copy; the persisted keys and reversible disable actions remain unchanged there. if showFiveMGControls || model.repo.activeDeviceIsOura { spo2CandidateCard } + if model.repo.activeDeviceIsOura { ouraMetCaloriesCard } // #2242 sleepStagingCard rawSensorDiagnosticsCard } @@ -1971,6 +1973,38 @@ struct SettingsView: View { } } + /// #2242: active calories from the Oura ring's OWN per-minute MET stream (0x50), Oura's documented + /// method with no fitted constant, in place of the HR-only Keytel path over the ring's sparse banked + /// HR (which does not track Oura's number, r ≈ −0.1). Default OFF; the toggle gates BOTH the + /// `ouraMetSample` writer and the analyzeDay read, so an OFF install's DB and scores are unchanged. + /// Oura-only, so it renders only for an active ring. + private var ouraMetCaloriesCard: some View { + SettingsSection( + icon: "flame.fill", + title: "Experimental · Oura Calories", + blurb: "Scores your day's calories from the ring's own minute-by-minute activity intensity instead of heart rate alone." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + Toggle(isOn: $ouraMetCaloriesEnabled) { + Text("Active calories from the ring's MET stream") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .onChangeCompat(of: ouraMetCaloriesEnabled) { _ in + // Re-score now so every cached day flips path on this toggle (the toggle is part of + // the day-cache config signature) instead of waiting for the next analyze loop. + Task { await model.intelligence.analyzeRecent(); await model.repo.refresh() } + } + Text("Uses Oura's documented method: each minute's intensity above 1.5 MET, at the standard MET rate for your weight, on top of resting energy for the minutes the ring reported. An estimate, not a measurement — on a day without a logged workout it matches the Oura app's own figure (Oura re-scores a logged workout's minutes by activity type, which NOOP does not), and a day the ring covered less than half of shows no number rather than a guess. Turning this on also starts storing the ring's MET samples on this device; it never feeds recovery or illness scoring. Off by default.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + // MARK: - Diagnostics (every model) /// Raw-sensor CSV export — a read-only diagnostic over the decoded streams NOOP already stores diff --git a/StrandTests/DayCacheConfigFieldTests.swift b/StrandTests/DayCacheConfigFieldTests.swift index 483c2bfc4f..cc2d0b625a 100644 --- a/StrandTests/DayCacheConfigFieldTests.swift +++ b/StrandTests/DayCacheConfigFieldTests.swift @@ -75,7 +75,7 @@ final class DayCacheConfigFieldTests: XCTestCase { "hrvBaseline", "rhrBaseline", "age", "sex", "stepTicksPerStep", "maxHROverride", "tzOffset", "sleepNeedHours", "sleepConsistency", "habitualMidsleep", "experimentalSleepV2", "motionAwareWake", "deepHrvWindow", "spo2CandidateDisplay", - "effortMethod", "dayCycleMode", + "effortMethod", "dayCycleMode", "ouraMetCalories", ]) } } diff --git a/StrandTests/DayCycleRecoveryTests.swift b/StrandTests/DayCycleRecoveryTests.swift index 47369732a5..f0d2b650ef 100644 --- a/StrandTests/DayCycleRecoveryTests.swift +++ b/StrandTests/DayCycleRecoveryTests.swift @@ -1,6 +1,7 @@ import XCTest import StrandAnalytics import WhoopStore +import WhoopProtocol @testable import Strand @MainActor @@ -161,3 +162,105 @@ final class DayCycleRecoveryTests: XCTestCase { skinTempDev: skinDev) } } + +// MARK: - #2242: the fold makes analyzeDay's MET-vs-HR energy decision over its own window + +/// 2026-09-17, first hardware day of the MET-calories toggle: `AnalyticsEngine.analyzeDay` logged the ring's +/// MET number (1220 kcal) and the export held 743 — Keytel over the wake-to-wake HR window, which this +/// fold recomputed unconditionally and `applying()` wrote over the day's `activeKcalEst`. These pin the +/// three outcomes: a covered MET cycle carries the MET total, a thin one is WITHHELD (no HR substitute), +/// and no MET reader (toggle off) is the byte-identical Keytel path. +extension DayCycleRecoveryTests { + + private struct CycleFixture { + let store: WhoopStore + let night: DayCycleIntelligenceIntegration.Night + let onset: Int + let now: Int + let day: String + static let owner = "oura-ring" + } + + /// One 8-h main sleep 22:00 → 06:00 UTC, `now` 12 h after wake, 1 Hz HR across the whole cycle so the + /// Keytel path has something to say when it is allowed to. + private func cycleFixture() async throws -> CycleFixture { + let store = try await WhoopStore.inMemory() + let onset = 1_755_208_800 - 2 * 3_600 // 2026-08-14 22:00 UTC + let wake = onset + 8 * 3_600 + let now = wake + 12 * 3_600 + let day = AnalyticsEngine.dayString(wake, offsetSec: 0) + try await store.upsertDevice(id: CycleFixture.owner, mac: nil, name: "Oura") + let hr = stride(from: onset, to: now, by: 1).map { HRSample(ts: $0, bpm: $0 < wake ? 52 : 74) } + try await store.insert(Streams(hr: hr), deviceId: CycleFixture.owner) + let sleep = CachedSleepSession(startTs: onset, endTs: wake, efficiency: 0.9, restingHr: 50, + avgHrv: nil, stagesJSON: nil, deviceId: CycleFixture.owner) + let daily = DailyMetric( + day: day, totalSleepMin: 460, efficiency: 0.9, deepMin: 80, remMin: 90, lightMin: 290, + disturbances: 3, restingHr: 50, avgHrv: nil, recovery: nil, strain: nil, exerciseCount: nil, + steps: nil, activeKcalEst: nil, skinTempC: nil, sleepHrOnly: nil) + let night = DayCycleIntelligenceIntegration.Night(daily: daily, sleeps: [sleep], workouts: [], + owner: CycleFixture.owner) + return CycleFixture(store: store, night: night, onset: onset, now: now, day: day) + } + + private func computeCycle(_ f: CycleFixture, + metReader: DayCycleIntelligenceIntegration.MetReader?) async + -> DayCycleIntelligenceIntegration.Result { + await DayCycleIntelligenceIntegration.compute( + nights: [f.night], editedRows: [], store: f.store, + candidates: [(owner: CycleFixture.owner, priority: 0)], + physiologyOwners: [CycleFixture.owner], workouts: [], + windowStart: f.onset - 86_400, now: f.now, offsetSec: 0, + habitualMidsleepSec: nil, ticksPerStep: 1, mode: .sleepOnset, + cache: DayCycleIntelligenceIntegration.Cache(), profile: UserProfile(), + maxHROverride: nil, effortMethod: .edwards, + recoveryReader: DayCycleIntelligenceIntegration.BoundaryRecoveryReader( + sleepSessions: { _, _, _ in [] }, markers: { _, _, _ in [] }), + metReader: metReader) + } + + func testCycleEnergyIsTheMetTotalWhenTheCycleIsCovered() async throws { + let f = try await cycleFixture() + // Every minute of the cycle at 1.1 MET, one 30-min 4.0 bout after wake. + let wake = f.onset + 8 * 3_600 + let met = stride(from: f.onset, to: f.now, by: 60).map { + Calories.MetSample(ts: $0, met: ($0 >= wake + 3_600 && $0 < wake + 5_400) ? 4.0 : 1.1) + } + var asked: [(String, Int, Int)] = [] + let result = await computeCycle(f) { owner, from, to in asked.append((owner, from, to)); return met } + + let expected = Calories.estimateDayEnergyFromMET(met, profile: UserProfile(), + dayStart: f.onset, dayEnd: f.now) + XCTAssertEqual(expected.coverageFraction, 1.0, accuracy: 1e-9) + XCTAssertEqual(result.caloriesByWakeDay[f.day], expected.totalKcal, "the MET total, not Keytel over the cycle HR") + XCTAssertEqual(asked.count, 1) + XCTAssertEqual(asked.first?.0, CycleFixture.owner) + XCTAssertEqual(asked.first?.1, f.onset) + XCTAssertEqual(asked.first?.2, f.now - 1, "the cycle window, inclusive end like every store read") + // And the fold carries it onto the row (the write that used to bring Keytel back). + let applied = DayCycleIntelligenceIntegration.applying(result, to: f.night.daily) + XCTAssertEqual(applied.activeKcalEst, expected.totalKcal) + } + + func testThinMetCoverageWithholdsTheCycleAndDoesNotSubstituteKeytel() async throws { + let f = try await cycleFixture() + // 10 % of the cycle covered — below the floor — with plenty of HR alongside. + let met = stride(from: f.onset, to: f.onset + 2 * 3_600, by: 60).map { Calories.MetSample(ts: $0, met: 3.0) } + let result = await computeCycle(f) { _, _, _ in met } + XCTAssertNil(result.caloriesByWakeDay[f.day], "withheld: no entry, no HR figure in its place") + let applied = DayCycleIntelligenceIntegration.applying(result, to: f.night.daily) + XCTAssertNil(applied.activeKcalEst) + // The rest of the fold is untouched by the decision. + XCTAssertNotNil(result.strainByWakeDay[f.day]) + } + + func testNoMetReaderIsTheKeytelPath() async throws { + let f = try await cycleFixture() + let result = await computeCycle(f, metReader: nil) + let keytel = try XCTUnwrap(result.caloriesByWakeDay[f.day]) + XCTAssertGreaterThan(keytel, 0) + // An empty MET read is the same as no reader: the HR path, byte for byte. + let empty = await computeCycle(f) { _, _, _ in [] } + XCTAssertEqual(empty.caloriesByWakeDay[f.day], keytel) + } +} diff --git a/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt b/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt index fe70628ce4..a9d91c3b71 100644 --- a/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt +++ b/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt @@ -116,6 +116,7 @@ internal object DayCycleIntelligenceIntegration { profile: UserProfile, maxHROverride: Double?, effortMethod: StrainScorer.Method, + ouraMetCalories: Boolean = false, ): PhysiologicalStepCycleEngine.Result { val witnesses = scoredNights.associate { result -> result.daily.day to dayWitness(resolvedOwners[result.daily.day].orEmpty(), result) @@ -123,7 +124,7 @@ internal object DayCycleIntelligenceIntegration { return PhysiologicalStepCycleEngine.compute( scoredNights, editedRows, resolvedOwners, candidatePriorities, witnesses, repo, tzOffsetSeconds, habitualMidsleepSec, windowStart, nowSeconds, stepTicksPerStep, traceSink, mode, - profile, maxHROverride, effortMethod, + profile, maxHROverride, effortMethod, ouraMetCalories, ) } diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 82501e07a7..3604d3d79a 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -61,6 +61,16 @@ object IntelligenceEngine { */ private val analyzeGate = Mutex() + /** + * #2242: the Experimental MET-calories toggle for the pass in flight. Carried on a field, NOT as a parameter + * of [analyzeRecentOnCpu], for a reason worth knowing: adding ONE parameter to that method shifts every local + * variable slot by one, and in a method that size that pushes hundreds of slot accesses across the 255 + * boundary into the `wide` form — +1.9 KB of bytecode for a single Boolean, straight through the JaCoCo + * budget (IntelligenceEngineJacocoBudgetTest). Written by [analyzeRecent] under [analyzeGate] immediately + * before the pass and read only inside it, so — like [dayScanCache] — there is no concurrent access. + */ + private var ouraMetCaloriesForPass: Boolean = false + /** #1816: optional sink for whether the strap banked ANY motion in the calibration scan window. * Set by the caller (AppViewModel / WhoopBleClient) before calling [analyzeRecent] and cleared * after, so the Today tile can distinguish "Need N more phone-step days" (motion exists, phone @@ -492,6 +502,11 @@ object IntelligenceEngine { // and passes it down, keeping this layer Context-free. EDWARDS default = byte-identical. effortMethod: StrainScorer.Method = StrainScorer.Method.EDWARDS, dayCycleMode: DayCycleMode = DayCycleMode.SLEEP_ONSET, + // #2242: the Experimental "active calories from the ring's MET stream" toggle. Same Context-free + // threading: the caller reads NoopPrefs.ouraMetCalories(context). When true each ring day's + // persisted 0x50 MET rows are read and handed to analyzeDay, which scores `activeKcalEst` by + // Oura's method instead of the HR path. Default false = byte-identical. + ouraMetCalories: Boolean = false, // Persisted backing for [stepsMotionCache]. Context-free like the rest of this layer, mirroring // manualStepCoefficient / persistStepsCalibration above: the Context-aware caller (AppViewModel) // reads and writes SharedPreferences and passes the accessors down. The defaults are no-ops, so a @@ -516,6 +531,7 @@ object IntelligenceEngine { // across the back-to-back passes an offload storm is made of. Reset and emit both live in this // wrapper, never in `analyzeRecentOnCpu`, whose ratchet margin has no room for either. StoreProbeTally.reset() + ouraMetCaloriesForPass = ouraMetCalories // #2242: see the field if (!stepsMotionCacheLoaded && stepsMotionCacheGet != null) { stepsMotionCacheLoaded = true val raw = stepsMotionCacheGet() @@ -864,6 +880,7 @@ object IntelligenceEngine { // window of days scored by a recipe the user just turned off, with nothing to explain it. effortMethod.toString(), dayCycleMode.persistedValue, + ouraMetCaloriesForPass.toString(), // #2242 ).joinToString("|") // Drop the whole cache on a config change. Under [analyzeGate] (this whole pass runs holding the // lock), so mutating the object-level cache here is race-free. @@ -1034,9 +1051,16 @@ object IntelligenceEngine { val vendorResp = OuraRespScale.forVendorRate(respRows, owner) val grav = repo.gravitySamplesForDevice(owner, from, to, StreamReadCap.GRAVITY) val steps = repo.stepSamples(owner, from, to, STREAM_LIMIT) + // Calendar-day window — defined here, ahead of its explanation below, because the skin/MET + // read helper takes it (#2242 reads the day's MET rows inside that helper on purpose: a + // NEW suspend call in this method spills every live local into the continuation, ~+700 + // instructions, straight through the JaCoCo budget; riding an existing one costs nothing). + val dayMidnight = midnightLocal(dayStart, tzOffsetSeconds) + val dayEnd = dayMidnight + SECONDS_PER_DAY - 1 val skinReads = readDaySkinAndWristOff( repo, owner, from, to, ownerSource, skinFamilyByOwner, skinWornToleranceByOwner, skinAnchorByOwner, skinAnchorResolvedOwners, skinAnchorScanFrom, skinAnchorScanTo, + dayMidnight, dayEnd, ouraMetCaloriesForPass, ) val skin = skinReads.skin val spo2 = skinReads.spo2 @@ -1053,8 +1077,7 @@ object IntelligenceEngine { // MIN_HR_SAMPLES gate above stays on the night window so empty days are still skipped. // `dayStart` is already a LOCAL midnight; midnightLocal is idempotent on it (the DAO range // is inclusive, so end at +86400-1s; analyzeDay also filters to the day). (#277) - val dayMidnight = midnightLocal(dayStart, tzOffsetSeconds) - val dayEnd = dayMidnight + SECONDS_PER_DAY - 1 + // (`dayMidnight` / `dayEnd` are declared above the skin/MET read, see there.) // Same [owner] as the night window above (I2): the additive day totals must come from the one // device that owns the day, never a mix. // #997: for a PAST day the [from, to] night read above already spans this calendar day (to = @@ -1155,6 +1178,11 @@ object IntelligenceEngine { // #1770 follow-up: route the Effort funnel through the SAME per-day recorder as the // `workout detect` and `sleep-detect` lines, so a report explains all three the same way. strainDiag = ::dayDiag, + // #2242: the owner's own MET series for the calorie path, or null = the HR path — read + // inside readDaySkinAndWristOff (see the note at its call) and taken straight off the holder. + dayMet = skinReads.dayMet, + dayMetNow = nowSeconds, + caloriesDiag = ::dayDiag, // same per-day recorder hr = hr, rr = rr, resp = resp, @@ -1644,6 +1672,7 @@ object IntelligenceEngine { tzOffsetSeconds, habitualMidsleepSec, windowStart, nowSeconds, profile.stepTicksPerStep, stepsTraceSink, dayCycleMode, profile, maxHROverride, effortMethod, + ouraMetCaloriesForPass, // #2242: the fold makes analyzeDay's MET-vs-HR decision over its window ) for (res in scoredNights) { @@ -2915,7 +2944,7 @@ object IntelligenceEngine { "hrvBaseline", "rhrBaseline", "age", "sex", "stepTicksPerStep", "maxHROverride", "tzOffset", "sleepNeedHours", "sleepConsistency", "habitualMidsleep", "experimentalSleepV2", "motionAwareWake", "deepHrvWindow", "spo2CandidateDisplay", - "effortMethod", "dayCycleMode", + "effortMethod", "dayCycleMode", "ouraMetCalories", ) /** Which config field(s) changed between two signatures, for the `configDropped` tally. @@ -3050,6 +3079,9 @@ object IntelligenceEngine { skinAnchorResolvedOwners: HashSet, skinAnchorScanFrom: Long, skinAnchorScanTo: Long, + dayMidnight: Long, + dayEnd: Long, + ouraMetCalories: Boolean, ): DaySkinReads { val skin = repo.skinTempSamples(owner, from, to, StreamReadCap.SKIN) // #93: WHOOP 4.0 raw SpO2 PPG samples for the night; analyzeDay banks the nightly red/IR ADC @@ -3096,7 +3128,20 @@ object IntelligenceEngine { // short off-wrist tail survives. Pairing needs WRIST_ON too (to bound each interval); a span // still open at the window end closes at `to`. Empty when the strap emitted no wrist events. val wristOff = AnalyticsEngine.offWristIntervals(repo.events(owner, from, to, STREAM_LIMIT), to) - return DaySkinReads(skin, spo2, skinFamily, skinWornToleranceSec, skinAnchorRaw, wristOff) + // #2242: the day owner's OWN per-minute MET series (an Oura ring's persisted 0x50 rows), calendar-day + // scoped like dayHr, read only while the Experimental toggle is on. Same `owner` as every other read + // — the registry's active id, never a raw address — so a WHOOP owner reads an empty table and stays + // on the HR path. null when empty or off = analyzeDay's byte-identical HR path. A past day's rows + // are re-read on every pass, so the wake drain that lands a whole day at once is picked up by the + // next re-score. Lives in THIS helper, not in analyzeRecentOnCpu, for the reason at the call site. + val dayMet: List? = if (ouraMetCalories) { + repo.ouraMetSamples(owner, dayMidnight, dayEnd, 4_000) + .map { Calories.MetSample(it.ts, it.met, it.epochS) } + .ifEmpty { null } + } else { + null + } + return DaySkinReads(skin, spo2, skinFamily, skinWornToleranceSec, skinAnchorRaw, wristOff, dayMet) } /** What [readDaySkinAndWristOff] hands back. A holder rather than loose returns so the call site @@ -3108,6 +3153,8 @@ object IntelligenceEngine { val skinWornToleranceSec: Long, val skinAnchorRaw: Double?, val wristOff: List>, + /** #2242: the day's MET samples for the calorie path, or null = HR path. */ + val dayMet: List?, ) /** diff --git a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt index b017af3bb1..3e3f702da5 100644 --- a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt @@ -1,5 +1,6 @@ package com.noop.analytics +import com.noop.data.HrSample import com.noop.data.MetricSeriesRow import com.noop.data.SleepSession import com.noop.data.WhoopRepository @@ -49,6 +50,40 @@ internal object PhysiologicalStepCycleEngine { effortMethod: StrainScorer.Method, profile: UserProfile, ): String = "$onset-$endExclusive|$hrWitness|rhr=$restingHr|max=${maxHr ?: "nil"}|$effortMethod|${profile.cacheKey}" + /** + * The cycle's energy, by the SAME decision AnalyticsEngine.analyzeDay takes for the calendar day (#2242), + * over the wake-to-wake window [onset, min(endExclusive, now)) instead. A device that measures its own + * minute-by-minute intensity decides it by that stream. This fold used to recompute Keytel over the + * cycle's HR unconditionally and the integration wrote that over the day's activeKcalEst, so on any phone + * with a day-cycle history the MET number never reached the row (2026-09-17 on iOS: the log said 1220 + * kcal, the export held 743 — the HR figure). Below the coverage floor the cycle is WITHHELD — null, and + * the HR figure is not substituted — as on the day path. Toggle off or no rows = Keytel. Swift twin: + * DayCycleIntelligenceIntegration.cycleCalories. + */ + private suspend fun cycleCalories( + cycleHr: List, onset: Long, endExclusive: Long, wakeDay: String, owner: String, + nowSeconds: Long, profile: UserProfile, effectiveMaxHr: Double?, restingHr: Double, + ouraMetCalories: Boolean, repo: WhoopRepository, stepsTraceSink: ((String) -> Unit)?, + ): Double? { + val cycleMet = if (ouraMetCalories && endExclusive - 1L >= onset) { + repo.ouraMetSamples(owner, onset, endExclusive - 1L, 4_000) + .map { Calories.MetSample(it.ts, it.met, it.epochS) } + } else { + emptyList() + } + if (cycleMet.isNotEmpty()) { + val met = Calories.estimateDayEnergyFromMet(cycleMet, profile, onset, minOf(endExclusive, nowSeconds)) + val covered = met.coverageFraction >= Calories.MET_MIN_COVERAGE_FRACTION + stepsTraceSink?.invoke( + "stepsCycle calories day=$wakeDay path=met coverage=${Math.round(met.coverageFraction * 100)}% " + + "active=${Math.round(met.activeKcal)} total=${Math.round(met.totalKcal)} " + + if (covered) "" else "withheld", + ) + return if (covered) met.totalKcal else null + } + return if (cycleHr.isNotEmpty()) Calories.estimateDayCalories(cycleHr, profile, effectiveMaxHr, restingHr) else null + } + suspend fun compute( scoredNights: List, editedRows: List, @@ -66,6 +101,7 @@ internal object PhysiologicalStepCycleEngine { profile: UserProfile, maxHROverride: Double?, effortMethod: StrainScorer.Method, + ouraMetCalories: Boolean = false, ): Result { if (dayCycleMode == DayCycleMode.MIDNIGHT) { return Result(emptyMap(), emptyMap(), emptyMap(), emptyMap(), emptyMap(), null, emptyList()) @@ -229,9 +265,10 @@ internal object PhysiologicalStepCycleEngine { CachedLoad( key = loadKey, strain = StrainScorer.strain(cycleHr, effectiveMaxHr, restingHr, effortMethod, profile.sex), - calories = if (cycleHr.isNotEmpty()) { - Calories.estimateDayCalories(cycleHr, profile, effectiveMaxHr, restingHr) - } else null, + calories = cycleCalories( + cycleHr, window.onset, window.endExclusive, wakeDay, fallbackOwner, nowSeconds, + profile, effectiveMaxHr, restingHr, ouraMetCalories, repo, stepsTraceSink, + ), ).also { loadCache[window.sleepId] = it } } load.strain?.let { strainByWakeDay[wakeDay] = it } diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index 0982b3fa8a..0864ce124f 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -142,6 +142,12 @@ class OuraLiveSource( * switching the toggle off restores the default with nothing left on the ring. Twin of Swift's * `notifyMaskFull`. */ private val notifyMaskFull: () -> Boolean = { false }, + /** #2242: persist one anchored 0x50 record's samples as `ouraMetSample` rows (one per minute) under + * [deviceId] — wired to `repository.insertOuraMetSamples`; default no-op keeps the scanner + tests inert. */ + private val persistMetSamples: (List) -> Unit = {}, + /** #2242 (default OFF): read live per record — the writer runs only while this is true, so an install + * that never turns the Experimental toggle on never grows the table. */ + private val metCalories: () -> Boolean = { false }, /** Diagnostic sink for the connect/auth/stream lifecycle - the SAME exportable strap log (#421). * Every line is prefixed "Oura: ". Statuses / UUIDs / counts only, NEVER a device address. Default * no-op keeps existing call sites compiling and tests silent. */ @@ -2179,6 +2185,25 @@ class OuraLiveSource( ringTs = e.value.ringTimestamp, utc = utc, state = e.value.state, secPerSample = 60, met = e.value.met, // 60 s = assumed MET cadence (s6.13) ) + // #2242: persist the record as one row per minute when the Experimental MET-calories + // toggle is on (anchored records only, same rule as the sidecar; the (deviceId, ts) key + // absorbs a re-serve). 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 from the + // timestamp), so sample i starts at `utc − (n − i) × epoch`. A record straddling local + // midnight therefore lands its minutes on the right days. + if (metCalories() && e.value.met.isNotEmpty()) { + val epoch = 60 + val n = e.value.met.size + persistMetSamples( + e.value.met.mapIndexed { i, met -> + com.noop.data.OuraMetSampleEntity( + deviceId = deviceId, ts = utc - (n - i).toLong() * epoch, + met = met, state = e.value.state, epochS = epoch, + ) + }, + ) + } } } is OuraEvent.RealStepsFields -> { diff --git a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt index 6da2a18d45..7fe51931d3 100644 --- a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt +++ b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt @@ -588,6 +588,8 @@ class SourceCoordinator( }, onsetKeying = { NoopPrefs.ouraOnsetKeying(ctx) }, // #1284 residual 3 notifyMaskFull = { NoopPrefs.ouraNotifyMaskFull(ctx) }, // packed-notification A/B + persistMetSamples = { rows -> scope.launch { runCatching { repo.insertOuraMetSamples(rows) } } }, // #2242 + metCalories = { NoopPrefs.ouraMetCalories(ctx) }, // #2242 log = straplog, // Oura connect/auth/stream lifecycle → the SAME exported strap log (#421) onBattery = batterySink, // ring battery → the same live state the WHOOP strap battery uses onModel = { model -> scope.launch { runCatching { registry.setModel(id, model) } } }, // #772: correct a name-guessed gen diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 3d1084cd6a..a4183730c4 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -3288,6 +3288,7 @@ class WhoopBleClient( // #103: SpO₂ candidate @82 display toggle — when ON, the engine computes and // persists the nightly @82 mean as "spo2_candidate" in metricSeries. spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(context), + ouraMetCalories = NoopPrefs.ouraMetCalories(context), // #2242 effortMethod = NoopPrefs.effortMethod(context), dayCycleMode = NoopPrefs.dayCycleMode(context), ) diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index ef3792174f..5797ec3351 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1304,6 +1304,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // #103: SpO₂ candidate @82 display toggle — when ON, the engine computes and // persists the nightly @82 mean as "spo2_candidate" in metricSeries. spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(appContext), + ouraMetCalories = NoopPrefs.ouraMetCalories(appContext), // #2242 effortMethod = NoopPrefs.effortMethod(appContext), dayCycleMode = NoopPrefs.dayCycleMode(appContext), ) @@ -1997,6 +1998,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { useMotionAwareWake = PuffinExperiment.from(appContext).motionAwareWake, // #103: SpO₂ candidate @82 display toggle — same flag the 15-min loop reads. spo2CandidateDisplay = NoopPrefs.spo2CandidateDisplay(appContext), + ouraMetCalories = NoopPrefs.ouraMetCalories(appContext), // #2242 effortMethod = NoopPrefs.effortMethod(appContext), dayCycleMode = NoopPrefs.dayCycleMode(appContext), ) @@ -2862,6 +2864,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { rescoreAfterEdit() } } + /** #2242: the MET-calories toggle is part of the day-cache config signature, so re-score now and every + * cached day flips path on the toggle instead of waiting for the next analyze loop. */ + fun setOuraMetCalories(enabled: Boolean) { + NoopPrefs.setOuraMetCalories(appContext, enabled) + viewModelScope.launch { rescoreAfterEdit() } + } + /** #1545: the Effort TRIMP recipe changes stored Effort for EVERY day in the window, so re-score on * the flip rather than leaving the user on the old recipe's numbers until the next analyze tick — * which the toggle's own copy promises. Twin of the iOS onChange handler. */ diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index ba4d978eff..492e6a32fd 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -702,6 +702,20 @@ object NoopPrefs { of(context).edit().putBoolean(KEY_OURA_NOTIFY_MASK_FULL, enabled).apply() } + /** #2242 (EXPERIMENTAL, default OFF): estimate active calories from the Oura ring's OWN per-minute MET + * stream (0x50) with Oura's documented method — minutes above 1.5 MET × RMR — instead of the HR-only + * Keytel path over the ring's sparse banked HR. Gates BOTH the writer (the ring's MET records are only + * persisted to `ouraMetSample` while this is on, so an OFF install's DB is byte-identical to today's) + * and the analyzeDay read. An estimate, not a measurement. Twin of iOS AppModel.ouraMetCaloriesKey. */ + const val KEY_OURA_MET_CALORIES = "noop.ouraMetCalories" + + fun ouraMetCalories(context: Context): Boolean = + of(context).getBoolean(KEY_OURA_MET_CALORIES, false) + + fun setOuraMetCalories(context: Context, enabled: Boolean) { + of(context).edit().putBoolean(KEY_OURA_MET_CALORIES, enabled).apply() + } + /** #1121: whether the opt-in "detailed capture" rolling strap-log file is on. Persisted so capture * RESUMES after the process is killed (AppViewModel re-arms the BLE client from this on launch). */ const val KEY_DETAILED_CAPTURE = "noop.detailedCapture" diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 4062ebb4d3..e4f2ec669c 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -3159,6 +3159,53 @@ fun SettingsScreen( color = Palette.textTertiary, ) + // --- #2242: Active calories from the Oura ring's MET stream — OFF by default. --- + // The ring's 0x50 per-minute MET series scored with Oura's documented method (minutes + // above 1.5 MET × RMR) in place of the HR-only Keytel path over the ring's sparse banked + // HR (r ≈ −0.1 against Oura's own number). The toggle gates BOTH the `ouraMetSample` + // writer and the analyzeDay read, so an OFF install's DB and scores are unchanged. Shown + // only when an Oura ring is paired. Mirrors the iOS "Experimental · Oura Calories" card. + var ouraPairedForMet by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + ouraPairedForMet = runCatching { vm.pairedDevices() }.getOrDefault(emptyList()) + .any { it.brand.equals("Oura", ignoreCase = true) } + } + if (ouraPairedForMet) { + SettingsRowDivider() + var ouraMetCalories by remember { mutableStateOf(NoopPrefs.ouraMetCalories(context)) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + uiString(R.string.settings_oura_met_calories_title), + style = NoopType.subhead, + color = Palette.textPrimary, + modifier = Modifier.weight(1f), + ) + Switch( + checked = ouraMetCalories, + onCheckedChange = { + ouraMetCalories = it + vm.setOuraMetCalories(it) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Palette.surfaceBase, + checkedTrackColor = Palette.accent, + uncheckedThumbColor = Palette.textSecondary, + uncheckedTrackColor = Palette.surfaceInset, + uncheckedBorderColor = Palette.hairline, + ), + ) + } + Text( + uiString(R.string.settings_oura_met_calories_caption), + style = NoopType.caption, + color = Palette.textTertiary, + ) + } + // --- #463: Personal daytime-stress baseline — OFF by default. --- // Scores today's intraday stress timeline against a PERSONAL cross-day rolling baseline // (Oura-style) instead of the day's own calm hours. The validated r≈0.6 HR-only margin is diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 9dd40e58bd..615ebf118a 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2833,4 +2833,6 @@ Koppelt sich direkt über Bluetooth mit deinem Strap: keine WHOOP-App, keine Cloud. Der Strap kann seine Herzfrequenz außerdem als Standard-Bluetooth-Sensor senden. Sendet die eigene Live-Herzfrequenz des Straps über Bluetooth für Garmin, Zwift oder Fitnessgeräte. Dein WHOOP 4.0 kann die Herzfrequenz direkt übertragen. Öffne Datenquellen und verwende „Herzfrequenz vom Band übertragen“ für Zwift, Peloton, Garmin oder Fitnessgeräte. Bei Bedarf kannst du alternativ „HF von diesem Telefon übertragen“ verwenden. + Aktive Kalorien aus dem MET-Datenstrom des Rings + Verwendet die von Oura dokumentierte Methode: die Intensität jeder Minute über 1,5 MET, mit der Standard-MET-Rate für Ihr Gewicht, zusätzlich zur Ruheenergie für die Minuten, die der Ring gemeldet hat. Eine Schätzung, keine Messung – an einem Tag ohne protokolliertes Training entspricht sie dem Wert der Oura-App (Oura bewertet die Minuten eines protokollierten Trainings nach Aktivitätstyp neu, NOOP nicht), und ein Tag, den der Ring weniger als zur Hälfte abgedeckt hat, zeigt keine Zahl statt einer Vermutung. Beim Einschalten werden außerdem die MET-Werte des Rings auf diesem Gerät gespeichert; sie fließen nie in die Erholungs- oder Krankheitsbewertung ein. Standardmäßig aus. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index d94fbef93a..b995e8e3be 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2820,4 +2820,6 @@ Se empareja directamente con tu pulsera por Bluetooth: sin app de WHOOP, sin nube. La pulsera también puede anunciar su frecuencia cardíaca como un sensor Bluetooth estándar. Difunde por Bluetooth la frecuencia cardíaca en vivo de la propia pulsera para Garmin, Zwift o equipos de gimnasio. Tu WHOOP 4.0 puede transmitir la frecuencia cardíaca directamente. Abre Fuentes de datos y usa «Transmitir frecuencia cardíaca desde la pulsera» para Zwift, Peloton, Garmin o equipos de gimnasio. También puedes usar «Transmitir FC desde este teléfono» cuando sea necesario. + Calorías activas a partir del flujo MET del anillo + Usa el método documentado por Oura: la intensidad de cada minuto por encima de 1,5 MET, a la tasa MET estándar para tu peso, además de la energía en reposo de los minutos que el anillo registró. Es una estimación, no una medición: en un día sin entrenamiento registrado coincide con la cifra de la propia app de Oura (Oura vuelve a puntuar los minutos de un entrenamiento registrado según el tipo de actividad, cosa que NOOP no hace), y un día que el anillo cubrió menos de la mitad no muestra ningún número en lugar de una suposición. Al activarlo también se empiezan a guardar las muestras MET del anillo en este dispositivo; nunca alimenta la puntuación de recuperación ni de enfermedad. Desactivado por defecto. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 520a36a4dc..49f2c53fa8 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2819,4 +2819,6 @@ S\'associe directement à votre bracelet par Bluetooth : pas d\'app WHOOP, pas de cloud. Le bracelet peut aussi annoncer sa fréquence cardiaque comme capteur Bluetooth standard. Diffuse en Bluetooth la fréquence cardiaque en direct propre au bracelet pour Garmin, Zwift ou les équipements de sport. Votre WHOOP 4.0 peut diffuser directement la fréquence cardiaque. Ouvrez Sources de données et utilisez « Diffuser la fréquence cardiaque du bracelet » pour Zwift, Peloton, Garmin ou les équipements de sport. Vous pouvez aussi utiliser « Diffuser la FC depuis ce téléphone » si nécessaire. + Calories actives à partir du flux MET de la bague + Utilise la méthode documentée par Oura : l\'intensité de chaque minute au-dessus de 1,5 MET, au taux MET standard pour votre poids, en plus de l\'énergie de repos des minutes rapportées par la bague. Une estimation, pas une mesure : un jour sans entraînement enregistré, elle correspond au chiffre de l\'app Oura elle-même (Oura recalcule les minutes d\'un entraînement enregistré selon le type d\'activité, ce que NOOP ne fait pas), et un jour couvert à moins de la moitié par la bague n\'affiche aucun chiffre plutôt qu\'une supposition. L\'activer démarre aussi l\'enregistrement des échantillons MET de la bague sur cet appareil ; cela n\'alimente jamais le score de récupération ni de maladie. Désactivé par défaut. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 2d742c87f5..f64cfec7a2 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2834,4 +2834,6 @@ Łączy się bezpośrednio z paskiem przez Bluetooth: bez aplikacji WHOOP i bez chmury. Pasek może też nadawać tętno jako standardowy czujnik Bluetooth. Transmituje własne tętno paska na żywo przez Bluetooth do Garmin, Zwift lub sprzętu na siłowni. WHOOP 4.0 może nadawać tętno bezpośrednio. Otwórz Źródła danych i użyj opcji „Transmituj tętno z opaski” dla Zwift, Peloton, Garmin lub sprzętu na siłowni. W razie potrzeby możesz też użyć opcji „Transmituj tętno z tego telefonu”. + Kalorie aktywne ze strumienia MET pierścienia + Używa udokumentowanej metody Oura: intensywność każdej minuty powyżej 1,5 MET, według standardowej stawki MET dla Twojej wagi, dodatkowo do energii spoczynkowej za minuty zgłoszone przez pierścień. To szacunek, nie pomiar — w dniu bez zarejestrowanego treningu zgadza się z wartością samej aplikacji Oura (Oura przelicza minuty zarejestrowanego treningu według typu aktywności, czego NOOP nie robi), a dzień, którego pierścień nie pokrył w co najmniej połowie, nie pokazuje żadnej liczby zamiast zgadywania. Włączenie rozpoczyna też zapisywanie próbek MET pierścienia na tym urządzeniu; nigdy nie zasila oceny regeneracji ani choroby. Domyślnie wyłączone. diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 6110f3719b..5c5419ed61 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2812,4 +2812,6 @@ Emparelha diretamente com a tua bracelete via Bluetooth: sem aplicação WHOOP, sem cloud. A bracelete também pode anunciar a frequência cardíaca como um sensor Bluetooth padrão. Transmite a frequência cardíaca em direto da bracelete por Bluetooth para Garmin, Zwift ou equipamento de ginásio. O teu WHOOP 4.0 pode transmitir a frequência cardíaca diretamente. Abre Fontes de dados e utiliza «Transmitir frequência cardíaca da bracelete» para Zwift, Peloton, Garmin ou equipamento de ginásio. Em alternativa, podes utilizar «Transmitir FC deste telemóvel» quando necessário. + Calorias ativas a partir do fluxo MET do anel + Usa o método documentado pela Oura: a intensidade de cada minuto acima de 1,5 MET, à taxa MET padrão para o seu peso, além da energia de repouso dos minutos que o anel registou. Uma estimativa, não uma medição — num dia sem treino registado coincide com o valor da própria app Oura (a Oura reavalia os minutos de um treino registado pelo tipo de atividade, o que o NOOP não faz), e um dia que o anel cobriu menos de metade não mostra número em vez de um palpite. Ao ativar, começa também a guardar as amostras MET do anel neste dispositivo; nunca alimenta a pontuação de recuperação nem de doença. Desligado por predefinição. diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index bc8220829f..91f59740ac 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2713,4 +2713,6 @@ Подключается к браслету напрямую по Bluetooth: без приложения WHOOP и без облака. Браслет также может передавать пульс как стандартный Bluetooth-датчик. Передаёт собственный пульс браслета в реальном времени по Bluetooth для Garmin, Zwift или тренажёров. WHOOP 4.0 может передавать пульс напрямую. Откройте «Источники данных» и включите «Транслировать пульс с браслета» для Zwift, Peloton, Garmin или тренажёров. При необходимости можно использовать «Передавать пульс с этого телефона». + Активные калории из потока MET кольца + Использует задокументированный метод Oura: интенсивность каждой минуты выше 1,5 MET по стандартной ставке MET для вашего веса, в дополнение к энергии покоя за минуты, о которых сообщило кольцо. Это оценка, а не измерение — в день без записанной тренировки она совпадает с показателем самого приложения Oura (Oura пересчитывает минуты записанной тренировки по типу активности, чего NOOP не делает), а день, который кольцо покрыло меньше чем наполовину, не показывает число вместо догадки. Включение также запускает сохранение образцов MET кольца на этом устройстве; они никогда не влияют на оценку восстановления или болезни. По умолчанию выключено. diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 5d5eea0d47..0c823a73ed 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2791,4 +2791,6 @@ 通过 Bluetooth 直接连接手环:无需 WHOOP 应用,也无需云端。手环还可以将心率作为标准 Bluetooth 传感器进行广播。 通过 Bluetooth 将手环自身的实时心率广播给 Garmin、Zwift 或健身器材。 WHOOP 4.0 可以直接广播心率。打开“数据来源”,启用“从手环广播心率”,即可连接 Zwift、Peloton、Garmin 或健身器材。需要时也可以使用“从此手机广播心率”。 + 根据戒指的 MET 数据流计算活动卡路里 + 采用 Oura 公开的方法:每一分钟高于 1.5 MET 的部分,按您体重对应的标准 MET 速率计算,再加上戒指已报告分钟的静息能量。这是估算而非测量——在没有记录锻炼的日子里,它与 Oura 应用自身的数值一致(Oura 会按活动类型重新计算已记录锻炼的分钟,NOOP 不会);戒指覆盖不足半天的日子将不显示数字,而不是给出猜测。开启后也会开始在本设备上存储戒指的 MET 样本;它永远不会影响恢复或疾病评分。默认关闭。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 62d321fced..0e9ab9bb57 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2849,4 +2849,6 @@ Pairs directly with your strap over Bluetooth: no WHOOP app, no cloud. The strap can also advertise its heart rate as a standard Bluetooth sensor. Broadcasts the strap\'s own live heart rate over Bluetooth for Garmin, Zwift or gym equipment. Your WHOOP 4.0 can broadcast heart rate directly. Open Data Sources and use “Broadcast heart rate from the strap” for Zwift, Peloton, Garmin or gym equipment. You can alternatively use “Broadcast HR from this phone” when needed. + Active calories from the ring\'s MET stream + Uses Oura\'s documented method: each minute\'s intensity above 1.5 MET, at the standard MET rate for your weight, on top of resting energy for the minutes the ring reported. An estimate, not a measurement — on a day without a logged workout it matches the Oura app\'s own figure (Oura re-scores a logged workout\'s minutes by activity type, which NOOP does not), and a day the ring covered less than half of shows no number rather than a guess. Turning this on also starts storing the ring\'s MET samples on this device; it never feeds recovery or illness scoring. Off by default. diff --git a/android/app/src/test/java/com/noop/analytics/DayCacheConfigFieldTest.kt b/android/app/src/test/java/com/noop/analytics/DayCacheConfigFieldTest.kt index 38b5c2360c..d60fc8bc6d 100644 --- a/android/app/src/test/java/com/noop/analytics/DayCacheConfigFieldTest.kt +++ b/android/app/src/test/java/com/noop/analytics/DayCacheConfigFieldTest.kt @@ -80,7 +80,7 @@ class DayCacheConfigFieldTest { "hrvBaseline", "rhrBaseline", "age", "sex", "stepTicksPerStep", "maxHROverride", "tzOffset", "sleepNeedHours", "sleepConsistency", "habitualMidsleep", "experimentalSleepV2", "motionAwareWake", "deepHrvWindow", "spo2CandidateDisplay", - "effortMethod", "dayCycleMode", + "effortMethod", "dayCycleMode", "ouraMetCalories", ), IntelligenceEngine.DAY_CACHE_CONFIG_FIELDS, ) diff --git a/android/app/src/test/java/com/noop/analytics/DayCycleMetCaloriesTest.kt b/android/app/src/test/java/com/noop/analytics/DayCycleMetCaloriesTest.kt new file mode 100644 index 0000000000..e19e61a128 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/DayCycleMetCaloriesTest.kt @@ -0,0 +1,124 @@ +package com.noop.analytics + +import android.content.Context +import androidx.room.Room +import com.noop.data.DailyMetric +import com.noop.data.HrSample +import com.noop.data.OuraMetSampleEntity +import com.noop.data.WhoopDatabase +import com.noop.data.WhoopRepository +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * #2242: the day-cycle fold makes `analyzeDay`'s MET-vs-HR energy decision over its own window. + * + * 2026-09-17, first hardware day of the MET-calories toggle (iOS): `analyzeDay` logged the ring's MET + * number (1220 kcal) and the export held 743 — Keytel over the wake-to-wake HR window, which this fold + * recomputed unconditionally and the integration wrote over the day's `activeKcalEst`. Same shape on + * Android. Twin of Swift `DayCycleRecoveryTests` (#2242 section): a covered MET cycle carries the MET + * total, a thin one is WITHHELD (no HR substitute), and the toggle off is the byte-identical Keytel path. + * Real Room in memory under Robolectric so the fold's own reads are the ones under test. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class DayCycleMetCaloriesTest { + + private val owner = "oura-ring" + private val onset = 1_755_208_800L - 2 * 3_600L // 2026-08-14 22:00 UTC + private val wake = onset + 8 * 3_600L + private val now = wake + 12 * 3_600L + private val day = AnalyticsEngine.dayString(wake, 0L) + + private val db = Room.inMemoryDatabaseBuilder( + RuntimeEnvironment.getApplication() as Context, WhoopDatabase::class.java, + ).allowMainThreadQueries().build() + private val repo = WhoopRepository(db.whoopDao()) + + @After fun close() = db.close() + + /** One 8-h main sleep, `now` 12 h after wake, 0.2 Hz HR across the cycle so Keytel has something to say. */ + private fun seedCycle(): DayResult = runBlocking { + repo.insertHr((onset until now step 5L).map { HrSample(owner, it, if (it < wake) 52 else 74) }) + val sleep = DetectedSleep( + start = onset, end = wake, efficiency = 0.9, + stages = listOf(StageSegment(onset, wake, "light")), restingHR = 50, avgHRV = null, + ) + DayResult( + daily = DailyMetric(deviceId = "$owner-noop", day = day, totalSleepMin = 460.0, restingHr = 50), + sleepSessions = listOf(sleep), workouts = emptyList(), recovery = null, strain = null, + ) + } + + private fun compute(night: DayResult, ouraMetCalories: Boolean): PhysiologicalStepCycleEngine.Result = runBlocking { + PhysiologicalStepCycleEngine.compute( + scoredNights = listOf(night), editedRows = emptyList(), + resolvedScoreOwnerByDay = mapOf(day to owner), + candidatePriorities = listOf(owner to 0), stepWitnessByDay = emptyMap(), repo = repo, + tzOffsetSeconds = 0L, habitualMidsleepSec = null, windowStart = onset - 86_400L, + nowSeconds = now, stepTicksPerStep = 1.0, stepsTraceSink = null, + dayCycleMode = DayCycleMode.SLEEP_ONSET, profile = UserProfile(), maxHROverride = null, + effortMethod = StrainScorer.Method.EDWARDS, ouraMetCalories = ouraMetCalories, + ) + } + + @Test + fun coveredMetCycleCarriesTheMetTotalNotKeytel() { + val night = seedCycle() + // Every minute of the cycle at 1.1 MET, one 30-min 4.0 bout after wake. + val met = (onset until now step 60L).map { + OuraMetSampleEntity(owner, it, if (it >= wake + 3_600 && it < wake + 5_400) 4.0 else 1.1, 0, 60) + } + runBlocking { assertEquals(met.size, repo.insertOuraMetSamples(met)) } + + val result = compute(night, ouraMetCalories = true) + + val expected = Calories.estimateDayEnergyFromMet( + met.map { Calories.MetSample(it.ts, it.met, it.epochS) }, UserProfile(), onset, now, + ) + assertEquals(1.0, expected.coverageFraction, 1e-9) + assertEquals(expected.totalKcal, result.cycleCaloriesByWakeDay[day]!!, 1e-9) + // And the fold carries it onto the row (the write that used to bring Keytel back). + val applied = DayCycleIntelligenceIntegration.apply(night.daily, result, "$owner-noop", mutableListOf()) + assertEquals(expected.totalKcal, applied.activeKcalEst!!, 1e-9) + } + + @Test + fun thinMetCoverageWithholdsTheCycleAndDoesNotSubstituteKeytel() { + val night = seedCycle() + // 10 % of the cycle covered — below the floor — with plenty of HR alongside. + val met = (onset until onset + 2 * 3_600L step 60L).map { OuraMetSampleEntity(owner, it, 3.0, 0, 60) } + runBlocking { repo.insertOuraMetSamples(met) } + + val result = compute(night, ouraMetCalories = true) + + assertNull("withheld: no entry, no HR figure in its place", result.cycleCaloriesByWakeDay[day]) + val applied = DayCycleIntelligenceIntegration.apply(night.daily, result, "$owner-noop", mutableListOf()) + assertNull(applied.activeKcalEst) + assertNotNull("the rest of the fold is untouched by the decision", result.cycleStrainByWakeDay[day]) + } + + @Test + fun toggleOffIsTheKeytelPath() { + val night = seedCycle() + val met = (onset until now step 60L).map { OuraMetSampleEntity(owner, it, 4.0, 0, 60) } + runBlocking { repo.insertOuraMetSamples(met) } + + val off = compute(night, ouraMetCalories = false) + val keytel = off.cycleCaloriesByWakeDay[day] + assertNotNull(keytel) + assertTrue(keytel!! > 0) + // An empty MET table with the toggle ON is the same as off: the HR path, byte for byte. + runBlocking { db.whoopDao().deleteOuraMetFor(owner) } + assertEquals(keytel, compute(night, ouraMetCalories = true).cycleCaloriesByWakeDay[day]!!, 0.0) + } +} diff --git a/docs/OURA_PROTOCOL.md b/docs/OURA_PROTOCOL.md index 57e59a3cb8..15d1cf8bf3 100644 --- a/docs/OURA_PROTOCOL.md +++ b/docs/OURA_PROTOCOL.md @@ -1010,6 +1010,37 @@ edit of the ring's tag. 86-minute walk, exactly 1·min⁻¹. Two caveats kept explicit: MET has a **~1 min recovery lag**, so it smears activity boundaries; and this validates that MET *tracks intensity*, NOT that the absolute MET scale is calibrated against Oura's own numbers — it stays Tier B and unscored. + - **✅ `0x50` DAY-SUM VALIDATED against Oura's own MET export, and its record timestamp is the END of the + record (NOOP, 2026-09-15/16, Gen 3, 16 days).** The Oura data export's `dailyactivity.csv` carries a + per-day 1440 × 60 s `met` series with one-decimal values — the same object the wire encodes. Over 16,839 + overlapping minutes, Σ_{MET ≥ 1.5}(MET − 1) computed from the NOOP decode reproduces the export's sum on + **10/10 clean days within 1–8 %** (08-15: 947 vs 947); the four days off by 15–25 % are the sidecar's + known coverage holes, not decode error (compare over the minutes BOTH sides have, never raw day totals). + That lifts the "not ground-truth-validated" caveat above at day scale: the two-slope byte formula is + right. **Timestamp semantics:** aligning the wire minutes to the export's minute series, every record + length *n* (2, 3, …, 13 samples) matches best at a shift of exactly **−n minutes**, i.e. the record's + timestamp is the END of its LAST sample and sample *i* covers `[ts − (n − i)·60, ts − (n − i − 1)·60)`. + Read that way 85 % of minutes match the export's value exactly (r = 0.90); read forward from the + timestamp only 23 % do (r = 0.57), and an earlier "best r at −2 min" figure was an artefact of pooling + record lengths. The export also carries a `0.1` value (a server-side non-wear marker) the wire never + shows. **Derived: active calories — Oura's rule recovered exactly.** On the export's own complete minute series, + `active_calories = k × Σ_{MET ≥ 1.5}(MET − 1.5)` 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, and k = 1.085–1.101 = **0.0175 × the wearer's + ~62–63 kg** — the textbook MET→kcal conversion (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹ at ≈ 5 kcal·L⁻¹). That is Oura's + support wording, "the portion that exceeds 1.5 MET", taken literally; an earlier `(MET − 1) × k` reading + (k ≈ 0.64 pre-2025, ≈ 1.0 after) was the wrong subtraction absorbing an intercept — there is no hidden + per-account constant. NOOP's `Calories.estimateDayEnergyFromMET` (#2242) therefore scores + `active = Σ_{MET ≥ 1.5}(MET − 1.5) × 0.0175 × weightKg` per minute, resting = revised Harris–Benedict over the + covered minutes, persists the rows as `ouraMetSample`, behind a default-off Experimental toggle. **Replay + (`worklog` `verify-2242-met-calories.py`, 08-13 → 08-26 sidecars):** on every day without a confirmed workout + NOOP's number is Oura's to 0.8–1.5 % (n = 8, r = 1.000; the residual is the profile weight), and the sidecar's + ~15–20 % ring-side coverage holes cost nothing — the minutes the ring does not log are rest. **The one systematic + difference:** a CONFIRMED workout in `workout.csv` has its minutes REWRITTEN in the export to the activity type's + average MET (08-18 golf 14:14–18:29 and 08-23 golf 09:10–13:13 read a flat 4.2–4.3 MET where the wire carries + the ring's 2.6–2.9), matching Oura's support text ("calculated based on average calorie burn rates for that + activity type") — a label, not the sensor — so on such a day the Oura app's figure sits 10–27 % above NOOP's. + Still an estimate of true expenditure (Kristiansson et al. 2023: lab MET vs calorimetry r 0.93 / MAPE 21 %, + free-living AEE MAPE 46–90 %). - **Walking-equivalent step estimate, scored against two reference devices (NOOP, 2026-08-02).** For the same walk, `activeMinutes × 100` (MET ≥ 3.0 → 82 active min) gives **8,200** against a measured **8,834** (Suunto `.fit` `total_cycles × 2`) and **7,868** (WHOOP, same walk): −7 % and +4 %. The From a49e6d0976d07315c427e87a9be1aef9f3af06e6 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 13:51:34 +0200 Subject: [PATCH 5/7] feat(oura): pair the MET store with its twins in the parity ledger Review follow-up. The parity ledger flagged the MET store's Kotlin reads and writes as one-sided; run in full it also flagged the MET toggle's arity changes to existing functions, which read as new identities. Paired, with twin claims where the ledger reads them: - `WhoopRepository.insertOuraMetSamples` <-> `WhoopStore.insertOuraMetSamples` - `WhoopRepository.ouraMetSamples` <-> `WhoopStore.ouraMetSamples` (on the Repository, since the DAO's `@Query` puts its declaration outside the claim's attach window) - `AnalyticsEngine.analyzeDay`, `IntelligenceEngine.analyzeRecent` and `DayCycleIntelligenceIntegration.compute`, whose parameter lists grew here Removed: `WhoopStore.ouraMetSampleCount` and `WhoopDao.countOuraMetFor`. Neither had a production caller (the Kotlin one had no caller at all); the store tests count through `ouraMetSamples` instead. `platform_specific` dispositions for what is Kotlin-only by construction: the Room `@Insert` and `@Query` primitives under the paired Repository functions, the per-table forget-device delete (Swift clears the table through the `DeviceRegistryStore` name list), the Room migration override (its GRDB twin is the `v47-oura-met-sample` closure, pinned by the shared schema oracle), and the two JVM 64 KiB extractions `readDaySkinAndWristOff` and `PhysiologicalStepCycleEngine.compute`. The authority is re-derived with `--migrate-authority` because `main` at 5783c4996 no longer reproduces its stored one. Verified: `parity_ledger.py` OK; `parity_ratchet.py --migrate-authority` 0 errors; `test_parity_*` 180 OK; StrandAnalytics 2068/0; WhoopStore 617/0; macOS `StrandTests` (DayCacheConfigField, DayCycleRecovery) 17/0; `NOOPiOS` builds; Android 6402 tests, the 8 failures reproduce identically on `upstream/main`; doc lint and `i18n_audit --ci` clean. Refs #2242 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV --- .../StrandAnalytics/AnalyticsEngine.swift | 1 + .../Sources/WhoopStore/OuraMetStore.swift | 8 ---- .../WhoopStoreTests/OuraMetStoreTests.swift | 6 +-- Tools/parity_dispositions.json | 48 +++++++++++++++++++ .../DayCycleIntelligenceIntegration.kt | 1 + .../com/noop/analytics/IntelligenceEngine.kt | 2 +- .../src/main/java/com/noop/data/WhoopDao.kt | 3 -- .../java/com/noop/data/WhoopRepository.kt | 4 +- 8 files changed, 56 insertions(+), 17 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 4ee5a0ae68..431ddb1d83 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -305,6 +305,7 @@ public enum AnalyticsEngine { /// and low enough to keep a partially-drained night. Twin of the Kotlin constant. public static let vendorRespMinSpanS = 3_600 + /// Score one day. Kotlin twin: `AnalyticsEngine.analyzeDay`. public static func analyzeDay(day: String, // Optional sink for the Effort funnel line. Nil (the default) builds // nothing at all — see StrainScorer.strain. A parameter rather than a diff --git a/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift index b51cf72ea6..22de581c72 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift @@ -92,12 +92,4 @@ extension WhoopStore { .map { OuraMetSample(ts: $0["ts"], met: $0["met"], state: $0["state"], epochS: $0["epochS"]) } } } - - /// Row count for a device (diagnostics / tests). - public func ouraMetSampleCount(deviceId: String) async throws -> Int { - try syncRead { db in - try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ouraMetSample WHERE deviceId = ?", - arguments: [deviceId]) ?? 0 - } - } } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift index 12dca895a2..ad38d6f1f9 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift @@ -96,7 +96,7 @@ final class OuraMetStoreTests: XCTestCase { XCTAssertEqual(again, 0) let none = try await store.insertOuraMetSamples([], deviceId: "oura-A") XCTAssertEqual(none, 0) - let count = try await store.ouraMetSampleCount(deviceId: "oura-A") + let count = try await store.ouraMetSamples(deviceId: "oura-A", from: .min, to: .max, limit: .max).count XCTAssertEqual(count, 3) let read = try await store.ouraMetSamples(deviceId: "oura-A", from: 1_755_208_800, @@ -118,8 +118,8 @@ final class OuraMetStoreTests: XCTestCase { _ = try await store.insertOuraMetSamples([OuraMetSample(ts: 1, met: 1.0, state: 0)], deviceId: "oura-A") _ = try await store.insertOuraMetSamples([OuraMetSample(ts: 1, met: 1.0, state: 0)], deviceId: "oura-B") try await store.deleteAllData(deviceId: "oura-A") - let a = try await store.ouraMetSampleCount(deviceId: "oura-A") - let b = try await store.ouraMetSampleCount(deviceId: "oura-B") + let a = try await store.ouraMetSamples(deviceId: "oura-A", from: .min, to: .max, limit: .max).count + let b = try await store.ouraMetSamples(deviceId: "oura-B", from: .min, to: .max, limit: .max).count XCTAssertEqual(a, 0) XCTAssertEqual(b, 1) } diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index b1efd58add..55c827c993 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -80,6 +80,54 @@ "identity_sha256": "ecbc737217fc7e501b46226d4560ed9f23d2bd636faeef2f5e59e0a7274821e1", "platform": "swift", "rationale": "Twin of the Kotlin `val OuraSpO2.channel` in android/app/src/main/java/com/noop/oura/OuraEvents.kt. Both are one-line extension properties over OuraSpO2 returning OuraSpO2Channel, added together in the same change. Kotlin declares its copy as a TOP-LEVEL extension property with a dotted receiver, which the ledger's Kotlin property scan does not index, so the Swift side reads as one-sided. The pair is real; only one half is visible to the inventory." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDao.kt::insertOuraMet/1#1", + "identity_sha256": "ddec05cb4bf20786165ab44ada3f021da5a83060efc6a2a9a85392ab242d2a42", + "platform": "kotlin", + "rationale": "Room DAO primitive with no Swift counterpart by construction: on Apple the same SQL runs inside the one WhoopStore (GRDB) function it serves (`WhoopStore.insertOuraMetSamples`, paired with `WhoopRepository.insertOuraMetSamples`); Room needs a separate @Insert for the Repository's twin-dropping insert to call." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDao.kt::ouraMetSamples/4#1", + "identity_sha256": "a1854344a932d6839a9ba60bfa59229736f06207d41ee9c3a1506b8dea2f776b", + "platform": "kotlin", + "rationale": "Room DAO primitive with no Swift counterpart by construction: on Apple the same SQL runs inside the one WhoopStore (GRDB) function it serves (`WhoopStore.ouraMetSamples`, paired with the `WhoopRepository.ouraMetSamples` forwarder over this query)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt::deleteOuraMetFor/1#1", + "identity_sha256": "a0dcb42ed0b5cc1e795f9432f8f647e21af231625d56224d705648c549387f68", + "platform": "kotlin", + "rationale": "Per-table Room delete used when a device is forgotten. Swift clears the same table through the table-name list in `DeviceRegistryStore` (\"ouraMetSample\"), so there is no per-table function to pair." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::readDaySkinAndWristOff/14#1", + "identity_sha256": "75d89046eeeab2049fbd702d33d217cd5591abcf24b8a2bb563cdee9ac9daf3f", + "platform": "kotlin", + "rationale": "Deliberately one-sided per its own doc: extracted from IntelligenceEngine's scoring method only to keep the JVM method (and its JaCoCo instrumentation) under the 64 KiB bytecode limit; the Swift engine keeps this block inline. The arity changed because the MET toggle is threaded through it (#2242)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt::compute/17#1", + "identity_sha256": "26c442cf09505ae210271258c4ca9e2acfc35604de744f9acbd70bdec9f3abaa", + "platform": "kotlin", + "rationale": "Kotlin-only extraction of the day-cycle fold, kept outside IntelligenceEngine for the JVM 64 KiB method limit (see its header); the Swift logic lives inside `DayCycleIntelligenceIntegration.compute`, which is paired with the Kotlin `DayCycleIntelligenceIntegration.compute`. The arity changed because the MET toggle is threaded through it (#2242)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDatabase.kt::migrate/1#39", + "identity_sha256": "81b9152e7189625064e51189ec7be31e1d1f57662438fe54df4c1f779a6bbe48", + "platform": "kotlin", + "rationale": "Room MIGRATION_40_41's migrate override (ouraMetSample, #2242). The GRDB twin is the registered `v47-oura-met-sample` migration closure, which is not a named function; the two are pinned against each other by the shared schema oracle, not by the ledger." } ] } diff --git a/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt b/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt index a9d91c3b71..3afb5c0cc2 100644 --- a/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt +++ b/android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt @@ -100,6 +100,7 @@ internal object DayCycleIntelligenceIntegration { } } + /** Swift twin: `DayCycleIntelligenceIntegration.compute`. */ suspend fun compute( scoredNights: List, editedRows: List, diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 3604d3d79a..b9fd3424cb 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -405,7 +405,7 @@ object IntelligenceEngine { * loop launches from viewModelScope (Dispatchers.Main), so without this hop the whole pass — * SleepStager / StrainScorer over up to 21 nights of 1 Hz data , ran on the MAIN THREAD and * ANR-killed the app once a few nights had accumulated. Dispatchers.Default is the CPU pool; Room's - * suspend DAO calls are main-safe under any dispatcher. (#125) + * suspend DAO calls are main-safe under any dispatcher. (#125) Swift twin: `IntelligenceEngine.analyzeRecent`. */ suspend fun analyzeRecent( repo: WhoopRepository, diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index ffd9c51bbe..06b5274cf0 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -731,9 +731,6 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun ouraMetSamples(deviceId: String, from: Long, to: Long, limit: Int): List - @Query("SELECT COUNT(*) FROM ouraMetSample WHERE deviceId = :deviceId") - suspend fun countOuraMetFor(deviceId: String): Int - @Query( "SELECT * FROM respSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + "ORDER BY ts ASC LIMIT :limit" diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 32caba26da..5fbc52f30a 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1462,7 +1462,7 @@ class WhoopRepository( * ([OuraMetSampleEntity.isTwin]: starts within half a period), or of one accepted earlier in the same * batch, is dropped; the first copy stays. Rows are assumed to * belong to one device (the writer's batch), read back per device. Returns the rows actually - * inserted. Swift `insertOuraMetSamples`. + * inserted. Swift twin: `WhoopStore.insertOuraMetSamples` (which takes the deviceId separately). */ suspend fun insertOuraMetSamples(rows: List): Int { if (rows.isEmpty()) return 0 @@ -1477,7 +1477,7 @@ class WhoopRepository( return inserted } - /** The ring's MET samples in [from, to], ascending (#2242). Swift `ouraMetSamples`. */ + /** The ring's MET samples in [from, to], ascending (#2242). Swift twin: `WhoopStore.ouraMetSamples`. */ suspend fun ouraMetSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): List = dao.ouraMetSamples(deviceId, from, to, limit) From 68abded738c6626c0c8a4c301f414e18ef7025a8 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 14:17:10 +0200 Subject: [PATCH 6/7] doc(calories): say at the MET assignment that activeKcalEst holds a total Review follow-up. `activeKcalEst = met.totalKcal` reads like a category error: the estimate carries the active share separately. It is correct, because the field is a pre-existing misnomer and the HR path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` in it. That is now written at both `analyzeDay` sites, so the next reader does not have to work it out again. Comment only. Refs #2242 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV --- .../Sources/StrandAnalytics/AnalyticsEngine.swift | 2 ++ android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 431ddb1d83..646650a3ac 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -1025,6 +1025,8 @@ public enum AnalyticsEngine { dayStart: metDayStart, dayEnd: metDayEnd) let coveragePct = Int((met.coverageFraction * 100).rounded()) if met.coverageFraction >= Calories.metMinCoverageFraction { + // A TOTAL, not the active share: `activeKcalEst` is a pre-existing misnomer, and the HR + // path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` here. activeKcalEst = met.totalKcal caloriesDiag?("calories \(day): MET path - coverage \(coveragePct)% (\(dayMet.count) samples), active \(Int(met.activeKcal.rounded())) kcal, resting \(Int(met.restingKcal.rounded())) kcal, total \(Int(met.totalKcal.rounded())) kcal") } else { diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 3c6e8ad7a8..299b03df14 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -941,6 +941,8 @@ object AnalyticsEngine { "active ${Math.round(met.activeKcal)} kcal, resting ${Math.round(met.restingKcal)} kcal, " + "total ${Math.round(met.totalKcal)} kcal", ) + // A TOTAL, not the active share: activeKcalEst is a pre-existing misnomer, and the HR + // path's estimateDayCalories already stores estimateDayEnergy(...).totalKcal here. met.totalKcal } else { caloriesDiag?.invoke( From a2031f9be0e1e107b687b20b2ee202fd4f73a9c8 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Thu, 24 Sep 2026 08:57:44 +0200 Subject: [PATCH 7/7] fix(oura): key an ended cycle's MET calories on the MET series and the toggle #2293's day-cycle load cache keys a cycle's Effort and calories on the window, the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met==:` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto #2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Refs #2242 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq --- .../Sources/WhoopStore/OuraMetStore.swift | 16 +++++++ .../WhoopStoreTests/OuraMetStoreTests.swift | 20 +++++++++ .../DayCycleIntelligenceIntegration.swift | 23 +++++++++- Strand/Data/IntelligenceEngine.swift | 3 ++ StrandTests/DayCycleRecoveryTests.swift | 43 ++++++++++++++++++- Tools/parity_dispositions.json | 22 ++++++++-- Tools/parity_twin_map.json | 20 ++++----- .../analytics/PhysiologicalStepCycleEngine.kt | 11 ++++- .../src/main/java/com/noop/data/WhoopDao.kt | 8 ++++ .../java/com/noop/data/WhoopRepository.kt | 7 +++ .../noop/analytics/DayCycleMetCaloriesTest.kt | 43 +++++++++++++++++++ .../analytics/RescoreUnchangedInputsTest.kt | 19 +++++++- 12 files changed, 214 insertions(+), 21 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift index 22de581c72..d44775e303 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift @@ -81,6 +81,22 @@ extension WhoopStore { } } + /// Cheap change-detector for a device's MET series over `[from, to]`: `(count, maxTs)`, computed over the + /// `(deviceId, ts)` key without fetching a row, the same shape as `hrFingerprint(deviceId:from:to:)`. The + /// day-cycle load cache keys a MET-scored cycle on it, so a later drain that banks minutes inside an + /// ended window moves the key (#2242). COALESCE so an empty window is `(0, 0)`. + public func ouraMetFingerprint(deviceId: String, from: Int, to: Int) async throws -> (count: Int, maxTs: Int) { + try syncRead { db in + guard let row = try Row.fetchOne(db, sql: """ + SELECT COUNT(*) AS c, COALESCE(MAX(ts), 0) AS m FROM ouraMetSample + WHERE deviceId = ? AND ts >= ? AND ts <= ? + """, arguments: [deviceId, from, to]) else { return (0, 0) } + let c: Int = row["c"] + let m: Int = row["m"] + return (c, m) + } + } + /// MET samples for a device in `[from, to]` (inclusive, like every stream read), ts ascending. public func ouraMetSamples(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [OuraMetSample] { try syncRead { db in diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift index ad38d6f1f9..92a3c89316 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift @@ -113,6 +113,26 @@ final class OuraMetStoreTests: XCTestCase { XCTAssertTrue(other.isEmpty) } + /// The day-cycle load cache's MET witness (#2242): `(count, maxTs)` over an inclusive window, per device, + /// `(0, 0)` when empty, and it moves on any insert inside the window. + func testFingerprintIsCountAndNewestTsOverTheWindow() async throws { + let store = try await WhoopStore.inMemory() + let empty = try await store.ouraMetFingerprint(deviceId: "oura-A", from: 0, to: .max) + XCTAssertEqual(empty.count, 0); XCTAssertEqual(empty.maxTs, 0) + let rows = (0..<3).map { OuraMetSample(ts: 1_755_208_800 + $0 * 60, met: 1.2, state: 2) } + _ = try await store.insertOuraMetSamples(rows, deviceId: "oura-A") + let all = try await store.ouraMetFingerprint(deviceId: "oura-A", from: 1_755_208_800, to: 1_755_208_920) + XCTAssertEqual(all.count, 3); XCTAssertEqual(all.maxTs, 1_755_208_920) + let bounded = try await store.ouraMetFingerprint(deviceId: "oura-A", from: 1_755_208_800, to: 1_755_208_919) + XCTAssertEqual(bounded.count, 2); XCTAssertEqual(bounded.maxTs, 1_755_208_860) + let other = try await store.ouraMetFingerprint(deviceId: "oura-B", from: 0, to: .max) + XCTAssertEqual(other.count, 0) + _ = try await store.insertOuraMetSamples([OuraMetSample(ts: 1_755_208_830, met: 2.0, state: 3)], deviceId: "oura-A") + let moved = try await store.ouraMetFingerprint(deviceId: "oura-A", from: 1_755_208_800, to: 1_755_208_920) + XCTAssertEqual(moved.count, 4, "a minute banked inside the window moves the count even when maxTs does not") + XCTAssertEqual(moved.maxTs, 1_755_208_920) + } + func testDeleteAllDataClearsTheRingsMetRows() async throws { let store = try await WhoopStore.inMemory() _ = try await store.insertOuraMetSamples([OuraMetSample(ts: 1, met: 1.0, state: 0)], deviceId: "oura-A") diff --git a/Strand/Data/DayCycleIntelligenceIntegration.swift b/Strand/Data/DayCycleIntelligenceIntegration.swift index 38eabf4795..ff7b60a629 100644 --- a/Strand/Data/DayCycleIntelligenceIntegration.swift +++ b/Strand/Data/DayCycleIntelligenceIntegration.swift @@ -50,6 +50,9 @@ import WhoopStore /// MET-calories toggle is off, which is the byte-identical HR path. Injected rather than read from /// `store` directly so the fold's MET decision can be pinned in a test without a live ring. typealias MetReader = (_ owner: String, _ from: Int, _ to: Int) async -> [Calories.MetSample] + /// `(count, maxTs)` of the cycle owner's MET rows in `[from, to]`, the witness the load cache keys a + /// MET-scored cycle on (#2242); `nil` return = unread, which never serves a cached load. + typealias MetFingerprint = (_ owner: String, _ from: Int, _ to: Int) async -> (count: Int, maxTs: Int)? static func recover(candidates: [(owner: String, priority: Int)], reader: BoundaryRecoveryReader, claimedDays: Set, windowStart: Int, now: Int, @@ -102,6 +105,7 @@ import WhoopStore profile: UserProfile, maxHROverride: Double?, effortMethod: StrainScorer.Method, recoveryReader: BoundaryRecoveryReader? = nil, metReader: MetReader? = nil, + metFingerprint: MetFingerprint? = nil, trace: ((String) -> Void)? = nil) async -> Result { guard mode == .sleepOnset else { return Result(stepsByWakeDay: [:], strainByWakeDay: [:], caloriesByWakeDay: [:], @@ -219,10 +223,25 @@ import WhoopStore hrWitness.append("\(owner)=\(fp.map { "\($0.count):\($0.maxTs)" } ?? "unread")") } } - let loadKey = "\(window.onset)-\(window.endExclusive)|\(hrWitness.joined(separator: ","))" + // #2242: with the MET-calories toggle on, an ended cycle's calories come from the owner's MET + // series, which the HR witness cannot see. Without this, a drain that banks MET minutes inside an + // ended window leaves the key unchanged and the cache serves calories from the thinner stream. + // `met=off` names the toggle state, so flipping it over unchanged data never serves the figure + // cached under the other setting. + let metWitness: String + if metReader == nil { + metWitness = "met=off" + } else if hrEndInclusive < window.onset { + metWitness = "met=empty" + } else { + let fp = await metFingerprint?(fallback, window.onset, hrEndInclusive) + metWitness = "met=\(fallback)=\(fp.map { "\($0.count):\($0.maxTs)" } ?? "unread")" + } + let loadKey = "\(window.onset)-\(window.endExclusive)|\(hrWitness.joined(separator: ","))|\(metWitness)" + "|rhr=\(restingHR)|max=\(effectiveMaxHR.map { "\($0)" } ?? "nil")|\(effortMethod)|\(profile.cacheKey)" let load: CachedLoad - if let hit = cache.loads[window.sleepId], hit.key == loadKey, !hrWitness.contains(where: { $0.hasSuffix("=unread") }) { + if let hit = cache.loads[window.sleepId], hit.key == loadKey, !hrWitness.contains(where: { $0.hasSuffix("=unread") }), + !metWitness.hasSuffix("=unread") { load = hit } else { var hrByTimestamp: [Int: HRSample] = [:] diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index c989440eec..41a13c1161 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -2084,6 +2084,9 @@ final class IntelligenceEngine: ObservableObject { let rows = (try? await store.ouraMetSamples(deviceId: owner, from: from, to: to, limit: 4_000)) ?? [] return rows.map { Calories.MetSample(ts: $0.ts, met: $0.met, secPerSample: $0.epochS) } } : nil, + metFingerprint: ouraMetCaloriesOn ? { owner, from, to in + try? await store.ouraMetFingerprint(deviceId: owner, from: from, to: to) + } : nil, trace: stepsTraceActive ? { self.diagnosticSink?($0, .steps) } : nil) // #299: `editsByStart` is now built PER DAY inside the scoring loop (scoped to the day each edit // belongs to), NOT window-wide here. sleepEditedDaily folds any edited row that isn't a twin of THIS diff --git a/StrandTests/DayCycleRecoveryTests.swift b/StrandTests/DayCycleRecoveryTests.swift index f0d2b650ef..bd75cc4d0d 100644 --- a/StrandTests/DayCycleRecoveryTests.swift +++ b/StrandTests/DayCycleRecoveryTests.swift @@ -204,6 +204,8 @@ extension DayCycleRecoveryTests { } private func computeCycle(_ f: CycleFixture, + cache: DayCycleIntelligenceIntegration.Cache = DayCycleIntelligenceIntegration.Cache(), + metFingerprint: DayCycleIntelligenceIntegration.MetFingerprint? = nil, metReader: DayCycleIntelligenceIntegration.MetReader?) async -> DayCycleIntelligenceIntegration.Result { await DayCycleIntelligenceIntegration.compute( @@ -212,11 +214,11 @@ extension DayCycleRecoveryTests { physiologyOwners: [CycleFixture.owner], workouts: [], windowStart: f.onset - 86_400, now: f.now, offsetSec: 0, habitualMidsleepSec: nil, ticksPerStep: 1, mode: .sleepOnset, - cache: DayCycleIntelligenceIntegration.Cache(), profile: UserProfile(), + cache: cache, profile: UserProfile(), maxHROverride: nil, effortMethod: .edwards, recoveryReader: DayCycleIntelligenceIntegration.BoundaryRecoveryReader( sleepSessions: { _, _, _ in [] }, markers: { _, _, _ in [] }), - metReader: metReader) + metReader: metReader, metFingerprint: metFingerprint) } func testCycleEnergyIsTheMetTotalWhenTheCycleIsCovered() async throws { @@ -263,4 +265,41 @@ extension DayCycleRecoveryTests { let empty = await computeCycle(f) { _, _, _ in [] } XCTAssertEqual(empty.caloriesByWakeDay[f.day], keytel) } + + /// #2242 × the cycle load cache: MET banked inside an already-scored cycle moves the cache key. The first + /// pass sees 10 % coverage and withholds; a later drain fills the cycle, and the next pass must score it + /// from MET instead of serving the withheld load cached under the heart-rate-only witness. + func testMetBankedAfterAPassIsNotServedFromTheCache() async throws { + let f = try await cycleFixture() + let all = stride(from: f.onset, to: f.now, by: 60).map { Calories.MetSample(ts: $0, met: 1.2) } + var banked = all.filter { $0.ts < f.onset + 2 * 3_600 } + let cache = DayCycleIntelligenceIntegration.Cache() + let fingerprint: DayCycleIntelligenceIntegration.MetFingerprint = { _, _, _ in + (banked.count, banked.last?.ts ?? 0) + } + let first = await computeCycle(f, cache: cache, metFingerprint: fingerprint) { _, _, _ in banked } + XCTAssertNil(first.caloriesByWakeDay[f.day]) + + banked = all + let second = await computeCycle(f, cache: cache, metFingerprint: fingerprint) { _, _, _ in banked } + let expected = Calories.estimateDayEnergyFromMET(all, profile: UserProfile(), dayStart: f.onset, dayEnd: f.now) + XCTAssertEqual(second.caloriesByWakeDay[f.day], expected.totalKcal) + } + + /// Flipping the toggle over unchanged data never serves the figure cached under the other setting. + func testAToggleFlipOverUnchangedDataRescoresTheCycle() async throws { + let f = try await cycleFixture() + let met = stride(from: f.onset, to: f.now, by: 60).map { Calories.MetSample(ts: $0, met: 2.0) } + let cache = DayCycleIntelligenceIntegration.Cache() + let fingerprint: DayCycleIntelligenceIntegration.MetFingerprint = { _, _, _ in (met.count, met.last?.ts ?? 0) } + let on = await computeCycle(f, cache: cache, metFingerprint: fingerprint) { _, _, _ in met } + let off = await computeCycle(f, cache: cache, metReader: nil) + let onKcal = try XCTUnwrap(on.caloriesByWakeDay[f.day]) + let offKcal = try XCTUnwrap(off.caloriesByWakeDay[f.day]) + XCTAssertNotEqual(onKcal, offKcal, "the two paths must differ for this test to mean anything") + let onAgain = await computeCycle(f, cache: cache, metFingerprint: fingerprint) { _, _, _ in met } + XCTAssertEqual(onAgain.caloriesByWakeDay[f.day], onKcal) + let offAgain = await computeCycle(f, cache: cache, metReader: nil) + XCTAssertEqual(offAgain.caloriesByWakeDay[f.day], offKcal) + } } diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 55c827c993..b70c47bd6c 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -44,10 +44,10 @@ { "type": "platform_specific", "kind": "add-unpaired-function", - "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt::loadCacheKey/7#1", - "identity_sha256": "d975e64811f395077839f35c6797284bac55e6ad3e3b5e049bf826d69d5a2131", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt::loadCacheKey/8#1", + "identity_sha256": "01eaca6b8864376cb2686a4b78cac00aa0184e65955409b143f1ea79e84c6e15", "platform": "kotlin", - "rationale": "Twin of the Swift cycle load cache key built inline in Strand/Data/DayCycleIntelligenceIntegration.swift (app layer, outside the governed roots)." + "rationale": "Twin of the Swift cycle load cache key built inline in Strand/Data/DayCycleIntelligenceIntegration.swift (app layer, outside the governed roots). The eighth parameter is the #2242 MET witness, the Swift `metWitness` in the same function." }, { "type": "platform_specific", @@ -128,6 +128,22 @@ "identity_sha256": "81b9152e7189625064e51189ec7be31e1d1f57662438fe54df4c1f779a6bbe48", "platform": "kotlin", "rationale": "Room MIGRATION_40_41's migrate override (ouraMetSample, #2242). The GRDB twin is the registered `v47-oura-met-sample` migration closure, which is not a named function; the two are pinned against each other by the shared schema oracle, not by the ledger." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDao.kt::countOuraMetInWindow/3#1", + "identity_sha256": "39c98f427c4f4824db26ca0ac3787342fcadb18f2164343357df0774d1e186ba", + "platform": "kotlin", + "rationale": "Room DAO primitive with no Swift counterpart by construction, like countHrInWindow/maxHrTsInWindow: on Apple the same COUNT/MAX aggregate runs inside the one WhoopStore (GRDB) function it serves (`WhoopStore.ouraMetFingerprint(deviceId:from:to:)`, paired with `WhoopRepository.ouraMetFingerprint`)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDao.kt::maxOuraMetTsInWindow/3#1", + "identity_sha256": "aa9ae5b52ceec7ecfc8ad685108be9ed461ad600e992a61e289b5eb52797fc57", + "platform": "kotlin", + "rationale": "Room DAO primitive with no Swift counterpart by construction, like countHrInWindow/maxHrTsInWindow: on Apple the same COUNT/MAX aggregate runs inside the one WhoopStore (GRDB) function it serves (`WhoopStore.ouraMetFingerprint(deviceId:from:to:)`, paired with `WhoopRepository.ouraMetFingerprint`)." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 22cb0533d1..2dfde97337 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,16 +18,16 @@ ] }, "authority": { - "files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"}, - "functions": {"count": 4479, "sha256": "bdb69394501d448ac150e19fa571eb18999e54ee7312d4c5517367d35926a419"}, - "properties": {"count": 463, "sha256": "7d80508acfd6418a9f1dc099d65a6890e9b663fb9b056ac2e939b0e0e42d488e"}, - "constants": {"count": 1955, "sha256": "1ec11be6a59062be6df2e194e0269df6d9a7a2720e5af121040fd8008a47b885"}, - "file_pairs": {"count": 74, "sha256": "1023813dc72dbe4122281d5bae27ac2b7a657dae5904cb5fd603032928199f94"}, - "function_pairs": {"count": 189, "sha256": "11df8bf87b16b535aeddd0c00471f87431236fd0b059484c07f6d5939ace9732"}, - "property_pairs": {"count": 149, "sha256": "8a62616eca6270e3d8899c47cfe8ae93691f16846ed1959e19a762827b6ec185"}, - "constant_pairs": {"count": 680, "sha256": "9f333ee3a2b11cd78ded18ba755731a26b40ed7e9d4fc1394498eb0a154437a7"}, - "unpaired_files": {"count": 376, "sha256": "654d951b8137dbb2266b83734fa1e3f1cb1648f15c1aab3d015cdef7e79deb75"}, - "unpaired_functions": {"count": 4113, "sha256": "9455f235143adef089d343d45a1b43e10445953b866afb351dfa1ab9b27b4d5b"}, + "files": {"count": 503, "sha256": "f3d0425ed53430f8f87deea65ba62df49e375c8a371b4c0f872b1962d203133f"}, + "functions": {"count": 4498, "sha256": "b189c6d6dfe033205ba8a1595aca519808484d3e419f55a9b27e3641f1ac6f59"}, + "properties": {"count": 465, "sha256": "ccffdd35fb8bf2fb67204f7c4bb6f723e409a802ae15987c4f7fde9ca608d1a3"}, + "constants": {"count": 1961, "sha256": "6760c08c07881d65761579a09535a8588f0d6e626f1a8080fb2e837712147a44"}, + "file_pairs": {"count": 78, "sha256": "8c29b3de1d4da59837979bd09b2ae949997b8e15837ac25175985ff302e7c6b4"}, + "function_pairs": {"count": 199, "sha256": "8a04c1ba0067e69c948b4fa54e0ae57798e4652d9b4d6c75fb7a875af82b6238"}, + "property_pairs": {"count": 150, "sha256": "4d15ec3678de1768be224f3bf75febff0f6ab00cd284ebb9c972bb1d2f4efdb5"}, + "constant_pairs": {"count": 683, "sha256": "ab7bcb29aa5c6ab049588f379f62dcec19413f63eb48d481a1c9b880996f19bb"}, + "unpaired_files": {"count": 373, "sha256": "d00b28000dbabb5c04665eec419c4f2d4faa923995df9a4c25a76c2918e0f245"}, + "unpaired_functions": {"count": 4115, "sha256": "4793ca07cd0cc48f64b786c5d14c66f4ed359a2d4e1a59d6f334fb6af8862189"}, "unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"}, "unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"} } diff --git a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt index 3e3f702da5..52a4b05650 100644 --- a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt @@ -46,9 +46,9 @@ internal object PhysiologicalStepCycleEngine { * the Swift `DayCycleIntelligenceIntegration` load cache. */ internal fun loadCacheKey( - onset: Long, endExclusive: Long, hrWitness: String, restingHr: Double, maxHr: Double?, + onset: Long, endExclusive: Long, hrWitness: String, metWitness: String, restingHr: Double, maxHr: Double?, effortMethod: StrainScorer.Method, profile: UserProfile, - ): String = "$onset-$endExclusive|$hrWitness|rhr=$restingHr|max=${maxHr ?: "nil"}|$effortMethod|${profile.cacheKey}" + ): String = "$onset-$endExclusive|$hrWitness|$metWitness|rhr=$restingHr|max=${maxHr ?: "nil"}|$effortMethod|${profile.cacheKey}" /** * The cycle's energy, by the SAME decision AnalyticsEngine.analyzeDay takes for the calendar day (#2242), @@ -256,6 +256,13 @@ internal object PhysiologicalStepCycleEngine { val loadKey = loadCacheKey( window.onset, window.endExclusive, repo.hrUnionFingerprint(fallbackOwner, window.onset, window.endExclusive - 1L), + // #2242: with the MET-calories toggle on, an ended cycle's calories come from the owner's MET + // series, which the HR witness cannot see: a drain banking MET minutes inside an ended window + // must move the key. `met=off` names the toggle state, so a flip over unchanged data never + // serves the figure cached under the other setting. Swift twin: the `metWitness` in + // DayCycleIntelligenceIntegration.compute. + if (!ouraMetCalories) "met=off" + else "met=$fallbackOwner=${repo.ouraMetFingerprint(fallbackOwner, window.onset, window.endExclusive - 1L)}", restingHr, effectiveMaxHr, effortMethod, profile, ) val load = loadCache[window.sleepId]?.takeIf { it.key == loadKey } ?: run { diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index 06b5274cf0..a97e669bcf 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -724,6 +724,14 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun sleepStateSamples(deviceId: String, from: Long, to: Long, limit: Int): List + // #2242: MET-series change-detector for the day-cycle load cache: row count + newest ts over the + // (deviceId, ts) key, never a row fetch; mirrors Swift WhoopStore.ouraMetFingerprint(deviceId:from:to:). + // COALESCE(MAX) → 0 for an empty window. + @Query("SELECT COUNT(*) FROM ouraMetSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to") + suspend fun countOuraMetInWindow(deviceId: String, from: Long, to: Long): Int + @Query("SELECT COALESCE(MAX(ts), 0) FROM ouraMetSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to") + suspend fun maxOuraMetTsInWindow(deviceId: String, from: Long, to: Long): Long + /** The Oura ring's OWN per-minute MET samples (#2242) in [from, to], ascending. Swift `ouraMetSamples`. */ @Query( "SELECT * FROM ouraMetSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 5fbc52f30a..41e21ded21 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1477,6 +1477,13 @@ class WhoopRepository( return inserted } + /** + * `count:maxTs` of a device's MET series over [from, to] (#2242), the witness the day-cycle load cache + * keys a MET-scored cycle on. Swift twin: `WhoopStore.ouraMetFingerprint(deviceId:from:to:)`. + */ + suspend fun ouraMetFingerprint(deviceId: String, from: Long, to: Long): String = + "${dao.countOuraMetInWindow(deviceId, from, to)}:${dao.maxOuraMetTsInWindow(deviceId, from, to)}" + /** The ring's MET samples in [from, to], ascending (#2242). Swift twin: `WhoopStore.ouraMetSamples`. */ suspend fun ouraMetSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): List = dao.ouraMetSamples(deviceId, from, to, limit) diff --git a/android/app/src/test/java/com/noop/analytics/DayCycleMetCaloriesTest.kt b/android/app/src/test/java/com/noop/analytics/DayCycleMetCaloriesTest.kt index e19e61a128..088961b827 100644 --- a/android/app/src/test/java/com/noop/analytics/DayCycleMetCaloriesTest.kt +++ b/android/app/src/test/java/com/noop/analytics/DayCycleMetCaloriesTest.kt @@ -121,4 +121,47 @@ class DayCycleMetCaloriesTest { runBlocking { db.whoopDao().deleteOuraMetFor(owner) } assertEquals(keytel, compute(night, ouraMetCalories = true).cycleCaloriesByWakeDay[day]!!, 0.0) } + + /** + * #2242 × the cycle load cache: MET banked inside an already-scored cycle moves the cache key. Here the + * first pass sees 10 % coverage and withholds; a later drain fills the cycle, and the next pass must + * score it from MET instead of serving the withheld load cached under the heart-rate-only witness. + */ + @Test + fun metBankedAfterAPassIsNotServedFromTheCache() { + val night = seedCycle() + val all = (onset until now step 60L).map { OuraMetSampleEntity(owner, it, 1.2, 0, 60) } + val thin = all.filter { it.ts < onset + 2 * 3_600L } + runBlocking { repo.insertOuraMetSamples(thin) } + assertNull(compute(night, ouraMetCalories = true).cycleCaloriesByWakeDay[day]) + + runBlocking { repo.insertOuraMetSamples(all - thin.toSet()) } + val expected = Calories.estimateDayEnergyFromMet( + all.map { Calories.MetSample(it.ts, it.met, it.epochS) }, UserProfile(), onset, now, + ) + assertEquals(expected.totalKcal, compute(night, ouraMetCalories = true).cycleCaloriesByWakeDay[day]!!, 1e-9) + } + + /** The MET witness: `count:maxTs` over an inclusive window, per device. Swift twin in OuraMetStoreTests. */ + @Test + fun ouraMetFingerprintIsCountAndNewestTs() = runBlocking { + assertEquals("0:0", repo.ouraMetFingerprint(owner, 0L, Long.MAX_VALUE)) + repo.insertOuraMetSamples((0 until 3).map { OuraMetSampleEntity(owner, onset + it * 60L, 1.2, 2, 60) }) + assertEquals("3:${onset + 120}", repo.ouraMetFingerprint(owner, onset, onset + 120)) + assertEquals("2:${onset + 60}", repo.ouraMetFingerprint(owner, onset, onset + 119)) + assertEquals("0:0", repo.ouraMetFingerprint("other", 0L, Long.MAX_VALUE)) + } + + /** Flipping the toggle over unchanged data never serves the figure cached under the other setting. */ + @Test + fun aToggleFlipOverUnchangedDataRescoresTheCycle() { + val night = seedCycle() + val met = (onset until now step 60L).map { OuraMetSampleEntity(owner, it, 2.0, 0, 60) } + runBlocking { repo.insertOuraMetSamples(met) } + val on = compute(night, ouraMetCalories = true).cycleCaloriesByWakeDay[day]!! + val off = compute(night, ouraMetCalories = false).cycleCaloriesByWakeDay[day]!! + assertTrue("the two paths must differ for this test to mean anything", on != off) + assertEquals(on, compute(night, ouraMetCalories = true).cycleCaloriesByWakeDay[day]!!, 0.0) + assertEquals(off, compute(night, ouraMetCalories = false).cycleCaloriesByWakeDay[day]!!, 0.0) + } } diff --git a/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt b/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt index f42cf87956..f36c236882 100644 --- a/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt @@ -23,18 +23,33 @@ class RescoreUnchangedInputsTest { fun theCycleLoadKeyMovesOnlyWithItsInputs() { val profile = UserProfile() fun key(witness: String, rhr: Double = 55.0) = PhysiologicalStepCycleEngine.loadCacheKey( - 1_000L, 87_400L, witness, rhr, 192.6, StrainScorer.Method.EDWARDS, profile) + 1_000L, 87_400L, witness, "met=off", rhr, 192.6, StrainScorer.Method.EDWARDS, profile) assertEquals(key("my-whoop=86000:87399"), key("my-whoop=86000:87399")) assertNotEquals(key("my-whoop=86000:87399"), key("my-whoop=86001:87399")) assertNotEquals(key("my-whoop=86000:87399"), key("my-whoop=86000:87399", rhr = 56.0)) } + /** + * #2242: a MET-scored cycle's calories come from a series the HR witness cannot see, so the MET witness + * and the toggle state are both in the key. + */ + @Test + fun theMetWitnessAndTheToggleStateMoveTheLoadKey() { + val profile = UserProfile() + fun key(met: String) = PhysiologicalStepCycleEngine.loadCacheKey( + 1_000L, 87_400L, "my-whoop=86000:87399", met, 55.0, 192.6, StrainScorer.Method.EDWARDS, profile) + assertEquals(key("met=oura=1440:87360"), key("met=oura=1440:87360")) + assertNotEquals(key("met=oura=1440:87360"), key("met=oura=1441:87360")) + assertNotEquals(key("met=oura=1440:87360"), key("met=oura=1440:87420")) + assertNotEquals(key("met=off"), key("met=oura=0:0")) + } + /** Each profile field moves the key: calories read weight, height, age and sex. */ @Test fun everyProfileFieldMovesTheLoadKey() { val base = UserProfile() fun key(p: UserProfile) = PhysiologicalStepCycleEngine.loadCacheKey( - 1_000L, 87_400L, "my-whoop=1:2", 55.0, 192.6, StrainScorer.Method.EDWARDS, p) + 1_000L, 87_400L, "my-whoop=1:2", "met=off", 55.0, 192.6, StrainScorer.Method.EDWARDS, p) listOf(base.copy(weightKg = 71.0), base.copy(heightCm = 171.0), base.copy(age = 31.0), base.copy(sex = "male"), base.copy(stepTicksPerStep = 2.0), base.copy(waistCm = 80.0)) .forEach { assertNotEquals(key(base), key(it)) }