///|
/// Readiness interpretation for daily recovery dashboards.
pub(all) enum ReadinessLevel {
VeryLow
Low
Moderate
High
VeryHigh
} derive(FromJson, ToJson, Debug, Eq)
///|
/// A bounded readiness score with explainable components.
pub(all) struct ReadinessScore {
score : Double
level : ReadinessLevel
baseline_z : Double
trend_component : Double
quality_component : Double
explanation : String
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Daily training load summary using session duration and intensity.
pub(all) struct TrainingLoadSummary {
total_minutes : Double
total_load : Double
average_intensity : Double
monotony : Double
strain : Double
acute_load : Double
chronic_load : Double
acute_chronic_ratio : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Robust baseline statistics for a sequence of morning RMSSD values.
pub(all) struct RobustBaseline {
center : Double
spread : Double
lower : Double
upper : Double
retained : Int
rejected : Int
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Compute a median/MAD baseline with a configurable outlier fence.
pub fn calculate_robust_baseline(
values : Array[Double],
fence : Double,
range_factor : Double,
) -> RobustBaseline {
if values.length() == 0 {
return {
center: 0.0,
spread: 0.0,
lower: 0.0,
upper: 0.0,
retained: 0,
rejected: 0,
}
}
let center = median_value(values)
let mad = median_absolute_deviation(values)
let scale = if mad == 0.0 { standard_deviation(values) } else { mad * 1.4826 }
let threshold = (if fence < 0.0 { 0.0 } else { fence }) * scale
let retained_values = []
for value in values {
if absolute_difference(value, center) <= threshold || scale == 0.0 {
retained_values.push(value)
}
}
let final_center = median_value(retained_values)
let final_spread = if retained_values.length() <= 1 {
if final_center == 0.0 {
0.0
} else {
final_center * 0.1
}
} else {
standard_deviation(retained_values)
}
let factor = if range_factor < 0.0 { 0.0 } else { range_factor }
{
center: final_center,
spread: final_spread,
lower: final_center - factor * final_spread,
upper: final_center + factor * final_spread,
retained: retained_values.length(),
rejected: values.length() - retained_values.length(),
}
}
///|
/// Convert a score into an ordinal readiness level.
pub fn readiness_level(score : Double) -> ReadinessLevel {
if score < 20.0 {
VeryLow
} else if score < 40.0 {
Low
} else if score < 60.0 {
Moderate
} else if score < 80.0 {
High
} else {
VeryHigh
}
}
///|
/// Calculate readiness from today's RMSSD, a baseline, and signal quality.
pub fn calculate_readiness(
today_rmssd : Double,
baseline : RobustBaseline,
trend_slope : Double,
quality_ratio : Double,
) -> ReadinessScore {
let spread = if baseline.spread <= 0.0 { 1.0 } else { baseline.spread }
let z = (today_rmssd - baseline.center) / spread
let baseline_component = (50.0 + z * 15.0).clamp(min=0.0, max=100.0)
let trend_component = (50.0 + trend_slope * 10.0).clamp(min=0.0, max=100.0)
let quality_component = quality_ratio.clamp(min=0.0, max=1.0) * 100.0
let score = (0.65 * baseline_component +
0.20 * trend_component +
0.15 * quality_component).clamp(min=0.0, max=100.0)
let level = readiness_level(score)
let explanation = if quality_ratio < 0.75 {
"low signal quality reduces confidence"
} else if z < -1.0 {
"RMSSD is below the robust personal baseline"
} else if z > 1.0 {
"RMSSD is above the robust personal baseline"
} else {
"RMSSD is close to the robust personal baseline"
}
{
score,
level,
baseline_z: z,
trend_component,
quality_component,
explanation,
}
}
///|
/// Calculate a session load as duration multiplied by normalized intensity.
pub fn session_training_load(
duration_minutes : Double,
intensity : Double,
) -> Double {
let duration = if duration_minutes < 0.0 { 0.0 } else { duration_minutes }
let effort = intensity.clamp(min=0.0, max=10.0)
duration * effort
}
///|
/// Calculate monotony as mean daily load divided by its standard deviation.
pub fn calculate_monotony(daily_loads : Array[Double]) -> Double {
let deviation = standard_deviation(daily_loads)
if deviation == 0.0 {
0.0
} else {
mean_value(daily_loads) / deviation
}
}
///|
/// Calculate training strain as total load multiplied by monotony.
pub fn calculate_strain(daily_loads : Array[Double]) -> Double {
sum_values(daily_loads) * calculate_monotony(daily_loads)
}
///|
/// Calculate acute/chronic load using recent and historical windows.
pub fn summarize_training_load(
daily_loads : Array[Double],
acute_days : Int,
chronic_days : Int,
) -> TrainingLoadSummary {
let n = daily_loads.length()
let acute_start = if n > acute_days && acute_days > 0 {
n - acute_days
} else {
0
}
let chronic_start = if n > chronic_days && chronic_days > 0 {
n - chronic_days
} else {
0
}
let acute_values = []
let chronic_values = []
for i in acute_start.. Array[ReadinessScore] {
let result = []
for i in 0..