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
62 changes: 58 additions & 4 deletions PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation
enum CoachAnomalyKind: String, Codable, Equatable {
case lowSpO2
case poorSleep
/// Reserved for a future baseline-aware detector (needs multi-day history).
/// Last night's resting HR sitting well above the learned 30-day baseline.
case restingHRDrift
}

Expand All @@ -21,10 +21,23 @@ struct CoachAnomaly: Equatable {
/// Pure, conservative anomaly detection over the notification context packet.
/// Thresholds are intentionally cautious — a missed alert is far better than a
/// false alarm on health data. Returns at most one anomaly, highest-priority
/// first. (Resting-HR drift is deferred — it needs a multi-day baseline the
/// 12-hour packet doesn't carry.)
/// first.
enum CoachAnomalyDetector {
static func detect(_ packet: NotificationContextPacket) -> CoachAnomaly? {
/// How far last night's resting HR must sit above the learned baseline before it's worth
/// interrupting for.
///
/// Five bpm is the usual consumer-wearable threshold for "your body is working harder than
/// usual at rest" — the signal that moves first with infection, alcohol, heat and
/// under-recovery, typically a day before the user notices anything. Below that the day-to-day
/// noise in an optical ring's overnight sampling swamps it.
static let driftBpm: Double = 5

/// …and the night must be recent. The baseline is a 30-day figure, so an old night compared
/// against it says nothing about today; `SleepService.latestSleep` already withholds stale
/// sessions, and this is the belt-and-braces check on the packet's own date string.
static let driftMaxNightAgeDays = 2

static func detect(_ packet: NotificationContextPacket, now: Date = Date()) -> CoachAnomaly? {
// 1. Low SpO₂ — most clinically meaningful. Require a few readings so a
// single noisy sample doesn't trigger an alert.
if packet.spo2Last12h.count >= 3, let lowest = packet.spo2Last12h.min, lowest < 90 {
Expand All @@ -45,6 +58,47 @@ enum CoachAnomalyDetector {
)
}

// 3. Resting-HR drift. Ordered last of the three deliberately: a short or broken night
// usually raises resting HR too, so when both trip, the sleep alert names the cause and
// this one would only restate its consequence. `detect` returns at most one anomaly, so
// this is a precedence choice between two messages about the same night.
if let drift = restingHRDrift(packet, now: now) { return drift }

return nil
}

// MARK: - Resting-HR drift

/// Fires when last night's resting HR sits `driftBpm` or more above the learned baseline.
///
/// Only the elevated direction is reported. A resting HR *below* baseline is usually good news
/// (fitness, a genuinely restful night) and is not something to push an unprompted alert about —
/// the same asymmetry the readiness score applies to HRV.
private static func restingHRDrift(
_ packet: NotificationContextPacket, now: Date
) -> CoachAnomaly? {
guard let resting = packet.restingHR else { return nil }

let drift = resting.lastNightBpm - resting.baselineBpm
guard drift >= driftBpm else { return nil }
guard isRecentNight(resting.nightOf, now: now) else { return nil }

let night = Int(resting.lastNightBpm.rounded())
let base = Int(resting.baselineBpm.rounded())
let delta = Int(drift.rounded())
return CoachAnomaly(
kind: .restingHRDrift,
facts: "Resting heart rate overnight was \(night) bpm, \(delta) bpm above the usual \(base) bpm "
+ "learned over the last 30 days. An elevated resting heart rate often shows up a day before "
+ "you feel run down, and also follows alcohol, heat, or a hard session the day before."
)
}

/// Whether the packet's night date is recent enough to say anything about today.
private static func isRecentNight(_ nightOf: String, now: Date, calendar: Calendar = .current) -> Bool {
guard let date = CoachDataAccess.parseLocalDate(nightOf) else { return false }
let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: date),
to: calendar.startOfDay(for: now)).day ?? .max
return days <= driftMaxNightAgeDays
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ enum CoachNotificationGenerator {
tip: "Go easy today and aim for an earlier wind-down.",
followUp: "Want tips for a better night tonight?")
case .restingHRDrift:
return CoachNotification(title: "A quick heads-up", body: anomaly.facts)
return CoachNotification(title: "Resting heart rate is up",
body: anomaly.facts,
tip: "Worth an easier day and some extra fluids; it usually settles in a night or two.",
followUp: "Want to look at what's been different this week?")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ final class CoachNotificationService {
let slot = forcedSlot(now: now) // only for building the context packet
let environment = await CoachEnvironmentContextService.shared.snapshot(now: now)
let packet = NotificationContextBuilder.build(slot: slot, context: modelContext, now: now, environment: environment)
guard let anomaly = CoachAnomalyDetector.detect(packet) else { return .noAnomaly }
guard let anomaly = CoachAnomalyDetector.detect(packet, now: now) else { return .noAnomaly }

if !force, isAnomalyDuplicate(anomaly, now: now) { return .noAnomaly }

Expand Down
56 changes: 55 additions & 1 deletion PulseLoop/Coach/Notifications/NotificationContextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ struct NotificationContextPacket: Encodable {
/// Present only when nutrition tracking is on, shared with the coach, AND the
/// check-in sub-toggle allows it.
var nutrition: CoachContextPacket.NutritionContext?
/// Last night measured against the learned resting-HR baseline.
///
/// This is the one block the 12-hour window can't supply on its own: drift is only meaningful
/// against a multi-day baseline, which is why `restingHRDrift` sat declared-but-unfired. The
/// baseline is already learned and persisted by `RestingHRBaselineService`, so the packet just
/// carries it in alongside the single night to compare it to.
var restingHR: RestingHRContext?

struct RestingHRContext: Encodable {
/// The learned 10th-percentile resting HR over 30 days. Non-nil implies established —
/// `RestingHRBaselineService` stores nil until it has ≥20 samples spanning ≥7 days.
var baselineBpm: Double
/// Last night's resting HR, measured the same way over the night's own HR samples.
var lastNightBpm: Double
/// How many HR samples that night figure came from, so the model can weigh it.
var sampleCount: Int
/// Local date of the night, so a stale night is visible rather than implied to be recent.
var nightOf: String
}
}

@MainActor
Expand Down Expand Up @@ -62,7 +81,42 @@ enum NotificationContextBuilder {
memories: packet.memories,
dataQualityWarnings: packet.dataQualityWarnings,
environment: environment,
nutrition: packet.nutrition
nutrition: packet.nutrition,
restingHR: restingHR(context: context, now: now)
)
}

/// Last night's resting HR beside the learned baseline, or nil when either is unavailable.
///
/// The night is bounded by the sleep session itself rather than a fixed clock window, so a shift
/// worker or a late night is measured over the hours they actually slept. `SleepService.latestSleep`
/// already withholds stale sessions, so a ring that hasn't synced in days yields nil here rather
/// than comparing against an old night.
static func restingHR(
context: ModelContext, now: Date = Date()
) -> NotificationContextPacket.RestingHRContext? {
guard let baseline = ProfileRepository.profile(context: context)?.hrRestingBaseline,
let night = SleepService.latestSleep(context: context) else { return nil }

let samples = MetricsRepository.measurements(
kind: .heartRate, start: night.session.startAt, end: night.session.endAt, context: context
).map(\.value).filter { $0 > 0 }

// A YCBT ring floors its all-day interval at 30 minutes, so a full night is only ~14 samples
// there against ~84 on a 5-minute Colmi. Ten keeps both usable while still refusing to call a
// handful of readings a resting heart rate.
guard samples.count >= minNightSamples else { return nil }

return .init(
baselineBpm: (baseline * 10).rounded() / 10,
lastNightBpm: (RestingHRBaselineService.percentile(
samples.sorted(), RestingHRBaselineService.restingPercentile
) * 10).rounded() / 10,
sampleCount: samples.count,
nightOf: CoachDataAccess.localDateString(night.session.date)
)
}

/// Fewest overnight HR samples that can stand in for a night's resting heart rate.
static let minNightSamples = 10
}
9 changes: 7 additions & 2 deletions PulseLoop/Services/RestingHRBaselineService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ enum RestingHRBaselineService {
}

let established = values.count >= minSamples && spanDays >= minSpanDays
let newBaseline = established ? percentile(values.sorted(), 0.10) : nil
let newBaseline = established ? percentile(values.sorted(), restingPercentile) : nil

// Stamp the refresh time even when not established, so we don't rescan on every foreground.
profile.hrRestingBaselineUpdatedAt = now
Expand All @@ -55,8 +55,13 @@ enum RestingHRBaselineService {
try? context.save()
}

/// The percentile this service treats as "resting" — the 10th. Shared so anything comparing a
/// single night against `hrRestingBaseline` measures that night the same way the baseline was
/// built, rather than inventing a second definition of resting HR.
static let restingPercentile = 0.10

/// Interpolated percentile (same formula as `BaselineStats.compute`).
private static func percentile(_ sorted: [Double], _ fraction: Double) -> Double {
static func percentile(_ sorted: [Double], _ fraction: Double) -> Double {
guard !sorted.isEmpty else { return 0 }
guard sorted.count > 1 else { return sorted[0] }
let rank = fraction * Double(sorted.count - 1)
Expand Down
175 changes: 175 additions & 0 deletions PulseLoopTests/RestingHRDriftTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import XCTest
import SwiftData
@testable import PulseLoop

/// `restingHRDrift` shipped as a declared-but-unfired `CoachAnomalyKind` — the detector reads a
/// 12-hour packet and drift only means anything against a multi-day baseline. These lock both halves
/// of the fix: the baseline now rides in the packet, and the detector's gates.
@MainActor
final class RestingHRDriftTests: XCTestCase {

/// Builds a packet carrying only what the drift detector reads; everything else is inert so a
/// higher-priority anomaly can't mask the case under test.
private func packet(
baseline: Double?, lastNight: Double?, nightOf: String, samples: Int = 40
) -> NotificationContextPacket {
var p = NotificationContextPacket(
slot: "morning", generatedAt: "", timezone: "UTC", profileName: "Sam",
goals: .init(stepsDaily: 10000, activeMinutesDaily: 45, sleepHours: 8, exerciseDaysWeekly: 4),
today: .init(localDate: nightOf, steps: 0, calories: nil, distanceKm: nil,
activeMinutes: nil, dataConfidence: "high"),
latestSleep: nil,
latestVitals: .init(latestHr: nil, latestHrAt: nil, latestSpo2: nil, latestSpo2At: nil,
restingHrEstimate: nil, peakHrToday: nil),
hrLast12h: .init(count: 0, avg: nil, min: nil, max: nil),
spo2Last12h: .init(count: 0, avg: nil, min: nil, max: nil),
recentWorkouts: [], memories: [], dataQualityWarnings: []
)
if let baseline, let lastNight {
p.restingHR = .init(baselineBpm: baseline, lastNightBpm: lastNight,
sampleCount: samples, nightOf: nightOf)
}
return p
}

private func today(_ now: Date = Date()) -> String {
CoachDataAccess.localDateString(now)
}

// MARK: - Threshold

func testFiresAtTheDriftThreshold() {
let now = Date()
let anomaly = CoachAnomalyDetector.detect(
packet(baseline: 54, lastNight: 59, nightOf: today(now)), now: now
)
XCTAssertEqual(anomaly?.kind, .restingHRDrift)
XCTAssertTrue(anomaly?.facts.contains("59 bpm") == true)
XCTAssertTrue(anomaly?.facts.contains("5 bpm above") == true)
}

func testSilentJustBelowTheThreshold() {
let now = Date()
XCTAssertNil(CoachAnomalyDetector.detect(
packet(baseline: 54, lastNight: 58.9, nightOf: today(now)), now: now
))
}

/// A resting HR *below* baseline is usually good news, and never an unprompted alert.
func testSilentWhenRestingHRIsBelowBaseline() {
let now = Date()
XCTAssertNil(CoachAnomalyDetector.detect(
packet(baseline: 60, lastNight: 50, nightOf: today(now)), now: now
))
}

// MARK: - Gates

func testSilentWithoutAnEstablishedBaseline() {
let now = Date()
XCTAssertNil(CoachAnomalyDetector.detect(
packet(baseline: nil, lastNight: nil, nightOf: today(now)), now: now
))
}

func testSilentOnAStaleNight() {
let now = Date()
let old = Calendar.current.date(byAdding: .day, value: -5, to: now) ?? now
XCTAssertNil(
CoachAnomalyDetector.detect(
packet(baseline: 54, lastNight: 70, nightOf: CoachDataAccess.localDateString(old)), now: now
),
"a five-day-old night says nothing about today, however elevated"
)
}

// MARK: - Precedence

/// A short night usually raises resting HR too. When both trip, the sleep alert names the cause
/// and drift would only restate its consequence — `detect` returns one anomaly, so sleep wins.
func testShortSleepOutranksDrift() {
let now = Date()
var p = packet(baseline: 54, lastNight: 70, nightOf: today(now))
p.latestSleep = .init(date: today(now), totalMin: 240, deepMin: 40, lightMin: 180,
awakeMin: 20, score: 40, confidence: "medium", decoderNote: "")
XCTAssertEqual(CoachAnomalyDetector.detect(p, now: now)?.kind, .poorSleep)
}

func testLowSpO2OutranksDrift() {
let now = Date()
var p = packet(baseline: 54, lastNight: 70, nightOf: today(now))
p.spo2Last12h = .init(count: 4, avg: 93, min: 88, max: 97)
XCTAssertEqual(CoachAnomalyDetector.detect(p, now: now)?.kind, .lowSpO2)
}

// MARK: - Copy

func testScriptedAlertIsActionable() {
let now = Date()
guard let anomaly = CoachAnomalyDetector.detect(
packet(baseline: 54, lastNight: 62, nightOf: today(now)), now: now
) else { return XCTFail("expected a drift anomaly") }

let notification = CoachNotificationGenerator.scriptedAnomaly(anomaly)
XCTAssertFalse(notification.title.isEmpty)
XCTAssertNotNil(notification.tip, "the offline fallback should still suggest something")
XCTAssertEqual(anomaly.dedupeKey, "anomaly:restingHRDrift")
}

// MARK: - Builder

/// The packet block is only built once there is both a learned baseline and a recent night with
/// enough overnight samples to stand in for a resting figure.
func testBuilderWithholdsBlockWhenBaselineUnlearned() throws {
let context = try TestSupport.makeContext()
let profile = UserProfile(name: "Sam")
context.insert(profile)
try? context.save()

XCTAssertNil(NotificationContextBuilder.restingHR(context: context))
}

func testBuilderMeasuresTheNightAtTheSamePercentileAsTheBaseline() throws {
let context = try TestSupport.makeContext()
let profile = UserProfile(name: "Sam")
profile.hrRestingBaseline = 54
context.insert(profile)

// A night of sleep, with HR samples spread across it.
let start = Calendar.current.date(bySettingHour: 23, minute: 0, second: 0, of: TestSupport.day(-1))
?? TestSupport.day(-1)
_ = TestSupport.insertSleep(nightStart: start, stages: Array(repeating: .light, count: 400), into: context)

// 40 readings from 60 to 99 bpm: the 10th percentile lands at 63.9.
for i in 0..<40 {
let ts = Calendar.current.date(byAdding: .minute, value: i * 10, to: start) ?? start
context.insert(Measurement(kind: .heartRate, value: Double(60 + i), unit: "bpm", timestamp: ts))
}
try? context.save()

let resting = NotificationContextBuilder.restingHR(context: context)
XCTAssertEqual(resting?.sampleCount, 40)
XCTAssertEqual(resting?.baselineBpm, 54)
XCTAssertEqual(resting?.lastNightBpm ?? 0, 63.9, accuracy: 0.05)
}

func testBuilderWithholdsBlockOnTooFewOvernightSamples() throws {
let context = try TestSupport.makeContext()
let profile = UserProfile(name: "Sam")
profile.hrRestingBaseline = 54
context.insert(profile)

let start = Calendar.current.date(bySettingHour: 23, minute: 0, second: 0, of: TestSupport.day(-1))
?? TestSupport.day(-1)
_ = TestSupport.insertSleep(nightStart: start, stages: Array(repeating: .light, count: 400), into: context)

// One under the floor — a handful of readings is not a resting heart rate.
for i in 0..<(NotificationContextBuilder.minNightSamples - 1) {
let ts = Calendar.current.date(byAdding: .minute, value: i * 10, to: start) ?? start
context.insert(Measurement(kind: .heartRate, value: 70, unit: "bpm", timestamp: ts))
}
try? context.save()

XCTAssertNil(NotificationContextBuilder.restingHR(context: context))
}
}
Loading