diff --git a/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift b/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift index d0813b3..8e27ef5 100644 --- a/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift +++ b/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift @@ -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 { @@ -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 { @@ -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 + } } diff --git a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift index 8190c47..1614af8 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift @@ -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?") } } diff --git a/PulseLoop/Coach/Notifications/CoachNotificationService.swift b/PulseLoop/Coach/Notifications/CoachNotificationService.swift index 58eec15..d5ced7f 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationService.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationService.swift @@ -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 } diff --git a/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift b/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift index 83ad54a..e0233d3 100644 --- a/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift +++ b/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift @@ -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 @@ -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 } diff --git a/PulseLoop/DesignSystem/HealthWatchCard.swift b/PulseLoop/DesignSystem/HealthWatchCard.swift new file mode 100644 index 0000000..5d32884 --- /dev/null +++ b/PulseLoop/DesignSystem/HealthWatchCard.swift @@ -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." + } +} diff --git a/PulseLoop/Services/HealthWatch.swift b/PulseLoop/Services/HealthWatch.swift new file mode 100644 index 0000000..f3d1f47 --- /dev/null +++ b/PulseLoop/Services/HealthWatch.swift @@ -0,0 +1,183 @@ +import Foundation + +/// Overnight signs that the body is working harder than usual — PulseLoop's equivalent of Oura's +/// Symptom Radar or Ultrahuman's Sleep Screener. +/// +/// **This is not a diagnosis and never names a condition.** It reports that several overnight signals +/// moved away from your own baselines together, which is a pattern that often precedes feeling +/// unwell by about a day — and equally often follows alcohol, heat, a hard session, or a bad night. +/// The copy says so every time. +/// +/// Pure maths; `HealthWatchService` reads the store. +enum HealthWatch { + + /// One signal's departure from its own baseline. + enum Level: Int, Comparable { + case normal = 0 + case notable = 1 + case strong = 2 + + static func < (lhs: Level, rhs: Level) -> Bool { lhs.rawValue < rhs.rawValue } + } + + /// The signals this reads. Each has a direction: only the side that indicates strain counts. + /// + /// A resting HR *below* baseline, an HRV *above* it, or a cooler night are not warning signs, and + /// flagging them would turn good news into an alert — the same asymmetry the resting-HR drift + /// detector applies. + enum Signal: String, CaseIterable { + case skinTemperature + case restingHeartRate + case hrv + case respiratoryRate + case bloodOxygen + + var title: String { + switch self { + case .skinTemperature: return "Skin temperature" + case .restingHeartRate: return "Resting heart rate" + case .hrv: return "HRV" + case .respiratoryRate: return "Breathing rate" + case .bloodOxygen: return "Blood oxygen" + } + } + + /// `(notable, strong)` departures from baseline, in the direction that indicates strain. + /// + /// Temperature and resting HR come first because they are the two that move earliest and + /// most reliably on consumer optical hardware. HRV's knots are proportional rather than + /// absolute because HRV spans an order of magnitude across healthy adults. + var thresholds: (notable: Double, strong: Double) { + switch self { + case .skinTemperature: return (0.5, 1.0) // °C above baseline + case .restingHeartRate: return (5, 10) // bpm above baseline + case .hrv: return (0.15, 0.30) // fraction below baseline + case .respiratoryRate: return (2, 4) // brpm above baseline + case .bloodOxygen: return (2, 4) // percentage points below baseline + } + } + + /// Whether a departure is measured as a fraction of baseline rather than an absolute step. + var isProportional: Bool { self == .hrv } + + /// Whether strain shows as a value *below* baseline. + var strainIsBelowBaseline: Bool { self == .hrv || self == .bloodOxygen } + + var unit: String { + switch self { + case .skinTemperature: return "°C" + case .restingHeartRate: return "bpm" + case .hrv: return "ms" + case .respiratoryRate: return "brpm" + case .bloodOxygen: return "%" + } + } + } + + /// One signal's reading against its baseline. + struct Reading: Equatable { + let signal: Signal + let value: Double + let baseline: Double + let level: Level + + /// Signed departure in the strain direction: positive means "further into strain". + var strainDelta: Double { + let raw = signal.strainIsBelowBaseline ? baseline - value : value - baseline + return signal.isProportional && baseline != 0 ? raw / baseline : raw + } + + var detail: String { + if signal.isProportional { + return "\(Int((strainDelta * 100).rounded()))% below your usual" + } + let magnitude = abs(strainDelta) + let formatted = signal == .skinTemperature + ? String(format: "%.1f", magnitude) + : "\(Int(magnitude.rounded()))" + let direction = strainDelta >= 0 ? "above" : "below" + return "\(formatted) \(signal.unit) \(direction) your usual" + } + } + + enum Status: String { + /// Nothing unusual, or not enough signals to say. + case clear = "No signs of strain" + case minor = "Minor signs of strain" + case major = "Major signs of strain" + } + + struct Result: Equatable { + let status: Status + /// Every signal that had a baseline to be judged against, in descending order of departure. + let readings: [Reading] + /// How many signals could be judged at all. + var signalsAvailable: Int { readings.count } + /// The ones that departed far enough to count. + var flagged: [Reading] { readings.filter { $0.level > .normal } } + } + + /// Fewest judgeable signals before this says anything. One signal moving is noise; the whole + /// value of a multi-signal detector is that it waits for agreement. + static let minSignals = 2 + + /// Total departure score at which the result turns `major`. Levels are 0/1/2 per signal, so 3 is + /// "one strong plus one notable", or "one strong and something else stirring". + static let majorScore = 3 + + /// Score at which it turns `minor` — two notable signals, or a single strong one. + static let minorScore = 2 + + static func level(for signal: Signal, value: Double, baseline: Double) -> Level { + let reading = Reading(signal: signal, value: value, baseline: baseline, level: .normal) + let delta = reading.strainDelta + guard delta.isFinite, delta > 0 else { return .normal } + let thresholds = signal.thresholds + if delta >= thresholds.strong { return .strong } + if delta >= thresholds.notable { return .notable } + return .normal + } + + /// Judges a night. `values` and `baselines` need only overlap — a signal missing from either is + /// simply not judged, which is how a jring (no temperature sensor, no breathing rate) still gets + /// a useful answer from the three it does have. + static func evaluate(values: [Signal: Double], baselines: [Signal: Double]) -> Result { + var readings: [Reading] = [] + for signal in Signal.allCases { + guard let value = values[signal], let baseline = baselines[signal], + value.isFinite, baseline.isFinite, baseline != 0 else { continue } + readings.append(Reading(signal: signal, value: value, baseline: baseline, + level: level(for: signal, value: value, baseline: baseline))) + } + readings.sort { $0.strainDelta > $1.strainDelta } + + // Below the floor there is nothing to corroborate, so the honest answer is "clear" rather + // than a warning built on one reading. + guard readings.count >= minSignals else { + return Result(status: .clear, readings: readings) + } + + let score = readings.reduce(0) { $0 + $1.level.rawValue } + let status: Status + switch score { + case majorScore...: status = .major + case minorScore...: status = .minor + default: status = .clear // a single notable signal on its own is noise + } + + return Result(status: status, readings: readings) + } + + /// One grounded sentence naming what moved, for the alert body and the coach. + static func facts(_ result: Result) -> String { + let flagged = result.flagged + guard !flagged.isEmpty else { return "Overnight signals all sat close to your usual ranges." } + let parts = flagged.map { "\($0.signal.title.lowercased()) \($0.detail)" } + let list = parts.count == 1 + ? parts[0] + : parts.dropLast().joined(separator: ", ") + " and " + (parts.last ?? "") + return "Last night \(list). Several signals moving together like this often shows up a day " + + "before you feel run down — though alcohol, heat, and a hard session the day before do " + + "the same thing." + } +} diff --git a/PulseLoop/Services/HealthWatchService.swift b/PulseLoop/Services/HealthWatchService.swift new file mode 100644 index 0000000..bb27106 --- /dev/null +++ b/PulseLoop/Services/HealthWatchService.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +/// Resolves last night's values and their 30-day baselines, and hands them to `HealthWatch`. +/// +/// Every signal is measured **over the night itself**, bounded by the sleep session rather than a +/// fixed clock window — the same choice the resting-HR drift detector makes, and for the same +/// reason: a shift worker's night is still their night. +@MainActor +enum HealthWatchService { + + /// How far back the per-signal baselines look. + static let baselineWindowDays = 30 + + /// Fewest nights a baseline needs before it is trusted. Matches `BaselineStats.isEstablished`'s + /// week-of-wear floor, counted in nights rather than samples because these are nightly figures. + static let minBaselineNights = 7 + + /// Fewest readings within one night for that night's figure to stand. A YCBT ring floors its + /// all-day interval at 30 minutes, so a full night is only ~14 samples there. + static let minNightSamples = 6 + + /// Last night judged against your own baselines, or nil when there is no recent night to judge. + static func evaluate(now: Date = Date(), context: ModelContext) -> HealthWatch.Result? { + guard let night = SleepService.latestSleep(context: context) else { return nil } + let start = night.session.startAt + let end = night.session.endAt + + var values: [HealthWatch.Signal: Double] = [:] + var baselines: [HealthWatch.Signal: Double] = [:] + + for signal in HealthWatch.Signal.allCases { + guard let value = nightValue(signal, start: start, end: end, context: context) else { continue } + guard let baseline = baseline(signal, before: start, context: context) else { continue } + values[signal] = value + baselines[signal] = baseline + } + + return HealthWatch.evaluate(values: values, baselines: baselines) + } + + /// One signal's figure for a single night. + /// + /// Heart rate uses the 10th percentile — its resting figure, matching how + /// `RestingHRBaselineService` builds the long-run baseline it is compared against. Everything + /// else uses the mean, since none of them have a "resting" notion distinct from their average. + static func nightValue( + _ signal: HealthWatch.Signal, start: Date, end: Date, context: ModelContext + ) -> Double? { + let samples = MetricsRepository.measurements( + kind: measurementKind(signal), start: start, end: end, limit: 2000, context: context + ).map(\.value).filter { $0 > 0 } + guard samples.count >= minNightSamples else { return nil } + + if signal == .restingHeartRate { + return RestingHRBaselineService.percentile(samples.sorted(), RestingHRBaselineService.restingPercentile) + } + return samples.reduce(0, +) / Double(samples.count) + } + + /// The signal's own baseline: the median of the preceding nights' figures. + /// + /// **Median of per-night figures, not a mean of every sample.** One night the ring recorded four + /// times as often as usual would otherwise dominate a raw sample mean, and a single feverish + /// night would drag the very baseline it needs to be judged against. + static func baseline( + _ signal: HealthWatch.Signal, before night: Date, context: ModelContext, calendar: Calendar = .current + ) -> Double? { + let windowStart = calendar.date(byAdding: .day, value: -baselineWindowDays, to: night) ?? night + // Windowed predicate fetch — the whole-table `SleepRepository.sessions` would read every + // night ever recorded to answer a 30-day question. + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.startAt >= windowStart && $0.startAt < night }, + sortBy: [SortDescriptor(\.startAt, order: .reverse)] + ) + let sessions = (try? context.fetch(descriptor)) ?? [] + + let nightly = sessions.compactMap { + nightValue(signal, start: $0.startAt, end: $0.endAt, context: context) + } + guard nightly.count >= minBaselineNights else { return nil } + + let sorted = nightly.sorted() + let mid = sorted.count / 2 + return sorted.count.isMultiple(of: 2) ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] + } + + private static func measurementKind(_ signal: HealthWatch.Signal) -> MeasurementKind { + switch signal { + case .skinTemperature: return .temperature + case .restingHeartRate: return .heartRate + case .hrv: return .hrv + case .respiratoryRate: return .respiratoryRate + case .bloodOxygen: return .spo2 + } + } +} diff --git a/PulseLoop/Services/RestingHRBaselineService.swift b/PulseLoop/Services/RestingHRBaselineService.swift index cbfc85b..5a18498 100644 --- a/PulseLoop/Services/RestingHRBaselineService.swift +++ b/PulseLoop/Services/RestingHRBaselineService.swift @@ -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 @@ -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) diff --git a/PulseLoop/ViewModels/TodayStore.swift b/PulseLoop/ViewModels/TodayStore.swift index 97ba418..0643859 100644 --- a/PulseLoop/ViewModels/TodayStore.swift +++ b/PulseLoop/ViewModels/TodayStore.swift @@ -37,6 +37,10 @@ final class TodayStore { /// `body`, which meant a full pass over the samples on every re-render (including every frame of /// a card drag). private(set) var hrvBaseline: BaselineStats? + /// Last night's overnight signals against their own baselines. Rebuilt here rather than in + /// `body` because it walks 30 nights of sessions and their readings — far too heavy per render. + /// nil, or a `.clear` result, renders nothing at all. + private(set) var healthWatch: HealthWatch.Result? /// Bumped whenever `cards` and the sample series are rebuilt. The reorder grid keys cell equality /// on this so dragging a card doesn't re-render every Swift Charts tile — see `ReorderCell`. private(set) var revision: Int = 0 @@ -87,6 +91,7 @@ final class TodayStore { hero = TodayInsights.deriveHero(built) capabilities = MetricsService.deviceCapabilities(modelContext) visibleMetrics = Self.computeVisible(context: modelContext) + healthWatch = HealthWatchService.evaluate(context: modelContext) rebuildCards() signature = sig } diff --git a/PulseLoop/Views/TodayView.swift b/PulseLoop/Views/TodayView.swift index c6390cf..826f104 100644 --- a/PulseLoop/Views/TodayView.swift +++ b/PulseLoop/Views/TodayView.swift @@ -97,6 +97,12 @@ struct TodayView: View { HeroInsightCardView(title: hero.title, summary: hero.summary, chips: hero.chips) } + // Conditional by design: a clear night renders nothing, so this never becomes a + // permanent "all clear" tile that people learn to stop reading. + if let watch = activeStore.healthWatch, watch.status != .clear { + HealthWatchCard(result: watch) + } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { tiles(activeStore) } diff --git a/PulseLoopTests/HealthWatchTests.swift b/PulseLoopTests/HealthWatchTests.swift new file mode 100644 index 0000000..a2eda8b --- /dev/null +++ b/PulseLoopTests/HealthWatchTests.swift @@ -0,0 +1,260 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// The multi-signal overnight strain detector. Its whole value is that it waits for *agreement* +/// between signals, so most of these pin the cases where it deliberately stays quiet. +@MainActor +final class HealthWatchTests: XCTestCase { + + /// Baselines a healthy adult might carry, for the signals to be judged against. + private let baselines: [HealthWatch.Signal: Double] = [ + .skinTemperature: 34.0, + .restingHeartRate: 54, + .hrv: 60, + .respiratoryRate: 14, + .bloodOxygen: 97, + ] + + // MARK: - Direction + + /// Only the strain side of each signal counts. A resting HR *below* baseline, an HRV *above* + /// it, or a cooler night are not warning signs — flagging them would turn good news into alerts. + func testOnlyTheStrainDirectionCounts() { + XCTAssertEqual(HealthWatch.level(for: .restingHeartRate, value: 44, baseline: 54), .normal) + XCTAssertEqual(HealthWatch.level(for: .restingHeartRate, value: 64, baseline: 54), .strong) + + XCTAssertEqual(HealthWatch.level(for: .hrv, value: 90, baseline: 60), .normal, "higher HRV is good news") + XCTAssertEqual(HealthWatch.level(for: .hrv, value: 40, baseline: 60), .strong) + + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 32.5, baseline: 34), .normal) + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 35.2, baseline: 34), .strong) + + XCTAssertEqual(HealthWatch.level(for: .bloodOxygen, value: 99, baseline: 97), .normal) + XCTAssertEqual(HealthWatch.level(for: .bloodOxygen, value: 93, baseline: 97), .strong) + } + + func testThresholdKnots() { + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 34.4, baseline: 34), .normal) + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 34.5, baseline: 34), .notable) + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 35.0, baseline: 34), .strong) + + XCTAssertEqual(HealthWatch.level(for: .restingHeartRate, value: 58.9, baseline: 54), .normal) + XCTAssertEqual(HealthWatch.level(for: .restingHeartRate, value: 59, baseline: 54), .notable) + + // HRV's knots are proportional, since it spans an order of magnitude across healthy adults. + XCTAssertEqual(HealthWatch.level(for: .hrv, value: 51, baseline: 60), .notable, "15% below") + XCTAssertEqual(HealthWatch.level(for: .hrv, value: 42, baseline: 60), .strong, "30% below") + } + + // MARK: - Agreement + + func testANormalNightIsClear() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 34.1, .restingHeartRate: 53, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .clear) + XCTAssertTrue(result.flagged.isEmpty) + } + + /// **The false-alarm guard.** One signal nudging past its knot is noise — a warm duvet, one + /// restless hour. Nothing fires until something corroborates it. + func testASingleNotableSignalStaysQuiet() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 34.6, .restingHeartRate: 53, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .clear) + XCTAssertEqual(result.flagged.count, 1, "it is still reported, just not acted on") + } + + func testTwoNotableSignalsAreMinor() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 34.6, .restingHeartRate: 60, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .minor) + } + + /// A single strong signal is worth a minor flag on its own — a full degree of overnight + /// temperature rise is not noise, even unaccompanied. + func testOneStrongSignalAloneIsMinor() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 35.2, .restingHeartRate: 53, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .minor) + } + + func testAStrongPlusANotableIsMajor() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 35.2, .restingHeartRate: 60, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .major) + XCTAssertEqual(result.flagged.count, 2) + } + + /// The classic pattern: warmer, working harder, less variable. + func testTheIllnessPatternIsMajor() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 35.3, .restingHeartRate: 66, .hrv: 38, .bloodOxygen: 96], + baselines: baselines + ) + XCTAssertEqual(result.status, .major) + XCTAssertGreaterThanOrEqual(result.flagged.count, 3) + } + + // MARK: - Availability + + /// A jring has no temperature sensor and no breathing rate. It still gets a usable answer from + /// the three signals it does have. + func testARingWithFewerSensorsStillWorks() { + let result = HealthWatch.evaluate( + values: [.restingHeartRate: 66, .hrv: 38, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.signalsAvailable, 3) + XCTAssertEqual(result.status, .major) + } + + /// One judgeable signal can't corroborate anything, so the honest answer is "clear" rather than + /// a warning built on a single reading. + func testOneJudgeableSignalNeverFlags() { + let result = HealthWatch.evaluate(values: [.restingHeartRate: 80], baselines: baselines) + XCTAssertEqual(result.signalsAvailable, 1) + XCTAssertEqual(result.status, .clear) + } + + func testSignalsWithoutABaselineAreNotJudged() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 36, .restingHeartRate: 70, .hrv: 30], + baselines: [.restingHeartRate: 54] // only one baseline established + ) + XCTAssertEqual(result.signalsAvailable, 1) + XCTAssertEqual(result.status, .clear) + } + + func testAZeroBaselineIsNotJudged() { + let result = HealthWatch.evaluate( + values: [.restingHeartRate: 70, .hrv: 30], + baselines: [.restingHeartRate: 0, .hrv: 0] + ) + XCTAssertEqual(result.signalsAvailable, 0) + } + + // MARK: - Copy + + /// Non-negotiable: the facts sentence names the mundane explanations, and never a condition. + func testFactsNameTheMundaneExplanationsAndNoDiagnosis() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 35.3, .restingHeartRate: 66, .hrv: 38], + baselines: baselines + ) + let facts = HealthWatch.facts(result) + XCTAssertTrue(facts.contains("alcohol")) + XCTAssertTrue(facts.contains("hard session")) + for word in ["infection", "illness", "fever", "sick", "virus", "flu"] { + XCTAssertFalse(facts.lowercased().contains(word), "must not name a condition: \(word)") + } + } + + func testFactsOnAClearNight() { + let result = HealthWatch.evaluate(values: [.restingHeartRate: 53, .hrv: 61], baselines: baselines) + XCTAssertTrue(HealthWatch.facts(result).contains("close to your usual")) + } + + // MARK: - Alerting + + /// Only `major` interrupts. `minor` means two signals nudged past their knots, which happens + /// after a glass of wine often enough that alerting on it would train people to dismiss the + /// ones that matter — it still reaches the card and the coach. + func testOnlyMajorFiresAnAlert() { + func packet(status: HealthWatch.Status, flagged: Int) -> NotificationContextPacket { + var p = NotificationContextPacket( + slot: "morning", generatedAt: "", timezone: "UTC", profileName: nil, + goals: .init(stepsDaily: 10000, activeMinutesDaily: 45, sleepHours: 8, exerciseDaysWeekly: 4), + today: .init(localDate: "2026-06-05", 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: [] + ) + p.healthWatch = .init( + status: status.rawValue, signalsAvailable: 4, + flagged: (0.. 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)) + } +} diff --git a/docs/project/anomaly-alerts.md b/docs/project/anomaly-alerts.md new file mode 100644 index 0000000..d4186bc --- /dev/null +++ b/docs/project/anomaly-alerts.md @@ -0,0 +1,197 @@ +--- +title: Proactive anomaly alerts +description: Every pattern PulseLoop will interrupt you for — the exact signal, threshold, and gates behind each one. +--- + +# Proactive anomaly alerts + +Most of what PulseLoop tells you, you asked for. Anomaly alerts are the exception: they arrive +unprompted, so the bar for firing one is deliberately high. + +This page documents every detector — the signal, the threshold, and every gate. PulseLoop's +principles commit to "documented metrics and an auditable coach, no black boxes", and an alert you +can't inspect is indistinguishable from a guess. + +!!! info "Off by default" + Proactive alerts are opt-in (**Settings → Notifications**) and only run when the coach is set to + Apple's on-device model, so an alert never triggers a paid cloud call on a background data event. + +The implementation is [`CoachAnomalyDetector.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift), +covered by unit tests that lock every number on this page. + +## Rules that apply to all alerts + +- **At most one alert per detection pass.** When several patterns trip at once, the highest-priority + one wins — see [Precedence](#precedence). +- **At most one alert per kind per day**, deduped on `anomaly:` in `CoachNotificationRecord`. +- **A missed alert beats a false alarm.** Every threshold below is set so that noise stays silent, + accepting that some genuine events go unremarked. +- **No alert diagnoses anything.** Copy describes what was measured and offers a benign next step. + +## The detectors + +### 1. Low blood oxygen — `lowSpO2` + +| | | +|---|---| +| **Signal** | Lowest SpO₂ reading in the last 12 hours | +| **Fires when** | `min(SpO₂) < 90%` | +| **Gates** | At least 3 readings in the window | +| **Rings** | Any with an SpO₂ sensor | + +The multi-reading gate exists because a single low sample is far more often a finger moving against +the sensor than genuine desaturation. + +### 2. Short sleep — `poorSleep` + +| | | +|---|---| +| **Signal** | Last night's total sleep | +| **Fires when** | `0 < totalMinutes < 300` (under 5 hours) | +| **Gates** | A sleep session exists for the night | +| **Rings** | Any with sleep tracking | + +The 5-hour cut is absolute rather than relative to your sleep goal: the message is about a night +short enough to matter physiologically, not about missing a target you set. + +### 3. Resting heart-rate drift — `restingHRDrift` + +| | | +|---|---| +| **Signal** | Last night's resting HR vs. your learned 30-day baseline | +| **Fires when** | `lastNight − baseline ≥ 5 bpm` | +| **Gates** | Baseline established · ≥ 10 overnight samples · night no more than 2 days old | +| **Rings** | Any with heart rate — every supported family | + +#### How both numbers are computed + +Both sides use the **10th percentile** of heart-rate samples, interpolated. Using one definition for +both is the whole point: comparing a night's *mean* against a 30-day *percentile* would produce a +difference that is mostly an artefact of the two formulas. + +``` +baseline = p10( HR samples over the last 30 days ) # RestingHRBaselineService +lastNight = p10( HR samples between sleep start and sleep end ) +drift = lastNight − baseline +``` + +The night is bounded by the **sleep session itself**, not a fixed clock window, so a late night or a +shift worker's schedule is measured over the hours actually slept. + +#### Why 5 bpm + +An elevated resting heart rate is the signal that moves first under infection, alcohol, heat, and +under-recovery — typically about a day before you notice anything. Five bpm is the usual +consumer-wearable threshold: below it, the night-to-night noise in an optical ring's overnight +sampling swamps the effect. + +#### Why only upward + +A resting HR *below* baseline is usually good news — improving fitness, or a genuinely restful +night — and is not something to interrupt anyone about. + +#### Why the sample floor is 10 + +A YCBT-family ring floors its all-day measurement interval at 30 minutes, so a full night yields +roughly 14 samples; a Colmi at the default 5-minute cadence yields roughly 84. Ten keeps the +detector usable on both while still refusing to call a handful of readings a resting heart rate. + +#### Why the baseline can be missing + +`RestingHRBaselineService` stores `nil` until it has **≥ 20 samples spanning ≥ 7 days**. Until then +there is nothing trustworthy to compare against and this detector stays silent — it does not fall +back to a population average. + +### 4. Health Watch — `healthWatch` + +| | | +|---|---| +| **Signal** | Five overnight signals, each against its own 30-day baseline | +| **Fires when** | The night scores **major** (see below) | +| **Gates** | At least 2 signals had an established baseline | +| **Rings** | Any — it uses whichever signals your ring can produce | + +PulseLoop's equivalent of Oura's Symptom Radar or Ultrahuman's Sleep Screener. **It never names a +condition, and the copy names the mundane explanations first** — alcohol, a warm room, and a hard +session the day before all look identical to the early hours of an infection. + +#### The signals, and their direction + +Only the side that indicates strain counts. A resting HR below baseline, an HRV above it, or a +cooler night are not warning signs, and flagging them would turn good news into an alert. + +| Signal | Direction | Notable | Strong | +|---|---|---|---| +| Skin temperature | above baseline | +0.5 °C | +1.0 °C | +| Resting heart rate | above baseline | +5 bpm | +10 bpm | +| HRV | below baseline | −15 % | −30 % | +| Breathing rate | above baseline | +2 brpm | +4 brpm | +| Blood oxygen | below baseline | −2 pts | −4 pts | + +HRV's knots are proportional rather than absolute because HRV spans roughly an order of magnitude +across healthy adults; the rest are absolute steps. + +#### Scoring + +Each signal scores 0 (normal), 1 (notable) or 2 (strong). The night's total decides: + +| Total | Status | Alert? | +|---|---|---| +| 0–1 | No signs of strain | — | +| 2 | Minor signs | No — card and coach only | +| ≥ 3 | Major signs | Yes | + +**A single notable signal never counts.** One reading nudging past its knot is a warm duvet or one +restless hour; the whole point of a multi-signal detector is that it waits for agreement. A single +*strong* signal scores 2 and lands at minor — a full degree of overnight temperature rise is not +noise, but it is not enough to interrupt for on its own either. + +**Only `major` fires an alert.** Minor means two signals nudged past their knots, which happens after +a glass of wine often enough that alerting on it would train you to dismiss the ones that matter. + +#### How the values and baselines are built + +Both are measured **over the night itself**, bounded by the sleep session rather than a fixed clock +window — so a late night or a shift schedule is measured over the hours actually slept. + +- Heart rate uses the **10th percentile** of the night, matching how the long-run resting baseline is + built, so the two are the same quantity. Every other signal uses the night's mean. +- A baseline is the **median of the preceding nights' figures** — not a mean of every sample. One + night the ring recorded four times as often as usual would otherwise dominate a raw sample mean, + and a single feverish night would drag the very baseline it needs to be judged against. +- A baseline needs **7 nights**; a night's figure needs **6 readings** (a YCBT ring floors its + interval at 30 minutes, so a full night is only ~14 samples there). + +#### On the Today screen + +The card is **conditional**: a clear night renders nothing at all. A permanent "all clear" tile would +be clutter on an already-dense grid, and would train people to stop reading it. + +## Precedence + +`detect` returns at most one anomaly, checked in this order: + +1. `lowSpO2` — the most clinically meaningful of the four. +2. `poorSleep` — fires right after a sleep download, when it is most actionable. +3. `healthWatch`. +4. `restingHRDrift`. + +Sleep outranks the two baseline detectors because a short or broken night usually raises resting HR +as well — when both trip, the sleep alert names the cause while the others would restate its +consequence. + +Health Watch outranks resting-HR drift 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. Drift still fires on its own +when resting HR moved and nothing corroborated it — or when it was the only signal with a baseline +at all, which a ring with fewer sensors reaches while Health Watch is still short of two. + +## What is deliberately not a detector + +- **Temperature deviation on its own**, and **HRV drops on their own.** Both are real illness + signals, but each is far too noisy alone — a warm room moves temperature, and HRV swings + night-to-night in healthy people. They belong inside Health Watch, where they only speak when + something else agrees, and that is where they now live. +- **Anything resembling a diagnosis.** No detector names a condition, and none ever will on + wellness-grade optical hardware. Health Watch reports that signals moved together and lists the + ordinary explanations first. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1..dc0d673 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Project: - Roadmap: project/roadmap.md - Architecture: project/architecture.md + - Proactive alerts: project/anomaly-alerts.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md