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
84 changes: 80 additions & 4 deletions PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ 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
/// Several overnight signals departing from their own baselines together.
case healthWatch
}

struct CoachAnomaly: Equatable {
Expand All @@ -21,10 +23,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 +60,67 @@ enum CoachAnomalyDetector {
)
}

// 3. Health Watch — several overnight signals departing together. Outranks resting-HR drift
// below because drift is *one of its own signals*: when both trip, the multi-signal
// result is strictly the better-corroborated message about the same night, and firing
// the single-signal one instead would understate what was actually seen.
if let watch = healthWatch(packet) { return watch }

// 4. Resting-HR drift on its own — the case where resting HR moved but nothing corroborated
// it, or where it was the only signal with a baseline at all (a jring with a week of wear
// can reach this while Health Watch is still short of two judgeable signals).
if let drift = restingHRDrift(packet, now: now) { return drift }

return nil
}

// MARK: - Health Watch

/// Fires on a `major` result only.
///
/// `minor` is deliberately silent: it means two signals nudged past their notable knots, which
/// happens after a glass of wine or a warm room often enough that alerting on it would train the
/// user to dismiss the ones that matter. The minor result still reaches the Today card and the
/// coach — it just doesn't interrupt.
private static func healthWatch(_ packet: NotificationContextPacket) -> CoachAnomaly? {
guard let watch = packet.healthWatch,
watch.status == HealthWatch.Status.major.rawValue,
!watch.flagged.isEmpty else { return nil }
return CoachAnomaly(kind: .healthWatch, facts: watch.facts)
}

// 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 @@ -74,8 +74,16 @@ enum CoachNotificationGenerator {
body: anomaly.facts,
tip: "Go easy today and aim for an earlier wind-down.",
followUp: "Want tips for a better night tonight?")
case .healthWatch:
return CoachNotification(title: "Worth taking it easy today",
body: anomaly.facts,
tip: "Nothing here names a cause — rest, fluids, and a lighter day cover most of them.",
followUp: "Want to look at what's been different this week?")
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
97 changes: 96 additions & 1 deletion PulseLoop/Coach/Notifications/NotificationContextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,46 @@ 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?
/// Last night's overnight signals against their own 30-day baselines. Present only when at
/// least two of them had a baseline to be judged against.
var healthWatch: HealthWatchContext?

struct HealthWatchContext: Encodable {
var status: String
var signalsAvailable: Int
/// Only the signals that departed far enough to count, worst first.
var flagged: [HealthWatchSignal]
/// The already-grounded sentence, so the model restates rather than re-derives it.
var facts: String
}

/// One departed signal. A sibling of `HealthWatchContext` rather than nested inside it, to stay
/// within SwiftLint's one-level nesting rule.
struct HealthWatchSignal: Encodable {
var signal: String
var value: Double
var baseline: Double
var detail: String
}

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 +102,62 @@ enum NotificationContextBuilder {
memories: packet.memories,
dataQualityWarnings: packet.dataQualityWarnings,
environment: environment,
nutrition: packet.nutrition
nutrition: packet.nutrition,
restingHR: restingHR(context: context, now: now),
healthWatch: healthWatch(context: context, now: now)
)
}

/// Last night's overnight signals against their own baselines, or nil when fewer than two could
/// be judged — see `HealthWatch.minSignals`.
static func healthWatch(
context: ModelContext, now: Date = Date()
) -> NotificationContextPacket.HealthWatchContext? {
guard let result = HealthWatchService.evaluate(now: now, context: context),
result.signalsAvailable >= HealthWatch.minSignals else { return nil }

return .init(
status: result.status.rawValue,
signalsAvailable: result.signalsAvailable,
flagged: result.flagged.map {
.init(signal: $0.signal.title, value: ($0.value * 10).rounded() / 10,
baseline: ($0.baseline * 10).rounded() / 10, detail: $0.detail)
},
facts: HealthWatch.facts(result)
)
}

/// 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
}
67 changes: 67 additions & 0 deletions PulseLoop/DesignSystem/HealthWatchCard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import SwiftUI

/// Overnight signs of strain, shown on Today **only when there are any**.
///
/// This is the one card allowed onto an already-dense Today grid, because it is conditional rather
/// than permanent: a clear night renders nothing at all. A permanent "all clear" tile would be
/// exactly the clutter the rest of this work avoids — and would also train people to stop reading it.
struct HealthWatchCard: View {
let result: HealthWatch.Result

var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(systemName: "waveform.path.ecg.rectangle")
.font(PulseFont.headline)
.foregroundStyle(accent)
Text(result.status.rawValue)
.font(PulseFont.subheadline.weight(.semibold))
.foregroundStyle(PulseColors.textPrimary)
Spacer(minLength: 4)
}

VStack(spacing: 8) {
ForEach(result.flagged, id: \.signal) { reading in
HStack(spacing: 10) {
Circle().fill(accent).frame(width: 6, height: 6)
Text(reading.signal.title)
.font(PulseFont.caption.weight(.semibold))
.foregroundStyle(PulseColors.textPrimary)
Spacer(minLength: 8)
Text(reading.detail)
.font(PulseFont.caption.monospacedDigit())
.foregroundStyle(PulseColors.textSecondary)
.lineLimit(1)
}
}
}

Text(disclaimer)
.font(PulseFont.caption2.weight(.regular))
.foregroundStyle(PulseColors.textMuted)
.fixedSize(horizontal: false, vertical: true)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(accent.opacity(0.10), in: RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)
.stroke(accent.opacity(0.3), lineWidth: 1)
)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(result.status.rawValue). \(HealthWatch.facts(result))")
}

private var accent: Color {
result.status == .major ? PulseColors.warning : PulseColors.textSecondary
}

/// Non-negotiable copy. The card must never read as a diagnosis, and must name the mundane
/// explanations before the worrying one.
private var disclaimer: String {
"Signals compared with your own recent nights, from \(result.signalsAvailable) "
+ "measurement\(result.signalsAvailable == 1 ? "" : "s") your ring records. "
+ "This isn't a diagnosis — alcohol, a warm room, and a hard session the day before all "
+ "look like this."
}
}
Loading