///|
/// A production-oriented training load ledger for longitudinal HRV workflows.
/// The module keeps raw session context, transparent dose components, and
/// conservative status labels together so downstream applications can explain
/// why a recommendation was produced.
pub(all) enum LoadIntensityBand {
LoadRecovery
LoadAerobic
LoadTempo
LoadThreshold
LoadHighIntensity
LoadMaximal
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) enum LoadRiskLevel {
LoadStable
LoadWatch
LoadCaution
LoadCritical
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct TrainingLoadEntry {
date : String
session_id : String
duration_minutes : Double
average_hr_bpm : Double
maximum_hr_bpm : Double
resting_hr_bpm : Double
rpe : Double
distance_km : Double
elevation_m : Double
signal_quality : Double
band : LoadIntensityBand
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct LoadModelConfig {
max_hr_bpm : Double
resting_hr_floor_bpm : Double
acute_window_days : Int
chronic_window_days : Int
easy_rpe : Double
hard_rpe : Double
quality_floor : Double
monotony_floor : Double
caution_ratio : Double
critical_ratio : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
pub fn LoadModelConfig::default() -> LoadModelConfig {
{
max_hr_bpm: 190.0,
resting_hr_floor_bpm: 35.0,
acute_window_days: 7,
chronic_window_days: 28,
easy_rpe: 3.0,
hard_rpe: 8.0,
quality_floor: 0.70,
monotony_floor: 2.0,
caution_ratio: 1.30,
critical_ratio: 1.60,
}
}
///|
pub(all) struct LoadDose {
duration_component : Double
cardiovascular_component : Double
perceived_effort_component : Double
distance_component : Double
elevation_component : Double
quality_weight : Double
raw_load : Double
effective_load : Double
intensity_score : Double
band : LoadIntensityBand
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct DailyLoadLedger {
date : String
entries : Array[TrainingLoadEntry]
doses : Array[LoadDose]
total_load : Double
effective_load : Double
duration_minutes : Double
session_count : Int
average_intensity : Double
quality_ratio : Double
recovery_cost : Double
high_intensity_minutes : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct RollingLoadProfile {
dates : Array[String]
daily_loads : Array[Double]
acute_load : Double
chronic_load : Double
acute_chronic_ratio : Double
exponentially_weighted_load : Double
monotony : Double
strain : Double
load_trend : Double
rest_day_count : Int
high_load_day_count : Int
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct LoadAlert {
date : String
level : LoadRiskLevel
code : String
title : String
explanation : String
observed : Double
threshold : Double
action : String
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct TrainingLoadPlan {
days : Array[DailyLoadLedger]
profile : RollingLoadProfile
alerts : Array[LoadAlert]
total_load : Double
total_effective_load : Double
average_session_load : Double
peak_day : String
recommended_easy_days : Int
} derive(FromJson, ToJson, Debug, Eq)
///|
fn load_clamp(value : Double, low : Double, high : Double) -> Double {
if value.is_nan() || value.is_inf() {
low
} else {
value.clamp(min=low, max=high)
}
}
///|
fn load_positive(value : Double) -> Double {
if value.is_nan() || value.is_inf() || value <= 0.0 {
0.0
} else {
value
}
}
///|
fn load_nonnegative_int(value : Int) -> Int {
if value < 0 {
0
} else {
value
}
}
///|
pub fn load_band_from_score(score : Double) -> LoadIntensityBand {
let value = load_clamp(score, 0.0, 1.0)
if value < 0.25 {
LoadRecovery
} else if value < 0.50 {
LoadAerobic
} else if value < 0.68 {
LoadTempo
} else if value < 0.82 {
LoadThreshold
} else if value < 0.94 {
LoadHighIntensity
} else {
LoadMaximal
}
}
///|
pub fn load_band_name(band : LoadIntensityBand) -> String {
match band {
LoadRecovery => "recovery"
LoadAerobic => "aerobic"
LoadTempo => "tempo"
LoadThreshold => "threshold"
LoadHighIntensity => "high_intensity"
LoadMaximal => "maximal"
}
}
///|
pub fn load_risk_name(level : LoadRiskLevel) -> String {
match level {
LoadStable => "stable"
LoadWatch => "watch"
LoadCaution => "caution"
LoadCritical => "critical"
}
}
///|
pub fn make_training_load_entry(
date : String,
session_id : String,
duration_minutes : Double,
average_hr_bpm : Double,
maximum_hr_bpm : Double,
resting_hr_bpm : Double,
rpe : Double,
distance_km : Double,
elevation_m : Double,
signal_quality : Double,
) -> TrainingLoadEntry {
let safe_max = if maximum_hr_bpm > 0.0 {
maximum_hr_bpm
} else {
average_hr_bpm
}
let score = if safe_max <= 0.0 {
rpe / 10.0
} else {
average_hr_bpm / safe_max
}
{
date,
session_id,
duration_minutes: load_clamp(duration_minutes, 0.0, 1440.0),
average_hr_bpm: load_clamp(average_hr_bpm, 0.0, 260.0),
maximum_hr_bpm: load_clamp(maximum_hr_bpm, 0.0, 260.0),
resting_hr_bpm: load_clamp(resting_hr_bpm, 0.0, 180.0),
rpe: load_clamp(rpe, 0.0, 10.0),
distance_km: load_positive(distance_km),
elevation_m: load_positive(elevation_m),
signal_quality: load_clamp(signal_quality, 0.0, 1.0),
band: load_band_from_score(score),
}
}
///|
pub fn training_load_entry_is_valid(
entry : TrainingLoadEntry,
config : LoadModelConfig,
) -> Bool {
entry.date.length() > 0 &&
entry.session_id.length() > 0 &&
entry.duration_minutes > 0.0 &&
entry.duration_minutes <= 1440.0 &&
entry.average_hr_bpm >= 0.0 &&
entry.average_hr_bpm <= 260.0 &&
entry.rpe >= 0.0 &&
entry.rpe <= 10.0 &&
entry.signal_quality >= config.quality_floor &&
!entry.duration_minutes.is_nan() &&
!entry.rpe.is_nan()
}
///|
pub fn load_entry_quality_weight(entry : TrainingLoadEntry) -> Double {
load_clamp(entry.signal_quality, 0.0, 1.0)
}
///|
pub fn load_entry_intensity_score(
entry : TrainingLoadEntry,
config : LoadModelConfig,
) -> Double {
let hr_score = if config.max_hr_bpm <= entry.resting_hr_bpm {
0.0
} else {
(entry.average_hr_bpm - entry.resting_hr_bpm) /
(config.max_hr_bpm - entry.resting_hr_bpm)
}
let rpe_score = entry.rpe / 10.0
let max_score = if entry.maximum_hr_bpm <= config.max_hr_bpm {
entry.maximum_hr_bpm / config.max_hr_bpm
} else {
1.0
}
(0.45 * hr_score + 0.40 * rpe_score + 0.15 * max_score).clamp(
min=0.0,
max=1.0,
)
}
///|
pub fn calculate_load_dose(
entry : TrainingLoadEntry,
config : LoadModelConfig,
) -> LoadDose {
let duration = load_clamp(entry.duration_minutes, 0.0, 1440.0)
let intensity = load_entry_intensity_score(entry, config)
let duration_component = duration * (0.35 + intensity)
let cardiovascular_component = if entry.average_hr_bpm <= entry.resting_hr_bpm {
0.0
} else {
duration *
((entry.average_hr_bpm - entry.resting_hr_bpm) / config.max_hr_bpm.max(1.0)).clamp(
min=0.0,
max=1.0,
) *
100.0
}
let perceived_effort_component = duration * entry.rpe * 1.25
let distance_component = entry.distance_km * (2.0 + intensity * 3.0)
let elevation_component = entry.elevation_m / 100.0 * (0.5 + intensity)
let raw = duration_component +
cardiovascular_component * 0.30 +
perceived_effort_component * 0.35 +
distance_component * 0.40 +
elevation_component
let quality = load_entry_quality_weight(entry)
let effective = raw * (0.50 + quality * 0.50)
{
duration_component,
cardiovascular_component,
perceived_effort_component,
distance_component,
elevation_component,
quality_weight: quality,
raw_load: raw,
effective_load: effective,
intensity_score: intensity,
band: load_band_from_score(intensity),
}
}
///|
pub fn load_dose_total(dose : LoadDose) -> Double {
dose.effective_load
}
///|
pub fn load_dose_is_high(dose : LoadDose) -> Bool {
dose.intensity_score >= 0.75 || dose.effective_load >= 350.0
}
///|
pub fn load_dose_recovery_cost(
dose : LoadDose,
entry : TrainingLoadEntry,
) -> Double {
let rpe_cost = entry.rpe * entry.duration_minutes / 10.0
let intensity_cost = dose.intensity_score * entry.duration_minutes
(rpe_cost * 0.60 + intensity_cost * 0.40) *
(1.10 - dose.quality_weight * 0.10)
}
///|
fn load_entry_index(entries : Array[TrainingLoadEntry], date : String) -> Int? {
for i in 0.. Array[TrainingLoadEntry] {
let result = []
for entry in entries {
result.push(entry)
}
result
}
///|
fn load_sort_entries(
entries : Array[TrainingLoadEntry],
) -> Array[TrainingLoadEntry] {
let result = load_copy_entries(entries)
result.sort_by((left, right) => {
if left.date < right.date {
-1
} else if left.date > right.date {
1
} else if left.session_id < right.session_id {
-1
} else if left.session_id > right.session_id {
1
} else {
0
}
})
result
}
///|
pub fn group_training_load_days(
entries : Array[TrainingLoadEntry],
config : LoadModelConfig,
) -> Array[DailyLoadLedger] {
let ordered = load_sort_entries(entries)
let days : Array[DailyLoadLedger] = []
for entry in ordered {
if !training_load_entry_is_valid(entry, config) {
continue
}
let dose = calculate_load_dose(entry, config)
match
load_entry_index(
days.map(day => {
date: day.date,
session_id: "",
duration_minutes: 1.0,
average_hr_bpm: 0.0,
maximum_hr_bpm: 0.0,
resting_hr_bpm: 0.0,
rpe: 0.0,
distance_km: 0.0,
elevation_m: 0.0,
signal_quality: 1.0,
band: LoadRecovery,
}),
entry.date,
) {
Some(index) => {
let current = days[index]
let count = current.session_count + 1
let entries_copy = load_copy_entries(current.entries)
let doses_copy = current.doses
entries_copy.push(entry)
doses_copy.push(dose)
let high_minutes = if load_dose_is_high(dose) {
current.high_intensity_minutes + entry.duration_minutes
} else {
current.high_intensity_minutes
}
let cost = load_dose_recovery_cost(dose, entry)
days[index] = {
date: current.date,
entries: entries_copy,
doses: doses_copy,
total_load: current.total_load + dose.raw_load,
effective_load: current.effective_load + dose.effective_load,
duration_minutes: current.duration_minutes + entry.duration_minutes,
session_count: count,
average_intensity: (
current.average_intensity * current.session_count.to_double() +
dose.intensity_score
) /
count.to_double(),
quality_ratio: (
current.quality_ratio * current.session_count.to_double() +
entry.signal_quality
) /
count.to_double(),
recovery_cost: current.recovery_cost + cost,
high_intensity_minutes: high_minutes,
}
}
None => {
let high_minutes = if load_dose_is_high(dose) {
entry.duration_minutes
} else {
0.0
}
days.push({
date: entry.date,
entries: [entry],
doses: [dose],
total_load: dose.raw_load,
effective_load: dose.effective_load,
duration_minutes: entry.duration_minutes,
session_count: 1,
average_intensity: dose.intensity_score,
quality_ratio: entry.signal_quality,
recovery_cost: load_dose_recovery_cost(dose, entry),
high_intensity_minutes: high_minutes,
})
}
}
}
days
}
///|
fn load_day_mean(days : Array[DailyLoadLedger]) -> Double {
let values = days.map(day => day.effective_load)
mean_value(values)
}
///|
fn load_day_sd(days : Array[DailyLoadLedger]) -> Double {
let values = days.map(day => day.effective_load)
standard_deviation(values)
}
///|
pub fn calculate_load_monotony(days : Array[DailyLoadLedger]) -> Double {
if days.length() == 0 {
0.0
} else {
let sd = load_day_sd(days)
if sd <= 0.000001 {
0.0
} else {
load_day_mean(days) / sd
}
}
}
///|
pub fn calculate_load_strain(days : Array[DailyLoadLedger]) -> Double {
sum_values(days.map(day => day.effective_load)) *
calculate_load_monotony(days)
}
///|
pub fn load_rest_day_count(days : Array[DailyLoadLedger]) -> Int {
let mut count = 0
for day in days {
if day.session_count == 0 || day.effective_load < 40.0 {
count += 1
}
}
count
}
///|
pub fn load_high_day_count(days : Array[DailyLoadLedger]) -> Int {
let mut count = 0
for day in days {
if day.effective_load >= 300.0 || day.high_intensity_minutes >= 20.0 {
count += 1
}
}
count
}
///|
pub fn rolling_load_average(
days : Array[DailyLoadLedger],
end_index : Int,
window : Int,
) -> Double {
let safe_end = end_index.clamp(min=0, max=days.length())
let safe_window = load_nonnegative_int(window)
if safe_end == 0 || safe_window == 0 {
0.0
} else {
let start = (safe_end - safe_window).max(0)
let values = []
for i in start.. Double {
let alpha = load_clamp(decay, 0.01, 1.0)
let mut value = 0.0
for day in days {
value = alpha * day.effective_load + (1.0 - alpha) * value
}
value
}
///|
pub fn calculate_load_ratio(
days : Array[DailyLoadLedger],
acute_window : Int,
chronic_window : Int,
) -> Double {
let acute = rolling_load_average(days, days.length(), acute_window)
let chronic = rolling_load_average(days, days.length(), chronic_window)
if chronic <= 0.000001 {
0.0
} else {
acute / chronic
}
}
///|
pub fn load_trend(days : Array[DailyLoadLedger]) -> Double {
fit_linear_trend(days.map(day => day.effective_load)).slope
}
///|
pub fn build_rolling_load_profile(
days : Array[DailyLoadLedger],
config : LoadModelConfig,
) -> RollingLoadProfile {
let dates = days.map(day => day.date)
let loads = days.map(day => day.effective_load)
let acute = rolling_load_average(
days,
days.length(),
config.acute_window_days,
)
let chronic = rolling_load_average(
days,
days.length(),
config.chronic_window_days,
)
let ratio = if chronic <= 0.000001 { 0.0 } else { acute / chronic }
{
dates,
daily_loads: loads,
acute_load: acute,
chronic_load: chronic,
acute_chronic_ratio: ratio,
exponentially_weighted_load: exponentially_weighted_load(days, 0.25),
monotony: calculate_load_monotony(days),
strain: calculate_load_strain(days),
load_trend: load_trend(days),
rest_day_count: load_rest_day_count(days),
high_load_day_count: load_high_day_count(days),
}
}
///|
pub fn classify_load_risk(
profile : RollingLoadProfile,
config : LoadModelConfig,
) -> LoadRiskLevel {
if profile.acute_chronic_ratio >= config.critical_ratio ||
profile.strain >= 1800.0 {
LoadCritical
} else if profile.acute_chronic_ratio >= config.caution_ratio ||
profile.monotony >= config.monotony_floor * 1.6 {
LoadCaution
} else if profile.acute_chronic_ratio >= 1.10 || profile.load_trend >= 25.0 {
LoadWatch
} else {
LoadStable
}
}
///|
pub fn load_alert_for_profile(
profile : RollingLoadProfile,
config : LoadModelConfig,
) -> LoadAlert? {
let level = classify_load_risk(profile, config)
match level {
LoadStable => None
LoadWatch =>
Some({
date: if profile.dates.length() == 0 {
""
} else {
profile.dates[profile.dates.length() - 1]
},
level,
code: "rising_load",
title: "Training load is rising",
explanation: "Recent load is above the established trend; keep the next session controlled.",
observed: profile.acute_chronic_ratio,
threshold: 1.10,
action: "Prefer easy aerobic work and review recovery signals.",
})
LoadCaution =>
Some({
date: if profile.dates.length() == 0 {
""
} else {
profile.dates[profile.dates.length() - 1]
},
level,
code: "load_ratio_caution",
title: "Acute load needs caution",
explanation: "The seven-day workload is materially above the chronic reference window.",
observed: profile.acute_chronic_ratio,
threshold: config.caution_ratio,
action: "Insert a recovery day before another hard session.",
})
LoadCritical =>
Some({
date: if profile.dates.length() == 0 {
""
} else {
profile.dates[profile.dates.length() - 1]
},
level,
code: "load_ratio_critical",
title: "Training load is critically high",
explanation: "Load ratio or strain crossed the safety threshold used by this model.",
observed: profile.acute_chronic_ratio.max(profile.strain / 1800.0),
threshold: config.critical_ratio,
action: "Pause high-intensity work and reassess recovery before resuming.",
})
}
}
///|
pub fn build_training_load_plan(
entries : Array[TrainingLoadEntry],
config : LoadModelConfig,
) -> TrainingLoadPlan {
let days = group_training_load_days(entries, config)
let profile = build_rolling_load_profile(days, config)
let alerts = []
match load_alert_for_profile(profile, config) {
Some(alert) => alerts.push(alert)
None => ()
}
let mut total = 0.0
let mut effective = 0.0
let mut peak = ""
let mut peak_value = -1.0
for day in days {
total += day.total_load
effective += day.effective_load
if day.effective_load > peak_value {
peak_value = day.effective_load
peak = day.date
}
}
let count = entries.length()
{
days,
profile,
alerts,
total_load: total,
total_effective_load: effective,
average_session_load: if count == 0 {
0.0
} else {
effective / count.to_double()
},
peak_day: peak,
recommended_easy_days: if classify_load_risk(profile, config)
is LoadCritical {
3
} else if profile.acute_chronic_ratio > 1.2 {
2
} else {
1
},
}
}
///|
pub fn load_plan_is_usable(plan : TrainingLoadPlan) -> Bool {
plan.days.length() > 0 &&
plan.total_effective_load >= 0.0 &&
!plan.profile.acute_chronic_ratio.is_nan() &&
!plan.profile.strain.is_nan()
}
///|
pub fn load_plan_feature_vector(plan : TrainingLoadPlan) -> Array[Double] {
[
plan.total_load,
plan.total_effective_load,
plan.average_session_load,
plan.profile.acute_load,
plan.profile.chronic_load,
plan.profile.acute_chronic_ratio,
plan.profile.exponentially_weighted_load,
plan.profile.monotony,
plan.profile.strain,
plan.profile.load_trend,
plan.profile.rest_day_count.to_double(),
plan.profile.high_load_day_count.to_double(),
plan.recommended_easy_days.to_double(),
]
}
///|
pub fn load_dose_to_row(
entry : TrainingLoadEntry,
dose : LoadDose,
) -> Array[String] {
[
entry.date,
entry.session_id,
entry.duration_minutes.to_string(),
entry.rpe.to_string(),
entry.average_hr_bpm.to_string(),
entry.distance_km.to_string(),
load_band_name(dose.band),
dose.raw_load.to_string(),
dose.effective_load.to_string(),
dose.quality_weight.to_string(),
]
}
///|
pub fn export_training_load_plan_csv(plan : TrainingLoadPlan) -> String {
let grid = [
[
"date", "session_id", "duration_minutes", "rpe", "average_hr_bpm", "distance_km",
"band", "raw_load", "effective_load", "quality_weight",
],
]
for day in plan.days {
for i in 0.. String {
let grid = [
[
"date", "session_count", "duration_minutes", "total_load", "effective_load",
"average_intensity", "quality_ratio", "recovery_cost", "high_intensity_minutes",
],
]
for day in plan.days {
grid.push([
day.date,
day.session_count.to_string(),
day.duration_minutes.to_string(),
day.total_load.to_string(),
day.effective_load.to_string(),
day.average_intensity.to_string(),
day.quality_ratio.to_string(),
day.recovery_cost.to_string(),
day.high_intensity_minutes.to_string(),
])
}
to_csv(grid)
}
///|
pub fn export_load_alerts_csv(plan : TrainingLoadPlan) -> String {
let grid = [
["date", "level", "code", "title", "observed", "threshold", "action"],
]
for alert in plan.alerts {
grid.push([
alert.date,
load_risk_name(alert.level),
alert.code,
alert.title,
alert.observed.to_string(),
alert.threshold.to_string(),
alert.action,
])
}
to_csv(grid)
}
///|
pub fn select_load_peaks(
days : Array[DailyLoadLedger],
limit : Int,
) -> Array[DailyLoadLedger] {
let result = load_copy_days(days)
result.sort_by((left, right) => {
if left.effective_load > right.effective_load {
-1
} else if left.effective_load < right.effective_load {
1
} else {
0
}
})
if limit >= 0 && result.length() > limit {
result.truncate(limit)
}
result
}
///|
fn load_copy_days(days : Array[DailyLoadLedger]) -> Array[DailyLoadLedger] {
let result = []
for day in days {
result.push(day)
}
result
}
///|
pub fn load_days_in_range(
days : Array[DailyLoadLedger],
start_date : String,
end_date : String,
) -> Array[DailyLoadLedger] {
let result = []
for day in days {
if day.date >= start_date && day.date <= end_date {
result.push(day)
}
}
result
}
///|
pub fn load_plan_summary_line(plan : TrainingLoadPlan) -> String {
"\{plan.days.length()} days, \{plan.total_effective_load.to_string()} effective load, ratio \{plan.profile.acute_chronic_ratio.to_string()}, risk \{load_risk_name(classify_load_risk(plan.profile, LoadModelConfig::default()))}"
}
///|
pub fn load_recommended_intensity(
plan : TrainingLoadPlan,
readiness_score : Double,
) -> Double {
let risk_factor = match
classify_load_risk(plan.profile, LoadModelConfig::default()) {
LoadStable => 1.0
LoadWatch => 0.80
LoadCaution => 0.55
LoadCritical => 0.30
}
load_clamp(readiness_score / 100.0 * risk_factor, 0.10, 1.0)
}
///|
pub fn load_schedule_is_monotonic(days : Array[DailyLoadLedger]) -> Bool {
for i in 1..= days[i].date {
return false
}
}
true
}
///|
pub fn load_plan_quality(plan : TrainingLoadPlan) -> Double {
if plan.days.length() == 0 {
0.0
} else {
mean_value(plan.days.map(day => day.quality_ratio))
}
}
///|
pub fn load_plan_duration(plan : TrainingLoadPlan) -> Double {
sum_values(plan.days.map(day => day.duration_minutes))
}
///|
pub fn load_plan_hard_minutes(plan : TrainingLoadPlan) -> Double {
sum_values(plan.days.map(day => day.high_intensity_minutes))
}
///|
pub fn load_plan_recovery_cost(plan : TrainingLoadPlan) -> Double {
sum_values(plan.days.map(day => day.recovery_cost))
}
///|
pub fn load_plan_with_quality_floor(
entries : Array[TrainingLoadEntry],
minimum_quality : Double,
) -> TrainingLoadPlan {
let config = LoadModelConfig::default()
let adjusted = {
max_hr_bpm: config.max_hr_bpm,
resting_hr_floor_bpm: config.resting_hr_floor_bpm,
acute_window_days: config.acute_window_days,
chronic_window_days: config.chronic_window_days,
easy_rpe: config.easy_rpe,
hard_rpe: config.hard_rpe,
quality_floor: minimum_quality.clamp(min=0.0, max=1.0),
monotony_floor: config.monotony_floor,
caution_ratio: config.caution_ratio,
critical_ratio: config.critical_ratio,
}
build_training_load_plan(entries, adjusted)
}
///|
pub fn load_entry_from_workout(
session : WorkoutSession,
session_id : String,
) -> TrainingLoadEntry {
make_training_load_entry(
session.date,
session_id,
session.duration_minutes,
0.0,
0.0,
60.0,
session.intensity,
0.0,
0.0,
1.0,
)
}
///|
pub fn load_plan_from_workouts(
sessions : Array[WorkoutSession],
) -> TrainingLoadPlan {
let entries = []
for i in 0.. Bool {
match level {
LoadStable => false
LoadWatch => false
LoadCaution => true
LoadCritical => true
}
}
///|
pub fn load_risk_score(level : LoadRiskLevel) -> Double {
match level {
LoadStable => 0.0
LoadWatch => 0.35
LoadCaution => 0.70
LoadCritical => 1.0
}
}
///|
pub fn load_profile_row(profile : RollingLoadProfile) -> Array[String] {
[
profile.acute_load.to_string(),
profile.chronic_load.to_string(),
profile.acute_chronic_ratio.to_string(),
profile.exponentially_weighted_load.to_string(),
profile.monotony.to_string(),
profile.strain.to_string(),
profile.load_trend.to_string(),
profile.rest_day_count.to_string(),
profile.high_load_day_count.to_string(),
]
}
///|
pub fn load_profile_csv(profile : RollingLoadProfile) -> String {
let grid = [
[
"acute_load", "chronic_load", "acute_chronic_ratio", "ewma_load", "monotony",
"strain", "load_trend", "rest_day_count", "high_load_day_count",
],
load_profile_row(profile),
]
to_csv(grid)
}
///|
pub fn load_plan_alert_count(plan : TrainingLoadPlan) -> Int {
plan.alerts.length()
}
///|
pub fn load_plan_peak_value(plan : TrainingLoadPlan) -> Double {
if plan.days.length() == 0 {
0.0
} else {
let peaks = select_load_peaks(plan.days, 1)
if peaks.length() == 0 {
0.0
} else {
peaks[0].effective_load
}
}
}
///|
pub fn load_plan_has_high_intensity(plan : TrainingLoadPlan) -> Bool {
plan.profile.high_load_day_count > 0
}
///|
pub fn load_plan_is_recovering(plan : TrainingLoadPlan) -> Bool {
plan.profile.load_trend < 0.0 && plan.profile.acute_chronic_ratio < 1.0
}
///|
pub fn load_plan_next_day_budget(
plan : TrainingLoadPlan,
readiness_score : Double,
) -> Double {
let base = if plan.profile.chronic_load <= 0.0 {
100.0
} else {
plan.profile.chronic_load * 0.90
}
base * load_recommended_intensity(plan, readiness_score)
}
///|
pub fn load_plan_compare(
current : TrainingLoadPlan,
previous : TrainingLoadPlan,
) -> Array[Double] {
[
current.total_effective_load - previous.total_effective_load,
current.profile.acute_load - previous.profile.acute_load,
current.profile.chronic_load - previous.profile.chronic_load,
current.profile.acute_chronic_ratio - previous.profile.acute_chronic_ratio,
current.profile.load_trend - previous.profile.load_trend,
current.profile.strain - previous.profile.strain,
]
}
///|
pub fn load_plan_is_more_stressful(
current : TrainingLoadPlan,
previous : TrainingLoadPlan,
) -> Bool {
let change = load_plan_compare(current, previous)
change[0] > 0.0 && change[3] >= 0.0
}
///|
pub fn load_plan_recovery_message(plan : TrainingLoadPlan) -> String {
let level = classify_load_risk(plan.profile, LoadModelConfig::default())
match level {
LoadStable => "Current load is within the recent reference range."
LoadWatch => "Load is rising; keep the next session controlled."
LoadCaution => "Add an easy or rest day and reassess morning recovery."
LoadCritical =>
"Avoid high intensity until recovery and signal quality improve."
}
}