Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ public enum ReadinessEngine {
public struct Signal: Sendable, Equatable {
public let key: String // "hrv" | "rhr" | "respRate" | "acwr" | "monotony"
public let label: String // short human label
public let evidence: String?
public let detail: String // one-line plain-English read
public let flag: Flag
public init(key: String, label: String, detail: String, flag: Flag) {
self.key = key; self.label = label; self.detail = detail; self.flag = flag
public init(key: String, label: String, evidence: String? = nil, detail: String, flag: Flag) {
self.key = key; self.label = label; self.evidence = evidence
self.detail = detail; self.flag = flag
}
}

Expand Down Expand Up @@ -98,6 +100,8 @@ public enum ReadinessEngine {
value: latest.avgHrv,
baseline: history.suffix(baselineWindow).compactMap { $0.avgHrv },
key: "hrv", label: "HRV",
unit: "ms",
decimals: 0,
higherIsBetter: true,
goodText: "above your baseline — well recovered",
neutralText: "in your normal range",
Expand All @@ -110,6 +114,8 @@ public enum ReadinessEngine {
value: latest.restingHr.map(Double.init),
baseline: history.suffix(baselineWindow).compactMap { $0.restingHr.map(Double.init) },
key: "rhr", label: "Resting HR",
unit: "bpm",
decimals: 0,
higherIsBetter: false,
goodText: "at or below baseline",
neutralText: "in your normal range",
Expand All @@ -129,9 +135,11 @@ public enum ReadinessEngine {
let z = (rr - m) / sd
if z >= 2.0 {
signals.append(Signal(key: "respRate", label: "Respiratory rate",
evidence: evidence(value: rr, baseline: m, unit: "rpm", decimals: 1),
detail: "up vs baseline — sometimes an early sign of getting sick", flag: .bad))
} else if z >= 1.5 {
signals.append(Signal(key: "respRate", label: "Respiratory rate",
evidence: evidence(value: rr, baseline: m, unit: "rpm", decimals: 1),
detail: "slightly raised vs baseline", flag: .watch))
}
}
Expand All @@ -147,7 +155,7 @@ public enum ReadinessEngine {
if chronic > 0 {
let ratio = acute / chronic
acwr = ratio
signals.append(acwrSignal(ratio))
signals.append(acwrSignal(ratio, acute: acute, chronic: chronic))
}
// Foster monotony over the last week of strain.
let week = Array(strainSeries.suffix(acuteWindow))
Expand All @@ -156,6 +164,7 @@ public enum ReadinessEngine {
monotony = mono
if mono >= 2.0 {
signals.append(Signal(key: "monotony", label: "Training variety",
evidence: "monotony \(String(format: "%.1f", mono))",
detail: "low — similar strain every day raises strain/illness risk", flag: .watch))
}
}
Expand All @@ -171,7 +180,8 @@ public enum ReadinessEngine {

/// Build a z-score signal for a metric where the baseline is the trailing window.
private static func zSignal(value: Double?, baseline: [Double],
key: String, label: String, higherIsBetter: Bool,
key: String, label: String, unit: String, decimals: Int,
higherIsBetter: Bool,
goodText: String, neutralText: String,
watchText: String, badText: String) -> Signal? {
guard let v = value, baseline.count >= minBaseline,
Expand All @@ -186,27 +196,44 @@ public enum ReadinessEngine {
case -1.0 ..< -0.5: flag = .watch; text = watchText
default: flag = .bad; text = badText
}
return Signal(key: key, label: label, detail: text, flag: flag)
return Signal(key: key, label: label,
evidence: evidence(value: v, baseline: m, unit: unit, decimals: decimals),
detail: text, flag: flag)
}

private static func acwrSignal(_ ratio: Double) -> Signal {
private static func acwrSignal(_ ratio: Double, acute: Double, chronic: Double) -> Signal {
let pct = String(format: "%.2f", ratio)
let evidence = "7d \(String(format: "%.1f", acute)) / 28d \(String(format: "%.1f", chronic))"
switch ratio {
case ..<0.8:
return Signal(key: "acwr", label: "Training load",
evidence: evidence,
detail: "ramping down (acute:chronic \(pct)) — room to build", flag: .watch)
case 0.8..<1.3:
return Signal(key: "acwr", label: "Training load",
evidence: evidence,
detail: "in the sweet spot (acute:chronic \(pct))", flag: .good)
case 1.3..<1.5:
return Signal(key: "acwr", label: "Training load",
evidence: evidence,
detail: "building fast (acute:chronic \(pct)) — watch fatigue", flag: .watch)
default:
return Signal(key: "acwr", label: "Training load",
evidence: evidence,
detail: "spiking (acute:chronic \(pct)) — higher injury risk", flag: .bad)
}
}

private static func evidence(value: Double, baseline: Double, unit: String, decimals: Int) -> String {
"\(format(value, decimals: decimals)) vs \(format(baseline, decimals: decimals)) \(unit)"
}

private static func format(_ value: Double, decimals: Int) -> String {
decimals == 0
? String(Int(value.rounded()))
: String(format: "%.\(decimals)f", value)
}

// MARK: Synthesis

private static func synthesize(signals: [Signal], hasHistory: Bool) -> (Level, String, String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ final class ReadinessEngineTests: XCTestCase {
XCTAssertEqual(r.signals.first { $0.key == "hrv" }?.flag, .good)
XCTAssertEqual(r.signals.first { $0.key == "rhr" }?.flag, .good)
XCTAssertEqual(r.signals.first { $0.key == "acwr" }?.flag, .good)
XCTAssertEqual(r.signals.first { $0.key == "hrv" }?.evidence, "72 vs 60 ms")
XCTAssertEqual(r.signals.first { $0.key == "rhr" }?.evidence, "46 vs 52 bpm")
XCTAssertEqual(r.signals.first { $0.key == "acwr" }?.evidence, "7d 10.0 / 28d 10.0")
}

func testRundownWhenTwoRecoverySignalsDown() {
Expand All @@ -59,6 +62,7 @@ final class ReadinessEngineTests: XCTestCase {
// Today resp rate well above baseline (~14) → illness-ish watch/bad signal present.
let r = ReadinessEngine.evaluate(days: baseline(todayHrv: 60, todayRhr: 52, todayStrain: 10, todayResp: 18))
XCTAssertTrue(r.signals.contains { $0.key == "respRate" })
XCTAssertEqual(r.signals.first { $0.key == "respRate" }?.evidence, "18.0 vs 14.0 rpm")
}

func testExplicitTodayWithoutMatchingRowIsInsufficient() {
Expand Down
12 changes: 10 additions & 2 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,16 @@ struct TodayView: View {
HStack(alignment: .top, spacing: 8) {
Circle().fill(flagColor(s.flag)).frame(width: 7, height: 7)
.padding(.top, 5)
Text(s.label).font(StrandFont.caption)
.foregroundStyle(StrandPalette.textSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(s.label).font(StrandFont.caption)
.foregroundStyle(StrandPalette.textSecondary)
if let evidence = s.evidence {
Text(evidence).font(StrandFont.captionNumber)
.foregroundStyle(StrandPalette.textTertiary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
.frame(width: 104, alignment: .leading)
Text(s.detail).font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
Expand Down
37 changes: 32 additions & 5 deletions android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.noop.analytics

import com.noop.data.DailyMetric
import java.util.Locale
import kotlin.math.roundToInt
import kotlin.math.sqrt

/**
Expand Down Expand Up @@ -45,6 +47,7 @@ object ReadinessEngine {
data class Signal(
val key: String, // "hrv" | "rhr" | "respRate" | "acwr" | "monotony"
val label: String, // short human label
val evidence: String? = null,
val detail: String, // one-line plain-English read
val flag: Flag,
)
Expand Down Expand Up @@ -108,6 +111,8 @@ object ReadinessEngine {
value = latest.avgHrv,
baseline = history.takeLast(baselineWindow).mapNotNull { it.avgHrv },
key = "hrv", label = "HRV",
unit = "ms",
decimals = 0,
higherIsBetter = true,
goodText = "above your baseline — well recovered",
neutralText = "in your normal range",
Expand All @@ -121,6 +126,8 @@ object ReadinessEngine {
value = latest.restingHr?.toDouble(),
baseline = history.takeLast(baselineWindow).mapNotNull { it.restingHr?.toDouble() },
key = "rhr", label = "Resting HR",
unit = "bpm",
decimals = 0,
higherIsBetter = false,
goodText = "at or below baseline",
neutralText = "in your normal range",
Expand All @@ -146,13 +153,15 @@ object ReadinessEngine {
signals.add(
Signal(
key = "respRate", label = "Respiratory rate",
evidence = evidence(value = rr, baseline = m, unit = "rpm", decimals = 1),
detail = "up vs baseline — sometimes an early sign of getting sick", flag = Flag.BAD,
)
)
} else if (z >= respZWatch) {
signals.add(
Signal(
key = "respRate", label = "Respiratory rate",
evidence = evidence(value = rr, baseline = m, unit = "rpm", decimals = 1),
detail = "slightly raised vs baseline", flag = Flag.WATCH,
)
)
Expand All @@ -170,7 +179,7 @@ object ReadinessEngine {
if (chronic > 0) {
val ratio = acute / chronic
acwr = ratio
signals.add(acwrSignal(ratio))
signals.add(acwrSignal(ratio, acute = acute, chronic = chronic))
}
// Foster monotony over the last week of strain.
val week = strainSeries.takeLast(acuteWindow)
Expand All @@ -183,6 +192,7 @@ object ReadinessEngine {
signals.add(
Signal(
key = "monotony", label = "Training variety",
evidence = "monotony ${String.format(Locale.US, "%.1f", mono)}",
detail = "low — similar strain every day raises strain/illness risk", flag = Flag.WATCH,
)
)
Expand All @@ -205,7 +215,7 @@ object ReadinessEngine {
/** Build a z-score signal for a metric where the baseline is the trailing window. */
private fun zSignal(
value: Double?, baseline: List<Double>,
key: String, label: String, higherIsBetter: Boolean,
key: String, label: String, unit: String, decimals: Int, higherIsBetter: Boolean,
goodText: String, neutralText: String,
watchText: String, badText: String,
): Signal? {
Expand All @@ -223,31 +233,48 @@ object ReadinessEngine {
z >= -1.0 -> { flag = Flag.WATCH; text = watchText }
else -> { flag = Flag.BAD; text = badText }
}
return Signal(key = key, label = label, detail = text, flag = flag)
return Signal(
key = key,
label = label,
evidence = evidence(value = value, baseline = m, unit = unit, decimals = decimals),
detail = text,
flag = flag,
)
}

private fun acwrSignal(ratio: Double): Signal {
val pct = String.format("%.2f", ratio)
private fun acwrSignal(ratio: Double, acute: Double, chronic: Double): Signal {
val pct = String.format(Locale.US, "%.2f", ratio)
val evidence = "7d ${String.format(Locale.US, "%.1f", acute)} / 28d ${String.format(Locale.US, "%.1f", chronic)}"
return when {
ratio < 0.8 -> Signal(
key = "acwr", label = "Training load",
evidence = evidence,
detail = "ramping down (acute:chronic $pct) — room to build", flag = Flag.WATCH,
)
ratio < 1.3 -> Signal(
key = "acwr", label = "Training load",
evidence = evidence,
detail = "in the sweet spot (acute:chronic $pct)", flag = Flag.GOOD,
)
ratio < 1.5 -> Signal(
key = "acwr", label = "Training load",
evidence = evidence,
detail = "building fast (acute:chronic $pct) — watch fatigue", flag = Flag.WATCH,
)
else -> Signal(
key = "acwr", label = "Training load",
evidence = evidence,
detail = "spiking (acute:chronic $pct) — higher injury risk", flag = Flag.BAD,
)
}
}

private fun evidence(value: Double, baseline: Double, unit: String, decimals: Int): String =
"${format(value, decimals)} vs ${format(baseline, decimals)} $unit"

private fun format(value: Double, decimals: Int): String =
if (decimals == 0) value.roundToInt().toString() else String.format(Locale.US, "%.${decimals}f", value)

// MARK: Synthesis

private fun synthesize(signals: List<Signal>, hasHistory: Boolean): Triple<Level, String, String> {
Expand Down
21 changes: 15 additions & 6 deletions android/app/src/main/java/com/noop/ui/TodayScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -895,12 +895,21 @@ private fun ReadinessSection(days: List<DailyMetric>) {
.background(flagColor(signal.flag)),
)
Spacer(Modifier.width(8.dp))
Text(
signal.label,
style = NoopType.caption,
color = Palette.textSecondary,
modifier = Modifier.width(104.dp),
)
Column(modifier = Modifier.width(104.dp)) {
Text(
signal.label,
style = NoopType.caption,
color = Palette.textSecondary,
)
signal.evidence?.let { evidence ->
Text(
evidence,
style = NoopType.captionNumber,
color = Palette.textTertiary,
maxLines = 1,
)
}
}
Text(
signal.detail,
style = NoopType.caption,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class ReadinessEngineTest {
assertEquals(ReadinessEngine.Flag.GOOD, r.signals.firstOrNull { it.key == "hrv" }?.flag)
assertEquals(ReadinessEngine.Flag.GOOD, r.signals.firstOrNull { it.key == "rhr" }?.flag)
assertEquals(ReadinessEngine.Flag.GOOD, r.signals.firstOrNull { it.key == "acwr" }?.flag)
assertEquals("72 vs 60 ms", r.signals.firstOrNull { it.key == "hrv" }?.evidence)
assertEquals("46 vs 52 bpm", r.signals.firstOrNull { it.key == "rhr" }?.evidence)
assertEquals("7d 10.0 / 28d 10.0", r.signals.firstOrNull { it.key == "acwr" }?.evidence)
}

@Test
Expand Down Expand Up @@ -96,6 +99,7 @@ class ReadinessEngineTest {
baseline(todayHrv = 60.0, todayRhr = 52, todayStrain = 10.0, todayResp = 18.0)
)
assertTrue(r.signals.any { it.key == "respRate" })
assertEquals("18.0 vs 14.0 rpm", r.signals.firstOrNull { it.key == "respRate" }?.evidence)
}

@Test
Expand Down
Loading