Skip to content

Commit 87c09cf

Browse files
committed
feat: Enhanced movement detection using audio spikes and HR delta
- Add addHRSample() to track HR changes for movement detection - detectMovement() now uses median-based baseline for spike detection - Combine audio movement with HR movement score for better stage classification - Integrate addHRSample() call when processing vitals from Health Connect - Add pagination to Health Connect queries for complete data retrieval
1 parent 70c7a36 commit 87c09cf

3 files changed

Lines changed: 186 additions & 43 deletions

File tree

services/healthConnect.ts

Lines changed: 101 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -203,31 +203,38 @@ export async function getRecentHeartRate(minutesBack: number = 30): Promise<Hear
203203
const endTime = new Date().toISOString();
204204
const startTime = new Date(Date.now() - minutesBack * 60 * 1000).toISOString();
205205

206-
const result = await healthConnect.readRecords('HeartRate', {
207-
timeRangeFilter: {
208-
operator: 'between',
209-
startTime,
210-
endTime,
211-
},
212-
ascendingOrder: false,
213-
pageSize: 100,
214-
});
206+
const allSamples: HeartRateSample[] = [];
207+
let pageToken: string | undefined;
208+
209+
do {
210+
const result = await healthConnect.readRecords('HeartRate', {
211+
timeRangeFilter: {
212+
operator: 'between',
213+
startTime,
214+
endTime,
215+
},
216+
ascendingOrder: false,
217+
pageSize: 1000,
218+
pageToken,
219+
});
215220

216-
const samples: HeartRateSample[] = [];
217-
for (const record of result.records) {
218-
if (record.samples) {
219-
for (const sample of record.samples) {
220-
samples.push({
221-
beatsPerMinute: sample.beatsPerMinute,
222-
time: sample.time,
223-
});
221+
for (const record of result.records) {
222+
if (record.samples) {
223+
for (const sample of record.samples) {
224+
allSamples.push({
225+
beatsPerMinute: sample.beatsPerMinute,
226+
time: sample.time,
227+
});
228+
}
224229
}
225230
}
226-
}
227231

228-
samples.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
232+
pageToken = result.pageToken;
233+
} while (pageToken);
234+
235+
allSamples.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
229236

230-
return samples;
237+
return allSamples;
231238
} catch (error) {
232239
console.error('Failed to read heart rate:', error);
233240
return [];
@@ -243,32 +250,89 @@ export async function getRecentHRV(minutesBack: number = 30): Promise<HRVSample[
243250
const endTime = new Date().toISOString();
244251
const startTime = new Date(Date.now() - minutesBack * 60 * 1000).toISOString();
245252

246-
const result = await healthConnect.readRecords('HeartRateVariabilityRmssd', {
247-
timeRangeFilter: {
248-
operator: 'between',
249-
startTime,
250-
endTime,
251-
},
252-
ascendingOrder: false,
253-
pageSize: 100,
254-
});
253+
const allSamples: HRVSample[] = [];
254+
let pageToken: string | undefined;
255+
256+
do {
257+
const result = await healthConnect.readRecords('HeartRateVariabilityRmssd', {
258+
timeRangeFilter: {
259+
operator: 'between',
260+
startTime,
261+
endTime,
262+
},
263+
ascendingOrder: false,
264+
pageSize: 1000,
265+
pageToken,
266+
});
255267

256-
const samples: HRVSample[] = result.records.map(
257-
(record: { heartRateVariabilityMillis: number; time: string }) => ({
258-
heartRateVariabilityMillis: record.heartRateVariabilityMillis,
259-
time: record.time,
260-
})
261-
);
268+
for (const record of result.records) {
269+
allSamples.push({
270+
heartRateVariabilityMillis: record.heartRateVariabilityMillis,
271+
time: record.time,
272+
});
273+
}
262274

263-
samples.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
275+
pageToken = result.pageToken;
276+
} while (pageToken);
264277

265-
return samples;
278+
allSamples.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
279+
280+
return allSamples;
266281
} catch (error) {
267282
console.error('Failed to read HRV:', error);
268283
return [];
269284
}
270285
}
271286

287+
type HealthRecordType =
288+
| 'HeartRate'
289+
| 'HeartRateVariabilityRmssd'
290+
| 'SleepSession'
291+
| 'RestingHeartRate'
292+
| 'OxygenSaturation'
293+
| 'RespiratoryRate'
294+
| 'Steps'
295+
| 'ActiveCaloriesBurned';
296+
297+
export async function getAvailableRecordCounts(): Promise<Record<string, number>> {
298+
if (Platform.OS !== 'android' || !healthConnect) {
299+
return {};
300+
}
301+
302+
const recordTypes: HealthRecordType[] = [
303+
'HeartRate',
304+
'HeartRateVariabilityRmssd',
305+
'SleepSession',
306+
'RestingHeartRate',
307+
'OxygenSaturation',
308+
'RespiratoryRate',
309+
'Steps',
310+
'ActiveCaloriesBurned',
311+
];
312+
313+
const counts: Record<string, number> = {};
314+
const endTime = new Date().toISOString();
315+
const startTime = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
316+
317+
for (const recordType of recordTypes) {
318+
try {
319+
const result = await healthConnect.readRecords(recordType, {
320+
timeRangeFilter: {
321+
operator: 'between',
322+
startTime,
323+
endTime,
324+
},
325+
pageSize: 1,
326+
});
327+
counts[recordType] = result.records?.length > 0 ? 1 : 0;
328+
} catch {
329+
counts[recordType] = -1;
330+
}
331+
}
332+
333+
return counts;
334+
}
335+
272336
export async function getRecentSleepSessions(hoursBack: number = 12): Promise<SleepStageSample[]> {
273337
if (Platform.OS !== 'android' || !healthConnect) {
274338
return [];

services/sleep.ts

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,13 @@ const SLEEP_BREATHING_REGULARITY = 0.85;
8080
const REM_RRV_THRESHOLD = 0.25;
8181
const DEEP_SLEEP_RRV_THRESHOLD = 0.1;
8282
const MOVEMENT_THRESHOLD = 0.15;
83+
const MOVEMENT_SPIKE_THRESHOLD = 3.0;
84+
const HR_MOVEMENT_DELTA_THRESHOLD = 8;
85+
const HR_MOVEMENT_WINDOW_SEC = 30;
8386

8487
let currentSession: SleepSession | null = null;
88+
let recentHRSamples: { hr: number; timestamp: number }[] = [];
89+
let hrMovementScore = 0;
8590
let audioContext: AudioContext | null = null;
8691
let analyzer: MeydaAnalyzerInstance | null = null;
8792
let mediaStream: MediaStream | null = null;
@@ -619,16 +624,84 @@ function calculateRRV(intervals: number[]): number {
619624
function detectMovement(rmsValues: number[]): number {
620625
if (rmsValues.length < 5) return 0;
621626

622-
let spikeCount = 0;
623-
const avgRms = rmsValues.reduce((a, b) => a + b, 0) / rmsValues.length;
627+
const sorted = [...rmsValues].sort((a, b) => a - b);
628+
const median = sorted[Math.floor(sorted.length / 2)];
629+
const p25 = sorted[Math.floor(sorted.length * 0.25)];
630+
const baselineRms = (median + p25) / 2;
624631

625-
for (const rms of rmsValues) {
626-
if (rms > avgRms * 3) {
627-
spikeCount++;
632+
let spikeScore = 0;
633+
let sustainedHighScore = 0;
634+
635+
for (let i = 0; i < rmsValues.length; i++) {
636+
const rms = rmsValues[i];
637+
const spikeRatio = baselineRms > 0.001 ? rms / baselineRms : 0;
638+
639+
if (spikeRatio > MOVEMENT_SPIKE_THRESHOLD) {
640+
spikeScore += 0.2;
641+
}
642+
643+
if (rms > adaptiveRmsThreshold * 2) {
644+
sustainedHighScore += 0.05;
628645
}
629646
}
630647

631-
return spikeCount / rmsValues.length;
648+
const audioMovement = Math.min(1, spikeScore + sustainedHighScore);
649+
const combinedMovement = Math.max(audioMovement, hrMovementScore * 0.8);
650+
651+
return combinedMovement;
652+
}
653+
654+
export function addHRSample(hr: number): void {
655+
const now = Date.now();
656+
recentHRSamples.push({ hr, timestamp: now });
657+
recentHRSamples = recentHRSamples.filter(
658+
(s) => now - s.timestamp < HR_MOVEMENT_WINDOW_SEC * 1000
659+
);
660+
661+
updateHRMovementScore();
662+
}
663+
664+
function updateHRMovementScore(): void {
665+
if (recentHRSamples.length < 3) {
666+
hrMovementScore = 0;
667+
return;
668+
}
669+
670+
const sorted = [...recentHRSamples].sort((a, b) => a.timestamp - b.timestamp);
671+
let maxDelta = 0;
672+
673+
for (let i = 1; i < sorted.length; i++) {
674+
const timeDiff = sorted[i].timestamp - sorted[i - 1].timestamp;
675+
if (timeDiff > 0 && timeDiff < 60000) {
676+
const hrDelta = Math.abs(sorted[i].hr - sorted[i - 1].hr);
677+
maxDelta = Math.max(maxDelta, hrDelta);
678+
}
679+
}
680+
681+
const minHR = Math.min(...sorted.map((s) => s.hr));
682+
const maxHR = Math.max(...sorted.map((s) => s.hr));
683+
const hrRange = maxHR - minHR;
684+
685+
if (maxDelta >= HR_MOVEMENT_DELTA_THRESHOLD || hrRange >= HR_MOVEMENT_DELTA_THRESHOLD * 1.5) {
686+
hrMovementScore = Math.min(1, maxDelta / HR_MOVEMENT_DELTA_THRESHOLD);
687+
} else {
688+
hrMovementScore = hrMovementScore * 0.9;
689+
}
690+
}
691+
692+
export function getMovementIndicators(): {
693+
audioMovement: number;
694+
hrMovement: number;
695+
combined: number;
696+
} {
697+
const recentRms = rmsHistory.slice(-20).map((r) => r.value);
698+
const audioMovement = recentRms.length > 0 ? detectMovement(recentRms) : 0;
699+
700+
return {
701+
audioMovement,
702+
hrMovement: hrMovementScore,
703+
combined: Math.max(audioMovement, hrMovementScore * 0.8),
704+
};
632705
}
633706

634707
function analyzeBreathing(): BreathingAnalysis {
@@ -864,6 +937,10 @@ export async function processVitalsUpdate(vitals: VitalsSnapshot): Promise<void>
864937

865938
addVitalsSample(vitals);
866939

940+
if (vitals.heartRate !== null) {
941+
addHRSample(vitals.heartRate);
942+
}
943+
867944
if (currentSession?.isActive) {
868945
const vitalsAnalysis = await analyzeVitalsWithLearning();
869946

services/sleepStageLearning.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ function countNights(timestamps: Date[]): number {
323323
export interface DebugReport {
324324
timestamp: string;
325325
platform: string;
326+
availableRecords: Record<string, number>;
326327
sleepStages: {
327328
total: number;
328329
byStage: Record<string, number>;
@@ -354,6 +355,7 @@ export async function runDebugReport(hoursBack: number = 48): Promise<DebugRepor
354355
const report: DebugReport = {
355356
timestamp: new Date().toISOString(),
356357
platform,
358+
availableRecords: {},
357359
sleepStages: { total: 0, byStage: {}, samples: [] },
358360
heartRate: { total: 0, oldest: null, newest: null, samples: [] },
359361
hrv: { total: 0, oldest: null, newest: null, samples: [] },

0 commit comments

Comments
 (0)