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 @@ -331,12 +331,11 @@ public enum HRVAnalyzer {
public init(ts: Int, rmssd: Double) { self.ts = ts; self.rmssd = rmssd }
}

/// Pure rolling/windowed rMSSD over an R-R series (#803). For each input interval, the window is the
/// trailing `windowSec` seconds ending at that interval's `ts`; the window's R-R values are cleaned with
/// the SAME range filter + Malik ectopic rejection the nightly path uses (`cleanRR`), and a point is
/// emitted only when at least `minBeatsPerWindow` clean intervals survive (so a sparse / artifact-heavy
/// window emits nothing rather than a noisy spike). The result is one `(ts, rMSSD)` per qualifying
/// window, in input order.
/// Pure rolling/windowed rMSSD over an R-R series (#803). For each input interval at `ts`, the window is
/// `(ts - windowSec, ts]`; that window's raw R-R values are cleaned locally with the SAME range filter +
/// Malik ectopic rejection the nightly path uses (`cleanRR`). A point is emitted only when at least
/// `minBeatsPerWindow` clean intervals survive (so a sparse / artifact-heavy window emits nothing rather
/// than a noisy spike). The result is one `(ts, rMSSD)` per qualifying window, in timestamp order.
///
/// - Parameters:
/// - rr: the R-R intervals (each carries its own wall-clock `ts` and `rrMs`). Need not be pre-sorted;
Expand All @@ -358,8 +357,8 @@ public enum HRVAnalyzer {
var left = 0 // index of the oldest interval still inside the trailing window
for right in 0..<sorted.count {
let edgeTs = sorted[right].ts
// Advance the left edge so [left, right] spans only the trailing `windowSec` ending at edgeTs.
while left < right && edgeTs - sorted[left].ts > windowSec { left += 1 }
// Advance the left edge so [left, right] contains exactly (edgeTs - windowSec, edgeTs].
while left < right && edgeTs - sorted[left].ts >= windowSec { left += 1 }
// Thinning stride: skip emitting until at least `stepSec` has passed since the last emitted point.
if stepSec > 0, let last = lastEmitTs, edgeTs - last < stepSec { continue }
// Clean the window's raw R-R values with the shared range + Malik ectopic pipeline, then
Expand Down
55 changes: 21 additions & 34 deletions android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -651,25 +651,25 @@ object HrvAnalyzer {
// The Deep Timeline's "HRV" trace used to plot RAW RR-interval values (ms) and label them "HRV",
// which is dishonest: an RR interval is NOT an HRV number, and the spikiness it shows is just the
// beat-to-beat heart period, not variability. [rollingRmssd] is the pure, on-device twin of the
// Swift HRVAnalyzer.rollingRmssd: it slides a [windowSec] window across the cleaned RR series and,
// for each RR sample, emits the Task-Force rMSSD over the beats inside that trailing window. The
// SAME Malik/range artifact filter the nightly path uses ([cleanRR]) is applied first, so an
// ectopic beat or an out-of-range RR can't inflate the curve. The Deep Timeline plots THIS, relabelled
// to honest windowed rMSSD. Pure: no clock, no IO. (#803)
// Swift HRVAnalyzer.rollingRmssd: it slides a [windowSec] window across the raw RR series and, for
// each RR sample, cleans the beats inside that trailing window before emitting the Task-Force rMSSD.
// The SAME Malik/range artifact filter the nightly path uses ([cleanRR]) is applied independently in
// each window, so an ectopic beat or an out-of-range RR can't inflate the curve or couple otherwise
// separate windows. The Deep Timeline plots THIS, relabelled to honest windowed rMSSD. Pure: no clock,
// no IO. (#803)

/** Default rolling-window width (seconds) for the Deep Timeline windowed rMSSD trace. ~5 min, the
* shortest span the Task Force calls a short-term recording, so the curve has enough beats to mean
* something without smoothing away the within-night swings. */
const val DEFAULT_ROLLING_WINDOW_SEC: Int = 300

/**
* Rolling/windowed rMSSD over an RR series. For each input sample (ascending by ts), computes the
* Task-Force rMSSD over the cleaned beats whose ts falls in the trailing window `(ts - windowSec, ts]`,
* emitting `(ts, rmssd)` only when at least [minBeatsPerWindow] clean beats survive in that window (a
* 2-beat window is one successive difference = a noisy spike, not HRV — matches the Swift twin's
* minBeatsPerWindow gate). Range + Malik ectopic filtering ([cleanRR]) is applied to the WHOLE series
* once, so artifacts never enter any window. Empty when fewer than [minBeatsPerWindow] input rows.
* Pure, deterministic.
* Rolling/windowed rMSSD over an RR series. For each input sample at `ts`, the window is
* `(ts - windowSec, ts]`; that window's raw RR values are cleaned locally with the same range + Malik
* ectopic filter the nightly path uses ([cleanRR]). Emits `(ts, rmssd)` only when at least
* [minBeatsPerWindow] clean beats survive (a 2-beat window is one successive difference = a noisy
* spike, not HRV — matches the Swift twin's minBeatsPerWindow gate). Empty when fewer than
* [minBeatsPerWindow] input rows. Pure, deterministic.
*
* @param rr the RR intervals (each carries a ts in unix SECONDS and rrMs); the Android twin of the
* Swift `[RRSample]`.
Expand All @@ -692,39 +692,26 @@ object HrvAnalyzer {
// Ascending by ts so the trailing-window scan is monotone (the table read is already ordered, but
// we don't assume it). Stable on equal ts.
val sorted = rr.sortedBy { it.ts }
// Clean the WHOLE series first (range + Malik ectopic), keeping each surviving beat's ts so a
// window can be cut by timestamp. cleanRR works on the raw ms values; we re-pair to ts by walking
// the same filters here so the kept ts/ms stay aligned (cleanRR drops items, losing the index map).
val ranged = sorted.filter { it.rrMs.toDouble() in RR_MIN_MS..RR_MAX_MS }
if (ranged.size < 2) return emptyList()
val cleanMs = rejectEctopic(ranged.map { it.rrMs.toDouble() })
// rejectEctopic preserves order and only drops items, so re-pair by walking both in lockstep: a
// kept ms value corresponds to the next not-yet-consumed ranged beat with that value.
val kept = ArrayList<RrInterval>(cleanMs.size)
var ri = 0
for (ms in cleanMs) {
while (ri < ranged.size && ranged[ri].rrMs.toDouble() != ms) ri++
if (ri < ranged.size) { kept.add(ranged[ri]); ri++ }
}
if (kept.size < 2) return emptyList()
val window = windowSec.toLong()
val out = ArrayList<Pair<Long, Double>>(kept.size)
val out = ArrayList<Pair<Long, Double>>(sorted.size)
var lo = 0
var lastEmitTs: Long? = null
for (hi in kept.indices) {
val tEnd = kept[hi].ts
for (hi in sorted.indices) {
val tEnd = sorted[hi].ts
val tStart = tEnd - window
// Advance the trailing edge so only beats with ts in (tStart, tEnd] remain.
while (lo < hi && kept[lo].ts <= tStart) lo++
while (lo < hi && sorted[lo].ts <= tStart) lo++
// Thinning stride (#1036): skip emitting until at least [stepSec] has passed since the last
// EMITTED point (measured against emits, not candidates), matching the Swift twin's stepSec branch.
val last = lastEmitTs
if (stepSec > 0 && last != null && tEnd - last < stepSec) continue
val span = kept.subList(lo, hi + 1).map { it.rrMs.toDouble() }
// Clean this raw window in place. Timestamps stay on the samples used to define the window,
// so no value-based matching back to timestamps is needed after cleaning.
val clean = cleanRR(sorted.subList(lo, hi + 1).map { it.rrMs.toDouble() })
// A window with too few clean beats is a noisy spike, not a trustworthy rMSSD — require
// [minBeatsPerWindow] survivors (#1035), matching the Swift HRVAnalyzer.rollingRmssd default (8).
if (span.size < minBeatsPerWindow) continue
val r = rmssdRaw(span) ?: continue
if (clean.size < minBeatsPerWindow) continue
val r = rmssdRaw(clean) ?: continue
out.add(tEnd to r)
lastEmitTs = tEnd
}
Expand Down