From 227cd9e47f7750c6c376bca7162d471785aa989d Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 16 Sep 2026 10:05:11 +0200 Subject: [PATCH 1/8] =?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=20(#2242)?= 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. The earlier (MET − 1) × k reading was the wrong subtraction absorbing an intercept — there is no hidden per-account constant. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a 68-case spread (four profiles × empty / rest floor / bout / 1.5 threshold edge / 12.7-12.8 decoder boundary / 60 % + 40 % coverage / 120 s cadence / duplicate ts / window edges / partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals. Pure and unwired: no store, no engine selection yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL --- .../StrandAnalytics/WorkoutDetector.swift | 99 ++++++++++ .../MetCaloriesTests.swift | 145 ++++++++++++++ .../com/noop/analytics/WorkoutDetector.kt | 108 +++++++++++ .../noop/analytics/MetCaloriesOracleTest.kt | 177 ++++++++++++++++++ 4 files changed, 529 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 1029bb5a05..620324cc86 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift @@ -845,4 +845,103 @@ 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 (a duplicate `ts` counts + /// once — the LOWER MET wins the tie, the conservative direction), capped at the day span. 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: the store's (deviceId, ts) key makes them unreachable from a single device, but a + // caller unioning devices could produce one. Ascending MET on a tie keeps the LOWER reading. + 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 lastTs = Int.min + for s in ordered { + if s.ts == lastTs { continue } + lastTs = s.ts + 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..6d83984b3a --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift @@ -0,0 +1,145 @@ +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) + } + + 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..b742ac6ebf 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,112 @@ 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 (a + * duplicate `ts` counts once — the LOWER MET wins the tie, the conservative direction), capped at the + * day span. 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: the store's (deviceId, ts) key makes them unreachable from a single device, but a + // caller unioning devices could produce one. Ascending MET on a tie keeps the LOWER reading. + val ordered = inDay.sortedWith(compareBy { it.ts }.thenBy { it.met }) + var covered = 0.0 + var activeKcal = 0.0 + var lastTs = Long.MIN_VALUE + for (s in ordered) { + if (s.ts == lastTs) continue + lastTs = s.ts + 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..f804418309 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt @@ -0,0 +1,177 @@ +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. 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) + } + 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", + "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", + "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", + "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", + ) +} From b086f4375c77b73744a16ecb4258cb585cc3bc9d Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 16 Sep 2026 10:12:36 +0200 Subject: [PATCH 2/8] =?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=20(#2242)?= 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. Swift: OuraMetStore (idempotent insert, bounded range read, count), the table on DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity, DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the committed v41 Room schema for the upgrade test. Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin); WhoopStore 609 green, com.noop.data 460 green incl. SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. No writer yet — the table is created empty. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL --- .../Sources/WhoopStore/Database.swift | 26 + .../WhoopStore/DeviceRegistryStore.swift | 3 + .../Sources/WhoopStore/OuraMetStore.swift | 62 + .../WhoopStoreTests/OuraMetStoreTests.swift | 60 + .../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 | 20 + .../src/main/java/com/noop/data/WhoopDao.kt | 15 + .../main/java/com/noop/data/WhoopDatabase.kt | 22 +- .../java/com/noop/data/WhoopRepository.kt | 11 + .../analytics/RegistryDayOwnerSourceTest.kt | 1 + .../noop/ble/SourceCoordinatorAdoptionTest.kt | 1 + .../java/com/noop/data/DeviceRegistryTest.kt | 3 + .../noop/data/OuraMetSampleMigrationTest.kt | 57 + .../com.noop.data.WhoopDatabase/41.json | 2210 +++++++++++++++++ .../app/src/test/resources/schema_oracle.json | 45 +- 17 files changed, 2579 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..a5e2aef492 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift @@ -0,0 +1,62 @@ +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 + } +} + +extension WhoopStore { + + /// Insert MET samples for a device. Idempotent by (deviceId, ts): a record the ring re-serves across + /// reconnects (common under connection churn) lands once. 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 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 samples { + 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..79c61ace54 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift @@ -0,0 +1,60 @@ +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"]) + } + + 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..6c3dc9b5be 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,26 @@ 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, +) + /** 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 387ca29788..b2deaf1309 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1444,6 +1444,17 @@ 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 (deviceId, ts). Returns the + * rows actually inserted. Swift `insertOuraMetSamples`. + */ + suspend fun insertOuraMetSamples(rows: List): Int = + if (rows.isEmpty()) 0 else dao.insertOuraMet(rows).count { it != -1L } + + /** 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..8cfbc782a2 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt @@ -0,0 +1,57 @@ +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) + } +} 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 10b184bac7bb03434e55b18bcdaddf9579430dac Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 16 Sep 2026 10:17:58 +0200 Subject: [PATCH 3/8] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied (#2242) 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. Nothing supplies dayMet yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL --- .../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 dfd7b9f04b..b04b6d4fce 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -340,6 +340,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 @@ -962,9 +984,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 863de4de39..6746f9fc8f 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) @@ -886,7 +902,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 76ba2835315f408c6441c9528d8f6e3888a8e19d Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 16 Sep 2026 10:33:33 +0200 Subject: [PATCH 4/8] feat(oura): persist the ring's MET records and feed them to the scorer, behind an Experimental toggle (#2242) 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 two things: • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one row per minute, next to the existing JSONL sidecar call. The record's timestamp is the END of its last sample: fitted against Oura's own per-minute export, every record length n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57 read forward), so sample i starts at utc − (n − i)·60 and a record straddling local midnight lands its minutes on the right days. Wired in SourceCoordinator to store.insertOuraMetSamples / repository.insertOuraMetSamples. • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar- day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now so today's coverage is judged against elapsed hours, and the calories line routed through the same per-day diag recorder as the Effort funnel. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day rather than serving stale calories (DayCacheConfigFieldTests / DayCacheConfigFieldTest updated). Android threads the flag Context-free like spo2CandidateDisplay (analyzeRecent; AppViewModel ×2 + WhoopBleClient callers) but carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff — both for analyzeRecentOnCpu's JaCoCo budget: a new parameter or a new suspend call in that method costs ~0.3 KB / ~0.7 K instructions of continuation spill, which the ratchet refuses (measured by bisecting the raw method size per commit). OFF keeps both the DB and every score byte-identical. Verified: macOS Strand build + StrandTests (1945 green), NOOPiOS build, Android compile + unit tests (the 8 failures on this machine are French-locale formatting tests that fail identically on upstream/main). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL --- Strand/App/AppModel.swift | 11 ++++ Strand/BLE/OuraLiveSource.swift | 25 +++++++++ Strand/BLE/SourceCoordinator.swift | 4 ++ Strand/Data/IntelligenceEngine.swift | 23 +++++++- StrandTests/DayCacheConfigFieldTests.swift | 2 +- .../com/noop/analytics/IntelligenceEngine.kt | 54 +++++++++++++++++-- .../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 | 2 + .../src/main/java/com/noop/ui/MainActivity.kt | 14 +++++ .../noop/analytics/DayCacheConfigFieldTest.kt | 2 +- 12 files changed, 158 insertions(+), 7 deletions(-) diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 588a25d37e..6f8ef580c9 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -2093,6 +2093,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 c089011816..79e50c18f0 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 @@ -1310,6 +1317,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 }, @@ -1324,6 +1333,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 @@ -2246,6 +2257,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/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index d657e2a979..f59ee56e84 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 @@ -1024,6 +1029,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 @@ -1326,6 +1332,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 @@ -1433,6 +1452,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 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/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 0831a654ef..cf98df41fe 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() @@ -863,6 +879,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. @@ -1033,9 +1050,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 @@ -1052,8 +1076,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 = @@ -1154,6 +1177,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, @@ -2898,7 +2926,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. @@ -3033,6 +3061,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 @@ -3079,7 +3110,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 @@ -3091,6 +3135,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/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index 12f739782a..3d54d7340b 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -140,6 +140,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. */ @@ -2169,6 +2175,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 1009f79ead..09dfeebac6 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 d66d6d0f4e..341933f663 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), ) @@ -1984,6 +1985,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), ) 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/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, ) From 7cc19976b350ecba744688947664b7d43b8ddddb Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 16 Sep 2026 10:33:34 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat(settings):=20Experimental=20=C2=B7=20O?= =?UTF-8?q?ura=20Calories=20toggle,=20copy=20and=20docs=20(#2242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gains an "Experimental · Oura Calories" section (iOS card / Android row), shown only when an Oura ring is the active/paired device, default OFF, with the caption stating the method (minutes above 1.5 MET × resting rate), that it is an estimate tracking Oura's own total to within a constant factor, the < 50 % coverage withhold, and that turning it on starts storing the ring's MET samples. Flipping it re-scores immediately on both platforms (the toggle is in the day-cache signature). Strings land in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/zh-Hant (iOS) and pl/ru/zh (Android) so the i18n ratchets stay green. Docs: OURA_PROTOCOL.md §6.13 gets the day-sum validation of the 0x50 decode against Oura's export (10/10 clean days within 1–8 %), the end-of-record timestamp semantics, and the derived active-calorie formula; README's Oura table gains an "Active calories" row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL --- README.md | 1 + Strand/Resources/Localizable.xcstrings | 46 +++++++++++++++++- Strand/Screens/SettingsView.swift | 34 ++++++++++++++ .../src/main/java/com/noop/ui/AppViewModel.kt | 7 +++ .../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 + docs/OURA_PROTOCOL.md | 31 ++++++++++++ 14 files changed, 181 insertions(+), 1 deletion(-) 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/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 2a104575e4..46455502b9 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -70,6 +70,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 /// #463 opt-in: score the intraday stress timeline against a PERSONAL cross-day baseline /// (`.baselineRelative`) instead of the day's own calm hours. Default off — the r≈0.6 margin is @@ -1922,6 +1923,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 } @@ -2439,6 +2441,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/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index 341933f663..8deccd7834 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -2851,6 +2851,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/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index fd5f059a57..a5a746ea70 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -3160,6 +3160,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 e5a174428b..09d97cf5d8 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2830,4 +2830,6 @@ Weicher Oura-Benachrichtigungsmaske ff (experimentell) Sendet beim nächsten Verbinden die SetNotification-Maske der offiziellen App (1c 01 ff) statt NOOPs 3f. Der Ring packt für die offizielle App ~10 Pakete in eine Benachrichtigung, für NOOP nur eines (9× langsameres Auslesen); das ist der erste Kandidat für den Schalter. Standardmäßig aus; die erste Verbindung nach dem Ausschalten läuft wieder mit 3f. Im Strap-Protokoll auf „-> notify_all(ff)“ achten und die Benachrichtigungsgrößen in der Rohaufzeichnung vergleichen. + 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 1bf83dfa45..e26a092e9b 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2817,4 +2817,6 @@ Más suave Máscara de notificación Oura ff (experimental) Envía la máscara SetNotification de la aplicación oficial (1c 01 ff) en lugar del 3f de NOOP en la próxima conexión. El anillo agrupa ~10 paquetes por notificación para la aplicación oficial y uno para NOOP (descarga 9× más lenta); este es el primer interruptor candidato. Desactivado por defecto; la conexión siguiente a desactivarlo vuelve a 3f. Busca «-> notify_all(ff)» en el registro de la pulsera y compara los tamaños de notificación en la captura sin procesar. + 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 1832d62aca..ada200a448 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2816,4 +2816,6 @@ Plus douce Masque de notification Oura ff (expérimental) Envoie le masque SetNotification de l’application officielle (1c 01 ff) au lieu du 3f de NOOP à la prochaine connexion. La bague regroupe ~10 paquets par notification pour l’application officielle et un seul pour NOOP (vidage 9× plus lent) ; c’est le premier commutateur candidat. Désactivé par défaut ; la connexion suivant la désactivation repasse en 3f. Surveillez « -> notify_all(ff) » dans le journal du bracelet et comparez la taille des notifications dans la capture brute. + 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 0aa63c4839..6d7d730cbf 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2831,4 +2831,6 @@ Łagodniejsze Maska powiadomień Oura ff (eksperymentalne) Przy następnym połączeniu wysyła maskę SetNotification oficjalnej aplikacji (1c 01 ff) zamiast 3f używanego przez NOOP. Pierścień pakuje ~10 pakietów w jedno powiadomienie dla oficjalnej aplikacji, a dla NOOP tylko jeden (9× wolniejsze zrzuty); to pierwszy kandydat na przełącznik. Domyślnie wyłączone; pierwsze połączenie po wyłączeniu wraca do 3f. Szukaj „-> notify_all(ff)” w dzienniku opaski i porównaj rozmiary powiadomień w surowym przechwyceniu. + 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 a44a5a40f7..015be7a6ef 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2809,4 +2809,6 @@ Mais suave Máscara de notificação Oura ff (experimental) Envia a máscara SetNotification da aplicação oficial (1c 01 ff) em vez do 3f do NOOP na próxima ligação. O anel agrupa ~10 pacotes por notificação para a aplicação oficial e um para o NOOP (descarga 9× mais lenta); este é o primeiro candidato a interruptor. Desligado por predefinição; a ligação seguinte após desligar volta a 3f. Procure «-> notify_all(ff)» no registo da pulseira e compare os tamanhos das notificações na captura em bruto. + 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 d0b2993d2c..5b2b88ce67 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2710,4 +2710,6 @@ Мягче Маска уведомлений Oura ff (экспериментально) При следующем подключении отправляет маску SetNotification официального приложения (1c 01 ff) вместо 3f, которую использует NOOP. Для официального приложения кольцо упаковывает ~10 пакетов в одно уведомление, для NOOP — только один (выгрузка в 9× медленнее); это первый кандидат на переключатель. По умолчанию выключено; первое подключение после выключения снова использует 3f. Ищите «-> notify_all(ff)» в журнале браслета и сравните размеры уведомлений в сырой записи. + Активные калории из потока 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 24136f2979..d5671dd788 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2788,4 +2788,6 @@ 更柔和 Oura 通知掩码 ff(实验性) 下次连接时发送官方应用的 SetNotification 掩码(1c 01 ff),而不是 NOOP 的 3f。戒指为官方应用把约 10 个数据包打包进一条通知,为 NOOP 只发一个(读取慢 9 倍);这是第一个候选开关。默认关闭;关闭后的下一次连接恢复为 3f。请在腕带日志中查看“-> notify_all(ff)”,并在原始捕获中比较通知大小。 + 根据戒指的 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 8d3555b1a0..450fb9cee6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2846,4 +2846,6 @@ Softer Oura notification mask ff (experimental) Sends the official app’s SetNotification mask (1c 01 ff) instead of NOOP’s 3f at the next connect. The ring packs ~10 packets per notification for the official app and one for NOOP (9× slower drains); this is the first candidate switch. Off by default; the next connect after turning it off is back on 3f. Watch the strap log for “-> notify_all(ff)” and compare notification sizes in the raw capture. + 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/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 31b0cc3a5e6d97aabef4353d0678d06e517cfcb5 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Thu, 17 Sep 2026 09:12:38 +0200 Subject: [PATCH 6/8] fix(oura): a MET minute re-served under another session anchor counts once (#2242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First hardware day of the toggle (2026-09-17, Gen 3): 157 of the 1,035 ouraMetSample rows stored for 09-16 were 3–4 s twins of another minute, and the day's active energy read +8 % (203 kcal against the Oura app's 187). The (deviceId, ts) key was built on "a re-served record is a no-op insert", which holds only while the anchor holds: `ts` is ring time through the session's own 0x13 anchor, so the same record served again under a second session — here an Oura-app replay from the app's older cursor — lands a few seconds off its first copy and the key sees a new minute. Two guards, both platforms, byte-identical: - Store: `insertOuraMetSamples` reads the stored intervals around the batch and drops any incoming sample whose interval overlaps a stored one, or one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). Pure rule extracted as `OuraMetSample.droppingOverlaps` / `OuraMetSampleEntity.droppingOverlaps` so it is testable without a database. - Estimator: `estimateDayEnergyFromMET` skips a sample that starts inside the interval it has already counted, so rows stored before this fix stop inflating coverage and active energy. The Kotlin twin is re-pinned by the oracle: the 68 existing lines are byte-identical to the previous literal (the rule changes nothing where intervals do not overlap) and three overlap shapes are added, 80 lines regenerated from the Swift twin's stdout. Tests: StrandAnalytics 2040/0 (2 new), WhoopStore 611/0 (2 new); Android MetCaloriesOracleTest 80/80, OuraMetSampleOverlapTest 2/2, full JVM suite 6246 with the same 8 pre-existing failures as the branch base (proven in a worktree at 7c36b25d2). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCUoRfjiQHTdb5G5bPs8rW --- .../StrandAnalytics/WorkoutDetector.swift | 25 ++++++---- .../MetCaloriesTests.swift | 23 +++++++++ .../Sources/WhoopStore/OuraMetStore.swift | 37 ++++++++++++-- .../WhoopStoreTests/OuraMetStoreTests.swift | 48 +++++++++++++++++++ .../com/noop/analytics/WorkoutDetector.kt | 25 ++++++---- .../src/main/java/com/noop/data/Entities.kt | 25 +++++++++- .../java/com/noop/data/WhoopRepository.kt | 23 +++++++-- .../noop/analytics/MetCaloriesOracleTest.kt | 25 +++++++++- .../noop/data/OuraMetSampleMigrationTest.kt | 37 ++++++++++++++ 9 files changed, 239 insertions(+), 29 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift index 620324cc86..5956b48aec 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift @@ -901,11 +901,14 @@ public enum Calories { /// 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 (a duplicate `ts` counts - /// once — the LOWER MET wins the tie, the conservative direction), capped at the day span. 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. + /// 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 whose interval OVERLAPS the one already counted 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. 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, @@ -924,15 +927,17 @@ public enum Calories { // kcal per excess-MET-minute for THIS wearer (the MET definition scales with body mass). let kcalPerMetMin = kcalPerKgPerMetMinute * weightKg - // Ties on ts: the store's (deviceId, ts) key makes them unreachable from a single device, but a - // caller unioning devices could produce one. Ascending MET on a tie keeps the LOWER reading. + // Ties on ts: ascending MET on a tie keeps the LOWER reading. Overlaps: a sample that starts + // before the previously counted interval ends 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. 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 lastTs = Int.min + var lastEnd = Int.min for s in ordered { - if s.ts == lastTs { continue } - lastTs = s.ts + if s.ts < lastEnd { continue } + lastEnd = s.ts + s.secPerSample let minutes = Double(s.secPerSample) / 60.0 covered += Double(s.secPerSample) guard s.met >= metActiveThreshold else { continue } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift index 6d83984b3a..3ec3806733 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift @@ -102,6 +102,29 @@ final class MetCaloriesTests: XCTestCase { 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 inside the interval 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 + } + + /// The first-starting copy wins even when the twin starts a second earlier than a LATER minute's own + /// sample would — overlap is judged against the interval just counted, so a 57-s-late twin of minute + /// 0 loses to minute 0, and minute 2 (which does not overlap minute 0) is kept. + func testOverlapIsAgainstTheCountedIntervalNotTheGrid() { + let r = Calories.estimateDayEnergyFromMET( + [M(ts: day0, met: 1.0), M(ts: day0 + 57, met: 5.0), M(ts: day0 + 120, met: 1.0)], + profile: UserProfile(), dayStart: day0, dayEnd: day1) + XCTAssertEqual(r.observedSeconds, 120) + XCTAssertEqual(r.activeKcal, 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)], diff --git a/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift index a5e2aef492..8a792994c4 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift @@ -17,22 +17,53 @@ public struct OuraMetSample: Equatable, Sendable { public init(ts: Int, met: Double, state: Int, epochS: Int = 60) { self.ts = ts; self.met = met; self.state = state; self.epochS = epochS } + + /// The samples of `incoming` that overlap neither a row of `existing` nor an earlier-starting sample + /// of `incoming` itself. Two intervals overlap when `a.ts < b.ts + b.epochS && b.ts < a.ts + a.epochS`. + /// 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.droppingOverlaps`. + public static func droppingOverlaps(_ incoming: [OuraMetSample], existing: [OuraMetSample]) -> [OuraMetSample] { + var kept = existing.map { ($0.ts, $0.ts + $0.epochS) } + var out: [OuraMetSample] = [] + for s in incoming.sorted(by: { $0.ts != $1.ts ? $0.ts < $1.ts : $0.met < $1.met }) { + let end = s.ts + s.epochS + if kept.contains(where: { s.ts < $0.1 && $0.0 < end }) { continue } + kept.append((s.ts, end)) + out.append(s) + } + return out + } } extension WhoopStore { - /// Insert MET samples for a device. Idempotent by (deviceId, ts): a record the ring re-serves across - /// reconnects (common under connection churn) lands once. Returns rows actually inserted. + /// 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 whose interval overlaps a stored one — or 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.droppingOverlaps(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 samples { + for s in accepted { try stmt.execute(arguments: [deviceId, s.ts, s.met, s.state, s.epochS]) n += db.changesCount } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift index 79c61ace54..61fbc6dcff 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift @@ -16,6 +16,54 @@ final class OuraMetStoreTests: XCTestCase { 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 interval overlap: 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.droppingOverlaps`). + func testDroppingOverlapsIsPureAndOrderIndependent() { + let t = 1_000 + let existing = [OuraMetSample(ts: t, met: 1.0, state: 0), OuraMetSample(ts: t + 120, met: 1.0, state: 0, epochS: 120)] + let incoming = [OuraMetSample(ts: t + 230, met: 2.0, state: 0), // overlaps the 120-s row [t+120, t+240) + OuraMetSample(ts: t + 60, met: 2.0, state: 0), // free minute + OuraMetSample(ts: t + 59, met: 9.0, state: 0), // overlaps [t, t+60) by one second + OuraMetSample(ts: t + 240, met: 2.0, state: 0), // touches, does not overlap + OuraMetSample(ts: t + 241, met: 2.0, state: 0)] // overlaps the accepted t+240 + let out = OuraMetSample.droppingOverlaps(incoming, existing: existing) + XCTAssertEqual(out.map(\.ts), [t + 60, t + 240]) + XCTAssertEqual(OuraMetSample.droppingOverlaps(incoming.reversed(), existing: existing).map(\.ts), [t + 60, t + 240]) + XCTAssertEqual(OuraMetSample.droppingOverlaps([], existing: existing), []) + } + func testInsertRoundTripAndDedup() async throws { let store = try await WhoopStore.inMemory() let rows = [ 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 b742ac6ebf..f40869a463 100644 --- a/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt +++ b/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt @@ -942,11 +942,14 @@ object Calories { * 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 (a - * duplicate `ts` counts once — the LOWER MET wins the tie, the conservative direction), capped at the - * day span. 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. + * 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 whose interval OVERLAPS the one already counted 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. 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, @@ -966,15 +969,17 @@ object Calories { // 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: the store's (deviceId, ts) key makes them unreachable from a single device, but a - // caller unioning devices could produce one. Ascending MET on a tie keeps the LOWER reading. + // Ties on ts: ascending MET on a tie keeps the LOWER reading. Overlaps: a sample that starts + // before the previously counted interval ends 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. val ordered = inDay.sortedWith(compareBy { it.ts }.thenBy { it.met }) var covered = 0.0 var activeKcal = 0.0 - var lastTs = Long.MIN_VALUE + var lastEnd = Long.MIN_VALUE for (s in ordered) { - if (s.ts == lastTs) continue - lastTs = s.ts + if (s.ts < lastEnd) continue + lastEnd = s.ts + s.secPerSample val minutes = s.secPerSample.toDouble() / 60.0 covered += s.secPerSample.toDouble() if (s.met < MET_ACTIVE_THRESHOLD) continue 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 6c3dc9b5be..ae49affbe9 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -290,7 +290,30 @@ data class OuraMetSampleEntity( val met: Double, val state: Int, val epochS: Int, -) +) { + companion object { + /** + * The samples of [incoming] that overlap neither a row of [existing] nor an earlier-starting sample + * of [incoming] itself. Two intervals overlap when `a.ts < b.ts + b.epochS && b.ts < a.ts + a.epochS`. + * 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.droppingOverlaps`. + */ + fun droppingOverlaps( + incoming: List, + existing: List, + ): List { + val kept = existing.map { it.ts to it.ts + it.epochS }.toMutableList() + val out = mutableListOf() + for (s in incoming.sortedWith(compareBy { it.ts }.thenBy { it.met })) { + val end = s.ts + s.epochS + if (kept.any { s.ts < it.second && it.first < end }) continue + kept += s.ts to end + out += s + } + return out + } + } +} /** Respiration raw-ADC sample (type-47). Swift `respSample` (v3). PK (deviceId, ts). */ @Entity(tableName = "respSample", primaryKeys = ["deviceId", "ts"]) 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 b2deaf1309..19da6ec36e 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1445,11 +1445,26 @@ class WhoopRepository( 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 (deviceId, ts). Returns the - * rows actually inserted. Swift `insertOuraMetSamples`. + * 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 whose interval overlaps a stored one, + * or 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()) 0 else dao.insertOuraMet(rows).count { it != -1L } + 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.droppingOverlaps(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): diff --git a/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt b/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt index f804418309..6a81330b65 100644 --- a/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt +++ b/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt @@ -11,7 +11,7 @@ import java.util.Locale * * [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. The CLAUDE.md parity rule: verify by oracle, not + * rebuilt below, one `%.6f` line per case and profile (80 lines: 17 shapes + the three 2026-09-17 overlap shapes, × 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. * @@ -92,6 +92,17 @@ class MetCaloriesOracleTest { 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, + ) + // Overlap is judged against the interval just counted: a 57-s-late twin of minute 0 loses; minute 2 is kept. + out += line("$pn/overlap-57s-twin", listOf(MetSample(day0, 1.0), MetSample(day0 + 57, 5.0), MetSample(day0 + 120, 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 } @@ -122,6 +133,9 @@ class MetCaloriesOracleTest { "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/overlap-57s-twin|2.196747|0.000000|120.000000|0.001389|2.196747", + "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", @@ -139,6 +153,9 @@ class MetCaloriesOracleTest { "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/overlap-57s-twin|2.500097|0.000000|120.000000|0.001389|2.500097", + "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", @@ -156,6 +173,9 @@ class MetCaloriesOracleTest { "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/overlap-57s-twin|1.921782|0.000000|120.000000|0.001389|1.921782", + "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", @@ -173,5 +193,8 @@ class MetCaloriesOracleTest { "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/overlap-57s-twin|2.321767|0.000000|120.000000|0.001389|2.321767", + "zeroed-profile/overlap-phone-day|1671.672000|0.000000|86400.000000|1.000000|1671.672000", ) } diff --git a/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt b/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt index 8cfbc782a2..65f8fbd0f6 100644 --- a/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt @@ -55,3 +55,40 @@ class OuraMetSampleMigrationTest { 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. Twin of Swift `OuraMetStoreTests.testDroppingOverlapsIsPureAndOrderIndependent`. + */ +class OuraMetSampleOverlapTest { + private fun s(ts: Long, met: Double = 1.0, epochS: Int = 60) = OuraMetSampleEntity("oura-A", ts, met, 0, epochS) + + @Test + fun droppingOverlaps_isPureAndOrderIndependent() { + val t = 1_000L + val existing = listOf(s(t), s(t + 120, epochS = 120)) + val incoming = listOf( + s(t + 230, 2.0), // overlaps the 120-s row [t+120, t+240) + s(t + 60, 2.0), // free minute + s(t + 59, 9.0), // overlaps [t, t+60) by one second + s(t + 240, 2.0), // touches, does not overlap + s(t + 241, 2.0), // overlaps the accepted t+240 + ) + assertEquals(listOf(t + 60, t + 240), OuraMetSampleEntity.droppingOverlaps(incoming, existing).map { it.ts }) + assertEquals( + listOf(t + 60, t + 240), + OuraMetSampleEntity.droppingOverlaps(incoming.reversed(), existing).map { it.ts }, + ) + assertEquals(emptyList(), OuraMetSampleEntity.droppingOverlaps(emptyList(), existing)) + } + + /** Twins inside one batch collapse the same way: earlier start wins, lower MET on an exact tie. */ + @Test + fun droppingOverlaps_withinOneBatch() { + val t = 1_000L + val out = OuraMetSampleEntity.droppingOverlaps(listOf(s(t, 5.0), s(t + 3, 2.0), s(t, 4.0)), emptyList()) + assertEquals(listOf(s(t, 4.0)), out) + } +} From c96522239cd7f42baac611628ad17c481509b631 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Thu, 17 Sep 2026 09:12:39 +0200 Subject: [PATCH 7/8] fix(analytics): the day-cycle fold keeps the MET energy decision instead of writing Keytel over it (#2242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the first hardware day the engine logged the ring's number — `calories 2026-09-16: MET path - coverage 72% (1035 samples), active 203 kcal, resting 1017 kcal, total 1220 kcal` — and the exported row held 743.66: Keytel over the wake-to-wake HR window. `DayCycleIntelligenceIntegration.compute` recomputed `Calories.estimateDayCalories` for every cycle unconditionally and `applying()` wrote that over `activeKcalEst` on every day at or after the first recovered wake day, so on any phone with a day-cycle history the toggle changed a log line and nothing the user could see. Same shape on Android (`PhysiologicalStepCycleEngine` + `DayCycleIntelligenceIntegration.apply`). The fold now makes the same decision `analyzeDay` makes, over its own window: when the cycle owner has MET rows, `estimateDayEnergyFromMET` over [onset, min(endExclusive, now)) with the same 50 % coverage floor; covered → the MET total, thin → WITHHELD (no entry, and the HR figure is not substituted), no rows or toggle off → Keytel as before. Swift threads a `metReader` closure (nil while the toggle is off, so the HR path is byte-identical); Kotlin passes the pass's toggle flag and reads `repo.ouraMetSamples` inside the engine — a Boolean argument at the call site rather than a lambda, to stay inside `analyzeRecentOnCpu`'s JaCoCo budget (IntelligenceEngineJacocoBudgetTest still green). A `stepsCycle calories … path=met` trace line names the decision. Tests, both platforms, on a real in-memory store with a seeded 8-h main sleep and HR across the cycle: a covered MET cycle carries the MET total onto the row, a 10 %-covered one is withheld with no Keytel in its place, no reader / empty table is the Keytel path byte for byte. Swift `DayCycleRecoveryTests` 10/10 (3 new, `xcodebuild test`), Kotlin `DayCycleMetCaloriesTest` 3/3 (Robolectric + Room). `Strand` (macOS) and `NOOPiOS` BUILD SUCCEEDED; `doc_comment_lint` OK. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCUoRfjiQHTdb5G5bPs8rW --- .../DayCycleIntelligenceIntegration.swift | 28 +++- Strand/Data/IntelligenceEngine.swift | 5 + StrandTests/DayCycleRecoveryTests.swift | 103 +++++++++++++++ .../DayCycleIntelligenceIntegration.kt | 3 +- .../com/noop/analytics/IntelligenceEngine.kt | 1 + .../analytics/PhysiologicalStepCycleEngine.kt | 29 +++- .../noop/analytics/DayCycleMetCaloriesTest.kt | 124 ++++++++++++++++++ 7 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 android/app/src/test/java/com/noop/analytics/DayCycleMetCaloriesTest.kt diff --git a/Strand/Data/DayCycleIntelligenceIntegration.swift b/Strand/Data/DayCycleIntelligenceIntegration.swift index 386e805c45..4881a11b32 100644 --- a/Strand/Data/DayCycleIntelligenceIntegration.swift +++ b/Strand/Data/DayCycleIntelligenceIntegration.swift @@ -41,6 +41,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] { @@ -91,6 +96,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: [:], @@ -207,7 +213,27 @@ import WhoopStore if let strain = StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR, method: effortMethod, sex: profile.sex) { strains[day] = strain } - if !cycleHR.isEmpty { + // #2242: a device that measures its own minute-by-minute intensity decides the cycle's energy by + // that stream — the SAME rule, floor and withholding `AnalyticsEngine.analyzeDay` applies to the + // calendar day, over the wake-to-wake window instead. 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 day + // is WITHHELD — no entry, and the HR figure is not substituted — exactly as on the day path. + let cycleMet = hrEndInclusive >= window.onset + ? await metReader?(fallback, window.onset, hrEndInclusive) ?? [] + : [] + if !cycleMet.isEmpty { + let met = Calories.estimateDayEnergyFromMET(cycleMet, profile: profile, + dayStart: window.onset, + dayEnd: min(window.endExclusive, now)) + if met.coverageFraction >= Calories.metMinCoverageFraction { + calories[day] = met.totalKcal + } + trace?("stepsCycle calories day=\(day) path=met coverage=\(Int((met.coverageFraction * 100).rounded()))% " + + "active=\(Int(met.activeKcal.rounded())) total=\(Int(met.totalKcal.rounded())) " + + (met.coverageFraction >= Calories.metMinCoverageFraction ? "" : "withheld")) + } else if !cycleHR.isEmpty { calories[day] = Calories.estimateDayCalories( cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR) } diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index f59ee56e84..0266489813 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -2072,6 +2072,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/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 cf98df41fe..3943096a46 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -1665,6 +1665,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) { 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 82c8fc3066..5f221eb8ce 100644 --- a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt @@ -49,6 +49,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()) @@ -204,7 +205,33 @@ internal object PhysiologicalStepCycleEngine { ?: profile.age.takeIf { it > 0 }?.let { StrainScorer.tanakaHRmax(it.toDouble()) } StrainScorer.strain(cycleHr, effectiveMaxHr, restingHr, effortMethod, profile.sex) ?.let { strainByWakeDay[wakeDay] = it } - if (cycleHr.isNotEmpty()) { + // #2242: a device that measures its own minute-by-minute intensity decides the cycle's energy by + // that stream — the SAME rule, floor and withholding AnalyticsEngine.analyzeDay applies to the + // calendar day, over the wake-to-wake window instead. 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 day is WITHHELD — no entry, and the HR figure is not substituted — as on the day path. + // Swift twin: DayCycleIntelligenceIntegration.compute (metReader). + val cycleMet = if (ouraMetCalories && window.endExclusive - 1L >= window.onset) { + repo.ouraMetSamples(fallbackOwner, window.onset, window.endExclusive - 1L, 4_000) + .map { Calories.MetSample(it.ts, it.met, it.epochS) } + } else { + emptyList() + } + if (cycleMet.isNotEmpty()) { + val met = Calories.estimateDayEnergyFromMet( + cycleMet, profile, window.onset, minOf(window.endExclusive, nowSeconds), + ) + if (met.coverageFraction >= Calories.MET_MIN_COVERAGE_FRACTION) { + caloriesByWakeDay[wakeDay] = met.totalKcal + } + 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 (met.coverageFraction >= Calories.MET_MIN_COVERAGE_FRACTION) "" else "withheld", + ) + } else if (cycleHr.isNotEmpty()) { caloriesByWakeDay[wakeDay] = Calories.estimateDayCalories( cycleHr, profile, effectiveMaxHr, restingHr, ) 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) + } +} From cd8ca15fefa0a6d63bd95c032fddf4eb3607e3a9 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Fri, 18 Sep 2026 08:57:43 +0200 Subject: [PATCH 8/8] fix(oura): a MET minute that starts 59 s after the last one is the next minute, not a re-served twin (#2242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second hardware day of the toggle (2026-09-18, Gen 3): the ring's own minute grid steps back one second between records — samples land at :02 for an hour, then :01 — so a real successor starts 59 s after the minute before it and overlaps it by one second. The overlap rule from the first day ("a sample whose interval overlaps the one already counted is the same minute") read every such successor as a twin. At the store, `droppingOverlaps` rejected the phase-step minute on insert — 8 holes on the first day after the rule landed, one per step, ~once an hour, invisible in the table except as a 119-s gap between rows. In the estimator, the six phase-step minutes that had been stored BEFORE the rule (the day's 7.8-MET peak among them) were skipped: `active 232 kcal` in the log against 245.7 by the exact rule over the same rows, −5.5 %, and −6.1 % against the Oura app's 247 where the fixed rule reads −0.5 %. A twin is 2–5 s off its first copy (the per-session 0x13 anchor); the next minute is 55–61 s on. Half a period tells them apart. Both platforms, store and estimator, the same integer test: two samples are the same minute when `|Δ| × 2 < min(epochS)` (`OuraMetSample.isTwin` / `OuraMetSampleEntity.isTwin`; `droppingOverlaps` becomes `droppingTwins`), and the estimator walk compares against the last COUNTED START rather than its interval end. The 3–4-s twins of 09-16's replay still collapse (the `overlap-3s-twins` and `overlap-phone-day` oracle lines are byte-identical); the 59-s successor is kept. Rows already dropped at the store on the first day are gone from that install (the ring re-serves them only under a replay); rows stored before the rule are counted correctly again by the estimator without a migration. Tests: StrandAnalytics 2041/0 (+1 net: the 57-s "twin" test replaced by the 59-s successor + a full stepping day), WhoopStore 612/0 (+1: the phase-step day inserts 120/120); Android MetCaloriesOracleTest 84 lines regenerated from the Swift twin's stdout (76 existing lines byte-identical, the 4 retired `overlap-57s-twin` lines removed, 8 phase-step lines added), OuraMetSampleTwinTest 3/3, DayCycleMetCaloriesTest 3/3; doc lint OK. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KBrA24fNXFBYAeLr65GVe1 --- .../StrandAnalytics/WorkoutDetector.swift | 35 +++++++++------ .../MetCaloriesTests.swift | 27 +++++++---- .../Sources/WhoopStore/OuraMetStore.swift | 36 +++++++++------ .../WhoopStoreTests/OuraMetStoreTests.swift | 44 ++++++++++++------ .../com/noop/analytics/WorkoutDetector.kt | 35 +++++++++------ .../src/main/java/com/noop/data/Entities.kt | 30 +++++++++---- .../java/com/noop/data/WhoopRepository.kt | 7 +-- .../noop/analytics/MetCaloriesOracleTest.kt | 25 ++++++++--- .../noop/data/OuraMetSampleMigrationTest.kt | 45 ++++++++++++------- 9 files changed, 190 insertions(+), 94 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift index 5956b48aec..6274359322 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift @@ -903,12 +903,18 @@ public enum Calories { /// /// 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 whose interval OVERLAPS the one already counted 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. 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. + /// 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, @@ -927,17 +933,20 @@ public enum Calories { // 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. Overlaps: a sample that starts - // before the previously counted interval ends 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. + // 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 lastEnd = Int.min + var lastStart = Int.min + var lastPeriod = 0 for s in ordered { - if s.ts < lastEnd { continue } - lastEnd = s.ts + s.secPerSample + 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 } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift index 3ec3806733..cdbb6edcb7 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/MetCaloriesTests.swift @@ -104,7 +104,8 @@ final class MetCaloriesTests: XCTestCase { /// 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 inside the interval already counted is that minute again: skipped, the first copy wins. + /// 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), @@ -114,15 +115,25 @@ final class MetCaloriesTests: XCTestCase { XCTAssertEqual(r.activeKcal, 2 * 2.5 * kcalPerMetMin, accuracy: 1e-9) // the 9.0 twin is dropped } - /// The first-starting copy wins even when the twin starts a second earlier than a LATER minute's own - /// sample would — overlap is judged against the interval just counted, so a 57-s-late twin of minute - /// 0 loses to minute 0, and minute 2 (which does not overlap minute 0) is kept. - func testOverlapIsAgainstTheCountedIntervalNotTheGrid() { + /// 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 + 57, met: 5.0), M(ts: day0 + 120, met: 1.0)], + [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, 120) - XCTAssertEqual(r.activeKcal, 0, accuracy: 1e-12) + 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() { diff --git a/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift index 8a792994c4..b51cf72ea6 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraMetStore.swift @@ -18,18 +18,27 @@ public struct OuraMetSample: Equatable, Sendable { self.ts = ts; self.met = met; self.state = state; self.epochS = epochS } - /// The samples of `incoming` that overlap neither a row of `existing` nor an earlier-starting sample - /// of `incoming` itself. Two intervals overlap when `a.ts < b.ts + b.epochS && b.ts < a.ts + a.epochS`. - /// 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.droppingOverlaps`. - public static func droppingOverlaps(_ incoming: [OuraMetSample], existing: [OuraMetSample]) -> [OuraMetSample] { - var kept = existing.map { ($0.ts, $0.ts + $0.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 }) { - let end = s.ts + s.epochS - if kept.contains(where: { s.ts < $0.1 && $0.0 < end }) { continue } - kept.append((s.ts, end)) + if kept.contains(where: { isTwin(s, $0) }) { continue } + kept.append(s) out.append(s) } return out @@ -45,8 +54,9 @@ extension WhoopStore { /// 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 whose interval overlaps a stored one — or one accepted earlier in the same - /// batch — is dropped; the first copy stays. Returns rows actually inserted. + /// 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 } @@ -57,7 +67,7 @@ extension WhoopStore { 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.droppingOverlaps(samples, existing: existing) + 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 diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift index 61fbc6dcff..12dca895a2 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraMetStoreTests.swift @@ -19,7 +19,7 @@ final class OuraMetStoreTests: XCTestCase { /// 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 interval overlap: the first copy stays. + /// 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 @@ -49,19 +49,37 @@ final class OuraMetStoreTests: XCTestCase { XCTAssertEqual(other, 1) } - /// The pure rule behind the insert (twin: Kotlin `OuraMetSampleEntity.droppingOverlaps`). - func testDroppingOverlapsIsPureAndOrderIndependent() { + /// 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, epochS: 120)] - let incoming = [OuraMetSample(ts: t + 230, met: 2.0, state: 0), // overlaps the 120-s row [t+120, t+240) - OuraMetSample(ts: t + 60, met: 2.0, state: 0), // free minute - OuraMetSample(ts: t + 59, met: 9.0, state: 0), // overlaps [t, t+60) by one second - OuraMetSample(ts: t + 240, met: 2.0, state: 0), // touches, does not overlap - OuraMetSample(ts: t + 241, met: 2.0, state: 0)] // overlaps the accepted t+240 - let out = OuraMetSample.droppingOverlaps(incoming, existing: existing) - XCTAssertEqual(out.map(\.ts), [t + 60, t + 240]) - XCTAssertEqual(OuraMetSample.droppingOverlaps(incoming.reversed(), existing: existing).map(\.ts), [t + 60, t + 240]) - XCTAssertEqual(OuraMetSample.droppingOverlaps([], existing: existing), []) + 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 { 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 f40869a463..b5ccc73dcd 100644 --- a/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt +++ b/android/app/src/main/java/com/noop/analytics/WorkoutDetector.kt @@ -944,12 +944,18 @@ object Calories { * * 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 whose interval OVERLAPS the one already counted 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. 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. + * 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, @@ -969,17 +975,20 @@ object Calories { // 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. Overlaps: a sample that starts - // before the previously counted interval ends 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. + // 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 lastEnd = Long.MIN_VALUE + var lastStart = Long.MIN_VALUE + var lastPeriod = 0 for (s in ordered) { - if (s.ts < lastEnd) continue - lastEnd = s.ts + s.secPerSample + 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 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 ae49affbe9..9befa5e5b3 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -293,21 +293,33 @@ data class OuraMetSampleEntity( ) { companion object { /** - * The samples of [incoming] that overlap neither a row of [existing] nor an earlier-starting sample - * of [incoming] itself. Two intervals overlap when `a.ts < b.ts + b.epochS && b.ts < a.ts + a.epochS`. - * 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.droppingOverlaps`. + * 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 droppingOverlaps( + 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.map { it.ts to it.ts + it.epochS }.toMutableList() + val kept = existing.toMutableList() val out = mutableListOf() for (s in incoming.sortedWith(compareBy { it.ts }.thenBy { it.met })) { - val end = s.ts + s.epochS - if (kept.any { s.ts < it.second && it.first < end }) continue - kept += s.ts to end + if (kept.any { isTwin(s, it) }) continue + kept += s out += s } return out 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 19da6ec36e..4761a911d2 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1448,8 +1448,9 @@ class WhoopRepository( * 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 whose interval overlaps a stored one, - * or one accepted earlier in the same batch, is dropped; the first copy stays. Rows are assumed to + * 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`. */ @@ -1460,7 +1461,7 @@ class WhoopRepository( 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.droppingOverlaps(batch, existing) + val accepted = OuraMetSampleEntity.droppingTwins(batch, existing) if (accepted.isNotEmpty()) inserted += dao.insertOuraMet(accepted).count { it != -1L } } return inserted diff --git a/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt b/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt index 6a81330b65..40234a6319 100644 --- a/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt +++ b/android/app/src/test/java/com/noop/analytics/MetCaloriesOracleTest.kt @@ -11,7 +11,8 @@ import java.util.Locale * * [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 (80 lines: 17 shapes + the three 2026-09-17 overlap shapes, × 4 profiles). The CLAUDE.md parity rule: verify by oracle, not + * 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. * @@ -97,8 +98,14 @@ class MetCaloriesOracleTest { "$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, ) - // Overlap is judged against the interval just counted: a 57-s-late twin of minute 0 loses; minute 2 is kept. - out += line("$pn/overlap-57s-twin", listOf(MetSample(day0, 1.0), MetSample(day0 + 57, 5.0), MetSample(day0 + 120, 1.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) @@ -134,7 +141,8 @@ class MetCaloriesOracleTest { "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/overlap-57s-twin|2.196747|0.000000|120.000000|0.001389|2.196747", + "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", @@ -154,7 +162,8 @@ class MetCaloriesOracleTest { "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/overlap-57s-twin|2.500097|0.000000|120.000000|0.001389|2.500097", + "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", @@ -174,7 +183,8 @@ class MetCaloriesOracleTest { "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/overlap-57s-twin|1.921782|0.000000|120.000000|0.001389|1.921782", + "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", @@ -194,7 +204,8 @@ class MetCaloriesOracleTest { "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/overlap-57s-twin|2.321767|0.000000|120.000000|0.001389|2.321767", + "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", ) } diff --git a/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt b/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt index 65f8fbd0f6..ed872235c7 100644 --- a/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt +++ b/android/app/src/test/java/com/noop/data/OuraMetSampleMigrationTest.kt @@ -60,35 +60,50 @@ class OuraMetSampleMigrationTest { * 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. Twin of Swift `OuraMetStoreTests.testDroppingOverlapsIsPureAndOrderIndependent`. + * 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 OuraMetSampleOverlapTest { +class OuraMetSampleTwinTest { private fun s(ts: Long, met: Double = 1.0, epochS: Int = 60) = OuraMetSampleEntity("oura-A", ts, met, 0, epochS) @Test - fun droppingOverlaps_isPureAndOrderIndependent() { + fun droppingTwins_isPureAndOrderIndependent() { val t = 1_000L - val existing = listOf(s(t), s(t + 120, epochS = 120)) + val existing = listOf(s(t), s(t + 120)) val incoming = listOf( - s(t + 230, 2.0), // overlaps the 120-s row [t+120, t+240) - s(t + 60, 2.0), // free minute - s(t + 59, 9.0), // overlaps [t, t+60) by one second - s(t + 240, 2.0), // touches, does not overlap - s(t + 241, 2.0), // overlaps the accepted t+240 + 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 + 60, t + 240), OuraMetSampleEntity.droppingOverlaps(incoming, existing).map { it.ts }) + assertEquals(listOf(t + 59, t + 150, t + 240), OuraMetSampleEntity.droppingTwins(incoming, existing).map { it.ts }) assertEquals( - listOf(t + 60, t + 240), - OuraMetSampleEntity.droppingOverlaps(incoming.reversed(), existing).map { it.ts }, + listOf(t + 59, t + 150, t + 240), + OuraMetSampleEntity.droppingTwins(incoming.reversed(), existing).map { it.ts }, ) - assertEquals(emptyList(), OuraMetSampleEntity.droppingOverlaps(emptyList(), existing)) + 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 droppingOverlaps_withinOneBatch() { + fun droppingTwins_withinOneBatch() { val t = 1_000L - val out = OuraMetSampleEntity.droppingOverlaps(listOf(s(t, 5.0), s(t + 3, 2.0), s(t, 4.0)), emptyList()) + 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) + } }