diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 16eb8578b..da8857c74 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -10269,7 +10269,13 @@ class DerivationEngine { 'confidence': nap.confidence, }, ]; - candidates?.addAll(detected.where((n) => !napEdits.any((e) => + // A nap longer than kMaxNapSec (3 h) is a sleep, not a nap — drop it + // before it can reach the suggestions, `nap_min`, sleep need or the + // timeline. Manual entries are already capped at entry time by + // manualNapWindowIsValid, which shares the same constant. The merge + // input below stays as-is; only the detected list is capped. + final capped = dropOversizedNaps(detected); + candidates?.addAll(capped.where((n) => !napEdits.any((e) => e.overlaps(n['start'] as int, n['end'] as int)))); final merged = applyNapEdits(const [], napEdits); diff --git a/lib/compute/nap_edits.dart b/lib/compute/nap_edits.dart index ccc49887d..2f55908c9 100644 --- a/lib/compute/nap_edits.dart +++ b/lib/compute/nap_edits.dart @@ -54,9 +54,14 @@ typedef NapMap = Map; /// scatter of two-minute entries would swamp the real ones in every total. const int kMinManualNapSec = 5 * 60; -/// Maximum. Anything longer is a sleep, not a nap, and belongs in the main -/// sleep window where the stager can actually say something about its stages. -const int kMaxManualNapSec = 6 * 60 * 60; +/// Maximum length of anything counted as a nap: 3 hours. Anything longer is a +/// sleep, not a nap, and belongs in the main sleep window where the stager can +/// actually say something about its stages. Applies to detected AND manually +/// logged naps alike — one cap, one place. +const int kMaxNapSec = 3 * 60 * 60; + +/// Maximum for a manually logged nap — the same cap as detected naps. +const int kMaxManualNapSec = kMaxNapSec; /// Whether a window can be logged as a nap. bool manualNapWindowIsValid(int startSec, int endSec) { @@ -129,6 +134,16 @@ List applyNapEdits(List detected, List edits) { return out; } +/// Drops every nap whose window exceeds [kMaxNapSec]. A detected bout longer +/// than 3 h is a sleep fragment the detector misfiled as a nap; crediting it +/// into `nap_min` would count a night's sleep as daytime rest. The filter is +/// pure so the rule is testable without a derivation run. +List dropOversizedNaps(List naps) => [ + for (final n in naps) + if ((n['end'] as num).toInt() - (n['start'] as num).toInt() <= kMaxNapSec) + n, + ]; + /// Total asleep minutes across [naps] — the `nap_min` scalar. /// /// Always a number, including 0 for an empty list. The absent-versus-zero diff --git a/test/nap_edits_test.dart b/test/nap_edits_test.dart index 2b745b4bf..36bd4f9cf 100644 --- a/test/nap_edits_test.dart +++ b/test/nap_edits_test.dart @@ -218,12 +218,37 @@ void main() { expect(manualNapWindowIsValid(0, kMaxManualNapSec), isTrue); }); + test('the cap is 3 hours — exactly 3h is a nap, 3h+1s is a night', () { + expect(kMaxNapSec, 3 * hour); + expect(kMaxManualNapSec, kMaxNapSec); + expect(manualNapWindowIsValid(0, 3 * hour), isTrue); + expect(manualNapWindowIsValid(0, 3 * hour + 1), isFalse); + }); + test('rejects a backwards or empty window', () { expect(manualNapWindowIsValid(100, 100), isFalse); expect(manualNapWindowIsValid(200, 100), isFalse); }); }); + group('dropOversizedNaps', () { + test('keeps naps up to 3h, drops longer ones', () { + final naps = [ + {'start': 0, 'end': 2 * hour, 'duration_min': 120}, + {'start': 3 * hour, 'end': 6 * hour, 'duration_min': 180}, // exactly 3h + {'start': 7 * hour, 'end': 11 * hour, 'duration_min': 240}, // 4h -> drop + ]; + final kept = dropOversizedNaps(naps); + expect(kept.length, 2); + expect(kept[0]['end'], 2 * hour); + expect(kept[1]['end'], 6 * hour); + }); + + test('an empty list stays empty', () { + expect(dropOversizedNaps(const []), isEmpty); + }); + }); + group('manualNapWindowHasEnded', () { test('accepts a nap that is over', () { expect(manualNapWindowHasEnded(14 * hour, 15 * hour), isTrue);