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
4 changes: 3 additions & 1 deletion ALGORITHMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative
| Function | File | Method | Citation |
|---|---|---|---|
| `vanHeesSleepWindow` | `sleep/van_hees.dart` | z-angle sleep/wake window detection | van Hees et al. |
| `immobilityMask` | `sleep/van_hees.dart` | the shared per-second z-angle immobility primitive (steps 1–3, no window selection) — used by both the nocturnal spine and naps | van Hees et al. |
| `detectNaps` | `sleep/nap.dart` | **the only nap source.** z-angle immobility bouts on the complement of the main sleep window, corroborated by an HR dip vs the user's *awake daytime* baseline. Reports TST and TIB separately; makes **no stage claim**. Deliberately NOT `AdvancedSleepStager`, whose `minSleepMin=60` + 90-min daytime guard exist to reject naps | van Hees et al. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `segmentSleep` (`SleepSegmentation`) | `sleep/segment.dart` | **single source of truth** for sleep windowing; delegates staging to `cardioStager` |
| `cardioStager` | `sleep/cardio_stager.dart` | Webster/Cole-Kripke actigraphy + HRV fusion — explicitly replaces Walch 2019 (documented WAKE over-call bias) |
| `AdvancedSleepStager` | `sleep/advanced_stager.dart` | AASM-style 4-class staging + hypnogram metrics (TIB/TST/SOL/WASO/REM-latency); `StagingMethod.cardio` is the wired production default, v1/v2 kept for regression coverage only |
Expand Down Expand Up @@ -126,7 +128,7 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative
| `roughNight` | `human/event_detection.dart` | neutral fallback descriptor when the signature is ambiguous | — |
| `percentileOfYou` / `personalRecord` | `human/percentile_of_you.dart` | percentile-vs-your-own-history, miss-tolerant personal-record streaks | — |
| `glassBoxReadiness` | `human/readiness_glassbox.dart` | **deprecated** — kept only for its percentile-of-you breakdown + narrative and edge back-compat; `readinessComposite` is canonical | — |
| `vo2maxEstimate` / `physiologicalAge` / `sleepNeed` / `strainTarget` / `recommendedBedtime` / `recommendedWake` / `sleepPerformance` / `detectNaps` | `human/coaching.dart` | deterministic coaching layer over the metrics above | Uth-Sørensen-style HR-ratio VO2max estimate (still `ESTIMATE`, never a lab claim) |
| `vo2maxEstimate` / `physiologicalAge` / `sleepNeed` / `strainTarget` / `recommendedBedtime` / `recommendedWake` / `sleepPerformance` | `human/coaching.dart` | deterministic coaching layer over the metrics above (naps come from `sleep/nap.dart`, not here) | Uth-Sørensen-style HR-ratio VO2max estimate (still `ESTIMATE`, never a lab claim) |
| `journalCorrelations` | `human/coaching.dart` | per-tag mean-difference correlation vs. logged outcomes, on-device, personal | — |

---
Expand Down
117 changes: 0 additions & 117 deletions lib/src/onehz/human/coaching.dart
Original file line number Diff line number Diff line change
@@ -1,124 +1,7 @@
import 'dart:math' as math;

import '../types.dart';
import '../sleep/advanced_stager.dart';

/// An index range [start, end) into the day's accel/hr arrays marking the MAIN
/// nocturnal sleep, so [detectNaps] can carve it (and its session) out.
class SleepWindowSpan {
final int start;
final int end;
const SleepWindowSpan(this.start, this.end);
}

class NapWindow {
final int startSec;
final int endSec;
final int durationSec;
final double confidence;
const NapWindow({
required this.startSec,
required this.endSec,
required this.durationSec,
required this.confidence,
});
}

/// Daytime naps as qualifying NON-MAIN sleep sessions from the single-source
/// [AdvancedSleepStager.detectSleep] pipeline. Reuses the exact same van Hees +
/// HR autonomic machinery the main sleep uses (no second detector): every
/// detected sleep session in [20 min, 3 h] that does NOT overlap [mainSleep] is
/// reported as a nap. HONEST: the same ESTIMATE ceiling as staging (wrist
/// autonomic, never PSG); returns an EMPTY list (present, low confidence) when
/// the detector finds no qualifying nap, and [Metric.absent] only when there is
/// too little data to run at all.
///
/// [accel]/[hr] 1 Hz gravity + HR for the whole day (same length/time base).
/// [mainSleep] index range of the main nocturnal sleep in those arrays, so it
/// (and any session overlapping it) is excluded. NapWindow start/end are seconds
/// RELATIVE to the first sample.
Metric<List<NapWindow>> detectNaps(
List<AccelSample> accel,
List<double> hr, {
SleepWindowSpan? mainSleep,
}) {
const inputs = ['accel_1hz', 'hr_1hz'];
const minNapSec = 20 * 60;
// Was 3h, which silently dropped a real second sleep block (biphasic/
// split sleep, shift work) — it's too long to be a nap but also loses to
// the main sleep pick, so it just vanished from every output with no
// signal it ever existed. 6h still excludes anything long enough to be
// arguably its own main sleep, while catching genuine secondary sleep.
const maxNapSec = 6 * 3600;
final n = math.min(accel.length, hr.length);
if (n < minNapSec) {
return const Metric<List<NapWindow>>.absent(
tier: Tier.estimate,
inputs_used: inputs,
note: 'too little data for nap detection (need ≥20 min)',
);
}

final baseSec = accel.first.tsMs ~/ 1000;
final grav = <GravTs>[
for (var i = 0; i < n; i++)
GravTs(accel[i].tsMs ~/ 1000, accel[i].x, accel[i].y, accel[i].z),
];
final hrTs = <HrTs>[
for (var i = 0; i < n; i++)
if (hr[i] > 0) HrTs(accel[i].tsMs ~/ 1000, hr[i]),
];

// Per-timestamp local offset (DST-correct) for the stager's daytime guard.
int tzAt(int ts) =>
DateTime.fromMillisecondsSinceEpoch(ts * 1000, isUtc: false)
.timeZoneOffset
.inSeconds;

final sessions =
AdvancedSleepStager.detectSleep(grav, hrTs, tzOffsetResolver: tzAt);

// Absolute-second bounds of the main sleep window (for overlap exclusion).
int? mainStartSec, mainEndSec;
if (mainSleep != null && mainSleep.end > mainSleep.start) {
final lo = mainSleep.start.clamp(0, n - 1);
final hi = (mainSleep.end - 1).clamp(0, n - 1);
mainStartSec = accel[lo].tsMs ~/ 1000;
mainEndSec = accel[hi].tsMs ~/ 1000;
}

final naps = <NapWindow>[];
for (final s in sessions) {
final dur = s.end - s.start;
if (dur < minNapSec || dur > maxNapSec) continue;
// Exclude the main nocturnal sleep: any session overlapping its window.
if (mainStartSec != null &&
mainEndSec != null &&
s.start < mainEndSec &&
s.end > mainStartSec) {
continue;
}
// Require the nap actually hold ≥20 min of asleep time (not just in-bed).
if (AdvancedSleepStager.hypnogramMetrics(s).tstS < minNapSec) continue;
naps.add(NapWindow(
startSec: s.start - baseSec,
endSec: s.end - baseSec,
durationSec: dur,
confidence: s.efficiency.clamp(0.0, 1.0),
));
}

return Metric<List<NapWindow>>(
value: naps,
confidence: naps.isEmpty ? 0.3 : 0.4,
tier: Tier.estimate,
inputs_used: inputs,
note: naps.isEmpty
? 'no qualifying naps (20 min–3 h) outside the main sleep window'
: '${naps.length} nap(s) via van Hees + HR autonomic ESTIMATE '
'(20 min–3 h, main sleep excluded); wrist estimate, not PSG',
);
}

class SleepNeed {
final double needSec;
Expand Down
Loading
Loading