diff --git a/PulseLoop/DesignSystem/ActivityScoreCard.swift b/PulseLoop/DesignSystem/ActivityScoreCard.swift new file mode 100644 index 0000000..d0d32a1 --- /dev/null +++ b/PulseLoop/DesignSystem/ActivityScoreCard.swift @@ -0,0 +1,119 @@ +import SwiftUI + +/// The day's movement score plus this week's training-load balance, as one compact card. +/// +/// Lives on the **Activity** tab rather than Today: Today is already a dense tile grid, and a score +/// that summarises the rest of that tab belongs beside what it summarises. The contributor +/// breakdown is disclosed on tap rather than shown by default, so the card stays one line tall +/// until asked. +struct ActivityScoreCard: View { + let result: ActivityScoreResult + let balance: TrainingLoad.Balance + + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Button { withAnimation(.snappy) { expanded.toggle() } } label: { + HStack(alignment: .center, spacing: 14) { + scoreDial + VStack(alignment: .leading, spacing: 3) { + Text("MOVEMENT") + .font(PulseFont.caption2.weight(.semibold)).tracking(1.0) + .foregroundStyle(PulseColors.textMuted) + Text(result.band.rawValue) + .font(PulseFont.headline) + .foregroundStyle(PulseColors.textPrimary) + Text(loadLine) + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textSecondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 4) + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(PulseFont.footnote.weight(.semibold)) + .foregroundStyle(PulseColors.textMuted) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Movement score \(result.score) out of 100, \(result.band.rawValue). \(loadLine)") + .accessibilityHint(expanded ? "Hides the breakdown" : "Shows what made up the score") + + if expanded { + VStack(spacing: 8) { + ForEach(result.contributors, id: \.kind) { contributor in + contributorRow(contributor) + } + if result.coverage < 1 { + Text(coverageNote) + .font(PulseFont.caption.weight(.regular)) + .foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + private var scoreDial: some View { + ZStack { + Circle() + .stroke(PulseColors.cardSoft, lineWidth: 6) + Circle() + .trim(from: 0, to: max(0.02, Double(result.score) / 100)) + .stroke(PulseColors.steps, style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Text("\(result.score)") + .font(PulseFont.title3.weight(.semibold)).monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + } + .frame(width: 58, height: 58) + } + + private func contributorRow(_ contributor: ActivityContributor) -> some View { + let fraction = contributor.maxPoints > 0 ? contributor.earned / contributor.maxPoints : 0 + return HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(contributor.kind.title) + .font(PulseFont.caption.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + Text(contributor.detail) + .font(PulseFont.caption2) + .foregroundStyle(PulseColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(PulseColors.cardSoft) + Capsule().fill(PulseColors.steps) + .frame(width: max(fraction > 0 ? 4 : 0, geo.size.width * fraction)) + } + } + .frame(width: 70, height: 6) + + Text("\(Int(contributor.earned.rounded()))/\(Int(contributor.maxPoints))") + .font(PulseFont.caption2.monospacedDigit()) + .foregroundStyle(PulseColors.textSecondary) + .frame(width: 42, alignment: .trailing) + } + } + + /// One sentence on this week's load. Says what it can't say when history is thin, rather than + /// showing a ratio built on a fortnight and calling it a baseline. + private var loadLine: String { + guard balance.ratio != nil else { return balance.band.detail } + return "Training load · \(balance.band.rawValue.lowercased())" + } + + private var coverageNote: String { + "Scored out of \(Int((result.coverage * 100).rounded())) — your ring didn't report everything " + + "this score can use, so the missing parts were left out rather than counted against you." + } +} diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 76aa345..a3c666b 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -510,7 +510,18 @@ final class UserGoal { var intakeFatG: Int? var updatedAt: Date - init(id: UUID = UUID(), steps: Int = 10000, sleepMinutes: Int = 480, activeMinutes: Int = 45, workoutsPerWeek: Int = 4, distanceMeters: Double = 8000, calories: Int = 500) { + /// The out-of-the-box targets, named so anything needing a fallback goal reads the same numbers + /// the initializer uses rather than restating them. + static let defaultSteps = 10000 + static let defaultSleepMinutes = 480 + static let defaultActiveMinutes = 45 + static let defaultWorkoutsPerWeek = 4 + static let defaultDistanceMeters: Double = 8000 + static let defaultCalories = 500 + + init(id: UUID = UUID(), steps: Int = UserGoal.defaultSteps, sleepMinutes: Int = UserGoal.defaultSleepMinutes, + activeMinutes: Int = UserGoal.defaultActiveMinutes, workoutsPerWeek: Int = UserGoal.defaultWorkoutsPerWeek, + distanceMeters: Double = UserGoal.defaultDistanceMeters, calories: Int = UserGoal.defaultCalories) { self.id = id self.steps = steps self.sleepMinutes = sleepMinutes diff --git a/PulseLoop/Services/ActivityScore.swift b/PulseLoop/Services/ActivityScore.swift new file mode 100644 index 0000000..7770a2b --- /dev/null +++ b/PulseLoop/Services/ActivityScore.swift @@ -0,0 +1,179 @@ +import Foundation + +/// One scored signal within a day's activity score, in the same shape the sleep score uses. +struct ActivityContributor: Equatable { + enum Kind: String, CaseIterable { + case steps + case activeMinutes + case energy + case regularity + + var title: String { + switch self { + case .steps: return "Steps" + case .activeMinutes: return "Active minutes" + case .energy: return "Active energy" + case .regularity: return "Movement through the day" + } + } + + var maxPoints: Double { + switch self { + case .steps: return 35 + case .activeMinutes: return 30 + case .energy: return 20 + case .regularity: return 15 + } + } + } + + let kind: Kind + let earned: Double + let maxPoints: Double + let detail: String +} + +enum ActivityBand: String, CaseIterable { + case restful = "Restful" + case light = "Light" + case active = "Active" + case veryActive = "Very active" + + init(score: Int) { + switch score { + case 85...: self = .veryActive + case 70..<85: self = .active + case 45..<70: self = .light + default: self = .restful + } + } +} + +struct ActivityScoreResult: Equatable { + let score: Int + let band: ActivityBand + let contributors: [ActivityContributor] + /// Fraction of the full 100-point picture this rested on. + let coverage: Double + let algorithmVersion: Int +} + +/// Everything the score reads about one day. A plain struct so the maths stays pure and testable — +/// `ActivityScoreService` does the fetching. +struct ActivityScoreInputs: Equatable { + let steps: Int? + let activeMinutes: Int? + let activeEnergyKcal: Double? + /// Hours between `regularityWindow` that contained at least `regularityStepFloor` steps, and how + /// many hours were observable at all. nil when the ring reports no intraday buckets. + let activeHours: Int? + let observableHours: Int? + + let stepsGoal: Int + let activeMinutesGoal: Int + let energyGoal: Int +} + +/// A daily movement score, 0–100 — PulseLoop's equivalent of Oura's Activity Score or Ultrahuman's +/// Movement Index. +/// +/// Every contributor is measured **against the user's own goals**, not a population target, because +/// the goals already exist and are already editable. A score built on a fixed 10,000 steps would be +/// telling a marathoner and a recovering patient the same thing. +/// +/// Missing signals leave the denominator rather than scoring zero — the rule the sleep score and +/// readiness both follow, and what lets one number mean the same thing on a ring that reports +/// intraday buckets and one that doesn't. +enum ActivityScore { + static let algorithmVersion = 1 + + /// A score is only produced when at least this many points were available. + static let minAvailablePoints: Double = 50 + + /// Steps within an hour that count it as an "active" hour. Matches the widely-used 250-per-hour + /// convention (Apple's stand-hour analogue) rather than inventing a threshold. + static let regularityStepFloor = 250 + + /// The window regularity is judged over: 08:00–22:00 local. Hours outside it are not counted + /// against you, since nobody should be scored for not walking at 4 a.m. + static let regularityWindow = 8..<22 + + /// Goal progress → points. + /// + /// Full marks at goal, 65 % at 60 % of goal, linear to zero below that. **Exceeding a goal is + /// never penalised**: overreaching is what training load is for, and a movement score that + /// docked you for a long hike would be actively misleading. + static func goalScore(actual: Double, goal: Double, points: Double) -> Double { + guard goal > 0, actual.isFinite, actual >= 0 else { return 0 } + let fraction = actual / goal + if fraction >= 1 { return points } + if fraction >= 0.6 { + return points * (0.65 + 0.35 * ((fraction - 0.6) / 0.4)) + } + return points * 0.65 * (fraction / 0.6) + } + + static func calculate(_ input: ActivityScoreInputs) -> ActivityScoreResult { + var contributors: [ActivityContributor] = [] + + if let steps = input.steps { + contributors.append(ActivityContributor( + kind: .steps, + earned: goalScore(actual: Double(steps), goal: Double(input.stepsGoal), + points: ActivityContributor.Kind.steps.maxPoints), + maxPoints: ActivityContributor.Kind.steps.maxPoints, + detail: "\(steps) of \(input.stepsGoal)" + )) + } + + if let active = input.activeMinutes { + contributors.append(ActivityContributor( + kind: .activeMinutes, + earned: goalScore(actual: Double(active), goal: Double(input.activeMinutesGoal), + points: ActivityContributor.Kind.activeMinutes.maxPoints), + maxPoints: ActivityContributor.Kind.activeMinutes.maxPoints, + detail: "\(active) of \(input.activeMinutesGoal) min" + )) + } + + // Ring-reported calories are unverified on the history path, so `ActivityDaily.calories` is + // nil for ring-history days — which correctly drops this contributor rather than scoring a + // number the app doesn't stand behind. + if let energy = input.activeEnergyKcal { + contributors.append(ActivityContributor( + kind: .energy, + earned: goalScore(actual: energy, goal: Double(input.energyGoal), + points: ActivityContributor.Kind.energy.maxPoints), + maxPoints: ActivityContributor.Kind.energy.maxPoints, + detail: "\(Int(energy.rounded())) of \(input.energyGoal) kcal" + )) + } + + // Regularity: a day that hits its step goal in one gym session and then sits for twelve + // hours is a different day from one that moves throughout, and only this contributor can + // tell them apart. + if let activeHours = input.activeHours, let observable = input.observableHours, observable > 0 { + contributors.append(ActivityContributor( + kind: .regularity, + earned: goalScore(actual: Double(activeHours), goal: Double(observable), + points: ActivityContributor.Kind.regularity.maxPoints), + maxPoints: ActivityContributor.Kind.regularity.maxPoints, + detail: "\(activeHours) of \(observable) hours with movement" + )) + } + + let available = contributors.reduce(0) { $0 + $1.maxPoints } + let earned = contributors.reduce(0) { $0 + $1.earned } + let score = available >= minAvailablePoints + ? Int(min(100, max(0, (earned / available * 100).rounded()))) + : 0 + + return ActivityScoreResult( + score: score, + band: ActivityBand(score: score), + contributors: contributors, + coverage: available / 100, + algorithmVersion: algorithmVersion + ) + } +} diff --git a/PulseLoop/Services/ActivityScoreService.swift b/PulseLoop/Services/ActivityScoreService.swift new file mode 100644 index 0000000..d917982 --- /dev/null +++ b/PulseLoop/Services/ActivityScoreService.swift @@ -0,0 +1,101 @@ +import Foundation +import SwiftData + +/// Reads the store and hands `ActivityScore` and `TrainingLoad` their inputs. The maths stays in +/// those two types; everything SwiftData-facing is here, mirroring how `SleepService` sits in front +/// of `SleepScore`. +@MainActor +enum ActivityScoreService { + + /// The day's movement score, or nil when the day has no activity row at all. + static func score(on day: Date = Date(), context: ModelContext) -> ActivityScoreResult? { + let startOfDay = Calendar.current.startOfDay(for: day) + guard let row = MetricsRepository.activity(on: startOfDay, context: context) else { return nil } + let goals = MetricsRepository.goals(context: context) + let regularity = movementRegularity(on: startOfDay, context: context) + + return ActivityScore.calculate(ActivityScoreInputs( + steps: row.steps, + activeMinutes: row.activeMinutes, + // Ring-history days carry no trustworthy calorie figure (`buildTodaySummary` blanks them + // for the same reason), so the contributor drops rather than scoring a guess. + activeEnergyKcal: row.source == ActivityService.ringHistorySource ? nil : row.calories, + activeHours: regularity?.activeHours, + observableHours: regularity?.observableHours, + stepsGoal: goals?.steps ?? UserGoal.defaultSteps, + activeMinutesGoal: goals?.activeMinutes ?? UserGoal.defaultActiveMinutes, + energyGoal: goals?.calories ?? UserGoal.defaultCalories + )) + } + + /// How much of the waking day carried movement, from the ring's intraday buckets. + /// + /// Returns nil when the day has no buckets — a ring that reports only a daily total can't + /// answer this, and the contributor is dropped rather than assumed. + /// + /// "Observable" hours are those the ring actually reported buckets for, not a flat 14: a ring + /// taken off at lunchtime should not be scored for the afternoon it never saw. + static func movementRegularity( + on day: Date, context: ModelContext, calendar: Calendar = .current + ) -> (activeHours: Int, observableHours: Int)? { + let startOfDay = calendar.startOfDay(for: day) + guard let end = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { return nil } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.timestamp >= startOfDay && $0.timestamp < end } + ) + let buckets = (try? context.fetch(descriptor)) ?? [] + guard !buckets.isEmpty else { return nil } + + var stepsByHour: [Int: Int] = [:] + for bucket in buckets { + let hour = calendar.component(.hour, from: bucket.timestamp) + guard ActivityScore.regularityWindow.contains(hour) else { continue } + stepsByHour[hour, default: 0] += max(0, bucket.steps) + } + guard !stepsByHour.isEmpty else { return nil } + + let active = stepsByHour.values.count { $0 >= ActivityScore.regularityStepFloor } + return (activeHours: active, observableHours: stepsByHour.count) + } + + // MARK: - Training load + + /// Daily Edwards load for the last `days` days, keyed by start-of-day. + /// + /// Days with no heart-rate readings are **absent from the map**, not zero — see + /// `TrainingLoad.balance`, which excludes them from both averages so an unworn week can't read + /// as a recovery week. + static func dailyLoad(days: Int = 28, now: Date = Date(), context: ModelContext, + calendar: Calendar = .current) -> [Date: Double] { + let start = calendar.date(byAdding: .day, value: -days, to: calendar.startOfDay(for: now)) + ?? calendar.startOfDay(for: now) + let profile = UserPhysiologyProfile(ProfileRepository.profile(context: context)) + let hrMax = TrainingLoad.maxHeartRate(age: profile.age) + + let samples = MetricsRepository.measurements( + kind: .heartRate, start: start, end: now, limit: 20_000, context: context + ).map { MetricSample(timestamp: $0.timestamp, value: $0.value) } + + var byDay: [Date: [MetricSample]] = [:] + for sample in samples { + byDay[calendar.startOfDay(for: sample.timestamp), default: []].append(sample) + } + // A single reading can't bound an interval, so it yields no load and the day stays absent. + return byDay.compactMapValues { daySamples in + let load = TrainingLoad.load(samples: daySamples, hrMax: hrMax) + return load > 0 ? load : nil + } + } + + /// This week's load against the last month's. + static func balance(now: Date = Date(), context: ModelContext) -> TrainingLoad.Balance { + TrainingLoad.balance(dailyLoad: dailyLoad(now: now, context: context), now: now) + } +} + +private extension Collection { + /// `count(where:)` is only available from iOS 18.4; this keeps the deployment target honest. + func count(_ isIncluded: (Element) -> Bool) -> Int { + reduce(0) { isIncluded($1) ? $0 + 1 : $0 } + } +} diff --git a/PulseLoop/Services/TrainingLoad.swift b/PulseLoop/Services/TrainingLoad.swift new file mode 100644 index 0000000..60ce9ba --- /dev/null +++ b/PulseLoop/Services/TrainingLoad.swift @@ -0,0 +1,152 @@ +import Foundation + +/// Heart-rate training load: how much cardiovascular work a day actually contained, and whether the +/// last week of it is in line with the last month. +/// +/// Pure maths — the SwiftData-facing side lives in `TrainingLoadService`. +/// +/// The model is **Edwards' summated heart-rate zones**, not Banister's TRIMP. Banister needs a +/// reliable average HR over a bounded session; PulseLoop's all-day data is sparse, irregularly +/// spaced, and has no session boundaries, so Edwards — which just weights time spent in each zone — +/// degrades far more gracefully. It is also the model the app can already show its working for, +/// since the workout summary screen already renders exactly these five zones. +enum TrainingLoad { + /// Edwards' weights: a minute in zone 5 counts five times a minute in zone 1. + static let zoneWeights: [Double] = [1, 2, 3, 4, 5] + + /// Zone floors as a fraction of maximum heart rate, matching the boundaries the workout summary + /// already draws (`hrZoneDurations`) so the two can never disagree. + static let zoneFloors: [Double] = [0.00, 0.60, 0.70, 0.80, 0.90] + + /// Age-predicted maximum heart rate. The plain 220 − age form, and the same fallback the workout + /// summary uses when age is unknown. + static func maxHeartRate(age: Int?) -> Double { + Double(age.map { 220 - $0 } ?? 190) + } + + /// Seconds spent in each of the five zones, low to high. + /// + /// Each sample is credited the gap to the next one, capped adaptively: fully up to about twice + /// the median spacing, and never more than five minutes. Without that cap an overnight gap + /// between two all-day readings would be credited as hours of zone-1 "work". Copied in spirit + /// from `hrZoneDurations`, which does the same for a single workout. + static func zoneSeconds(samples: [MetricSample], hrMax: Double) -> [Double] { + var seconds = [Double](repeating: 0, count: 5) + let sorted = samples.sorted { $0.timestamp < $1.timestamp } + guard sorted.count > 1, hrMax > 0 else { return seconds } + + let gaps = zip(sorted, sorted.dropFirst()) + .map { $1.timestamp.timeIntervalSince($0.timestamp) } + .filter { $0 > 0 } + .sorted() + let median = gaps.isEmpty ? 30 : gaps[gaps.count / 2] + let cap = min(300, max(30, median * 2)) + + for (a, b) in zip(sorted, sorted.dropFirst()) { + let dt = min(cap, b.timestamp.timeIntervalSince(a.timestamp)) + guard dt > 0 else { continue } + seconds[zoneIndex(forHeartRate: a.value, hrMax: hrMax)] += dt + } + return seconds + } + + /// Which zone a reading falls in, 0-based. + static func zoneIndex(forHeartRate bpm: Double, hrMax: Double) -> Int { + guard hrMax > 0 else { return 0 } + let fraction = bpm / hrMax + // Walk down so the highest floor a reading clears wins. + for index in stride(from: zoneFloors.count - 1, through: 1, by: -1) where fraction >= zoneFloors[index] { + return index + } + return 0 + } + + /// Edwards load for a set of readings: Σ (minutes in zone × zone weight). + /// + /// Unitless by construction — it is a weighted minute count, not an energy figure. A day of + /// gentle walking lands in the tens; a hard hour lands in the low hundreds. + static func load(samples: [MetricSample], hrMax: Double) -> Double { + zip(zoneSeconds(samples: samples, hrMax: hrMax), zoneWeights) + .reduce(0) { $0 + ($1.0 / 60) * $1.1 } + } + + // MARK: - Acute vs chronic + + /// Days of recent load against the longer-run baseline. + struct Balance: Equatable { + /// Mean daily load over the last 7 days. + let acute: Double + /// Mean daily load over the last 28 days. + let chronic: Double + /// `acute / chronic`, or nil when the chronic window is empty or too thin to trust. + let ratio: Double? + /// How many of the 28 chronic days actually carried data. + let chronicDaysCovered: Int + + var band: Band { Band(ratio: ratio) } + } + + /// The acute:chronic workload ratio's usual reading. Bands are the sports-science convention: + /// roughly 0.8–1.3 is the range associated with the lowest injury risk in the literature, with + /// anything past 1.5 flagged as a spike. + /// + /// Presented as guidance, not a verdict: the evidence base is contested and was built on + /// athletes with far better data than an optical ring provides. + enum Band: String { + case detraining = "Detraining" + case steady = "Steady" + case building = "Building" + case spike = "Spike" + case unknown = "Not enough history" + + init(ratio: Double?) { + guard let ratio, ratio.isFinite else { self = .unknown; return } + // Upper bounds inclusive: a ratio of exactly 1.3 is the top of steady, not the bottom + // of building. + switch ratio { + case ..<0.8: self = .detraining + case ...1.3: self = .steady + case ...1.5: self = .building + default: self = .spike + } + } + + var detail: String { + switch self { + case .detraining: return "This week is lighter than your recent normal." + case .steady: return "This week is in line with your recent normal." + case .building: return "This week is a step up from your recent normal." + case .spike: return "This week is well above your recent normal — worth easing off." + case .unknown: return "A few more weeks of wear and this will have something to compare against." + } + } + } + + /// Fewest covered days in the 28-day window before a ratio means anything. Below this the + /// chronic average is really a short-window average wearing a long window's name. + static let minChronicDays = 14 + + /// Acute (7-day) against chronic (28-day) mean daily load. + /// + /// **Days with no data are excluded from both means, not counted as rest.** A week the ring + /// wasn't worn is not a week of recovery, and averaging in zeros would manufacture a + /// "detraining" reading out of a charging cable. + static func balance(dailyLoad: [Date: Double], now: Date = Date(), calendar: Calendar = .current) -> Balance { + func mean(overLastDays days: Int) -> (mean: Double, covered: Int) { + let cutoff = calendar.date(byAdding: .day, value: -days, to: calendar.startOfDay(for: now)) + ?? calendar.startOfDay(for: now) + let values = dailyLoad.filter { $0.key >= cutoff }.map(\.value) + guard !values.isEmpty else { return (0, 0) } + return (values.reduce(0, +) / Double(values.count), values.count) + } + + let acute = mean(overLastDays: 7) + let chronic = mean(overLastDays: 28) + let ratio: Double? = (chronic.covered >= minChronicDays && chronic.mean > 0) + ? acute.mean / chronic.mean + : nil + + return Balance(acute: acute.mean, chronic: chronic.mean, + ratio: ratio, chronicDaysCovered: chronic.covered) + } +} diff --git a/PulseLoop/Views/ActivityView.swift b/PulseLoop/Views/ActivityView.swift index c4e3b7d..28fe67c 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -21,11 +21,17 @@ struct ActivityView: View { @State private var summary: TodaySummary? @State private var stale: [ActivitySession] = [] @State private var caloriesAvailable = false + /// Movement score + training-load balance, cached off the render path for the same reason the + /// summary is: `dailyLoad` walks up to 28 days of HR samples and must not run per `body`. + @State private var activityScore: ActivityScoreResult? + @State private var loadBalance: TrainingLoad.Balance? private func reload() { summary = MetricsService.buildTodaySummary(context: modelContext) stale = ActivityRecorderService.recoverStaleSession(context: modelContext) caloriesAvailable = MetricsService.isVisible(.calories, context: modelContext) + activityScore = ActivityScoreService.score(context: modelContext) + loadBalance = ActivityScoreService.balance(context: modelContext) } var body: some View { @@ -51,6 +57,12 @@ struct ActivityView: View { path.append(AppRoute.activityTrends) } + // Sits under the rings it summarises. Absent entirely on a day with no activity + // row — there is nothing to score yet, and an empty dial reads as a zero. + if let activityScore, let loadBalance { + ActivityScoreCard(result: activityScore, balance: loadBalance) + } + // Calorie-intake sibling of the daily summary. `summary.nutrition` is only // populated while the nutrition feature is enabled — nothing renders otherwise. if let nutrition = summary.nutrition { diff --git a/PulseLoopTests/ActivityScoreTests.swift b/PulseLoopTests/ActivityScoreTests.swift new file mode 100644 index 0000000..c9fdb6a --- /dev/null +++ b/PulseLoopTests/ActivityScoreTests.swift @@ -0,0 +1,149 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// The daily movement score: goal-relative contributors, no penalty for exceeding a goal, and the +/// same missing-data-leaves-the-denominator rule the sleep score follows. +@MainActor +final class ActivityScoreTests: XCTestCase { + + private func inputs( + steps: Int? = 10000, activeMinutes: Int? = 45, energy: Double? = 500, + activeHours: Int? = 10, observableHours: Int? = 10 + ) -> ActivityScoreInputs { + ActivityScoreInputs( + steps: steps, activeMinutes: activeMinutes, activeEnergyKcal: energy, + activeHours: activeHours, observableHours: observableHours, + stepsGoal: 10000, activeMinutesGoal: 45, energyGoal: 500 + ) + } + + private func contributor(_ kind: ActivityContributor.Kind, in result: ActivityScoreResult) -> ActivityContributor? { + result.contributors.first { $0.kind == kind } + } + + // MARK: - Goal curve + + func testGoalScoreKnots() { + XCTAssertEqual(ActivityScore.goalScore(actual: 100, goal: 100, points: 35), 35, accuracy: 0.001) + XCTAssertEqual(ActivityScore.goalScore(actual: 60, goal: 100, points: 35), 22.75, accuracy: 0.001, + "65% of the points at 60% of the goal") + XCTAssertEqual(ActivityScore.goalScore(actual: 0, goal: 100, points: 35), 0, accuracy: 0.001) + XCTAssertEqual(ActivityScore.goalScore(actual: 30, goal: 100, points: 35), 11.375, accuracy: 0.001, + "linear below the soft knot") + } + + /// Overreaching is what training load is for. A movement score that docked a long hike would be + /// actively misleading. + func testExceedingAGoalIsNeverPenalised() { + XCTAssertEqual(ActivityScore.goalScore(actual: 250, goal: 100, points: 35), 35, accuracy: 0.001) + XCTAssertEqual(ActivityScore.calculate(inputs(steps: 40000)).score, 100) + } + + func testZeroGoalCannotDivideByZero() { + XCTAssertEqual(ActivityScore.goalScore(actual: 5000, goal: 0, points: 35), 0, accuracy: 0.001) + } + + // MARK: - Contributors + + func testAllFourContributorsScoreAFullDay() { + let result = ActivityScore.calculate(inputs()) + XCTAssertEqual(Set(result.contributors.map(\.kind)), [.steps, .activeMinutes, .energy, .regularity]) + XCTAssertEqual(result.coverage, 1.0, accuracy: 0.001) + XCTAssertEqual(result.score, 100) + XCTAssertEqual(result.band, .veryActive) + } + + /// A ring-history day carries no trustworthy calorie figure, so energy drops out — the day is + /// scored out of 80 rather than docked 20 for a number the app doesn't stand behind. + func testMissingEnergyLeavesTheDenominator() { + let result = ActivityScore.calculate(inputs(energy: nil)) + XCTAssertNil(contributor(.energy, in: result)) + XCTAssertEqual(result.coverage, 0.8, accuracy: 0.001) + XCTAssertEqual(result.score, 100, "a full day is still a full day on 80 available points") + } + + /// A ring that reports only a daily total can't answer the regularity question. + func testMissingIntradayBucketsDropRegularity() { + let result = ActivityScore.calculate(inputs(activeHours: nil, observableHours: nil)) + XCTAssertNil(contributor(.regularity, in: result)) + XCTAssertEqual(result.coverage, 0.85, accuracy: 0.001) + } + + /// Regularity is judged against the hours the ring actually observed, so a ring taken off at + /// lunchtime isn't scored for the afternoon it never saw. + func testRegularityIsRelativeToObservedHours() { + let halfDay = ActivityScore.calculate(inputs(activeHours: 5, observableHours: 5)) + XCTAssertEqual(contributor(.regularity, in: halfDay)?.earned ?? 0, 15, accuracy: 0.001) + + let sedentary = ActivityScore.calculate(inputs(activeHours: 2, observableHours: 10)) + XCTAssertLessThan(contributor(.regularity, in: sedentary)?.earned ?? 99, 8) + } + + /// Two days can hit the same step count and score differently: one moved throughout, the other + /// sat still around a single session. + func testRegularityDistinguishesOneBigSessionFromAMovingDay() { + let spread = ActivityScore.calculate(inputs(activeHours: 10, observableHours: 10)) + let oneBurst = ActivityScore.calculate(inputs(activeHours: 2, observableHours: 10)) + XCTAssertGreaterThan(spread.score, oneBurst.score) + } + + func testTooFewSignalsScoresZero() { + let sparse = ActivityScoreInputs( + steps: 8000, activeMinutes: nil, activeEnergyKcal: nil, + activeHours: nil, observableHours: nil, + stepsGoal: 10000, activeMinutesGoal: 45, energyGoal: 500 + ) + let result = ActivityScore.calculate(sparse) + XCTAssertEqual(result.score, 0, "35 available points can't be dressed up as a score out of 100") + XCTAssertLessThan(result.coverage, 0.5) + } + + // MARK: - Invariants + + func testContributorWeightsSumToOneHundred() { + XCTAssertEqual(ActivityContributor.Kind.allCases.reduce(0) { $0 + $1.maxPoints }, 100, accuracy: 0.001) + } + + func testBands() { + XCTAssertEqual(ActivityBand(score: 100), .veryActive) + XCTAssertEqual(ActivityBand(score: 85), .veryActive) + XCTAssertEqual(ActivityBand(score: 84), .active) + XCTAssertEqual(ActivityBand(score: 70), .active) + XCTAssertEqual(ActivityBand(score: 69), .light) + XCTAssertEqual(ActivityBand(score: 45), .light) + XCTAssertEqual(ActivityBand(score: 44), .restful) + } + + // MARK: - Regularity from the store + + func testMovementRegularityCountsHoursOverTheStepFloor() throws { + let context = try TestSupport.makeContext() + let calendar = Calendar.current + let day = TestSupport.day(0) + + // Three hours inside the window: two busy, one barely moving. + for (hour, steps) in [(9, 400), (13, 60), (17, 900)] { + let ts = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day + context.insert(ActivityBucketSample(timestamp: ts, steps: steps, distanceMeters: 0)) + } + // And one at 03:00, outside the waking window — must not count either way. + let night = calendar.date(bySettingHour: 3, minute: 0, second: 0, of: day) ?? day + context.insert(ActivityBucketSample(timestamp: night, steps: 900, distanceMeters: 0)) + try? context.save() + + let regularity = try XCTUnwrap(ActivityScoreService.movementRegularity(on: day, context: context)) + XCTAssertEqual(regularity.observableHours, 3, "03:00 is outside 08:00–22:00") + XCTAssertEqual(regularity.activeHours, 2, "the 60-step hour is under the 250 floor") + } + + func testMovementRegularityIsNilWithoutBuckets() throws { + let context = try TestSupport.makeContext() + XCTAssertNil(ActivityScoreService.movementRegularity(on: TestSupport.day(0), context: context)) + } + + func testScoreIsNilWithoutAnActivityRow() throws { + let context = try TestSupport.makeContext() + XCTAssertNil(ActivityScoreService.score(on: TestSupport.day(0), context: context)) + } +} diff --git a/PulseLoopTests/TrainingLoadTests.swift b/PulseLoopTests/TrainingLoadTests.swift new file mode 100644 index 0000000..902640b --- /dev/null +++ b/PulseLoopTests/TrainingLoadTests.swift @@ -0,0 +1,163 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Edwards' summated heart-rate zones, and the acute:chronic balance built on them. +@MainActor +final class TrainingLoadTests: XCTestCase { + + private let hrMax: Double = 190 // the age-unknown fallback + + /// `count` readings one minute apart, all at the same bpm. + private func samples(bpm: Double, minutes: Int, from start: Date = Date(timeIntervalSince1970: 1_760_000_000)) -> [MetricSample] { + (0...minutes).map { MetricSample(timestamp: start.addingTimeInterval(Double($0) * 60), value: bpm) } + } + + // MARK: - Zones + + func testZoneBoundariesMatchTheWorkoutSummary() { + XCTAssertEqual(TrainingLoad.zoneIndex(forHeartRate: 100, hrMax: 200), 0, "50% → zone 1") + XCTAssertEqual(TrainingLoad.zoneIndex(forHeartRate: 120, hrMax: 200), 1, "60% → zone 2") + XCTAssertEqual(TrainingLoad.zoneIndex(forHeartRate: 140, hrMax: 200), 2, "70% → zone 3") + XCTAssertEqual(TrainingLoad.zoneIndex(forHeartRate: 160, hrMax: 200), 3, "80% → zone 4") + XCTAssertEqual(TrainingLoad.zoneIndex(forHeartRate: 180, hrMax: 200), 4, "90% → zone 5") + XCTAssertEqual(TrainingLoad.zoneIndex(forHeartRate: 220, hrMax: 200), 4, "above max stays in zone 5") + } + + func testMaxHeartRateFallsBackWhenAgeIsUnknown() { + XCTAssertEqual(TrainingLoad.maxHeartRate(age: 30), 190) + XCTAssertEqual(TrainingLoad.maxHeartRate(age: nil), 190) + } + + // MARK: - Load + + /// Sixty minutes in zone 1 is 60 weighted minutes; the same hour in zone 5 is five times that. + func testLoadIsWeightedMinutes() { + let easy = TrainingLoad.load(samples: samples(bpm: 90, minutes: 60), hrMax: hrMax) + XCTAssertEqual(easy, 60, accuracy: 0.5) + + let hard = TrainingLoad.load(samples: samples(bpm: 180, minutes: 60), hrMax: hrMax) + XCTAssertEqual(hard, 300, accuracy: 0.5) + XCTAssertEqual(hard / easy, 5, accuracy: 0.05) + } + + /// **The gap cap.** Two readings twelve hours apart must not be credited as twelve hours of + /// zone-1 work — that would turn an overnight sampling gap into the biggest session of the week. + func testAnOvernightGapIsCappedNotCredited() { + let start = Date(timeIntervalSince1970: 1_760_000_000) + let sparse = [ + MetricSample(timestamp: start, value: 90), + MetricSample(timestamp: start.addingTimeInterval(12 * 3600), value: 90), + ] + // A lone gap has no median to widen the cap, so the 5-minute ceiling applies. + XCTAssertEqual(TrainingLoad.load(samples: sparse, hrMax: hrMax), 5, accuracy: 0.5) + } + + func testASingleReadingHasNoLoad() { + XCTAssertEqual(TrainingLoad.load(samples: samples(bpm: 150, minutes: 0), hrMax: hrMax), 0, accuracy: 0.001) + XCTAssertEqual(TrainingLoad.load(samples: [], hrMax: hrMax), 0, accuracy: 0.001) + } + + // MARK: - Acute vs chronic + + private func dailyLoad(_ values: [Int: Double], now: Date) -> [Date: Double] { + let calendar = Calendar.current + var out: [Date: Double] = [:] + for (daysAgo, load) in values { + let day = calendar.startOfDay(for: calendar.date(byAdding: .day, value: -daysAgo, to: now) ?? now) + out[day] = load + } + return out + } + + func testSteadyWeekReadsAsSteady() { + let now = Date() + let load = dailyLoad(Dictionary(uniqueKeysWithValues: (0..<28).map { ($0, 100.0) }), now: now) + let balance = TrainingLoad.balance(dailyLoad: load, now: now) + + XCTAssertEqual(balance.ratio ?? 0, 1.0, accuracy: 0.01) + XCTAssertEqual(balance.band, .steady) + } + + func testASharpWeekReadsAsASpike() { + let now = Date() + var values = Dictionary(uniqueKeysWithValues: (7..<28).map { ($0, 100.0) }) + for day in 0..<7 { values[day] = 300 } + let balance = TrainingLoad.balance(dailyLoad: dailyLoad(values, now: now), now: now) + + XCTAssertEqual(balance.band, .spike) + XCTAssertGreaterThan(balance.ratio ?? 0, 1.5) + } + + func testAQuietWeekReadsAsDetraining() { + let now = Date() + var values = Dictionary(uniqueKeysWithValues: (7..<28).map { ($0, 200.0) }) + for day in 0..<7 { values[day] = 40 } + let balance = TrainingLoad.balance(dailyLoad: dailyLoad(values, now: now), now: now) + + XCTAssertEqual(balance.band, .detraining) + } + + /// **The rule that matters most.** A week the ring wasn't worn is not a week of recovery — + /// averaging in zeros would manufacture a "detraining" reading out of a charging cable. + func testUnwornDaysAreExcludedNotCountedAsRest() { + let now = Date() + // Worn on 20 days at a steady 100; the other 8 are simply absent. + let values = Dictionary(uniqueKeysWithValues: (0..<28).filter { $0 % 7 != 3 }.map { ($0, 100.0) }) + let balance = TrainingLoad.balance(dailyLoad: dailyLoad(values, now: now), now: now) + + XCTAssertEqual(balance.ratio ?? 0, 1.0, accuracy: 0.01, "the missing days don't drag the mean down") + XCTAssertEqual(balance.band, .steady) + } + + /// A ratio built on a fortnight isn't a chronic baseline, so it isn't published as one. + func testThinHistoryWithholdsTheRatio() { + let now = Date() + let load = dailyLoad(Dictionary(uniqueKeysWithValues: (0..<10).map { ($0, 100.0) }), now: now) + let balance = TrainingLoad.balance(dailyLoad: load, now: now) + + XCTAssertNil(balance.ratio) + XCTAssertEqual(balance.band, .unknown) + XCTAssertEqual(balance.chronicDaysCovered, 10) + } + + /// Band edges are inclusive at the top, so a ratio sitting exactly on 1.3 stays steady. + func testBandBoundariesAreInclusiveAtTheTop() { + XCTAssertEqual(TrainingLoad.Band(ratio: 0.79), .detraining) + XCTAssertEqual(TrainingLoad.Band(ratio: 0.8), .steady) + XCTAssertEqual(TrainingLoad.Band(ratio: 1.3), .steady) + XCTAssertEqual(TrainingLoad.Band(ratio: 1.31), .building) + XCTAssertEqual(TrainingLoad.Band(ratio: 1.5), .building) + XCTAssertEqual(TrainingLoad.Band(ratio: 1.51), .spike) + XCTAssertEqual(TrainingLoad.Band(ratio: nil), .unknown) + XCTAssertEqual(TrainingLoad.Band(ratio: .nan), .unknown) + } + + func testEmptyHistoryIsUnknownNotZero() { + let balance = TrainingLoad.balance(dailyLoad: [:], now: Date()) + XCTAssertNil(balance.ratio) + XCTAssertEqual(balance.band, .unknown) + } + + // MARK: - Through the store + + func testDailyLoadOmitsDaysWithNoReadings() throws { + let context = try TestSupport.makeContext() + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + + // An hour of steady effort today; nothing yesterday. + for minute in 0...60 { + let ts = calendar.date(byAdding: .minute, value: minute, to: today.addingTimeInterval(9 * 3600)) ?? today + context.insert(Measurement(kind: .heartRate, value: 140, unit: "bpm", timestamp: ts)) + } + try? context.save() + + let load = ActivityScoreService.dailyLoad(context: context) + XCTAssertNotNil(load[today]) + XCTAssertGreaterThan(load[today] ?? 0, 100, "an hour in zone 3 is ~180 weighted minutes") + + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today + XCTAssertNil(load[yesterday], "a day with no readings is absent, not zero") + } +} diff --git a/docs/project/activity-score.md b/docs/project/activity-score.md new file mode 100644 index 0000000..c1f9c17 --- /dev/null +++ b/docs/project/activity-score.md @@ -0,0 +1,162 @@ +--- +title: Movement score & training load +description: How PulseLoop scores a day's movement and weighs this week's training against the last month — every contributor, weight, and threshold. +--- + +# Movement score & training load + +Two numbers on the Activity tab, both computed on your device. + +- The **movement score** answers "how well did I move today?" — PulseLoop's equivalent of Oura's + Activity Score or Ultrahuman's Movement Index. +- **Training load** answers "is this week in line with my recent normal?" + +Both are documented in full here. PulseLoop's principles commit to "documented metrics and an +auditable coach, no black boxes". + +Implementations: +[`ActivityScore.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/ActivityScore.swift) +and +[`TrainingLoad.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/TrainingLoad.swift) +(pure maths), with +[`ActivityScoreService.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/ActivityScoreService.swift) +reading the store. Unit tests lock every number on this page. + +--- + +## Movement score + +**Algorithm version: 1.** Bands: Very active ≥ 85, Active ≥ 70, Light ≥ 45, Restful below. + +### Contributors + +| Contributor | Points | Measured against | +|---|---|---| +| Steps | 35 | Your daily step goal | +| Active minutes | 30 | Your daily active-minutes goal | +| Active energy | 20 | Your daily calorie-burn goal | +| Movement through the day | 15 | The hours your ring actually observed | + +Everything is scored **against your own goals**, not a population target. The goals already exist +and are already editable in Settings; a score built on a fixed 10,000 steps would tell a marathoner +and someone recovering from surgery the same thing. + +### The goal curve + +``` +fraction = actual ÷ goal + +fraction ≥ 1.0 → full points +fraction = 0.6 → 65% of points +fraction = 0 → 0 +``` + +…interpolating linearly between those knots. + +**Exceeding a goal is never penalised.** Overreaching is what training load is for; a movement score +that docked you for a long hike would be actively misleading. + +### Movement through the day + +A day that hits its step goal in one gym session and then sits for twelve hours is a different day +from one that moves throughout, and this is the only contributor that can tell them apart. + +An hour counts as active if it contains at least **250 steps** — the widely used stand-hour +convention rather than a threshold invented here. Only hours between **08:00 and 22:00** are +considered; nobody should be scored for not walking at 4 a.m. + +The denominator is the hours your ring **actually reported buckets for**, not a flat 14. A ring taken +off at lunchtime isn't scored for the afternoon it never saw. + +### Missing data is never scored as zero + +A contributor with no data leaves the denominator rather than being scored as zero: + +``` +score = 100 × (points earned) ÷ (points available) +``` + +| Situation | Available | Why | +|---|---|---| +| Full day, ring reports intraday buckets | 100 | — | +| Ring-history day (no trustworthy calories) | 80 | `ActivityDaily.calories` is blanked for ring-history rows | +| Ring reports only daily totals | 85 | No intraday buckets to judge regularity from | +| Both of the above | 65 | — | + +A score is only produced when **at least 50 points** were available. + +The energy exclusion is deliberate and pre-existing: the ring's calorie field is unverified, so +`buildTodaySummary` already blanks it for ring-history days. Scoring it would put weight on a number +the app explicitly doesn't stand behind. + +--- + +## Training load + +### The model + +**Edwards' summated heart-rate zones**, not Banister's TRIMP: + +``` +load = Σ (minutes in zone i × weight i) weights: 1, 2, 3, 4, 5 +``` + +Zone floors are fractions of maximum heart rate — 0%, 60%, 70%, 80%, 90% — the same boundaries the +workout summary screen already draws, so the two can never disagree. Maximum heart rate is the plain +`220 − age`, falling back to 190 when age is unknown. + +Banister's TRIMP needs a reliable average heart rate over a bounded session. PulseLoop's all-day data +is sparse, irregularly spaced, and has no session boundaries, so Edwards — which only needs time in +each zone — degrades far more gracefully. + +The result is **unitless**: a weighted minute count, not an energy figure. A day of gentle walking +lands in the tens; a hard hour lands in the low hundreds. + +### The gap cap + +Each reading is credited the interval to the next one, capped at about twice the median spacing and +never more than five minutes. + +Without that cap, two all-day readings twelve hours apart would be credited as twelve hours of +zone-1 work — turning an overnight sampling gap into the biggest session of the week. A ring sampling +every 5 minutes credits its full interval; a YCBT ring floored at 30 minutes credits five of each +thirty, which understates load rather than inventing it. + +### Acute vs chronic + +``` +acute = mean daily load over the last 7 days +chronic = mean daily load over the last 28 days +ratio = acute ÷ chronic +``` + +| Ratio | Band | +|---|---| +| < 0.8 | Detraining | +| 0.8 – 1.3 | Steady | +| 1.3 – 1.5 | Building | +| > 1.5 | Spike | + +These are the sports-science convention — roughly 0.8–1.3 is the range associated with lowest injury +risk in the literature. Presented as guidance, not a verdict: the evidence base is contested and was +built on athletes with far better data than an optical ring provides. + +### Days with no data are excluded, not counted as rest + +This is the most important rule here. A week the ring wasn't worn is **not** a week of recovery, and +averaging in zeros would manufacture a "detraining" reading out of a charging cable. + +Both means are taken over the days that actually carried readings. A ratio is withheld entirely until +at least **14 of the 28 chronic days** have data — below that the chronic average is really a +short-window average wearing a long window's name, and dividing one by the other says nothing. + +--- + +## Where they appear + +Both live on the **Activity tab**, under the rings they summarise — not on Today, which is already a +dense tile grid. The contributor breakdown is disclosed on tap, so the card stays one line tall until +asked. + +The card is absent entirely on a day with no activity row: there is nothing to score yet, and an +empty dial reads as a zero. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1..4f888f1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Project: - Roadmap: project/roadmap.md - Architecture: project/architecture.md + - Movement score & training load: project/activity-score.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md