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 @@ -27,6 +27,18 @@ public enum CircadianEngine {
/// Days at/above which the fit reads as full-confidence.
public static let goodDaysForFit: Int = 14
/// A cosinor fit with amplitude below this fraction of the mesor is "arrhythmic" — too flat to phase.
///
/// #982 — RELATIVE, applied to a signal (mean HR, see `ActivityBin`) whose mesor is ~45-75 bpm rather
/// than the near-zero mesor a motion volume would have. So the effective bar is an absolute amplitude
/// of `0.10 x mesor`: about 6.5 bpm at a 65 bpm mesor, about 4.5 bpm at 45. It scales WITH the mesor,
/// so a low-resting wearer faces a LOWER absolute bar, not a higher one — the opposite of the concern
/// raised in #982, which assumed the gate penalises the fittest.
///
/// Deliberately NOT re-tuned to an absolute bpm floor. The shape is arguably wrong for this domain,
/// but every candidate value is unvalidated, nobody is currently silenced by it, and the direction of
/// the harm is not established (it needs a wearer whose amplitude is disproportionately small for
/// their mesor — an n=1 observation). `CircadianEngineTests` pins what the gate costs at each mesor so
/// the trade is visible to whoever does have the data to change it.
public static let minRelativeAmplitude: Double = 0.10
/// Max clock-shift the planner steps per day (hours) — the well-established ~1 h/day re-entrainment rate.
public static let maxShiftPerDayHours: Double = 1.0
Expand All @@ -39,8 +51,17 @@ public enum CircadianEngine {

// MARK: - Inputs

/// One per-hour rest-activity sample: the local clock hour (0..<24, may be fractional) and the motion
/// volume in that bin (e.g. StepsEstimateEngine.dayMotionIntensity per hour). Higher = more active.
/// One per-hour rest-activity sample: the local clock hour (0..<24, may be fractional) and the
/// rhythm signal in that bin. Higher = more active.
///
/// #982 — this said "motion volume (e.g. StepsEstimateEngine.dayMotionIntensity per hour)". It has
/// never been fed that. The only production caller pools per-hour MEAN HEART RATE in bpm
/// (`AppModel.swift`, `sums[hour] += b.bpm`), and that is the right choice on this hardware: WHOOP 4.0
/// motion is too sparse to stage sleep at all (#345), while HR carries a strong circadian rhythm.
///
/// The domain matters because `minRelativeAmplitude` gates on `amplitude / |mesor|`, and HR arrives
/// with a large DC offset that motion does not have — see that constant for what the gate actually
/// costs in bpm.
public struct ActivityBin: Equatable, Sendable {
public let hour: Double
public let activity: Double
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,33 @@ final class CircadianEngineTests: XCTestCase {
XCTAssertEqual(CircadianEngine.clock(-1.0), "23:00") // wraps
XCTAssertEqual(CircadianEngine.clock(7.25), "07:15")
}

// MARK: - #982: what the RELATIVE gate costs, in bpm, at a real HR mesor

/// The engine is fed mean HEART RATE, not the motion volume its doc used to claim, and
/// `minRelativeAmplitude` gates on `amplitude / |mesor|`. Against a signal carrying a ~45-75 bpm DC
/// offset that makes the real bar an ABSOLUTE `0.10 x mesor` bpm — which these pin.
///
/// The pair that matters is the last two: the SAME 5 bpm swing is arrhythmic at a 65 bpm mesor and
/// readable at 45. The bar scales WITH the mesor, so a low-resting wearer faces a LOWER absolute
/// requirement, not a higher one. #982 raised the opposite concern — that the gate penalises the
/// fittest — and that only holds for someone whose amplitude is disproportionately small for their
/// mesor. Pinned here so the direction is a fact rather than an argument, for whoever re-tunes it.
private func confidence(mesor: Double, amp: Double) -> CircadianEngine.PhaseConfidence {
CircadianEngine.estimatePhase(bins: profile(mesor: mesor, amp: amp, acrophase: 16),
daysObserved: CircadianEngine.goodDaysForFit,
habitualWakeHour: 7)!.confidence
}

func testTypicalMesorNeedsAboutSixAndAHalfBpmOfSwing() {
XCTAssertNotEqual(confidence(mesor: 65, amp: 8), .unreadable) // 0.123 - clears 0.10
XCTAssertEqual(confidence(mesor: 65, amp: 5), .unreadable) // 0.077 - does not
}

func testTheSameSwingIsReadableAtALowerMesor() {
// 5 bpm: arrhythmic at 65, rhythmic at 45. The gate favours the low-resting wearer.
XCTAssertEqual(confidence(mesor: 65, amp: 5), .unreadable)
XCTAssertNotEqual(confidence(mesor: 45, amp: 5), .unreadable) // 0.111
XCTAssertEqual(confidence(mesor: 45, amp: 4), .unreadable) // 0.089
}
}
16 changes: 15 additions & 1 deletion android/app/src/main/java/com/noop/analytics/CircadianEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,28 @@ object CircadianEngine {
// ── Tuning constants (pinned by test; mirror the Swift twin exactly) ──
const val minDaysForFit: Int = 7
const val goodDaysForFit: Int = 14
/**
* #982 — RELATIVE, applied to mean HR (mesor ~45-75 bpm), not to a near-zero-mesor motion volume. The
* effective bar is `0.10 x mesor`: ~6.5 bpm at a 65 bpm mesor, ~4.5 bpm at 45. It scales WITH the
* mesor, so a low-resting wearer faces a LOWER absolute bar — the opposite of the concern in #982.
* Deliberately NOT re-tuned: every candidate value is unvalidated and nobody is currently silenced.
* `CircadianEngineTest` pins what it costs at each mesor. Mirrors the Swift twin exactly.
*/
const val minRelativeAmplitude: Double = 0.10
const val maxShiftPerDayHours: Double = 1.0
const val cbtMinBeforeWakeHours: Double = 2.5
const val acrophaseAfterCbtMinHours: Double = 12.0

// ── Inputs ──

/** One per-hour rest-activity sample: local clock hour (0..<24, may be fractional) + motion volume. */
/**
* One per-hour rest-activity sample: local clock hour (0..<24, may be fractional) + the rhythm signal.
*
* #982 — this said "motion volume". It has never been fed that: the only production caller pools
* per-hour MEAN HEART RATE in bpm, and that is the right choice on this hardware (WHOOP 4.0 motion is
* too sparse to stage sleep at all, #345). The domain matters because [minRelativeAmplitude] gates on
* `amplitude / |mesor|` and HR arrives with a large DC offset motion does not have — see that constant.
*/
data class ActivityBin(val hour: Double, val activity: Double)

// ── Cosinor ──
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,35 @@ class CircadianEngineTest {
assertEquals("23:00", CircadianEngine.clock(-1.0))
assertEquals("07:15", CircadianEngine.clock(7.25))
}

// ── #982: what the RELATIVE gate costs, in bpm, at a real HR mesor ──

/**
* The engine is fed mean HEART RATE, not the motion volume its doc used to claim, and
* [CircadianEngine.minRelativeAmplitude] gates on `amplitude / |mesor|`. Against a signal carrying a
* ~45-75 bpm DC offset that makes the real bar an ABSOLUTE `0.10 x mesor` bpm.
*
* The pair that matters is [theSameSwingIsReadableAtALowerMesor]: the SAME 5 bpm swing is arrhythmic
* at a 65 bpm mesor and readable at 45, so the bar scales WITH the mesor and a low-resting wearer
* faces a LOWER absolute requirement. #982 raised the opposite concern. Pinned so the direction is a
* fact rather than an argument. Twin of the Swift `CircadianEngineTests` pair.
*/
private fun confidence(mesor: Double, amp: Double): CircadianEngine.PhaseConfidence =
CircadianEngine.estimatePhase(
profile(mesor, amp, 16.0),
CircadianEngine.goodDaysForFit,
7.0,
)!!.confidence

@Test fun typicalMesorNeedsAboutSixAndAHalfBpmOfSwing() {
assertTrue(confidence(65.0, 8.0) != CircadianEngine.PhaseConfidence.UNREADABLE) // 0.123
assertEquals(CircadianEngine.PhaseConfidence.UNREADABLE, confidence(65.0, 5.0)) // 0.077
}

@Test fun theSameSwingIsReadableAtALowerMesor() {
// 5 bpm: arrhythmic at 65, rhythmic at 45. The gate favours the low-resting wearer.
assertEquals(CircadianEngine.PhaseConfidence.UNREADABLE, confidence(65.0, 5.0))
assertTrue(confidence(45.0, 5.0) != CircadianEngine.PhaseConfidence.UNREADABLE) // 0.111
assertEquals(CircadianEngine.PhaseConfidence.UNREADABLE, confidence(45.0, 4.0)) // 0.089
}
}