///|
/// A training session aligned with a morning recovery measurement.
pub(all) struct WorkoutSession {
date : String
duration_minutes : Double
intensity : Double
rmssd_before : Double
rmssd_after : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Daily training and recovery aggregates.
pub(all) struct TrainingDaySummary {
date : String
session_count : Int
duration_minutes : Double
total_load : Double
average_intensity : Double
rmssd_change : Double
recovery_cost : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Longitudinal training-load indicators.
pub(all) struct TrainingPlanMetrics {
days : Array[TrainingDaySummary]
total_load : Double
average_daily_load : Double
load_trend : Double
monotony : Double
strain : Double
recovery_cost : Double
high_load_days : Int
rest_days : Int
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Calculate the load of one session.
pub fn workout_load(session : WorkoutSession) -> Double {
session_training_load(session.duration_minutes, session.intensity)
}
///|
/// Group sessions by their ISO date while preserving input order.
pub fn summarize_training_days(
sessions : Array[WorkoutSession],
) -> Array[TrainingDaySummary] {
let result = []
for session in sessions {
let existing = sessions_date_index(result, session.date)
let load = workout_load(session)
let recovery_cost = (session.rmssd_before - session.rmssd_after).clamp(
min=0.0,
max=10000.0,
)
match existing {
Some(index) => {
let current = result[index]
let count = current.session_count + 1
result[index] = {
date: current.date,
session_count: count,
duration_minutes: current.duration_minutes + session.duration_minutes,
total_load: current.total_load + load,
average_intensity: (
current.average_intensity * current.session_count.to_double() +
session.intensity
) /
count.to_double(),
rmssd_change: current.rmssd_change +
session.rmssd_after -
session.rmssd_before,
recovery_cost: current.recovery_cost + recovery_cost,
}
}
None =>
result.push({
date: session.date,
session_count: 1,
duration_minutes: session.duration_minutes,
total_load: load,
average_intensity: session.intensity,
rmssd_change: session.rmssd_after - session.rmssd_before,
recovery_cost,
})
}
}
result
}
///|
/// Find a date in a training summary array.
fn sessions_date_index(days : Array[TrainingDaySummary], date : String) -> Int? {
for i in 0.. TrainingPlanMetrics {
let days = summarize_training_days(sessions)
let loads = []
let mut recovery_cost = 0.0
let mut high_load_days = 0
for day in days {
loads.push(day.total_load)
recovery_cost += day.recovery_cost
if day.total_load >= 300.0 {
high_load_days += 1
}
}
let rest_days = if days.length() == 0 {
0
} else {
days.length() - high_load_days
}
{
days,
total_load: sum_values(loads),
average_daily_load: mean_value(loads),
load_trend: fit_linear_trend(loads).slope,
monotony: calculate_monotony(loads),
strain: calculate_strain(loads),
recovery_cost,
high_load_days,
rest_days,
}
}
///|
/// Recommend a recovery day when load and recent recovery cost are high.
pub fn recommend_recovery_day(metrics : TrainingPlanMetrics) -> Bool {
metrics.strain > 1000.0 ||
metrics.recovery_cost > 50.0 ||
metrics.load_trend > 50.0
}
///|
/// Return the dates with the largest training loads.
pub fn peak_training_days(
metrics : TrainingPlanMetrics,
limit : Int,
) -> Array[TrainingDaySummary] {
let result = []
for day in metrics.days {
result.push(day)
}
result.sort_by((left, right) => {
if left.total_load > right.total_load {
-1
} else if left.total_load < right.total_load {
1
} else {
0
}
})
if limit >= 0 && result.length() > limit {
result.truncate(limit)
}
result
}
///|
/// Calculate a seven-day load ratio from a session list.
pub fn acute_chronic_workout_ratio(
sessions : Array[WorkoutSession],
date_index : Int,
) -> Double {
let days = summarize_training_days(sessions)
let loads = []
for day in days {
loads.push(day.total_load)
}
let acute_start = if date_index > 7 { date_index - 7 } else { 0 }
let chronic_start = if date_index > 28 { date_index - 28 } else { 0 }
let acute = []
let chronic = []
for i in acute_start..