@@ -354,6 +354,178 @@ export function to4Class(stage3: SleepStage3): SleepStage {
354354 }
355355}
356356
357+ // ============================================================================
358+ // Feature Extraction from HR Samples
359+ // ============================================================================
360+
361+ interface HRFeatures {
362+ meanHR : number ;
363+ stdHR : number ;
364+ rangeHR : number ;
365+ pseudoRMSSD : number ;
366+ coefficientOfVariation : number ;
367+ sampleCount : number ;
368+ }
369+
370+ function extractHRFeatures ( hrSamples : Array < { beatsPerMinute : number ; time : string } > ) : HRFeatures {
371+ if ( hrSamples . length === 0 ) {
372+ return {
373+ meanHR : 0 ,
374+ stdHR : 0 ,
375+ rangeHR : 0 ,
376+ pseudoRMSSD : 0 ,
377+ coefficientOfVariation : 0 ,
378+ sampleCount : 0 ,
379+ } ;
380+ }
381+
382+ const hrs = hrSamples . map ( ( s ) => s . beatsPerMinute ) ;
383+ const meanHR = mean ( hrs ) ;
384+ const stdHR = std ( hrs ) ;
385+ const rangeHR = Math . max ( ...hrs ) - Math . min ( ...hrs ) ;
386+ const coefficientOfVariation = meanHR > 0 ? stdHR / meanHR : 0 ;
387+
388+ let pseudoRMSSD = 0 ;
389+ if ( hrSamples . length >= 2 ) {
390+ const sortedSamples = [ ...hrSamples ] . sort (
391+ ( a , b ) => new Date ( a . time ) . getTime ( ) - new Date ( b . time ) . getTime ( )
392+ ) ;
393+
394+ const rrIntervals : number [ ] = [ ] ;
395+ for ( const sample of sortedSamples ) {
396+ if ( sample . beatsPerMinute > 0 ) {
397+ rrIntervals . push ( 60000 / sample . beatsPerMinute ) ;
398+ }
399+ }
400+
401+ if ( rrIntervals . length >= 2 ) {
402+ const successiveDiffs : number [ ] = [ ] ;
403+ for ( let i = 1 ; i < rrIntervals . length ; i ++ ) {
404+ successiveDiffs . push ( Math . pow ( rrIntervals [ i ] - rrIntervals [ i - 1 ] , 2 ) ) ;
405+ }
406+ if ( successiveDiffs . length > 0 ) {
407+ pseudoRMSSD = Math . sqrt ( mean ( successiveDiffs ) ) ;
408+ }
409+ }
410+ }
411+
412+ return {
413+ meanHR,
414+ stdHR,
415+ rangeHR,
416+ pseudoRMSSD,
417+ coefficientOfVariation,
418+ sampleCount : hrSamples . length ,
419+ } ;
420+ }
421+
422+ // ============================================================================
423+ // Data Export for Local Analysis
424+ // ============================================================================
425+
426+ export interface ExportedTrainingData {
427+ exportTime : string ;
428+ sleepStages : Array < { stage : string ; startTime : string ; endTime : string } > ;
429+ hrSamples : Array < { beatsPerMinute : number ; time : string } > ;
430+ stageFeatures : Record <
431+ string ,
432+ {
433+ hrFeatures : HRFeatures ;
434+ durationMinutes : number ;
435+ sampleCount : number ;
436+ }
437+ > ;
438+ }
439+
440+ export async function exportTrainingData ( hoursBack : number = 48 ) : Promise < ExportedTrainingData > {
441+ const platform = Platform . OS ;
442+
443+ const [ sleepStages , hrSamples ] = await Promise . all ( [
444+ platform === 'ios'
445+ ? healthKit . getRecentSleepSessions ( hoursBack )
446+ : healthConnect . getRecentSleepSessions ( hoursBack ) ,
447+ platform === 'ios'
448+ ? healthKit . getRecentHeartRate ( hoursBack * 60 )
449+ : healthConnect . getRecentHeartRate ( hoursBack * 60 ) ,
450+ ] ) ;
451+
452+ const stageFeatures : ExportedTrainingData [ 'stageFeatures' ] = { } ;
453+ const STAGES : SleepStage3 [ ] = [ 'awake' , 'nrem' , 'rem' ] ;
454+
455+ for ( const stage3 of STAGES ) {
456+ const matchingHRs : Array < { beatsPerMinute : number ; time : string } > = [ ] ;
457+ let totalDurationMs = 0 ;
458+
459+ for ( const stageRecord of sleepStages ) {
460+ const stageStart = new Date ( stageRecord . startTime ) ;
461+ const stageEnd = new Date ( stageRecord . endTime ) ;
462+ const recordStage = to3Class ( stageRecord . stage as SleepStage ) ;
463+
464+ if ( recordStage === stage3 ) {
465+ totalDurationMs += stageEnd . getTime ( ) - stageStart . getTime ( ) ;
466+
467+ const matching = hrSamples . filter ( ( hr ) => {
468+ const t = new Date ( hr . time ) ;
469+ return t >= stageStart && t <= stageEnd ;
470+ } ) ;
471+ matchingHRs . push ( ...matching ) ;
472+ }
473+ }
474+
475+ stageFeatures [ stage3 ] = {
476+ hrFeatures : extractHRFeatures ( matchingHRs ) ,
477+ durationMinutes : totalDurationMs / 60000 ,
478+ sampleCount : matchingHRs . length ,
479+ } ;
480+ }
481+
482+ return {
483+ exportTime : new Date ( ) . toISOString ( ) ,
484+ sleepStages : sleepStages . map ( ( s ) => ( {
485+ stage : s . stage ,
486+ startTime : s . startTime ,
487+ endTime : s . endTime ,
488+ } ) ) ,
489+ hrSamples : hrSamples . map ( ( h ) => ( {
490+ beatsPerMinute : h . beatsPerMinute ,
491+ time : h . time ,
492+ } ) ) ,
493+ stageFeatures,
494+ } ;
495+ }
496+
497+ export function formatExportedData ( data : ExportedTrainingData ) : string {
498+ const lines : string [ ] = [ ] ;
499+ lines . push ( '╔══════════════════════════════════════════════════════════════╗' ) ;
500+ lines . push ( '║ EXPORTED TRAINING DATA ANALYSIS ║' ) ;
501+ lines . push ( '╚══════════════════════════════════════════════════════════════╝' ) ;
502+ lines . push ( '' ) ;
503+ lines . push ( `Export Time: ${ data . exportTime } ` ) ;
504+ lines . push ( `Sleep Stages: ${ data . sleepStages . length } ` ) ;
505+ lines . push ( `HR Samples: ${ data . hrSamples . length } ` ) ;
506+ lines . push ( '' ) ;
507+
508+ lines . push ( '┌─────────────────────────────────────────────────────────────┐' ) ;
509+ lines . push ( '│ FEATURES BY STAGE │' ) ;
510+ lines . push ( '├─────────────────────────────────────────────────────────────┤' ) ;
511+
512+ for ( const [ stage , features ] of Object . entries ( data . stageFeatures ) ) {
513+ const f = features . hrFeatures ;
514+ lines . push ( `│ ${ stage . toUpperCase ( ) } ` ) ;
515+ lines . push (
516+ `│ Duration: ${ features . durationMinutes . toFixed ( 1 ) } min (${ features . sampleCount } HR samples)`
517+ ) ;
518+ lines . push ( `│ HR Mean±Std: ${ f . meanHR . toFixed ( 1 ) } ±${ f . stdHR . toFixed ( 1 ) } bpm` ) ;
519+ lines . push ( `│ HR Range: ${ f . rangeHR . toFixed ( 1 ) } bpm` ) ;
520+ lines . push ( `│ Pseudo-RMSSD: ${ f . pseudoRMSSD . toFixed ( 1 ) } ms` ) ;
521+ lines . push ( `│ CV: ${ ( f . coefficientOfVariation * 100 ) . toFixed ( 2 ) } %` ) ;
522+ lines . push ( '│' ) ;
523+ }
524+ lines . push ( '└─────────────────────────────────────────────────────────────┘' ) ;
525+
526+ return lines . join ( '\n' ) ;
527+ }
528+
357529// ============================================================================
358530// Training from Historical Data
359531// ============================================================================
0 commit comments