///|
/// Longitudinal session analytics for personal HRV tracking.
///|
/// One analyzed session with quality metadata.
pub(all) struct SessionObservation {
session_id : String
date : String
duration_minutes : Double
rmssd : Double
sdnn : Double
mean_hr : Double
quality_score : Double
training_load : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Aggregated session analytics.
pub(all) struct SessionAnalytics {
count : Int
usable_count : Int
mean_rmssd : Double
median_rmssd : Double
rmssd_trend : Double
rmssd_volatility : Double
mean_hr : Double
mean_quality : Double
total_load : Double
load_trend : Double
best_session_id : String
worst_session_id : String
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Per-session status relative to a rolling baseline.
pub(all) struct SessionStatus {
session_id : String
baseline_rmssd : Double
rmssd_z : Double
load : Double
quality_score : Double
recovery_label : String
should_review : Bool
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Return whether an observation is usable for longitudinal scoring.
pub fn session_is_usable(
observation : SessionObservation,
minimum_quality : Double,
) -> Bool {
observation.rmssd > 0.0 &&
observation.mean_hr > 0.0 &&
observation.quality_score >= minimum_quality
}
///|
/// Calculate a robust baseline over the preceding sessions.
pub fn session_baseline(
observations : Array[SessionObservation],
end_exclusive : Int,
window_size : Int,
) -> (Double, Double) {
let end = if end_exclusive < 0 {
0
} else if end_exclusive > observations.length() {
observations.length()
} else {
end_exclusive
}
let window = if window_size <= 0 {
end
} else if end < window_size {
end
} else {
window_size
}
let start = end - window
let values = []
for i in start.. 0.0 {
values.push(observations[i].rmssd)
}
}
(median_value(values), median_absolute_deviation(values) * 1.4826)
}
///|
/// Calculate a baseline-relative session status.
pub fn classify_session_status(
observation : SessionObservation,
baseline_rmssd : Double,
baseline_scale : Double,
load_threshold : Double,
) -> SessionStatus {
let scale = if baseline_scale <= 1.0 { 1.0 } else { baseline_scale }
let z = if baseline_rmssd == 0.0 {
0.0
} else {
(observation.rmssd - baseline_rmssd) / scale
}
let label = if observation.quality_score < 0.55 {
"low_quality"
} else if z <= -1.5 {
"below_baseline"
} else if z >= 1.5 {
"above_baseline"
} else {
"within_baseline"
}
{
session_id: observation.session_id,
baseline_rmssd,
rmssd_z: z,
load: observation.training_load,
quality_score: observation.quality_score,
recovery_label: label,
should_review: observation.quality_score < 0.55 ||
z <= -1.5 ||
observation.training_load >= load_threshold,
}
}
///|
/// Create statuses for all observations using a trailing baseline.
pub fn classify_session_history(
observations : Array[SessionObservation],
window_size : Int,
load_threshold : Double,
) -> Array[SessionStatus] {
let result = []
for i in 0.. SessionAnalytics {
let rmssd = []
let hr = []
let quality = []
let loads = []
let mut usable = 0
let mut best = ""
let mut worst = ""
let mut best_value = -1.0
let mut worst_value = 1.0e18
for observation in observations {
rmssd.push(observation.rmssd)
hr.push(observation.mean_hr)
quality.push(observation.quality_score)
loads.push(observation.training_load)
if session_is_usable(observation, minimum_quality) {
usable += 1
}
if observation.rmssd > best_value {
best_value = observation.rmssd
best = observation.session_id
}
if observation.rmssd < worst_value {
worst_value = observation.rmssd
worst = observation.session_id
}
}
{
count: observations.length(),
usable_count: usable,
mean_rmssd: mean_value(rmssd),
median_rmssd: median_value(rmssd),
rmssd_trend: fit_linear_trend(rmssd).slope,
rmssd_volatility: standard_deviation(rmssd),
mean_hr: mean_value(hr),
mean_quality: mean_value(quality),
total_load: sum_values(loads),
load_trend: fit_linear_trend(loads).slope,
best_session_id: best,
worst_session_id: worst,
}
}
///|
/// Return indices of sessions that differ materially from a baseline.
pub fn session_outlier_indices(
observations : Array[SessionObservation],
z_threshold : Double,
window_size : Int,
) -> Array[Int] {
let result = []
let threshold = if z_threshold < 0.0 { 0.0 } else { z_threshold }
for i in 0.. 0.0 &&
absolute_difference(observations[i].rmssd, baseline) / scale >= threshold {
result.push(i)
}
}
result
}
///|
/// Calculate a load-adjusted recovery score.
pub fn load_adjusted_recovery(status : SessionStatus) -> Double {
let recovery = (50.0 + status.rmssd_z * 15.0).clamp(min=0.0, max=100.0)
let load_penalty = (status.load / 100.0).clamp(min=0.0, max=30.0)
(recovery - load_penalty).clamp(min=0.0, max=100.0)
}
///|
/// Return a sorted copy from highest recovery score to lowest.
pub fn rank_sessions_by_recovery(
observations : Array[SessionObservation],
window_size : Int,
load_threshold : Double,
) -> Array[SessionStatus] {
let statuses = classify_session_history(
observations, window_size, load_threshold,
)
statuses.sort_by((left, right) => {
let l = load_adjusted_recovery(left)
let r = load_adjusted_recovery(right)
if l > r {
-1
} else if l < r {
1
} else {
0
}
})
statuses
}
///|
/// Calculate a workload-to-recovery correlation.
pub fn load_recovery_correlation(
observations : Array[SessionObservation],
) -> Double {
let loads = []
let recovery = []
for observation in observations {
loads.push(observation.training_load)
recovery.push(observation.rmssd)
}
correlation_value(loads, recovery)
}
///|
/// Return the proportion of sessions that need review.
pub fn session_review_ratio(statuses : Array[SessionStatus]) -> Double {
if statuses.length() == 0 {
return 0.0
}
let mut count = 0
for status in statuses {
if status.should_review {
count += 1
}
}
count.to_double() / statuses.length().to_double()
}
///|
/// Create a fixed-order longitudinal feature vector.
pub fn session_analytics_feature_vector(
summary : SessionAnalytics,
) -> Array[Double] {
[
summary.count.to_double(),
summary.usable_count.to_double(),
summary.mean_rmssd,
summary.median_rmssd,
summary.rmssd_trend,
summary.rmssd_volatility,
summary.mean_hr,
summary.mean_quality,
summary.total_load,
summary.load_trend,
]
}
///|
/// Return whether longitudinal analytics contain enough signal.
pub fn session_analytics_is_usable(summary : SessionAnalytics) -> Bool {
summary.count > 0 &&
summary.usable_count > 0 &&
summary.mean_rmssd > 0.0 &&
summary.mean_quality > 0.0
}