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
118 changes: 118 additions & 0 deletions lib/src/onehz/clinical/hrv_time.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,84 @@ Metric<double> nocturnalRmssd(
);
}

/// Sleep-session nightly RMSSD (ms) as the arithmetic mean of cleaned
/// consecutive 5-minute window RMSSDs.
///
/// 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<double> sleepSessionWindowedRmssd(
List<double> rrMs,
List<double> 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<double>.absent(
tier: Tier.high,
inputs_used: inputs,
note: 'invalid or empty RR session window',
);
}

final buckets = <int, List<double>>{};
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] ??= <double>[]).add(rrMs[i]);
}

if (buckets.isEmpty) {
return const Metric<double>.absent(
tier: Tier.high,
inputs_used: inputs,
note: 'no RR beats inside the session window',
);
}

final rmssds = <double>[];
final indices = buckets.keys.toList()..sort();
for (final idx in indices) {
final cleaned = _cleanWindowRr(buckets[idx]!);
if (cleaned.length < 2) continue;
final rmssd = _rmssdRaw(cleaned);
if (rmssd != null) rmssds.add(rmssd);
}

if (rmssds.isEmpty) {
return const Metric<double>.absent(
tier: Tier.high,
inputs_used: inputs,
note: 'no valid 5-min windows for sleep-session RMSSD',
);
}

final meanRmssd = mean(rmssds)!;
final conf = clamp(rmssds.length / 12.0, 0.3, 0.95);
return Metric<double>(
value: meanRmssd,
confidence: conf,
tier: Tier.high,
inputs_used: inputs,
note: 'sleep-session HRV: mean RMSSD over cleaned 5-min windows.',
);
}

/// Group NN intervals into consecutive 5-minute (300 000 ms) segments by beat
/// time. Segments with <2 beats are dropped.
List<List<double>> _fiveMinSegments(List<double> nn, List<double> times) {
Expand All @@ -199,3 +277,43 @@ List<List<double>> _fiveMinSegments(List<double> nn, List<double> times) {
if (cur.length >= 2) out.add(cur);
return out;
}

List<double> _cleanWindowRr(List<double> rr) =>
_rejectWindowEctopic([for (final v in rr) if (v >= 300 && v <= 2000) v]);

List<double> _rejectWindowEctopic(List<double> nn) {
const radius = 2;
const threshold = 0.20;
if (nn.length <= radius) return nn;
final kept = <double>[];
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 = <double>[];
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<double> 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));
}
57 changes: 11 additions & 46 deletions lib/src/onehz/sleep/advanced_stager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import 'dart:math' as math;
import '../types.dart';
import '../clinical/hrv_time.dart';

// ── Input sample types (HrTs / GravTs / RrTs / RespTs)

Expand Down Expand Up @@ -632,23 +633,16 @@ class AdvancedSleepStager {
}

static double? _sessionAvgHRV(int start, int end, List<RrTs> 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 = <double>[];
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 = <double>[for (final r in rr) r.rrMs];
final rrTsMs = <double>[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 ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<double> _rangeFilter(List<double> rr) =>
[for (final v in rr) if (v >= _rrMinMs && v <= _rrMaxMs) v];

static List<double> _rejectEctopic(List<double> nn) {
if (nn.length <= _ectopicWindowRadius) return nn;
final kept = <double>[];
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 = <double>[];
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<double> _cleanRR(List<double> rr) => _rejectEctopic(_rangeFilter(rr));

static double? _rmssdRaw(List<double> nn) {
if (nn.length < 2) return null;
var sumSq = 0.0;
Expand Down
157 changes: 157 additions & 0 deletions lib/src/onehz/workout/hr_zones.dart
Original file line number Diff line number Diff line change
@@ -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<HeartRateZone> 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<double> seconds; // z1..z5
final double belowZone1;

const TimeInHeartRateZone({
required this.seconds,
required this.belowZone1,
}) : assert(seconds.length == 5);

double get total => seconds.fold<double>(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<String, int> 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<double> 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 = <HeartRateZone>[];
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<HrSample> hr,
HeartRateZoneSet zoneSet,
) {
final sorted = [...hr]..sort((a, b) => a.tsMs.compareTo(b.tsMs));
final zoneSeconds = List<double>.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<HrSample> sorted) {
if (sorted.length < 2) return 1.0;
final gaps = <double>[];
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);
}
}
1 change: 1 addition & 0 deletions lib/src/onehz/workout/workout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading