From 539031b4b144b29dbafb6cd588a35612947d0db0 Mon Sep 17 00:00:00 2001 From: Gideon Zenz Date: Tue, 24 Mar 2026 17:17:26 +0100 Subject: [PATCH] SleepAnalysis: Fix awake duration overflow when gap is not bridged Only commit gap time to awakeSleepDuration when sleep actually resumes (bridge confirmed), instead of speculatively counting all non-sleep time as awake. Fixes total duration exceeding session span. --- .../activities/charts/SleepAnalysis.java | 3 +- .../activities/charts/SleepAnalysisTest.java | 261 ++++++++++++++++++ 2 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysisTest.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysis.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysis.java index 1e188f8ef2..dd1bda928d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysis.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysis.java @@ -44,6 +44,8 @@ public class SleepAnalysis { if (isSleep(sample)) { if (sleepStart == null) sleepStart = getDateFromSample(sample); + else + awakeSleepDuration += durationSinceLastSleep; sleepEnd = getDateFromSample(sample); durationSinceLastSleep = 0; @@ -77,7 +79,6 @@ public class SleepAnalysis { } else { durationSinceLastSleep += durationSinceLastSample; if (sleepStart != null) { - awakeSleepDuration += durationSinceLastSample; if (durationSinceLastSleep > MAX_WAKE_PHASE_LENGTH) { if (lightSleepDuration + deepSleepDuration + remSleepDuration + awakeSleepDuration > MIN_SESSION_LENGTH) result.add(new SleepSession(sleepStart, sleepEnd, lightSleepDuration, deepSleepDuration, remSleepDuration, awakeSleepDuration)); diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysisTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysisTest.java new file mode 100644 index 0000000000..79e177da9e --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysisTest.java @@ -0,0 +1,261 @@ +package nodomain.freeyourgadget.gadgetbridge.activities.charts; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class SleepAnalysisTest { + + private static final int BASE = 1_000_000; + private static final int MIN = 60; + + @Test + public void testEmptySamples() { + List sessions = + new SleepAnalysis().calculateSleepSessions(Collections.emptyList()); + assertEquals(0, sessions.size()); + } + + @Test + public void testSingleSleepType() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 5 * MIN, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 10 * MIN, ActivityKind.LIGHT_SLEEP) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(1, sessions.size()); + assertSession(sessions.get(0), BASE, BASE + 10 * MIN, 600, 0, 0, 0); + } + + @Test + public void testMixedSleepTypes() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 5 * MIN, ActivityKind.DEEP_SLEEP), + sleep(BASE + 10 * MIN, ActivityKind.REM_SLEEP), + sleep(BASE + 15 * MIN, ActivityKind.LIGHT_SLEEP) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(1, sessions.size()); + assertSession(sessions.get(0), BASE, BASE + 15 * MIN, 300, 300, 300, 0); + } + + @Test + public void testSessionTooShort() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 4 * MIN, ActivityKind.LIGHT_SLEEP) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(0, sessions.size()); + } + + @Test + public void testGapBridgedByResumingSleep() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 10 * MIN, ActivityKind.DEEP_SLEEP), + idle(BASE + 20 * MIN), + idle(BASE + 28 * MIN), + sleep(BASE + 36 * MIN, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 46 * MIN, ActivityKind.DEEP_SLEEP) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(1, sessions.size()); + assertSession(sessions.get(0), BASE, BASE + 46 * MIN, + 480, + 1200, + 0, + 1080); + } + + @Test + public void testGapNotBridgedByActivity() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.DEEP_SLEEP), + sleep(BASE + 10 * MIN, ActivityKind.DEEP_SLEEP), + idle(BASE + 20 * MIN), + activity(BASE + 30 * MIN, 100) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(1, sessions.size()); + assertSession(sessions.get(0), BASE, BASE + 10 * MIN, 0, 600, 0, 0); + } + + @Test + public void testGapNotBridgedByTimeout() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.DEEP_SLEEP), + sleep(BASE + 30 * MIN, ActivityKind.DEEP_SLEEP), + idle(BASE + 61 * MIN), + idle(BASE + 92 * MIN) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(1, sessions.size()); + assertSession(sessions.get(0), BASE, BASE + 30 * MIN, 0, 1800, 0, 0); + } + + @Test + public void testTrailingNonSleepExcludedFromAwake() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 10 * MIN, ActivityKind.DEEP_SLEEP), + idle(BASE + 20 * MIN), + idle(BASE + 30 * MIN) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(1, sessions.size()); + assertSession(sessions.get(0), BASE, BASE + 10 * MIN, 0, 600, 0, 0); + } + + @Test + public void testMultipleBridgedGaps() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.DEEP_SLEEP), + sleep(BASE + 10 * MIN, ActivityKind.DEEP_SLEEP), + idle(BASE + 20 * MIN), + sleep(BASE + 28 * MIN, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 38 * MIN, ActivityKind.LIGHT_SLEEP), + idle(BASE + 48 * MIN), + sleep(BASE + 56 * MIN, ActivityKind.REM_SLEEP), + sleep(BASE + 66 * MIN, ActivityKind.REM_SLEEP) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(1, sessions.size()); + assertSession(sessions.get(0), BASE, BASE + 66 * MIN, + 1080, + 600, + 1080, + 1200); + } + + @Test + public void testDeviceReportedAwakeSleep() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.DEEP_SLEEP), + sleep(BASE + 10 * MIN, ActivityKind.DEEP_SLEEP), + sleep(BASE + 20 * MIN, ActivityKind.AWAKE_SLEEP), + sleep(BASE + 30 * MIN, ActivityKind.AWAKE_SLEEP), + sleep(BASE + 40 * MIN, ActivityKind.LIGHT_SLEEP) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(1, sessions.size()); + assertSession(sessions.get(0), BASE, BASE + 40 * MIN, 600, 600, 0, 1200); + } + + @Test + public void testMixedDeviceAwakeAndBridgedGap() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.DEEP_SLEEP), + sleep(BASE + 10 * MIN, ActivityKind.DEEP_SLEEP), + sleep(BASE + 20 * MIN, ActivityKind.AWAKE_SLEEP), + idle(BASE + 30 * MIN), + sleep(BASE + 38 * MIN, ActivityKind.LIGHT_SLEEP) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(1, sessions.size()); + assertSession(sessions.get(0), BASE, BASE + 38 * MIN, + 480, + 600, + 0, + 1200); + } + + @Test + public void testMultipleSessions() { + List samples = Arrays.asList( + sleep(BASE, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 10 * MIN, ActivityKind.DEEP_SLEEP), + activity(BASE + 10 * MIN + 1, 200), + sleep(BASE + 10 * MIN + 2, ActivityKind.LIGHT_SLEEP), + sleep(BASE + 20 * MIN + 2, ActivityKind.REM_SLEEP) + ); + List sessions = + new SleepAnalysis().calculateSleepSessions(samples); + assertEquals(2, sessions.size()); + assertEquals(new Date(BASE * 1000L), sessions.get(0).getSleepStart()); + assertEquals(new Date((BASE + 10 * MIN) * 1000L), sessions.get(0).getSleepEnd()); + assertEquals(600, sessions.get(0).getDeepSleepDuration()); + assertEquals(new Date((BASE + 10 * MIN + 2) * 1000L), sessions.get(1).getSleepStart()); + assertEquals(new Date((BASE + 20 * MIN + 2) * 1000L), sessions.get(1).getSleepEnd()); + assertEquals(600, sessions.get(1).getRemSleepDuration()); + } + + private static void assertSession(SleepAnalysis.SleepSession session, + int expectedStart, + int expectedEnd, + long expectedLight, + long expectedDeep, + long expectedRem, + long expectedAwake) { + assertEquals(new Date(expectedStart * 1000L), session.getSleepStart()); + assertEquals(new Date(expectedEnd * 1000L), session.getSleepEnd()); + assertEquals("light", expectedLight, session.getLightSleepDuration()); + assertEquals("deep", expectedDeep, session.getDeepSleepDuration()); + assertEquals("rem", expectedRem, session.getRemSleepDuration()); + assertEquals("awake", expectedAwake, session.getAwakeSleepDuration()); + + long total = session.getLightSleepDuration() + session.getDeepSleepDuration() + + session.getRemSleepDuration() + session.getAwakeSleepDuration(); + long span = expectedEnd - expectedStart; + assertTrue("Duration sum (" + total + ") exceeds session span (" + span + ")", + total <= span); + } + + private static MockSample sleep(int timestamp, ActivityKind kind) { + return new MockSample(timestamp, kind, 0); + } + + private static MockSample idle(int timestamp) { + return new MockSample(timestamp, ActivityKind.ACTIVITY, 0); + } + + private static MockSample activity(int timestamp, int steps) { + return new MockSample(timestamp, ActivityKind.ACTIVITY, steps); + } + + private static class MockSample implements ActivitySample { + private final int timestamp; + private final ActivityKind kind; + private final int steps; + + MockSample(int timestamp, ActivityKind kind, int steps) { + this.timestamp = timestamp; + this.kind = kind; + this.steps = steps; + } + + @Override public int getTimestamp() { return timestamp; } + @Override public ActivityKind getKind() { return kind; } + @Override public int getSteps() { return steps; } + @Override public SampleProvider getProvider() { return null; } + @Override public int getRawKind() { return kind.getCode(); } + @Override public int getRawIntensity() { return NOT_MEASURED; } + @Override public float getIntensity() { return 0; } + @Override public int getDistanceCm() { return NOT_MEASURED; } + @Override public int getActiveCalories() { return NOT_MEASURED; } + @Override public int getHeartRate() { return NOT_MEASURED; } + @Override public void setHeartRate(int value) {} + } +}