Skip to content
Merged
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 @@ -149,7 +149,8 @@ extension RecoveryScorer {
// full set, dropping one and renormalising returns the same score, collapsing the delta to
// 0 even for a clearly good or bad term.
func points(_ neutralised: Double?) -> Int {
Int((full - (neutralised ?? full)).rounded())
// Shared Swift/Kotlin rule: nearest integer, with exact half-ties away from zero.
Int((full - (neutralised ?? full)).rounded(.toNearestOrAwayFromZero))
}

var drivers: [ChargeDriver] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,99 @@ final class ChargeDriversTests: XCTestCase {
nightsSinceUpdate: 0, status: nValid >= 14 ? .trusted : .provisional)
}

// MARK: - Integer marginal rounding parity (#51)

func testDriverPointRoundingUsesNearestWithHalfTiesAwayFromZero() {
func hrvMarginal(hrv: Double, rhr: Double, hrvBaseline: BaselineState,
rhrBaseline: BaselineState? = nil) -> (raw: Double, points: Int) {
let full = RecoveryScorer.recovery(
hrv: hrv, rhr: rhr, resp: nil,
hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline,
respBaseline: nil, sleepPerf: nil)!
let neutral = RecoveryScorer.recovery(
hrv: hrvBaseline.baseline, rhr: rhr, resp: nil,
hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline,
respBaseline: nil, sleepPerf: nil)!
let row = RecoveryScorer.chargeDrivers(
hrv: hrv, rhr: rhr, resp: nil,
hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline,
respBaseline: nil, sleepPerf: nil)
.first { $0.label == "Heart rate variability" }!
return (full - neutral, row.deltaPoints)
}

let negativeBaseline = BaselineState(
baseline: 30.0, spread: 0.55, nValid: 14,
nightsSinceUpdate: 0, status: .trusted)
let negativeBelowTie = hrvMarginal(
hrv: 29.991177275907276, rhr: 60.0, hrvBaseline: negativeBaseline)
let negativeTie = hrvMarginal(
hrv: 29.99117725828923, rhr: 60.0, hrvBaseline: negativeBaseline)
let negativeBeyondTie = hrvMarginal(
hrv: 29.991177240671185, rhr: 60.0, hrvBaseline: negativeBaseline)
XCTAssertGreaterThan(negativeBelowTie.raw, -0.5)
XCTAssertEqual(negativeBelowTie.points, 0)
XCTAssertEqual(negativeTie.raw, -0.5)
XCTAssertEqual(negativeTie.points, -1)
XCTAssertLessThan(negativeBeyondTie.raw, -0.5)
XCTAssertEqual(negativeBeyondTie.points, -1)

let positiveHRVBaseline = BaselineState(
baseline: 30.0, spread: 0.55, nValid: 14,
nightsSinceUpdate: 0, status: .trusted)
let positiveRHRBaseline = BaselineState(
baseline: 60.0, spread: 0.1, nValid: 14,
nightsSinceUpdate: 0, status: .trusted)
let positiveBelowTie = hrvMarginal(
hrv: 33.09890762408082, rhr: 58.541,
hrvBaseline: positiveHRVBaseline, rhrBaseline: positiveRHRBaseline)
let positiveTie = hrvMarginal(
hrv: 33.099135135290354, rhr: 58.541,
hrvBaseline: positiveHRVBaseline, rhrBaseline: positiveRHRBaseline)
let positiveBeyondTie = hrvMarginal(
hrv: 33.09936273466694, rhr: 58.541,
hrvBaseline: positiveHRVBaseline, rhrBaseline: positiveRHRBaseline)
XCTAssertLessThan(positiveBelowTie.raw, 0.5)
XCTAssertEqual(positiveBelowTie.points, 0)
XCTAssertEqual(positiveTie.raw, 0.5)
XCTAssertEqual(positiveTie.points, 1)
XCTAssertGreaterThan(positiveBeyondTie.raw, 0.5)
XCTAssertEqual(positiveBeyondTie.points, 1)
}

func testIssue51NegativeHalfTieUsesDefaultArg8WithoutChangingScoreOrDriverFields() {
let hrvBaseline = BaselineState(
baseline: 30.0, spread: 0.55, nValid: 14,
nightsSinceUpdate: 0, status: .trusted)
let scoreBefore = RecoveryScorer.recovery(
hrv: 29.99117725828923, rhr: 60.0, resp: nil,
hrvBaseline: hrvBaseline, rhrBaseline: nil,
respBaseline: nil, sleepPerf: nil)
let neutralScore = RecoveryScorer.recovery(
hrv: hrvBaseline.baseline, rhr: 60.0, resp: nil,
hrvBaseline: hrvBaseline, rhrBaseline: nil,
respBaseline: nil, sleepPerf: nil)

// Intentionally omit arg 8 (skinTempDev) to exercise the real default path from #51.
let drivers = RecoveryScorer.chargeDrivers(
hrv: 29.99117725828923, rhr: 60.0, resp: nil,
hrvBaseline: hrvBaseline, rhrBaseline: nil,
respBaseline: nil, sleepPerf: nil)
let scoreAfter = RecoveryScorer.recovery(
hrv: 29.99117725828923, rhr: 60.0, resp: nil,
hrvBaseline: hrvBaseline, rhrBaseline: nil,
respBaseline: nil, sleepPerf: nil)

XCTAssertEqual(scoreBefore! - neutralScore!, -0.5)
XCTAssertEqual(scoreAfter, scoreBefore)
XCTAssertEqual(drivers, [ChargeDriver(
label: "Heart rate variability",
deltaPoints: -1,
valueText: "30 ms",
baselineText: "30 ms baseline",
verdict: "below baseline, limiting recovery")])
}

// MARK: - Presence / omission

func testColdStartHRVBaselineYieldsNoDrivers() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,11 @@ object RecoveryDrivers {
// weighting, same logistic) so the points can never drift from the headline. A term reaches
// z = 0 at: HRV / resting HR / respiration = the baseline mean, Rest quality = sleepPerfCenter,
// skin-temp deviation = 0. Mirrors the Swift ChargeDrivers `points(...)` helper.
fun points(neutralised: Double?): Int = (full - (neutralised ?: full)).roundToInt()
fun points(neutralised: Double?): Int {
val delta = full - (neutralised ?: full)
// Shared Swift/Kotlin rule: nearest integer, with exact half-ties away from zero.
return if (delta < 0.0) -Math.round(-delta).toInt() else Math.round(delta).toInt()
}

// Did the parasympathetic-saturation signature fire on THIS night (low HRV corroborated by a low,
// decoupled resting HR)? Detection ONLY: the guard's easing is not applied, so deltaPoints below is
Expand Down
108 changes: 108 additions & 0 deletions android/app/src/test/java/com/noop/analytics/RecoveryDriversTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,114 @@ class RecoveryDriversTest {
status = if (nValid >= 14) BaselineStatus.TRUSTED else BaselineStatus.PROVISIONAL,
)

@Test fun driverPointRoundingUsesNearestWithHalfTiesAwayFromZero() {
fun hrvMarginal(
hrv: Double,
rhr: Double,
hrvBaseline: BaselineState,
rhrBaseline: BaselineState? = null,
): Pair<Double, Int> {
val full = RecoveryScorer.recovery(
hrv = hrv, rhr = rhr, resp = null,
hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline,
respBaseline = null, sleepPerf = null,
)!!
val neutral = RecoveryScorer.recovery(
hrv = hrvBaseline.baseline, rhr = rhr, resp = null,
hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline,
respBaseline = null, sleepPerf = null,
)!!
val row = RecoveryDrivers.chargeDrivers(
hrv = hrv, rhr = rhr, resp = null,
hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline,
respBaseline = null, sleepPerf = null,
).first { it.label == "Heart rate variability" }
return (full - neutral) to row.deltaPoints
}

val negativeBaseline = BaselineState(
baseline = 30.0, spread = 0.55, nValid = 14,
nightsSinceUpdate = 0, status = BaselineStatus.TRUSTED,
)
val negativeBelowTie = hrvMarginal(29.991177275907276, 60.0, negativeBaseline)
val negativeTie = hrvMarginal(29.99117725828923, 60.0, negativeBaseline)
val negativeBeyondTie = hrvMarginal(29.991177240671185, 60.0, negativeBaseline)
assertTrue(negativeBelowTie.first > -0.5)
assertEquals(0, negativeBelowTie.second)
assertEquals(-0.5, negativeTie.first, 0.0)
assertEquals(-1, negativeTie.second)
assertTrue(negativeBeyondTie.first < -0.5)
assertEquals(-1, negativeBeyondTie.second)

val positiveHRVBaseline = BaselineState(
baseline = 30.0, spread = 0.55, nValid = 14,
nightsSinceUpdate = 0, status = BaselineStatus.TRUSTED,
)
val positiveRHRBaseline = BaselineState(
baseline = 60.0, spread = 0.1, nValid = 14,
nightsSinceUpdate = 0, status = BaselineStatus.TRUSTED,
)
val positiveBelowTie = hrvMarginal(
33.09890762408082, 58.541, positiveHRVBaseline, positiveRHRBaseline,
)
val positiveTie = hrvMarginal(
33.099135135290354, 58.541, positiveHRVBaseline, positiveRHRBaseline,
)
val positiveBeyondTie = hrvMarginal(
33.09936273466694, 58.541, positiveHRVBaseline, positiveRHRBaseline,
)
assertTrue(positiveBelowTie.first < 0.5)
assertEquals(0, positiveBelowTie.second)
assertEquals(0.5, positiveTie.first, 0.0)
assertEquals(1, positiveTie.second)
assertTrue(positiveBeyondTie.first > 0.5)
assertEquals(1, positiveBeyondTie.second)
}

@Test fun issue51NegativeHalfTieUsesDefaultArg8WithoutChangingScoreOrDriverFields() {
val hrvBaseline = BaselineState(
baseline = 30.0, spread = 0.55, nValid = 14,
nightsSinceUpdate = 0, status = BaselineStatus.TRUSTED,
)
val scoreBefore = RecoveryScorer.recovery(
hrv = 29.99117725828923, rhr = 60.0, resp = null,
hrvBaseline = hrvBaseline, rhrBaseline = null,
respBaseline = null, sleepPerf = null,
)
val neutralScore = RecoveryScorer.recovery(
hrv = hrvBaseline.baseline, rhr = 60.0, resp = null,
hrvBaseline = hrvBaseline, rhrBaseline = null,
respBaseline = null, sleepPerf = null,
)

// Intentionally omit arg 8 (skinTempDev) to exercise the real default path from #51.
val drivers = RecoveryDrivers.chargeDrivers(
hrv = 29.99117725828923, rhr = 60.0, resp = null,
hrvBaseline = hrvBaseline, rhrBaseline = null,
respBaseline = null, sleepPerf = null,
)
val scoreAfter = RecoveryScorer.recovery(
hrv = 29.99117725828923, rhr = 60.0, resp = null,
hrvBaseline = hrvBaseline, rhrBaseline = null,
respBaseline = null, sleepPerf = null,
)

assertEquals(-0.5, scoreBefore!! - neutralScore!!, 0.0)
assertEquals(scoreBefore, scoreAfter)
assertEquals(
listOf(
ChargeDriver(
label = "Heart rate variability",
deltaPoints = -1,
valueText = "30 ms",
baselineText = "30 ms baseline",
verdict = "below baseline, limiting recovery",
),
),
drivers,
)
}

@Test fun allTermsPresentYieldOneRowEachInOrder() {
val drivers = RecoveryDrivers.chargeDrivers(
hrv = 62.0, rhr = 51.0, resp = 15.0,
Expand Down