Skip to content

Commit ac912bd

Browse files
committed
v1.0.1: Fix tab bar text clipping, add HMM temporal smoothing for sleep stages
- Fix bottom tab bar labels being vertically clipped on mobile web (increased height to 80px) - Add uppercase styling to tab labels - Rename 'Favorites' to 'Saved' and 'Dream' to 'Sleep' for clarity - Implement HMM Forward Algorithm temporal smoothing to prevent rapid sleep stage switching - Add minimum dwell time constraints (60s general, 120s for REM) - Add hysteresis for state transitions - Update about page APK link to v1.0.1
1 parent 8988961 commit ac912bd

4 files changed

Lines changed: 292 additions & 22 deletions

File tree

app/(tabs)/_layout.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ function TabBarIcon({
2929

3030
export default function TabLayout() {
3131
const insets = useSafeAreaInsets();
32-
const bottomPadding = Platform.OS === 'web' ? 10 : Math.max(insets.bottom, 10);
33-
const tabBarHeight = 64 + (Platform.OS === 'web' ? 0 : Math.max(insets.bottom - 8, 0));
32+
const bottomPadding = Platform.OS === 'web' ? 16 : Math.max(insets.bottom, 10);
33+
const tabBarHeight = Platform.OS === 'web' ? 80 : 64 + Math.max(insets.bottom - 8, 0);
3434

3535
return (
3636
<Tabs
@@ -46,12 +46,12 @@ export default function TabLayout() {
4646
height: tabBarHeight,
4747
},
4848
tabBarLabelStyle: {
49-
fontSize: 9,
49+
fontSize: 10,
5050
fontWeight: '500',
5151
fontFamily: fontFamily.regular,
52-
letterSpacing: 0.3,
52+
letterSpacing: 0.2,
5353
textTransform: 'uppercase',
54-
marginBottom: 2,
54+
marginBottom: 4,
5555
},
5656
headerShown: false,
5757
}}
@@ -77,7 +77,7 @@ export default function TabLayout() {
7777
<Tabs.Screen
7878
name="favorites"
7979
options={{
80-
title: 'Favorites',
80+
title: 'Saved',
8181
tabBarIcon: ({ color, focused }) => (
8282
<TabBarIcon name="heart" color={color} focused={focused} />
8383
),
@@ -95,7 +95,7 @@ export default function TabLayout() {
9595
<Tabs.Screen
9696
name="dream"
9797
options={{
98-
title: 'Dream',
98+
title: 'Sleep',
9999
tabBarIcon: ({ color, focused }) => (
100100
<TabBarIcon name="moon" color={color} focused={focused} />
101101
),

app/about.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Text, Heading, MonoText } from '@/components/ui/Text';
66
import { colors, spacing, borderRadius } from '@/theme/tokens';
77

88
const ANDROID_APK_URL =
9-
'https://github.com/ContextLab/dream-stream/releases/download/v1.0.0/dream-stream-v1.0.0.apk';
9+
'https://github.com/ContextLab/dream-stream/releases/download/v1.0.1/dream-stream-v1.0.1.apk';
1010
const isWeb = Platform.OS === 'web';
1111

1212
export default function AboutScreen() {
@@ -183,7 +183,7 @@ export default function AboutScreen() {
183183
<Pressable style={styles.downloadButton} onPress={() => openLink(ANDROID_APK_URL)}>
184184
<Ionicons name="download-outline" size={20} color={colors.gray[950]} />
185185
<Text variant="body" weight="semibold" style={styles.downloadButtonText}>
186-
Download APK (v1.0.0)
186+
Download APK (v1.0.1)
187187
</Text>
188188
</Pressable>
189189
<View style={styles.installInstructions}>
@@ -253,7 +253,7 @@ export default function AboutScreen() {
253253

254254
<View style={styles.footer}>
255255
<MonoText color="muted" style={styles.footerText}>
256-
v1.0.0 | Made with care for dreamers everywhere
256+
v1.0.1 | Made with care for dreamers everywhere
257257
</MonoText>
258258
</View>
259259
</ScrollView>

services/remOptimizedClassifier.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ import type { SleepStage } from '@/types/database';
1818
import * as healthConnect from './healthConnect';
1919
import * as healthKit from './healthKit';
2020
import { storage } from '@/lib/storage';
21+
import {
22+
smoothSleepStage,
23+
resetTemporalSmoother,
24+
getSmootherDiagnostics,
25+
} from './temporalSmoothing';
26+
27+
export { getSmootherDiagnostics } from './temporalSmoothing';
2128

2229
// ============================================================================
2330
// Types - 3-class classification
@@ -323,6 +330,7 @@ export function startRemOptimizedSession(): void {
323330
rmssdHistory = [];
324331
consecutiveRemSignals = 0;
325332
consecutiveAwakeSignals = 0;
333+
resetTemporalSmoother();
326334
}
327335

328336
export function stopRemOptimizedSession(): void {
@@ -1391,19 +1399,15 @@ export function classifyRemOptimized(
13911399
dataSource = 'prediction';
13921400
}
13931401

1394-
// Apply temporal guards
1395-
// Guard 1: No REM in first 60 minutes
1396-
if (temporal.minutesSinceSleepStart < 60 && stage === 'rem') {
1397-
stage = 'nrem';
1398-
probabilities.rem *= 0.3;
1399-
probabilities.nrem += probabilities.rem * 0.7;
1400-
}
1402+
const smoothingResult = smoothSleepStage(
1403+
probabilities,
1404+
model?.transitionMatrix ?? DEFAULT_TRANSITIONS,
1405+
temporal.minutesSinceSleepStart
1406+
);
14011407

1402-
// Guard 2: Hysteresis - require significant change to exit REM
1403-
if (previousStage === 'rem' && stage !== 'rem' && probabilities.rem > 0.3) {
1404-
stage = 'rem'; // Stay in REM if probability still reasonable
1405-
confidence *= 0.9;
1406-
}
1408+
stage = smoothingResult.stage;
1409+
probabilities = smoothingResult.probabilities;
1410+
confidence = smoothingResult.confidence;
14071411

14081412
// Update state
14091413
previousStage = stage;

services/temporalSmoothing.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/**
2+
* Temporal Smoothing for Sleep Stage Classification
3+
*
4+
* Implements a Hidden Markov Model (HMM) Forward Algorithm to prevent
5+
* rapid state switching ("jitter") in sleep stage classification.
6+
*
7+
* Key features:
8+
* 1. HMM Forward Algorithm - Uses transition matrix to smooth predictions
9+
* 2. Minimum Dwell Time - Prevents transitions within N seconds of last change
10+
* 3. Hysteresis - Requires higher confidence to exit a state than to enter
11+
* 4. Exponential smoothing of raw probabilities before HMM
12+
*
13+
* Based on sleep staging literature:
14+
* - Radha et al. 2019: Temporal context is critical for sleep staging
15+
* - Standard polysomnography uses 30-second epochs with transition rules
16+
*/
17+
18+
import type { SleepStage3, Stage3Probabilities } from './remOptimizedClassifier';
19+
20+
// ============================================================================
21+
// Configuration Constants
22+
// ============================================================================
23+
24+
/** Minimum time (ms) to stay in a state before allowing transition */
25+
const MIN_DWELL_TIME_MS = 60000; // 60 seconds - prevents rapid flickering
26+
27+
/** Minimum time in REM before allowing exit (REM cycles are typically 10-20 min) */
28+
const MIN_REM_DWELL_TIME_MS = 120000; // 2 minutes
29+
30+
/** Exponential smoothing factor for raw probabilities (0-1, higher = more smoothing) */
31+
const PROBABILITY_SMOOTHING_ALPHA = 0.3;
32+
33+
/** Confidence threshold multiplier for exiting current state (hysteresis) */
34+
const EXIT_THRESHOLD_MULTIPLIER = 1.3;
35+
36+
/** Minimum probability difference required to change states */
37+
const MIN_PROBABILITY_DELTA = 0.15;
38+
39+
// ============================================================================
40+
// Types
41+
// ============================================================================
42+
43+
interface SmootherState {
44+
beliefState: Stage3Probabilities;
45+
currentStage: SleepStage3;
46+
stageEntryTime: number;
47+
smoothedProbabilities: Stage3Probabilities;
48+
transitionCount: number;
49+
lastTransitionTime: number;
50+
}
51+
52+
export interface SmoothingResult {
53+
stage: SleepStage3;
54+
probabilities: Stage3Probabilities;
55+
confidence: number;
56+
wasSmoothed: boolean;
57+
dwellTimeMs: number;
58+
transitionBlocked: boolean;
59+
blockReason: string | null;
60+
}
61+
62+
// ============================================================================
63+
// Module State
64+
// ============================================================================
65+
66+
let state: SmootherState = {
67+
beliefState: { awake: 0.5, nrem: 0.4, rem: 0.1 },
68+
currentStage: 'awake',
69+
stageEntryTime: Date.now(),
70+
smoothedProbabilities: { awake: 0.5, nrem: 0.4, rem: 0.1 },
71+
transitionCount: 0,
72+
lastTransitionTime: Date.now(),
73+
};
74+
75+
// ============================================================================
76+
// Public API
77+
// ============================================================================
78+
79+
/**
80+
* Reset the temporal smoother state. Call when starting a new sleep session.
81+
*/
82+
export function resetTemporalSmoother(): void {
83+
const now = Date.now();
84+
state = {
85+
beliefState: { awake: 0.5, nrem: 0.4, rem: 0.1 },
86+
currentStage: 'awake',
87+
stageEntryTime: now,
88+
smoothedProbabilities: { awake: 0.5, nrem: 0.4, rem: 0.1 },
89+
transitionCount: 0,
90+
lastTransitionTime: now,
91+
};
92+
}
93+
94+
/**
95+
* Get current smoother state for debugging/display.
96+
*/
97+
export function getSmootherState(): Readonly<SmootherState> {
98+
return { ...state };
99+
}
100+
101+
/**
102+
* Apply temporal smoothing to sleep stage probabilities using HMM Forward Algorithm.
103+
*
104+
* @param rawProbabilities - Point-in-time probabilities from classifier
105+
* @param transitionMatrix - T[from][to] transition probabilities
106+
* @param minutesSinceSleepStart - Time context for REM likelihood
107+
* @returns Smoothed classification result
108+
*/
109+
export function smoothSleepStage(
110+
rawProbabilities: Stage3Probabilities,
111+
transitionMatrix: Record<SleepStage3, Record<SleepStage3, number>>,
112+
minutesSinceSleepStart: number
113+
): SmoothingResult {
114+
const now = Date.now();
115+
const stages: SleepStage3[] = ['awake', 'nrem', 'rem'];
116+
117+
const smoothedRaw = applyExponentialSmoothing(rawProbabilities, state.smoothedProbabilities);
118+
state.smoothedProbabilities = smoothedRaw;
119+
120+
const hmmProbabilities = applyHMMForward(smoothedRaw, transitionMatrix);
121+
state.beliefState = hmmProbabilities;
122+
123+
let candidateStage: SleepStage3 = 'nrem';
124+
let maxProb = 0;
125+
for (const stage of stages) {
126+
if (hmmProbabilities[stage] > maxProb) {
127+
maxProb = hmmProbabilities[stage];
128+
candidateStage = stage;
129+
}
130+
}
131+
132+
const dwellTimeMs = now - state.stageEntryTime;
133+
let transitionBlocked = false;
134+
let blockReason: string | null = null;
135+
let finalStage = candidateStage;
136+
137+
if (candidateStage !== state.currentStage) {
138+
const minDwell = state.currentStage === 'rem' ? MIN_REM_DWELL_TIME_MS : MIN_DWELL_TIME_MS;
139+
140+
if (dwellTimeMs < minDwell) {
141+
transitionBlocked = true;
142+
blockReason = `Dwell time ${Math.round(dwellTimeMs / 1000)}s < min ${Math.round(minDwell / 1000)}s`;
143+
finalStage = state.currentStage;
144+
}
145+
146+
if (!transitionBlocked) {
147+
const currentProb = hmmProbabilities[state.currentStage];
148+
const candidateProb = hmmProbabilities[candidateStage];
149+
const requiredDelta = MIN_PROBABILITY_DELTA * EXIT_THRESHOLD_MULTIPLIER;
150+
151+
if (candidateProb - currentProb < requiredDelta) {
152+
transitionBlocked = true;
153+
blockReason = `Probability delta ${(candidateProb - currentProb).toFixed(3)} < required ${requiredDelta.toFixed(3)}`;
154+
finalStage = state.currentStage;
155+
}
156+
}
157+
158+
if (!transitionBlocked && candidateStage === 'rem' && minutesSinceSleepStart < 60) {
159+
transitionBlocked = true;
160+
blockReason = `REM blocked: only ${minutesSinceSleepStart.toFixed(0)} min into sleep (need 60+)`;
161+
finalStage = state.currentStage;
162+
}
163+
}
164+
165+
const wasSmoothed = finalStage !== candidateStage;
166+
if (finalStage !== state.currentStage) {
167+
state.currentStage = finalStage;
168+
state.stageEntryTime = now;
169+
state.transitionCount++;
170+
state.lastTransitionTime = now;
171+
}
172+
173+
const sortedProbs = Object.values(hmmProbabilities).sort((a, b) => b - a);
174+
const confidence = sortedProbs[0] - sortedProbs[1] + 0.3;
175+
176+
return {
177+
stage: finalStage,
178+
probabilities: hmmProbabilities,
179+
confidence: Math.min(1, Math.max(0, confidence)),
180+
wasSmoothed,
181+
dwellTimeMs,
182+
transitionBlocked,
183+
blockReason,
184+
};
185+
}
186+
187+
// ============================================================================
188+
// Internal Functions
189+
// ============================================================================
190+
191+
/**
192+
* Apply exponential moving average smoothing to probabilities.
193+
*/
194+
function applyExponentialSmoothing(
195+
current: Stage3Probabilities,
196+
previous: Stage3Probabilities
197+
): Stage3Probabilities {
198+
const alpha = PROBABILITY_SMOOTHING_ALPHA;
199+
return {
200+
awake: alpha * current.awake + (1 - alpha) * previous.awake,
201+
nrem: alpha * current.nrem + (1 - alpha) * previous.nrem,
202+
rem: alpha * current.rem + (1 - alpha) * previous.rem,
203+
};
204+
}
205+
206+
/**
207+
* Apply HMM Forward Algorithm update.
208+
*
209+
* For each state s_t:
210+
* P(s_t | observations) ∝ P(observation | s_t) × Σ[P(s_t | s_{t-1}) × P(s_{t-1})]
211+
*
212+
* Where:
213+
* - P(observation | s_t) = rawProbabilities (emission/likelihood from sensors)
214+
* - P(s_t | s_{t-1}) = transitionMatrix (learned from historical data)
215+
* - P(s_{t-1}) = beliefState (our current belief)
216+
*/
217+
function applyHMMForward(
218+
likelihoods: Stage3Probabilities,
219+
transitionMatrix: Record<SleepStage3, Record<SleepStage3, number>>
220+
): Stage3Probabilities {
221+
const stages: SleepStage3[] = ['awake', 'nrem', 'rem'];
222+
const nextBelief: Stage3Probabilities = { awake: 0, nrem: 0, rem: 0 };
223+
224+
for (const toStage of stages) {
225+
let prediction = 0;
226+
for (const fromStage of stages) {
227+
prediction += state.beliefState[fromStage] * transitionMatrix[fromStage][toStage];
228+
}
229+
nextBelief[toStage] = prediction * likelihoods[toStage];
230+
}
231+
232+
const sum = nextBelief.awake + nextBelief.nrem + nextBelief.rem;
233+
if (sum > 0) {
234+
nextBelief.awake /= sum;
235+
nextBelief.nrem /= sum;
236+
nextBelief.rem /= sum;
237+
} else {
238+
return { awake: 0.33, nrem: 0.34, rem: 0.33 };
239+
}
240+
241+
return nextBelief;
242+
}
243+
244+
// ============================================================================
245+
// Diagnostic Functions
246+
// ============================================================================
247+
248+
/**
249+
* Get diagnostic information about the smoother for debugging.
250+
*/
251+
export function getSmootherDiagnostics(): {
252+
currentStage: SleepStage3;
253+
beliefState: Stage3Probabilities;
254+
dwellTimeSeconds: number;
255+
transitionCount: number;
256+
timeSinceLastTransitionSeconds: number;
257+
} {
258+
const now = Date.now();
259+
return {
260+
currentStage: state.currentStage,
261+
beliefState: { ...state.beliefState },
262+
dwellTimeSeconds: Math.round((now - state.stageEntryTime) / 1000),
263+
transitionCount: state.transitionCount,
264+
timeSinceLastTransitionSeconds: Math.round((now - state.lastTransitionTime) / 1000),
265+
};
266+
}

0 commit comments

Comments
 (0)