Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
113 changes: 113 additions & 0 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -845,4 +845,117 @@ public enum Calories {
estimateDayEnergy(hrSamples, profile: profile, hrmax: hrmax,
restingHR: restingHR).totalKcal
}

// MARK: MET-stream day energy (Oura 0x50, #2242)

/// One metabolic-equivalent sample from a device that streams its OWN activity intensity — the
/// Oura ring's `0x50` activity record, one value per 60 s (`OURA_PROTOCOL.md` §6.13). `ts` is the
/// unix second the sample's interval STARTS; `secPerSample` is how long it covers (60 on every ring
/// observed; carried per sample rather than assumed so a different cadence scales, not skews).
public struct MetSample: Equatable, Sendable {
public let ts: Int
public let met: Double
public let secPerSample: Int
public init(ts: Int, met: Double, secPerSample: Int = 60) {
self.ts = ts; self.met = met; self.secPerSample = secPerSample
}
}

/// Whole-day energy from a MET stream. Same split as `DayEnergyEstimate` (so the persisted
/// `totalKcal` keeps its meaning), plus the share of the day the stream actually covered — the
/// caller decides whether that is enough to mint a number (`metMinCoverageFraction`), and the UI
/// can caption it. `coverageFraction` is over the day window the caller passed, so today's
/// partial day is judged against the hours that have elapsed, not against 24 h.
public struct MetEnergyEstimate: Equatable, Sendable {
public let restingKcal: Double
public let activeKcal: Double
public let observedSeconds: Double
public let coverageFraction: Double

public var totalKcal: Double { restingKcal + activeKcal }
}

/// Below this MET a minute is rest, not activity, and it is also what an active minute is measured
/// FROM: Oura's documented method counts "the portion that exceeds 1.5 MET" (Oura support, "How Oura
/// Measures Steps & Activity"; Kristiansson et al. 2023 — "AEE starts accumulating at > 1.5 MET").
public static let metActiveThreshold = 1.5
/// kcal per kg per MET-minute — the definition of a MET, not a fit: 1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹
/// (ACSM) at ≈ 5 kcal per litre of O₂ → 0.0175 kcal·kg⁻¹·min⁻¹. Against Oura's own export this exact
/// rule reproduces `active_calories` with r = 1.000 and 0.6 kcal/day RMSE over 75 days (and r 0.9999
/// / 3 kcal over 396 pre-2025 days), the wearer's weight being the only input — see
/// OURA_PROTOCOL.md §6.13. The constant was IDENTIFIED by that comparison, not fitted to it.
public static let kcalPerKgPerMetMinute = 0.0175
/// The least of the day the stream must cover before the MET estimate is trusted for the persisted
/// number. Below it the day is mostly unknown — a ring off the finger, a drain that never came — and
/// a half-day sum would read as a low-activity day rather than a missing one.
public static let metMinCoverageFraction = 0.5

/// Active + resting energy for one calendar day from the device's own MET stream.
///
/// `active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 × weightKg × (secPerSample/60)` over the samples
/// inside `[dayStart, dayEnd)` — Oura's documented method (the portion above 1.5 MET) at the
/// standard MET→kcal definition, applied to the ring's own minute-by-minute series. No fitted
/// constant, and it IS Oura's number: against the Oura export it reproduces `active_calories` to
/// 0.6 kcal/day RMSE (r = 1.000). `restingKcal` is the same revised Harris–Benedict BMR the HR path
/// uses (`restingKcalPerS`), over the covered seconds, so `totalKcal` keeps the HR path's meaning.
/// A MET→kcal figure is still an ESTIMATE of true expenditure (free-living MAPE 46–90 % against
/// accelerometry in Kristiansson 2023) — label it so.
///
/// Coverage: `observedSeconds` is the sum of the covered sample intervals, capped at the day span.
/// A minute counts ONCE: a duplicate `ts` keeps the LOWER MET (the conservative direction), and a
/// sample that starts within HALF a period of the one already counted is that minute again and is
/// dropped — the ring re-serves a record under a fresh per-session `0x13` anchor a few seconds off
/// the first copy (2026-09-17: 157 of 1,035 stored rows were 3–4 s twins of another minute, +8 % on
/// the day), and two rows 3 s apart are one minute, not two. Half a period, not "any overlap": the
/// ring's own minute grid steps by a second between records (the per-record anchor rounds
/// differently — `:02` then `:01`), so a successor can start 59 s after the minute before it and
/// overlap it by one second; judged by overlap, that successor was dropped and the day lost a
/// whole minute per phase step (2026-09-18: six on one day, the day's 7.8-MET peak among them,
/// −5.5 % on active energy). A twin is 2–5 s off; a successor is 55–61 s off; 30 s tells them
/// apart. Missing minutes are UNKNOWN and contribute nothing to either term: never extrapolate a
/// gap to activity, and never bank resting energy for time nobody observed. `restingKcal` therefore
/// scales with coverage exactly as the HR path's does.
public static func estimateDayEnergyFromMET(_ samples: [MetSample],
profile: UserProfile,
dayStart: Int,
dayEnd: Int) -> MetEnergyEstimate {
let daySpan = Double(max(0, dayEnd - dayStart))
let inDay = samples.filter { $0.ts >= dayStart && $0.ts < dayEnd && $0.secPerSample > 0 }
if inDay.isEmpty || daySpan <= 0 {
return MetEnergyEstimate(restingKcal: 0, activeKcal: 0, observedSeconds: 0, coverageFraction: 0)
}

let weightKg = profile.weightKg > 0 ? profile.weightKg : 70.0
let heightCm = profile.heightCm > 0 ? profile.heightCm : 170.0
let age = profile.age > 0 ? profile.age : 30.0
let coeffs = resolveCoeffs(profile.sex)
let restingRate = restingKcalPerS(coeffs, weightKg: weightKg, heightCm: heightCm, age: age)
// kcal per excess-MET-minute for THIS wearer (the MET definition scales with body mass).
let kcalPerMetMin = kcalPerKgPerMetMinute * weightKg

// Ties on ts: ascending MET on a tie keeps the LOWER reading. Twins: a sample that starts less
// than half a period after the previously counted start is the same minute served again (see
// the doc above) — the earlier-starting copy wins and the twin is skipped, so neither coverage
// nor active energy counts a minute twice. Integer test, same expression as the Kotlin twin and
// as `OuraMetSample.isTwin`: `Δ × 2 < min(period, lastPeriod)`.
let ordered = inDay.sorted { $0.ts != $1.ts ? $0.ts < $1.ts : $0.met < $1.met }
var covered = 0.0
var activeKcal = 0.0
var lastStart = Int.min
var lastPeriod = 0
for s in ordered {
if lastPeriod > 0 && (s.ts - lastStart) * 2 < min(s.secPerSample, lastPeriod) { continue }
lastStart = s.ts
lastPeriod = s.secPerSample
let minutes = Double(s.secPerSample) / 60.0
covered += Double(s.secPerSample)
guard s.met >= metActiveThreshold else { continue }
activeKcal += (s.met - metActiveThreshold) * kcalPerMetMin * minutes
}
let observedSeconds = min(covered, daySpan)
return MetEnergyEstimate(restingKcal: restingRate * observedSeconds,
activeKcal: activeKcal,
observedSeconds: observedSeconds,
coverageFraction: observedSeconds / daySpan)
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading