From cdfc543423cd3316a2fff130de4a5c9d97a5da2e Mon Sep 17 00:00:00 2001 From: ak710 Date: Sun, 2 Aug 2026 15:20:12 -0400 Subject: [PATCH] Add a daily movement score and heart-rate training load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PulseLoop had goals, rings and a readiness score but no answer to "how well did I move today?" — the thing Oura calls an Activity Score and Ultrahuman a Movement Index. And nothing weighed this week's effort against recent normal. The movement score is four contributors scored against the user's own goals rather than a population target: steps (35), active minutes (30), active energy (20), and movement spread through the day (15). Goals already exist and are already editable, and a score built on a fixed 10,000 steps would tell a marathoner and someone recovering from surgery the same thing. Exceeding a goal is never penalised. Overreaching is what training load is for; a movement score that docked a long hike would be actively misleading. The regularity contributor is what separates a day that hit its step goal in one gym session from one that moved throughout. An hour counts as active at 250+ steps (the stand-hour convention, not a threshold invented here), judged only over 08:00-22:00, and against the hours the ring actually reported buckets for — so a ring taken off at lunchtime isn't scored for the afternoon it never saw. Missing contributors leave the denominator rather than scoring zero, as sleep and readiness do: a ring-history day has no trustworthy calorie figure so it is scored out of 80, and a ring with no intraday buckets out of 85. Training load uses Edwards' summated heart-rate zones rather than Banister's TRIMP. Banister needs a reliable average HR over a bounded session; all-day ring data is sparse, irregularly spaced and has no session boundaries, so Edwards — which only needs time in each zone — degrades far better. Zone floors match the boundaries the workout summary already draws, so the two can't disagree. Each reading is credited the gap to the next, capped at ~2x the median spacing and never over five minutes, so an overnight sampling gap can't become the biggest session of the week. Acute (7-day) against chronic (28-day) mean daily load, with the usual bands. Days with no readings are excluded from both means, never 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. The ratio is withheld entirely until 14 of the 28 chronic days carry data. 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. Full tables and rationale in docs/project/activity-score.md. Co-Authored-By: Claude Opus 5 --- .../DesignSystem/ActivityScoreCard.swift | 119 ++++++++++++ PulseLoop/Models/PulseModels.swift | 13 +- PulseLoop/Services/ActivityScore.swift | 179 ++++++++++++++++++ PulseLoop/Services/ActivityScoreService.swift | 101 ++++++++++ PulseLoop/Services/TrainingLoad.swift | 152 +++++++++++++++ PulseLoop/Views/ActivityView.swift | 12 ++ PulseLoopTests/ActivityScoreTests.swift | 149 +++++++++++++++ PulseLoopTests/TrainingLoadTests.swift | 163 ++++++++++++++++ docs/project/activity-score.md | 162 ++++++++++++++++ mkdocs.yml | 1 + 10 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 PulseLoop/DesignSystem/ActivityScoreCard.swift create mode 100644 PulseLoop/Services/ActivityScore.swift create mode 100644 PulseLoop/Services/ActivityScoreService.swift create mode 100644 PulseLoop/Services/TrainingLoad.swift create mode 100644 PulseLoopTests/ActivityScoreTests.swift create mode 100644 PulseLoopTests/TrainingLoadTests.swift create mode 100644 docs/project/activity-score.md 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