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
141 changes: 89 additions & 52 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,70 +170,81 @@ extension RecoveryScorer {

// ── HRV (dominant driver; always present once the score exists) ──────────
// Higher HRV vs baseline supports recovery. Neutral = HRV at the baseline mean.
let hrvPoints = points(recovery(hrv: hrvBaseline.baseline, rhr: rhr, resp: resp,
hrvBaseline: hrvBaseline, rhrBaseline: rhrB,
respBaseline: respBaseline, sleepPerf: sleepPerf,
skinTempDev: skinTempDev))
drivers.append(ChargeDriver(
label: "Heart rate variability",
deltaPoints: points(recovery(hrv: hrvBaseline.baseline, rhr: rhr, resp: resp,
hrvBaseline: hrvBaseline, rhrBaseline: rhrB,
respBaseline: respBaseline, sleepPerf: sleepPerf,
skinTempDev: skinTempDev)),
deltaPoints: hrvPoints,
valueText: "\(Int(hrv.rounded())) ms",
baselineText: "\(Int(hrvBaseline.baseline.rounded())) ms baseline",
verdict: hrvVerdict(value: hrv, baseline: hrvBaseline.baseline,
saturationDetected: hrvSaturationDetected)))
shown: hrv.rounded(), shownBaseline: hrvBaseline.baseline.rounded(),
points: hrvPoints, saturationDetected: hrvSaturationDetected)))

// ── Resting HR (lower vs baseline supports recovery) ─────────────────────
// Neutral = resting HR at the baseline mean.
if let b = rhrB {
let rhrPoints = points(recovery(hrv: hrv, rhr: b.baseline, resp: resp,
hrvBaseline: hrvBaseline, rhrBaseline: rhrB,
respBaseline: respBaseline, sleepPerf: sleepPerf,
skinTempDev: skinTempDev))
drivers.append(ChargeDriver(
label: "Resting heart rate",
deltaPoints: points(recovery(hrv: hrv, rhr: b.baseline, resp: resp,
hrvBaseline: hrvBaseline, rhrBaseline: rhrB,
respBaseline: respBaseline, sleepPerf: sleepPerf,
skinTempDev: skinTempDev)),
deltaPoints: rhrPoints,
valueText: "\(Int(rhr.rounded())) bpm",
baselineText: "\(Int(b.baseline.rounded())) bpm baseline",
verdict: rhrVerdict(value: rhr, baseline: b.baseline)))
verdict: lowerIsBetterVerdict(value: rhr, baseline: b.baseline,
shown: rhr.rounded(), shownBaseline: b.baseline.rounded(),
points: rhrPoints)))
}

// ── Rest quality (the Rest composite; neutral at sleepPerfCenter) ────────
if let sp = sleepPerf {
let sleepPoints = points(recovery(hrv: hrv, rhr: rhr, resp: resp,
hrvBaseline: hrvBaseline, rhrBaseline: rhrB,
respBaseline: respBaseline, sleepPerf: sleepPerfCenter,
skinTempDev: skinTempDev))
drivers.append(ChargeDriver(
label: "Sleep quality",
deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: resp,
hrvBaseline: hrvBaseline, rhrBaseline: rhrB,
respBaseline: respBaseline, sleepPerf: sleepPerfCenter,
skinTempDev: skinTempDev)),
deltaPoints: sleepPoints,
valueText: "\(Int((sp * 100).rounded()))%",
baselineText: "", // centred on a fixed "good night", not a learned baseline
verdict: sleepVerdict(sleepPerf: sp)))
verdict: sleepVerdict(points: sleepPoints)))
}

// ── Respiration (lower vs baseline supports recovery) ────────────────────
// Neutral = respiration at the baseline mean.
if let r = resp, let b = respBaseline {
drivers.append(ChargeDriver(
label: "Respiratory rate",
deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: b.baseline,
let respPoints = points(recovery(hrv: hrv, rhr: rhr, resp: b.baseline,
hrvBaseline: hrvBaseline, rhrBaseline: rhrB,
respBaseline: respBaseline, sleepPerf: sleepPerf,
skinTempDev: skinTempDev)),
skinTempDev: skinTempDev))
drivers.append(ChargeDriver(
label: "Respiratory rate",
deltaPoints: respPoints,
valueText: String(format: "%.1f br/min", locale: Locale(identifier: "en_US_POSIX"), r),
baselineText: String(format: "%.1f br/min baseline", locale: Locale(identifier: "en_US_POSIX"), b.baseline),
verdict: respVerdict(value: r, baseline: b.baseline)))
verdict: lowerIsBetterVerdict(value: r, baseline: b.baseline,
shown: (r * 10).rounded() / 10,
shownBaseline: (b.baseline * 10).rounded() / 10,
points: respPoints)))
}

// ── Skin-temp deviation (symmetric penalty: any drift lowers Charge) ─────
// Neutral = zero drift, so the delta is always <= 0 (a penalty removed).
if let dev = skinTempDev {
drivers.append(ChargeDriver(
label: "Skin temperature",
deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: resp,
let skinPoints = points(recovery(hrv: hrv, rhr: rhr, resp: resp,
hrvBaseline: hrvBaseline, rhrBaseline: rhrB,
respBaseline: respBaseline, sleepPerf: sleepPerf,
skinTempDev: 0)),
skinTempDev: 0))
drivers.append(ChargeDriver(
label: "Skin temperature",
deltaPoints: skinPoints,
valueText: skinTempDevText(dev),
baselineText: "", // a deviation already; the reference is the personal baseline (0)
verdict: skinTempVerdict(dev)))
verdict: skinTempVerdict(dev, points: skinPoints)))
}

// Biggest mover first; stable on ties (preserves the append order above).
Expand All @@ -251,42 +262,68 @@ extension RecoveryScorer {
// When adding or rewording a verdict, add the identical key to Strand's Localizable.xcstrings;
// Tools/test_home_i18n.py enforces that complete engine-to-catalog contract.

static func hrvVerdict(value: Double, baseline: Double, saturationDetected: Bool = false) -> String {
if value > baseline { return "above baseline, supporting recovery" }
if value < baseline {
// Parasympathetic-saturation signature detected: resting HR is also low and decoupled from
// HRV. NAME the pattern, but keep the plain "limiting recovery" read, because that is what
// the score actually did: the easing is detected-only and did NOT change these points. The
// hedge is deliberate too, since the same low-HRV + low-RHR pattern is also reported for
// non-functional overreaching, which is the opposite of benign.
return saturationDetected
? "below baseline, limiting recovery, though low resting HR suggests this may be parasympathetic saturation rather than fatigue"
: "below baseline, limiting recovery"
}
return "at baseline"
// A verdict must agree with the two things its row shows: the value against its baseline at the
// precision printed, and the rounded points. The EFFECT (supporting / limiting) comes from the points;
// the DIRECTION from the printed values, falling back to the raw ones only when the printed values tie
// and the term still moved the score, which is then said as "slightly". Comparing raw values alone let
// a row read "51 bpm · 51 bpm baseline · 0 pts" and still say "above baseline, limiting recovery".

/// Where a value sits for the verdict: nil when the row honestly reads as at baseline. Kotlin twin:
/// `RecoveryDrivers.direction`.
private static func direction(value: Double, baseline: Double, shown: Double, shownBaseline: Double,
points: Int) -> (higher: Bool, slight: Bool)? {
if shown != shownBaseline { return (shown > shownBaseline, false) }
if points == 0 || value == baseline { return nil }
return (value > baseline, true)
}

static func rhrVerdict(value: Double, baseline: Double) -> String {
if value < baseline { return "below baseline, supporting recovery" }
if value > baseline { return "above baseline, limiting recovery" }
return "at baseline"
/// HRV's verdict. Kotlin twin: `RecoveryDrivers.hrvVerdict`.
static func hrvVerdict(value: Double, baseline: Double, shown: Double, shownBaseline: Double,
points: Int, saturationDetected: Bool = false) -> String {
guard let d = direction(value: value, baseline: baseline, shown: shown, shownBaseline: shownBaseline,
points: points) else { return "at baseline" }
if points == 0 {
return d.higher ? "above baseline, too small to change Charge" : "below baseline, too small to change Charge"
}
if d.higher { return d.slight ? "slightly above baseline, supporting recovery" : "above baseline, supporting recovery" }
// Parasympathetic-saturation signature detected: resting HR is also low and decoupled from
// HRV. NAME the pattern, but keep the plain "limiting recovery" read, because that is what
// the score actually did: the easing is detected-only and did NOT change these points. The
// hedge is deliberate too, since the same low-HRV + low-RHR pattern is also reported for
// non-functional overreaching, which is the opposite of benign.
if saturationDetected {
return "below baseline, limiting recovery, though low resting HR suggests this may be parasympathetic saturation rather than fatigue"
}
return d.slight ? "slightly below baseline, limiting recovery" : "below baseline, limiting recovery"
}

static func respVerdict(value: Double, baseline: Double) -> String {
if value < baseline { return "below baseline, supporting recovery" }
if value > baseline { return "above baseline, limiting recovery" }
return "at baseline"
/// Resting HR and respiration, where lower than baseline supports recovery. `shown` and
/// `shownBaseline` are the values at the precision the row prints them. Kotlin twin:
/// `RecoveryDrivers.lowerIsBetterVerdict`.
static func lowerIsBetterVerdict(value: Double, baseline: Double, shown: Double, shownBaseline: Double,
points: Int) -> String {
guard let d = direction(value: value, baseline: baseline, shown: shown, shownBaseline: shownBaseline,
points: points) else { return "at baseline" }
if points == 0 {
return d.higher ? "above baseline, too small to change Charge" : "below baseline, too small to change Charge"
}
if d.higher { return d.slight ? "slightly above baseline, limiting recovery" : "above baseline, limiting recovery" }
return d.slight ? "slightly below baseline, supporting recovery" : "below baseline, supporting recovery"
}

static func sleepVerdict(sleepPerf: Double) -> String {
if sleepPerf > sleepPerfCenter { return "a strong night, supporting recovery" }
if sleepPerf < sleepPerfCenter { return "below a good night, limiting recovery" }
/// Rest quality's verdict, from its points. Kotlin twin: `RecoveryDrivers.sleepVerdict`.
static func sleepVerdict(points: Int) -> String {
if points > 0 { return "a strong night, supporting recovery" }
if points < 0 { return "below a good night, limiting recovery" }
return "a typical night"
}

static func skinTempVerdict(_ dev: Double) -> String {
// Symmetric penalty: any drift from baseline lowers Charge; at baseline it is neutral.
if abs(dev) <= skinTempTypicalBandC { return "near baseline" }
/// Skin temperature's verdict: near baseline exactly when the drift cost 0 points. Kotlin twin:
/// `RecoveryDrivers.skinTempVerdict`.
static func skinTempVerdict(_ dev: Double, points: Int) -> String {
// Symmetric penalty: any drift lowers Charge, so points are never positive. Near baseline means
// the drift cost nothing at the shown precision; otherwise the sign of the drift names it.
if points == 0 { return "near baseline" }
return dev > 0
? "warmer than baseline, limiting recovery"
: "cooler than baseline, limiting recovery"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ final class ChargeDriversTests: XCTestCase {
deltaPoints: -1,
valueText: "30 ms",
baselineText: "30 ms baseline",
verdict: "below baseline, limiting recovery")])
verdict: "slightly below baseline, limiting recovery")])
}

// MARK: - Presence / omission
Expand Down Expand Up @@ -169,7 +169,32 @@ final class ChargeDriversTests: XCTestCase {
XCTAssertGreaterThanOrEqual(resp.deltaPoints, 0) // minor 0.05-weight term; direction below
XCTAssertTrue(hrv.verdict.contains("supporting recovery"))
XCTAssertTrue(rhr.verdict.contains("supporting recovery"))
XCTAssertTrue(resp.verdict.contains("supporting recovery"))
// A minor term that rounds to 0 points states its direction without claiming an effect.
XCTAssertTrue(resp.deltaPoints > 0 ? resp.verdict == "below baseline, supporting recovery"
: resp.verdict == "below baseline, too small to change Charge")
}

/// The row's words must agree with what the row prints: equal printed values read "at baseline" even
/// when the raw values differ, a difference worth 0 points claims no effect, and skin temperature is
/// "near baseline" only when it cost nothing.
func testVerdictsAgreeWithThePrintedValuesAndPoints() {
typealias R = RecoveryScorer
// Printed 51 vs 51, worth 0 points: at baseline, whatever the raw values.
XCTAssertEqual(R.lowerIsBetterVerdict(value: 51.3, baseline: 50.8, shown: 51, shownBaseline: 51, points: 0),
"at baseline")
// Printed tie that still cost a point: said as slight, never as "at baseline".
XCTAssertEqual(R.lowerIsBetterVerdict(value: 51.3, baseline: 50.8, shown: 51, shownBaseline: 51, points: -1),
"slightly above baseline, limiting recovery")
// A printed difference worth 0 points claims no effect.
XCTAssertEqual(R.lowerIsBetterVerdict(value: 52, baseline: 51, shown: 52, shownBaseline: 51, points: 0),
"above baseline, too small to change Charge")
XCTAssertEqual(R.lowerIsBetterVerdict(value: 55, baseline: 51, shown: 55, shownBaseline: 51, points: -3),
"above baseline, limiting recovery")
XCTAssertEqual(R.hrvVerdict(value: 40, baseline: 50, shown: 40, shownBaseline: 50, points: -8),
"below baseline, limiting recovery")
XCTAssertEqual(R.hrvVerdict(value: 50, baseline: 50, shown: 50, shownBaseline: 50, points: 0), "at baseline")
XCTAssertEqual(R.skinTempVerdict(0.2, points: -1), "warmer than baseline, limiting recovery")
XCTAssertEqual(R.skinTempVerdict(0.2, points: 0), "near baseline")
}

func testBadInputsGiveNegativeContributions() {
Expand Down
Loading
Loading