From 3ab4c159e628e0a5d1a799d35fa2ad67c0df95e5 Mon Sep 17 00:00:00 2001 From: LocalEdge Date: Sun, 28 Jun 2026 22:23:17 +0200 Subject: [PATCH 1/3] HRV from main repo --- lib/src/onehz/clinical/hrv_time.dart | 117 +++++++++++++++++++++++++++ test/onehz/clinical_test.dart | 38 +++++++++ 2 files changed, 155 insertions(+) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index ea2d0cc..3157296 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -178,6 +178,83 @@ Metric nocturnalRmssd( ); } +/// NOOP-compatible nightly RMSSD (ms). +/// +/// Mirrors NOOP's `sessionAvgHRV`: split the detected sleep session into +/// consecutive 5-minute windows, apply the simple NOOP cleaner +/// (range-filter [300, 2000] ms + Malik-style ectopic rejection against a local +/// median), compute RMSSD inside each valid window, then return the ARITHMETIC +/// MEAN across windows. This is intentionally distinct from [nocturnalRmssd], +/// which uses cleaned NN + median-of-windows robustness. +/// +/// [rrMs]/[rrTsMs] are the raw RR intervals and their beat-end epoch times in +/// milliseconds. [startSec]/[endSec] bound the chosen sleep session in epoch +/// seconds. The implementation is one-pass over the time-sorted RR stream: +/// beats are bucketed once by `(tsSec - startSec) ~/ windowSec`. +Metric noopNightlyRmssd( + List rrMs, + List rrTsMs, { + required int startSec, + required int endSec, + int windowSec = 300, +}) { + const inputs = ['rr_sleep_window']; + if (startSec <= 0 || + endSec <= startSec || + rrMs.isEmpty || + rrTsMs.isEmpty || + rrMs.length != rrTsMs.length) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'invalid or empty RR session window', + ); + } + + final buckets = >{}; + for (var i = 0; i < rrMs.length; i++) { + final tsSec = (rrTsMs[i] / 1000.0).round(); + if (tsSec < startSec || tsSec >= endSec) continue; + final idx = ((tsSec - startSec) ~/ windowSec); + (buckets[idx] ??= []).add(rrMs[i]); + } + + if (buckets.isEmpty) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no RR beats inside the session window', + ); + } + + final rmssds = []; + final indices = buckets.keys.toList()..sort(); + for (final idx in indices) { + final cleaned = _noopCleanRr(buckets[idx]!); + if (cleaned.length < 2) continue; + final rmssd = _rmssdRaw(cleaned); + if (rmssd != null) rmssds.add(rmssd); + } + + if (rmssds.isEmpty) { + return const Metric.absent( + tier: Tier.high, + inputs_used: inputs, + note: 'no valid 5-min windows for noop nightly RMSSD', + ); + } + + final meanRmssd = mean(rmssds)!; + final conf = clamp(rmssds.length / 12.0, 0.3, 0.95); + return Metric( + value: meanRmssd, + confidence: conf, + tier: Tier.high, + inputs_used: inputs, + note: 'NOOP nightly HRV: mean RMSSD over cleaned 5-min sleep-session windows.', + ); +} + /// Group NN intervals into consecutive 5-minute (300 000 ms) segments by beat /// time. Segments with <2 beats are dropped. List> _fiveMinSegments(List nn, List times) { @@ -199,3 +276,43 @@ List> _fiveMinSegments(List nn, List times) { if (cur.length >= 2) out.add(cur); return out; } + +List _noopCleanRr(List rr) => + _noopRejectEctopic([for (final v in rr) if (v >= 300 && v <= 2000) v]); + +List _noopRejectEctopic(List nn) { + const radius = 2; + const threshold = 0.20; + if (nn.length <= radius) return nn; + final kept = []; + for (var i = 0; i < nn.length; i++) { + final lo = math.max(0, i - radius); + final hi = math.min(nn.length - 1, i + radius); + final neighbors = []; + for (var j = lo; j <= hi; j++) { + if (j != i) neighbors.add(nn[j]); + } + if (neighbors.length < 2) { + kept.add(nn[i]); + continue; + } + final med = median(neighbors); + if (med == null || med <= 0) { + kept.add(nn[i]); + continue; + } + final deviation = (nn[i] - med).abs() / med; + if (deviation <= threshold) kept.add(nn[i]); + } + return kept; +} + +double? _rmssdRaw(List nn) { + if (nn.length < 2) return null; + var sumSq = 0.0; + for (var i = 1; i < nn.length; i++) { + final d = nn[i] - nn[i - 1]; + sumSq += d * d; + } + return math.sqrt(sumSq / (nn.length - 1)); +} diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index a65b225..c6cff98 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -268,6 +268,44 @@ void main() { }); }); + group('NOOP nightly RMSSD (mean of cleaned 5-min windows)', () { + test('matches the arithmetic mean of per-window RMSSDs', () { + final rr = []; + final ts = []; + var beatTsMs = 0.0; + + void addWindow(List vals, double startTsMs) { + beatTsMs = startTsMs; + for (final v in vals) { + rr.add(v); + ts.add(beatTsMs); + beatTsMs += 1000.0; + } + } + + addWindow([1000, 1010, 990], 1000.0); // bucket 0, RMSSD = 15.8113883... + addWindow([1000, 1050, 950], 301000.0); // bucket 1, RMSSD = 79.0569415... + + final m = noopNightlyRmssd( + rr, + ts, + startSec: 1, + endSec: 601, + windowSec: 300, + ); + expect(m.present, isTrue); + expect(m.value, closeTo((15.8113883 + 79.0569415) / 2.0, 1e-6)); + }); + + test('drops out-of-range and Malik-style ectopic beats before RMSSD', () { + final rr = [1000, 1000, 200, 1000, 1000]; + final ts = [1000, 2000, 3000, 4000, 5000]; + final m = noopNightlyRmssd(rr, ts, startSec: 1, endSec: 301); + expect(m.present, isTrue); + expect(m.value, closeTo(0.0, 1e-9)); + }); + }); + group('strain score (0-21 log-squash of TRIMP)', () { test('pins TRIMP -> strain check-points', () { expect(strainScore(0), closeTo(0.0, 1e-9)); From 1371256d4b76146edc7eec0b88816426317cc9e8 Mon Sep 17 00:00:00 2001 From: LocalEdge Date: Sun, 28 Jun 2026 22:41:27 +0200 Subject: [PATCH 2/3] remove duplication --- lib/src/onehz/clinical/hrv_time.dart | 29 ++++++------ lib/src/onehz/sleep/advanced_stager.dart | 57 +++++------------------- test/onehz/clinical_test.dart | 6 +-- 3 files changed, 29 insertions(+), 63 deletions(-) diff --git a/lib/src/onehz/clinical/hrv_time.dart b/lib/src/onehz/clinical/hrv_time.dart index 3157296..897fd92 100644 --- a/lib/src/onehz/clinical/hrv_time.dart +++ b/lib/src/onehz/clinical/hrv_time.dart @@ -178,20 +178,21 @@ Metric nocturnalRmssd( ); } -/// NOOP-compatible nightly RMSSD (ms). +/// Sleep-session nightly RMSSD (ms) as the arithmetic mean of cleaned +/// consecutive 5-minute window RMSSDs. /// -/// Mirrors NOOP's `sessionAvgHRV`: split the detected sleep session into -/// consecutive 5-minute windows, apply the simple NOOP cleaner -/// (range-filter [300, 2000] ms + Malik-style ectopic rejection against a local -/// median), compute RMSSD inside each valid window, then return the ARITHMETIC -/// MEAN across windows. This is intentionally distinct from [nocturnalRmssd], -/// which uses cleaned NN + median-of-windows robustness. +/// Split the detected sleep session into consecutive 5-minute windows, apply a +/// simple RR cleaner (range-filter [300, 2000] ms + Malik-style ectopic +/// rejection against a local median), compute RMSSD inside each valid window, +/// then return the ARITHMETIC MEAN across windows. This is intentionally +/// distinct from [nocturnalRmssd], which uses cleaned NN + +/// median-of-windows robustness. /// /// [rrMs]/[rrTsMs] are the raw RR intervals and their beat-end epoch times in /// milliseconds. [startSec]/[endSec] bound the chosen sleep session in epoch /// seconds. The implementation is one-pass over the time-sorted RR stream: /// beats are bucketed once by `(tsSec - startSec) ~/ windowSec`. -Metric noopNightlyRmssd( +Metric sleepSessionWindowedRmssd( List rrMs, List rrTsMs, { required int startSec, @@ -230,7 +231,7 @@ Metric noopNightlyRmssd( final rmssds = []; final indices = buckets.keys.toList()..sort(); for (final idx in indices) { - final cleaned = _noopCleanRr(buckets[idx]!); + final cleaned = _cleanWindowRr(buckets[idx]!); if (cleaned.length < 2) continue; final rmssd = _rmssdRaw(cleaned); if (rmssd != null) rmssds.add(rmssd); @@ -240,7 +241,7 @@ Metric noopNightlyRmssd( return const Metric.absent( tier: Tier.high, inputs_used: inputs, - note: 'no valid 5-min windows for noop nightly RMSSD', + note: 'no valid 5-min windows for sleep-session RMSSD', ); } @@ -251,7 +252,7 @@ Metric noopNightlyRmssd( confidence: conf, tier: Tier.high, inputs_used: inputs, - note: 'NOOP nightly HRV: mean RMSSD over cleaned 5-min sleep-session windows.', + note: 'sleep-session HRV: mean RMSSD over cleaned 5-min windows.', ); } @@ -277,10 +278,10 @@ List> _fiveMinSegments(List nn, List times) { return out; } -List _noopCleanRr(List rr) => - _noopRejectEctopic([for (final v in rr) if (v >= 300 && v <= 2000) v]); +List _cleanWindowRr(List rr) => + _rejectWindowEctopic([for (final v in rr) if (v >= 300 && v <= 2000) v]); -List _noopRejectEctopic(List nn) { +List _rejectWindowEctopic(List nn) { const radius = 2; const threshold = 0.20; if (nn.length <= radius) return nn; diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index edf2c12..d3236f1 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -16,6 +16,7 @@ import 'dart:math' as math; import '../types.dart'; +import '../clinical/hrv_time.dart'; // ── Input sample types (HrTs / GravTs / RrTs / RespTs) @@ -632,23 +633,16 @@ class AdvancedSleepStager { } static double? _sessionAvgHRV(int start, int end, List rr) { - final seg = [for (final r in rr) if (r.ts >= start && r.ts <= end) r]; - if (seg.isEmpty) return null; - const windowS = 300; - final vals = []; - var t = start; - while (t < end) { - final bucket = - [for (final r in seg) if (r.ts >= t && r.ts < t + windowS) r.rrMs]; - final cleaned = _cleanRR(bucket); - if (cleaned.length >= 2) { - final r = _rmssdRaw(cleaned); - if (r != null) vals.add(r); - } - t += windowS; - } - if (vals.isEmpty) return null; - return vals.reduce((a, b) => a + b) / vals.length; + if (start <= 0 || end <= start || rr.isEmpty) return null; + final rrMs = [for (final r in rr) r.rrMs]; + final rrTsMs = [for (final r in rr) r.ts * 1000.0]; + final metric = sleepSessionWindowedRmssd( + rrMs, + rrTsMs, + startSec: start, + endSec: end, + ); + return metric.present ? metric.value : null; } // ── Epoch grid ────────────────────────────────────────────────────────────── @@ -1576,39 +1570,10 @@ class AdvancedSleepStager { // ── shared math (population std, percentile) ────────────────────────────── static const double _rrMinMs = 300, _rrMaxMs = 2000; - static const double _ectopicThreshold = 0.20; - static const int _ectopicWindowRadius = 2; static List _rangeFilter(List rr) => [for (final v in rr) if (v >= _rrMinMs && v <= _rrMaxMs) v]; - static List _rejectEctopic(List nn) { - if (nn.length <= _ectopicWindowRadius) return nn; - final kept = []; - for (var i = 0; i < nn.length; i++) { - final lo = math.max(0, i - _ectopicWindowRadius); - final hi = math.min(nn.length - 1, i + _ectopicWindowRadius); - final neighbours = []; - for (var j = lo; j <= hi; j++) { - if (j != i) neighbours.add(nn[j]); - } - if (neighbours.length < 2) { - kept.add(nn[i]); - continue; - } - final med = _median(neighbours)!; - if (med <= 0) { - kept.add(nn[i]); - continue; - } - final dev = (nn[i] - med).abs() / med; - if (dev <= _ectopicThreshold) kept.add(nn[i]); - } - return kept; - } - - static List _cleanRR(List rr) => _rejectEctopic(_rangeFilter(rr)); - static double? _rmssdRaw(List nn) { if (nn.length < 2) return null; var sumSq = 0.0; diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index c6cff98..e5644aa 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -268,7 +268,7 @@ void main() { }); }); - group('NOOP nightly RMSSD (mean of cleaned 5-min windows)', () { + group('sleep-session nightly RMSSD (mean of cleaned 5-min windows)', () { test('matches the arithmetic mean of per-window RMSSDs', () { final rr = []; final ts = []; @@ -286,7 +286,7 @@ void main() { addWindow([1000, 1010, 990], 1000.0); // bucket 0, RMSSD = 15.8113883... addWindow([1000, 1050, 950], 301000.0); // bucket 1, RMSSD = 79.0569415... - final m = noopNightlyRmssd( + final m = sleepSessionWindowedRmssd( rr, ts, startSec: 1, @@ -300,7 +300,7 @@ void main() { test('drops out-of-range and Malik-style ectopic beats before RMSSD', () { final rr = [1000, 1000, 200, 1000, 1000]; final ts = [1000, 2000, 3000, 4000, 5000]; - final m = noopNightlyRmssd(rr, ts, startSec: 1, endSec: 301); + final m = sleepSessionWindowedRmssd(rr, ts, startSec: 1, endSec: 301); expect(m.present, isTrue); expect(m.value, closeTo(0.0, 1e-9)); }); From 29cdd215b6fd8e97bf71952e87babf367656e6ac Mon Sep 17 00:00:00 2001 From: LocalEdge Date: Sun, 28 Jun 2026 23:23:29 +0200 Subject: [PATCH 3/3] improve HR Zone compute --- lib/src/onehz/workout/hr_zones.dart | 157 ++++++++++++++++++++++++++++ lib/src/onehz/workout/workout.dart | 1 + test/onehz/clinical_test.dart | 43 ++++++++ 3 files changed, 201 insertions(+) create mode 100644 lib/src/onehz/workout/hr_zones.dart diff --git a/lib/src/onehz/workout/hr_zones.dart b/lib/src/onehz/workout/hr_zones.dart new file mode 100644 index 0000000..5bd2e7c --- /dev/null +++ b/lib/src/onehz/workout/hr_zones.dart @@ -0,0 +1,157 @@ +import 'dart:math' as math; + +import '../types.dart'; + +/// One display heart-rate zone defined by a bpm interval. +class HeartRateZone { + final int number; // 1..5 + final double lower; // inclusive bpm + final double upper; // exclusive except zone 5 + final double lowerPct; // fraction of HRmax + final double upperPct; // fraction of HRmax + + const HeartRateZone({ + required this.number, + required this.lower, + required this.upper, + required this.lowerPct, + required this.upperPct, + }); +} + +/// The five display heart-rate zones built from a max HR. +class HeartRateZoneSet { + final List zones; + final double maxHr; + final String source; // "tanaka" or "manual" + + const HeartRateZoneSet({ + required this.zones, + required this.maxHr, + required this.source, + }) : assert(zones.length == 5); + + /// Return the zone number (1..5), or 0 when below zone 1. + int zoneNumber(double bpm) { + for (final zone in zones) { + if (zone.number == 5) { + if (bpm >= zone.lower) return 5; + } else if (bpm >= zone.lower && bpm < zone.upper) { + return zone.number; + } + } + return 0; + } +} + +/// Time spent in each display heart-rate zone. +class TimeInHeartRateZone { + final List seconds; // z1..z5 + final double belowZone1; + + const TimeInHeartRateZone({ + required this.seconds, + required this.belowZone1, + }) : assert(seconds.length == 5); + + double get total => seconds.fold(belowZone1, (sum, v) => sum + v); + + double secondsInZone(int zone) => + zone >= 1 && zone <= 5 ? seconds[zone - 1] : 0; + + /// Rounded whole minutes per zone, suitable for the app's existing payload. + Map toRoundedMinuteMap() => { + 'z1': (seconds[0] / 60.0).round(), + 'z2': (seconds[1] / 60.0).round(), + 'z3': (seconds[2] / 60.0).round(), + 'z4': (seconds[3] / 60.0).round(), + 'z5': (seconds[4] / 60.0).round(), + }; +} + +/// Canonical display HR zones: %HRmax bands with duration-aware accumulation. +class HeartRateZones { + /// Zone edges for z1..z5: 50/60/70/80/90/100% HRmax. + static const List zoneEdges = [0.50, 0.60, 0.70, 0.80, 0.90, 1.00]; + + /// Tanaka (2001) age-predicted max HR. + static double tanakaMaxHr(double age) => 208.0 - 0.7 * age; + + /// Build zones from age or a manual max-HR override. + static HeartRateZoneSet zones({ + required double age, + double? maxHrOverride, + }) { + if (maxHrOverride != null) { + return zonesFromMaxHr(maxHrOverride, source: 'manual'); + } + return zonesFromMaxHr(tanakaMaxHr(age), source: 'tanaka'); + } + + /// Build zones directly from a known max HR. + static HeartRateZoneSet zonesFromMaxHr(double maxHr, {String source = 'manual'}) { + final built = []; + for (var i = 0; i < 5; i++) { + final loPct = zoneEdges[i]; + final hiPct = zoneEdges[i + 1]; + built.add(HeartRateZone( + number: i + 1, + lower: loPct * maxHr, + upper: hiPct * maxHr, + lowerPct: loPct, + upperPct: hiPct, + )); + } + return HeartRateZoneSet(zones: built, maxHr: maxHr, source: source); + } + + /// Time-in-zone from a time-ordered HR stream. + /// + /// Each sample is credited with the duration until the next sample. The tail + /// sample gets the median plausible interval so a regular stream is fully + /// accounted for without letting one pathological gap dominate a zone. + static TimeInHeartRateZone timeInZone( + List hr, + HeartRateZoneSet zoneSet, + ) { + final sorted = [...hr]..sort((a, b) => a.tsMs.compareTo(b.tsMs)); + final zoneSeconds = List.filled(5, 0); + var below = 0.0; + if (sorted.isEmpty) { + return TimeInHeartRateZone(seconds: zoneSeconds, belowZone1: 0); + } + + final tailSeconds = _medianIntervalSeconds(sorted); + for (var i = 0; i < sorted.length; i++) { + final sample = sorted[i]; + if (!sample.valid) continue; + final durSeconds = i < sorted.length - 1 + ? _boundedGapSeconds(sorted[i + 1].tsMs - sample.tsMs, tailSeconds) + : tailSeconds; + final zone = zoneSet.zoneNumber(sample.hr); + if (zone >= 1) { + zoneSeconds[zone - 1] += durSeconds; + } else { + below += durSeconds; + } + } + return TimeInHeartRateZone(seconds: zoneSeconds, belowZone1: below); + } + + static double _boundedGapSeconds(double gapMs, double fallbackSeconds) { + final gapSeconds = gapMs / 1000.0; + return gapSeconds > 0 ? math.min(gapSeconds, fallbackSeconds) : fallbackSeconds; + } + + static double _medianIntervalSeconds(List sorted) { + if (sorted.length < 2) return 1.0; + final gaps = []; + for (var i = 1; i < sorted.length; i++) { + final gapSeconds = (sorted[i].tsMs - sorted[i - 1].tsMs) / 1000.0; + if (gapSeconds > 0 && gapSeconds <= 300) gaps.add(gapSeconds); + } + if (gaps.isEmpty) return 1.0; + gaps.sort(); + return math.max(gaps[gaps.length ~/ 2], 1.0); + } +} diff --git a/lib/src/onehz/workout/workout.dart b/lib/src/onehz/workout/workout.dart index 6800203..358adfe 100644 --- a/lib/src/onehz/workout/workout.dart +++ b/lib/src/onehz/workout/workout.dart @@ -20,4 +20,5 @@ library onehz_workout; export 'sport.dart'; export 'calories.dart'; export 'auto_detect.dart'; +export 'hr_zones.dart'; export 'workout_detect.dart'; diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index e5644aa..3e65b64 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -207,6 +207,49 @@ void main() { }); }); + group('display heart-rate zones', () { + test('builds Tanaka %HRmax zones and includes HRmax in zone 5', () { + final zones = HeartRateZones.zones(age: 40); + expect(zones.source, 'tanaka'); + expect(zones.maxHr, closeTo(180.0, 1e-9)); + expect(zones.zoneNumber(89.9), 0); + expect(zones.zoneNumber(90.0), 1); + expect(zones.zoneNumber(108.0), 2); + expect(zones.zoneNumber(180.0), 5); + }); + + test('accumulates duration until next sample and rounds to zone minutes', () { + final zoneSet = HeartRateZones.zonesFromMaxHr(200); + final time = HeartRateZones.timeInZone([ + const HrSample(0, 110), // z1 for 60 s + const HrSample(60000, 130), // z2 for 60 s + const HrSample(120000, 150), // z3 for 60 s + const HrSample(180000, 170), // z4 for 60 s + const HrSample(240000, 190), // z5 for tail median 60 s + ], zoneSet); + expect(time.secondsInZone(1), closeTo(60, 1e-9)); + expect(time.secondsInZone(2), closeTo(60, 1e-9)); + expect(time.secondsInZone(3), closeTo(60, 1e-9)); + expect(time.secondsInZone(4), closeTo(60, 1e-9)); + expect(time.secondsInZone(5), closeTo(60, 1e-9)); + expect(time.toRoundedMinuteMap(), + {'z1': 1, 'z2': 1, 'z3': 1, 'z4': 1, 'z5': 1}); + }); + + test('caps pathological gaps at the median plausible interval', () { + final zoneSet = HeartRateZones.zonesFromMaxHr(200); + final time = HeartRateZones.timeInZone([ + const HrSample(0, 130), // z2 + const HrSample(1000, 150), // z3 + const HrSample(2000, 190), // z5, next gap huge + const HrSample(700000, 190), // huge gap capped to 1 s + ], zoneSet); + expect(time.secondsInZone(2), closeTo(1, 1e-9)); + expect(time.secondsInZone(3), closeTo(1, 1e-9)); + expect(time.secondsInZone(5), closeTo(2, 1e-9)); + }); + }); + group('robust nocturnal RMSSD (median-of-5min-windows)', () { test('robust RMSSD tracks the stable level while whole-night is inflated', () {