From 73e8272945f2f96e3f4eb1078cb0d7f837d915c0 Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:46:47 +0200 Subject: [PATCH] Move day-cycle heart-rate processing off the main actor --- .../DayCycleIntelligenceIntegration.swift | 31 ++++++----- StrandTests/DayCycleRecoveryTests.swift | 53 +++++++++++++++++++ 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/Strand/Data/DayCycleIntelligenceIntegration.swift b/Strand/Data/DayCycleIntelligenceIntegration.swift index b7a922925a..3e262cbf58 100644 --- a/Strand/Data/DayCycleIntelligenceIntegration.swift +++ b/Strand/Data/DayCycleIntelligenceIntegration.swift @@ -219,21 +219,24 @@ import WhoopStore if let hit = cache.loads[window.sleepId], hit.key == loadKey, !hrWitness.contains(where: { $0.hasSuffix("=unread") }) { load = hit } else { - var hrByTimestamp: [Int: HRSample] = [:] - if hrEndInclusive >= window.onset { - for owner in owners { - let rows = (try? await store.hrSamples( - deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? [] - for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row } + // A day of HR merging and scoring must not block scrolling during a foreground rescore. + let scored = await Task.detached(priority: .utility) { + var hrByTimestamp: [Int: HRSample] = [:] + if hrEndInclusive >= window.onset { + for owner in owners { + let rows = (try? await store.hrSamples( + deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? [] + for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row } + } } - } - let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts } - load = CachedLoad( - key: loadKey, - strain: StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR, - method: effortMethod, sex: profile.sex), - calories: cycleHR.isEmpty ? nil : Calories.estimateDayCalories( - cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR)) + let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts } + return ( + strain: StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR, + method: effortMethod, sex: profile.sex), + calories: cycleHR.isEmpty ? nil : Calories.estimateDayCalories( + cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR)) + }.value + load = CachedLoad(key: loadKey, strain: scored.strain, calories: scored.calories) cache.loads[window.sleepId] = load } if let strain = load.strain { strains[day] = strain } diff --git a/StrandTests/DayCycleRecoveryTests.swift b/StrandTests/DayCycleRecoveryTests.swift index 47369732a5..a7cf83f93a 100644 --- a/StrandTests/DayCycleRecoveryTests.swift +++ b/StrandTests/DayCycleRecoveryTests.swift @@ -1,5 +1,6 @@ import XCTest import StrandAnalytics +import WhoopProtocol import WhoopStore @testable import Strand @@ -137,6 +138,58 @@ final class DayCycleRecoveryTests: XCTestCase { } } + func testCycleLoadPreservesOwnerPriorityBoundariesAndCachedResults() async throws { + let store = try await WhoopStore.inMemory() + let onset = 1_788_904_800 + let now = onset + 85_000 + let daily = recoveryDailyFixture() + let sleep = CachedSleepSession(startTs: onset, endTs: onset + 28_800, + efficiency: 0.9, restingHr: 58, avgHrv: 48, stagesJSON: nil) + let samples = (0..<80_000).map { HRSample(ts: onset + $0, bpm: 70 + $0 % 80) } + _ = try await store.insert(Streams(hr: samples), deviceId: "strap") + let duplicates = stride(from: 0, through: 80_000, by: 2).map { HRSample(ts: onset + $0, bpm: 190) } + _ = try await store.insert(Streams(hr: duplicates + [HRSample(ts: onset - 1, bpm: 200), + HRSample(ts: now, bpm: 200)]), deviceId: "canonical") + // Bit patterns recorded from this fixture before moving the existing work off the main actor. + let expected: [String: (strain: UInt64, calories: UInt64)] = [ + "male-edwards": (4_634_812_706_201_377_178, 4_663_630_262_385_791_330), + "male-banister": (4_635_039_997_245_071_032, 4_663_630_262_385_791_330), + "female-edwards": (4_634_812_706_201_377_178, 4_662_157_254_104_897_468), + "female-banister": (4_635_082_218_491_577_631, 4_662_157_254_104_897_468) + ] + for sex in ["male", "female"] { + for method in [StrainScorer.Method.edwards, .banister] { + let cache = DayCycleIntelligenceIntegration.Cache() + let profile = UserProfile(weightKg: 75, heightCm: 180, age: 35, sex: sex) + func compute() async -> DayCycleIntelligenceIntegration.Result { + await DayCycleIntelligenceIntegration.compute( + nights: [.init(daily: daily, sleeps: [sleep], workouts: [], owner: "strap")], + editedRows: [], store: store, candidates: [("strap", 0)], + physiologyOwners: ["canonical", "strap"], workouts: [], windowStart: onset, + now: now, offsetSec: 0, habitualMidsleepSec: nil, ticksPerStep: 1, + mode: .sleepOnset, cache: cache, profile: profile, maxHROverride: 190, + effortMethod: method) + } + let result = await compute() + let strain = try XCTUnwrap(result.strainByWakeDay[daily.day]) + let calories = try XCTUnwrap(result.caloriesByWakeDay[daily.day]) + let oracle = try XCTUnwrap(expected["\(sex)-\(method)"]) + XCTAssertEqual(strain.bitPattern, oracle.strain) + XCTAssertEqual(calories.bitPattern, oracle.calories) + XCTAssertEqual(result.onsetByWakeDay[daily.day], onset) + let cached = await compute() + XCTAssertEqual(cached.strainByWakeDay, result.strainByWakeDay) + XCTAssertEqual(cached.caloriesByWakeDay, result.caloriesByWakeDay) + if sex == "female", method == .banister { + _ = try await store.insert(Streams(hr: [HRSample(ts: onset + 80_001, bpm: 180)]), + deviceId: "canonical") + let updated = await compute() + XCTAssertGreaterThan(try XCTUnwrap(updated.caloriesByWakeDay[daily.day]), calories) + } + } + } + } + private func recoveryBaseline(_ mean: Double, spread: Double, status: BaselineStatus = .trusted) -> BaselineState { BaselineState(baseline: mean, spread: spread,