Skip to content
Open
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 @@ -353,6 +353,7 @@ public enum AnalyticsEngine {
/// and low enough to keep a partially-drained night. Twin of the Kotlin constant.
public static let vendorRespMinSpanS = 3_600

/// Kotlin twin: `AnalyticsEngine.analyzeDay`.
public static func analyzeDay(day: String,
// Optional sink for the Effort funnel line. Nil (the default) builds
// nothing at all — see StrainScorer.strain. A parameter rather than a
Expand Down Expand Up @@ -530,7 +531,9 @@ public enum AnalyticsEngine {
// %HRR with no floor. Threaded rather than read from a global so this
// stays a pure function, and defaulted so every existing caller and
// test is byte-identical.
effortMethod: StrainScorer.Method = .edwards) -> DayResult {
effortMethod: StrainScorer.Method = .edwards,
sleepDayWindow: SleepRecalculationWindow? = nil,
preservedSleep: [CachedSleepSession] = []) -> DayResult {

// Precompute the day's UTC bounds ONCE (#996). `dayString(ts, offsetSec:)` formats the UTC
// calendar day of (ts + offset) with a FIXED offset, so "== day" is exactly membership in
Expand All @@ -540,7 +543,13 @@ public enum AnalyticsEngine {
// formatter compare (locked by AnalyticsEngineDayBoundsTests, incl. fractional offsets).
let dayStartUtc = dayStartUtcSeconds(day)
let dayEndUtc = dayStartUtc + 86_400
func tsInDay(_ ts: Int) -> Bool { (ts + tzOffsetSeconds) >= dayStartUtc && (ts + tzOffsetSeconds) < dayEndUtc }
func tsInDay(_ ts: Int) -> Bool {
if let window = sleepDayWindow {
precondition(window.day == day)
return window.contains(ts)
}
return (ts + tzOffsetSeconds) >= dayStartUtc && (ts + tzOffsetSeconds) < dayEndUtc
}

// ── Sleep detection + staging ─────────────────────────────────────────
let detectedSessions = SleepStager.detectSleep(hr: hr, rr: rr, resp: resp, gravity: gravity,
Expand All @@ -553,20 +562,27 @@ public enum AnalyticsEngine {
// night-window stream the caller passed for the rest of this analysis; the pass self-gates on its
// observed density, so an empty/sparse `steps` (e.g. a WHOOP 4.0, which never emits StepSample at
// all) is a no-op regardless of `useMotionAwareWake`.
let refinedSessions = useMotionAwareWake
let refined = useMotionAwareWake
? detectedSessions.map { WakeMotionRefinement.refine($0, grav: gravity, steps: steps) }
: detectedSessions
let refinedSessions = refined.filter { detected in
!preservedSleep.contains { $0.effectiveStartTs < detected.end && detected.start < $0.endTs }
}
let protectedProvided = preservedSleep.compactMap { sleepSession(fromProvided: $0) }
let allProvidedSleep = providedSleep.filter { provided in
!preservedSleep.contains { $0.effectiveStartTs < provided.end && provided.start < $0.endTs }
} + protectedProvided
// #804 Fix A: fold in the caller's device-provided hypnogram (see `providedSleep`). Empty = the
// byte-identical motion-only path. Otherwise enrich each provided session's nightly restingHR/avgHRV
// from THIS day's hr/rr over its window (the stored ring row carries neither), using the SAME helpers
// detectSleep populates a session with, then keep only the detected sessions that DON'T overlap a
// provided one (provided is authoritative where they collide; a separate nap survives).
let allSessions: [SleepSession]
if providedSleep.isEmpty {
if allProvidedSleep.isEmpty {
allSessions = refinedSessions
} else {
let rrSorted = rr.sortedByTsStable()
let enrichedProvided: [SleepSession] = providedSleep.map { s in
let enrichedProvided: [SleepSession] = allProvidedSleep.map { s in
// #1884: no HR-only special case any more. This used to short-circuit on `s.hrOnly` to
// preserve #1801's display-only guarantee, which withheld both values. Now that an HR-only
// session reports what it measured, that clause is not merely redundant — an HR-only night
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation
import WhoopStore

/// Refresh only Charge cells whose existing 21-day baseline can include the selected night.
/// Uses persisted inputs and the same baseline/Recovery/Rest functions as the scorer; no detection.
public enum SleepRecalculationDependents {
public static func updates(selectedDay: String, anchorDay: String, refreshBaseline: Bool, imported: [DailyMetric], computed: [DailyMetric],
sourceByDay: [String: String], importedSource: String,
hrvEpoch: Double, recoveryEpoch: Double) -> [SleepRecoveryUpdate] {
guard let selected = Baselines.isoEpochDay(selectedDay), let anchor = Baselines.isoEpochDay(anchorDay),
anchor >= selected, anchor - selected < ChargeBaselines.windowDays else { return [] }
let resolved = ChargeBaselines.resolve(imported: imported, own: computed, anchorDay: anchorDay,
hrvEpoch: hrvEpoch, recoveryEpoch: recoveryEpoch)
let sourceDays = Set((imported + computed).filter { $0.day <= anchorDay }.map(\.day)).sorted()
let respEpoch = max(recoveryEpoch, Baselines.deviceEraEpoch(
sourceDays.map { (day: $0, sourceId: sourceByDay[$0] ?? importedSource) }))
let respBase = Baselines.foldHistory(resolved.respHistory.values, dayKeys: resolved.respHistory.dayKeys,
cfg: Baselines.respCfg, baselineEpoch: respEpoch)
let skinCfg = Baselines.metricCfg["skin_temp"]!
let skinHistory = ChargeBaselines.history(imported: [], own: computed.map { ($0.day, $0.skinTempC) },
anchorDay: anchorDay, cfg: skinCfg, baselineEpoch: recoveryEpoch)
let skinBase = Baselines.foldHistory(skinHistory.values, dayKeys: skinHistory.dayKeys,
cfg: skinCfg, baselineEpoch: recoveryEpoch)
var updates: [SleepRecoveryUpdate] = []
for row in computed.sorted(by: { $0.day < $1.day }) {
guard let day = Baselines.isoEpochDay(row.day), day <= anchor,
anchor - day < ChargeBaselines.windowDays,
refreshBaseline || row.day == selectedDay,
let hrv = row.avgHrv, let rhr = row.restingHr else { continue }
let skinDev: Double?
if let mean = row.skinTempC {
skinDev = skinBase.usable ? (Baselines.deviation(mean, state: skinBase).delta * 100).rounded() / 100 : nil
} else {
skinDev = row.skinTempDevC // no raw mean: retain the legacy deviation
}
let restQuality = AnalyticsEngine.Rest.composite(daily: row).map { $0 / 100 } ?? row.efficiency
let recovery = resolved.hrv.usable ? RecoveryScorer.recovery(
hrv: hrv, rhr: Double(rhr), resp: row.respRateBpm,
hrvBaseline: resolved.hrv, rhrBaseline: resolved.restingHR.usable ? resolved.restingHR : nil,
respBaseline: respBase.usable ? respBase : nil, sleepPerf: restQuality, skinTempDev: skinDev) : nil
let update = SleepRecoveryUpdate(day: row.day, recovery: recovery, skinTempDevC: skinDev, sourceId: sourceByDay[row.day])
if update.changes(row) { updates.append(update) }
}
return updates
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// A completed local wake day selected explicitly, independent of the normal recent scan.
/// Calendar midnights preserve 23/25-hour days; the read includes the scorer's neighboring context.
public struct SleepRecalculationWindow: Equatable, Sendable {
public let day: String
public let start: Int
public let end: Int
public let offsetSeconds: Int
public var readStart: Int { start - StreamReadCap.lookbackSeconds }

public func contains(_ timestamp: Int) -> Bool {
timestamp >= start && timestamp < end
}

public static func make(day: String, timeZone: TimeZone, now: Int) -> Self? {
let parts = day.split(separator: "-", omittingEmptySubsequences: false)
guard parts.count == 3, parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
let year = Int(parts[0]), let month = Int(parts[1]), let date = Int(parts[2]) else { return nil }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
guard let midnight = calendar.date(from: DateComponents(year: year, month: month, day: date)),
calendar.component(.year, from: midnight) == year,
calendar.component(.month, from: midnight) == month,
calendar.component(.day, from: midnight) == date,
let next = calendar.date(byAdding: .day, value: 1, to: midnight) else { return nil }
let start = Int(midnight.timeIntervalSince1970)
let end = Int(next.timeIntervalSince1970)
guard start < end, end <= now else { return nil }
return Self(day: day, start: start, end: end, offsetSeconds: timeZone.secondsFromGMT(for: midnight))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import XCTest
import WhoopStore
@testable import StrandAnalytics

final class SleepRecalculationDependentsTests: XCTestCase {
private func rows(hrv: Double) -> [DailyMetric] {
var result: [DailyMetric] = []
for i in 0..<31 {
let day = String(format: "2026-07-%02d", i + 1)
let value: Double = i == 19 ? hrv : Double(45 + (i % 7) * 3)
let resp: Double = 15.0 + Double(i % 4) * 0.2
let skin: Double = 33.0 + Double(i % 3) * 0.1
result.append(DailyMetric(day: day, totalSleepMin: 420, efficiency: 0.9,
deepMin: 90, remMin: 100, lightMin: 230, disturbances: 1, restingHr: 48 + i % 5,
avgHrv: value, recovery: -1, strain: 10, exerciseCount: 0, respRateBpm: resp, skinTempC: skin))
}
return result
}

private func updates(hrv: Double, refresh: Bool, selected: String = "2026-07-20") -> [SleepRecoveryUpdate] {
SleepRecalculationDependents.updates(selectedDay: selected, anchorDay: "2026-07-31", refreshBaseline: refresh,
imported: [], computed: rows(hrv: hrv), sourceByDay: [:], importedSource: "my-whoop",
hrvEpoch: 0, recoveryEpoch: 0)
}

func testOnlyTheExistingRecentBaselineWindowCanChange() {
let result = updates(hrv: 80, refresh: true)
XCTAssertEqual(result.map(\.day), (11...31).map { String(format: "2026-07-%02d", $0) })
XCTAssertTrue(updates(hrv: 80, refresh: true, selected: "2026-07-01").isEmpty)
XCTAssertTrue(updates(hrv: 80, refresh: true, selected: "2026-08-01").isEmpty)
}

func testUnchangedBaselineInputsRefreshOnlySelectedCharge() {
XCTAssertEqual(updates(hrv: 80, refresh: false).map(\.day), ["2026-07-20"])
XCTAssertNotEqual(updates(hrv: 20, refresh: true), updates(hrv: 120, refresh: true))
}

func testSwiftOracleForKotlin() {
var actual: [String] = []
for hrv in [20.0, 40.0, 80.0, 120.0] {
for refresh in [false, true] {
let values = updates(hrv: hrv, refresh: refresh).map {
"\($0.day):\($0.recovery.map(String.init(describing:)) ?? "nil"):\($0.skinTempDevC.map(String.init(describing:)) ?? "nil")"
}.joined(separator: ";")
actual.append("\(Int(hrv))|\(refresh)|\(values)")
XCTAssertFalse(values.isEmpty)
}
}
let expected = """
20|false|2026-07-20:1.0898008256514333:-0.01
20|true|2026-07-11:65.32420131230487:-0.01;2026-07-12:70.6828797948411:0.09;2026-07-13:77.44651062186608:-0.11;2026-07-14:81.67989610449119:-0.01;2026-07-15:24.91999580514455:0.09;2026-07-16:44.69739202612113:-0.11;2026-07-17:53.7519605534091:-0.01;2026-07-18:59.79840270808479:0.09;2026-07-19:65.66911582956358:-0.11;2026-07-20:1.0898008256514333:-0.01;2026-07-21:86.95548049684118:0.09;2026-07-22:33.272977419763485:-0.11;2026-07-23:39.299186624263605:-0.01;2026-07-24:45.31275048416332:0.09;2026-07-25:54.13115718435345:-0.11;2026-07-26:74.36835296651162:-0.01;2026-07-27:78.78332343853279:0.09;2026-07-28:82.6844267943711:-0.11;2026-07-29:28.54241577630786:-0.01;2026-07-30:33.82725931938255:0.09;2026-07-31:55.452658777165766:-0.11
40|false|2026-07-20:15.33364258561113:-0.01
40|true|2026-07-11:66.37246191295942:-0.01;2026-07-12:71.44762686950502:0.09;2026-07-13:77.92785705946825:-0.11;2026-07-14:81.95282906131843:-0.01;2026-07-15:26.347259541600412:0.09;2026-07-16:46.32062075209378:-0.11;2026-07-17:55.141780234075696:-0.01;2026-07-18:60.913758871911384:0.09;2026-07-19:66.50266795966225:-0.11;2026-07-20:15.33364258561113:-0.01;2026-07-21:87.16217085712255:0.09;2026-07-22:34.955910548564134:-0.11;2026-07-23:40.87125797089343:-0.01;2026-07-24:46.70472487954506:0.09;2026-07-25:55.28617637957595:-0.11;2026-07-26:75.07086969378537:-0.01;2026-07-27:79.2437855008177:0.09;2026-07-28:82.94551899534244:-0.11;2026-07-29:30.093990149917868:-0.01;2026-07-30:35.307761470393714:0.09;2026-07-31:56.83241462753154:-0.11
80|false|2026-07-20:97.38816204857353:-0.01
80|true|2026-07-11:65.32420131230487:-0.01;2026-07-12:70.6828797948411:0.09;2026-07-13:77.44651062186608:-0.11;2026-07-14:81.67989610449119:-0.01;2026-07-15:24.91999580514455:0.09;2026-07-16:44.69739202612113:-0.11;2026-07-17:53.7519605534091:-0.01;2026-07-18:59.79840270808479:0.09;2026-07-19:65.66911582956358:-0.11;2026-07-20:97.38816204857353:-0.01;2026-07-21:86.95548049684118:0.09;2026-07-22:33.272977419763485:-0.11;2026-07-23:39.299186624263605:-0.01;2026-07-24:45.31275048416332:0.09;2026-07-25:54.13115718435345:-0.11;2026-07-26:74.36835296651162:-0.01;2026-07-27:78.78332343853279:0.09;2026-07-28:82.6844267943711:-0.11;2026-07-29:28.54241577630786:-0.01;2026-07-30:33.82725931938255:0.09;2026-07-31:55.452658777165766:-0.11
120|false|2026-07-20:99.98810350209193:-0.01
120|true|2026-07-11:65.32420131230487:-0.01;2026-07-12:70.6828797948411:0.09;2026-07-13:77.44651062186608:-0.11;2026-07-14:81.67989610449119:-0.01;2026-07-15:24.91999580514455:0.09;2026-07-16:44.69739202612113:-0.11;2026-07-17:53.7519605534091:-0.01;2026-07-18:59.79840270808479:0.09;2026-07-19:65.66911582956358:-0.11;2026-07-20:99.98810350209193:-0.01;2026-07-21:86.95548049684118:0.09;2026-07-22:33.272977419763485:-0.11;2026-07-23:39.299186624263605:-0.01;2026-07-24:45.31275048416332:0.09;2026-07-25:54.13115718435345:-0.11;2026-07-26:74.36835296651162:-0.01;2026-07-27:78.78332343853279:0.09;2026-07-28:82.6844267943711:-0.11;2026-07-29:28.54241577630786:-0.01;2026-07-30:33.82725931938255:0.09;2026-07-31:55.452658777165766:-0.11
"""
XCTAssertEqual(actual.joined(separator: "\n"), expected)

}
}
Loading
Loading