///|
/// Sleep and overnight recovery summaries built on RR and HRV measurements.
///|
/// Coarse sleep stage labels suitable for wearable summaries.
pub(all) enum SleepStage {
Awake
Light
Deep
Rem
Unknown
} derive(FromJson, ToJson, Debug, Eq)
///|
/// One scored sleep epoch.
pub(all) struct SleepEpoch {
start_minute : Double
duration_minutes : Double
stage : SleepStage
mean_rr : Double
rmssd : Double
quality_ratio : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Overnight recovery summary.
pub(all) struct SleepRecoverySummary {
total_minutes : Double
asleep_minutes : Double
awake_minutes : Double
deep_minutes : Double
rem_minutes : Double
light_minutes : Double
sleep_efficiency : Double
stage_transition_count : Int
overnight_mean_rr : Double
overnight_rmssd : Double
overnight_quality : Double
recovery_delta : Double
fragmentation_index : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Return whether an epoch contributes to sleep duration.
pub fn sleep_stage_is_asleep(stage : SleepStage) -> Bool {
match stage {
Awake | Unknown => false
Light | Deep | Rem => true
}
}
///|
/// Return a stable numeric code for a sleep stage.
pub fn sleep_stage_code(stage : SleepStage) -> Int {
match stage {
Awake => 0
Light => 1
Deep => 2
Rem => 3
Unknown => -1
}
}
///|
/// Infer a coarse stage from RR-derived recovery markers.
pub fn classify_sleep_epoch(
mean_rr : Double,
rmssd : Double,
movement_score : Double,
quality_ratio : Double,
) -> SleepStage {
if quality_ratio < 0.5 {
return Unknown
}
if movement_score > 0.65 {
return Awake
}
if rmssd <= 0.0 || mean_rr <= 0.0 {
return Unknown
}
if rmssd > 70.0 && movement_score < 0.20 {
Deep
} else if movement_score > 0.35 {
Rem
} else {
Light
}
}
///|
/// Count transitions between consecutive scored epochs.
pub fn sleep_stage_transitions(epochs : Array[SleepEpoch]) -> Int {
if epochs.length() <= 1 {
return 0
}
let mut count = 0
for i in 1.. Double {
let mut total = 0.0
for epoch in epochs {
if epoch.stage == stage && epoch.duration_minutes > 0.0 {
total += epoch.duration_minutes
}
}
total
}
///|
/// Calculate the proportion of epochs with usable signal quality.
pub fn sleep_quality_ratio(epochs : Array[SleepEpoch]) -> Double {
if epochs.length() == 0 {
return 0.0
}
let mut total = 0.0
for epoch in epochs {
total += epoch.quality_ratio.clamp(min=0.0, max=1.0)
}
total / epochs.length().to_double()
}
///|
/// Aggregate RR and RMSSD across epoch summaries using duration weights.
pub fn weighted_sleep_hrv(epochs : Array[SleepEpoch]) -> (Double, Double) {
let mut rr = 0.0
let mut rmssd = 0.0
let mut weight = 0.0
for epoch in epochs {
let duration = if epoch.duration_minutes < 0.0 {
0.0
} else {
epoch.duration_minutes
}
let quality = epoch.quality_ratio.clamp(min=0.0, max=1.0)
let contribution = duration * quality
rr += epoch.mean_rr * contribution
rmssd += epoch.rmssd * contribution
weight += contribution
}
if weight == 0.0 {
(0.0, 0.0)
} else {
(rr / weight, rmssd / weight)
}
}
///|
/// Calculate sleep efficiency from epoch durations.
pub fn calculate_sleep_efficiency(epochs : Array[SleepEpoch]) -> Double {
let mut total = 0.0
let mut asleep = 0.0
for epoch in epochs {
let duration = if epoch.duration_minutes < 0.0 {
0.0
} else {
epoch.duration_minutes
}
total += duration
if sleep_stage_is_asleep(epoch.stage) {
asleep += duration
}
}
if total == 0.0 {
0.0
} else {
asleep / total
}
}
///|
/// Calculate an epoch fragmentation index per hour of recording.
pub fn calculate_fragmentation_index(epochs : Array[SleepEpoch]) -> Double {
let durations = []
for epoch in epochs {
durations.push(
if epoch.duration_minutes < 0.0 {
0.0
} else {
epoch.duration_minutes
},
)
}
let total = sum_values(durations)
if total == 0.0 {
0.0
} else {
sleep_stage_transitions(epochs).to_double() / (total / 60.0)
}
}
///|
/// Compare overnight RMSSD to a personal baseline.
pub fn overnight_recovery_delta(
overnight_rmssd : Double,
baseline_rmssd : Double,
) -> Double {
if baseline_rmssd == 0.0 {
0.0
} else {
(overnight_rmssd - baseline_rmssd) / baseline_rmssd * 100.0
}
}
///|
/// Build a complete overnight recovery summary.
pub fn summarize_sleep_recovery(
epochs : Array[SleepEpoch],
baseline_rmssd : Double,
) -> SleepRecoverySummary {
let durations = []
for epoch in epochs {
durations.push(
if epoch.duration_minutes < 0.0 {
0.0
} else {
epoch.duration_minutes
},
)
}
let total = sum_values(durations)
let asleep = sleep_stage_minutes(epochs, Light) +
sleep_stage_minutes(epochs, Deep) +
sleep_stage_minutes(epochs, Rem)
let awake = sleep_stage_minutes(epochs, Awake)
let (mean_rr, rmssd) = weighted_sleep_hrv(epochs)
{
total_minutes: total,
asleep_minutes: asleep,
awake_minutes: awake,
deep_minutes: sleep_stage_minutes(epochs, Deep),
rem_minutes: sleep_stage_minutes(epochs, Rem),
light_minutes: sleep_stage_minutes(epochs, Light),
sleep_efficiency: calculate_sleep_efficiency(epochs),
stage_transition_count: sleep_stage_transitions(epochs),
overnight_mean_rr: mean_rr,
overnight_rmssd: rmssd,
overnight_quality: sleep_quality_ratio(epochs),
recovery_delta: overnight_recovery_delta(rmssd, baseline_rmssd),
fragmentation_index: calculate_fragmentation_index(epochs),
}
}
///|
/// Return the first sleep onset epoch, if one exists.
pub fn sleep_onset(epochs : Array[SleepEpoch]) -> SleepEpoch? {
for epoch in epochs {
if sleep_stage_is_asleep(epoch.stage) {
return Some(epoch)
}
}
None
}
///|
/// Return the last epoch that contributes to sleep duration.
pub fn sleep_offset(epochs : Array[SleepEpoch]) -> SleepEpoch? {
let mut result : SleepEpoch? = None
for epoch in epochs {
if sleep_stage_is_asleep(epoch.stage) {
result = Some(epoch)
}
}
result
}
///|
/// Return awake intervals longer than a configurable threshold.
pub fn prolonged_awakenings(
epochs : Array[SleepEpoch],
threshold_minutes : Double,
) -> Array[SleepEpoch] {
let result = []
let threshold = if threshold_minutes < 0.0 { 0.0 } else { threshold_minutes }
for epoch in epochs {
if epoch.stage == Awake && epoch.duration_minutes >= threshold {
result.push(epoch)
}
}
result
}
///|
/// Calculate a sleep regularity score from start-time deltas.
pub fn sleep_regularity(start_minutes : Array[Double]) -> Double {
if start_minutes.length() <= 1 {
return 0.0
}
let deltas = []
for i in 0..<(start_minutes.length() - 1) {
deltas.push(absolute_difference(start_minutes[i + 1], start_minutes[i]))
}
let deviation = standard_deviation(deltas)
(1.0 - deviation / 120.0).clamp(min=0.0, max=1.0)
}
///|
/// Create a compact sleep feature vector.
pub fn sleep_feature_vector(summary : SleepRecoverySummary) -> Array[Double] {
[
summary.total_minutes,
summary.asleep_minutes,
summary.awake_minutes,
summary.deep_minutes,
summary.rem_minutes,
summary.light_minutes,
summary.sleep_efficiency,
summary.stage_transition_count.to_double(),
summary.overnight_mean_rr,
summary.overnight_rmssd,
summary.overnight_quality,
summary.recovery_delta,
summary.fragmentation_index,
]
}
///|
/// Return whether an overnight summary is safe to display.
pub fn sleep_summary_is_usable(summary : SleepRecoverySummary) -> Bool {
summary.asleep_minutes >= 0.0 &&
summary.sleep_efficiency >= 0.0 &&
summary.sleep_efficiency <= 1.0 &&
summary.overnight_quality > 0.0
}