|
|
|
@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
//! Модель воркаутов intervals.icu: парсинг event-JSON и разворачивание
|
|
|
|
|
|
|
|
//! вложенных повторов в линейный список сегментов для исполнения на станке.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// TODO(phase 2): убрать, когда модуль будет подключён к tauri-командам.
|
|
|
|
|
|
|
|
#![allow(dead_code)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Чистые (доменные) типы =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Целевая мощность шага. Значения — в % от FTP (все скачанные воркауты
|
|
|
|
|
|
|
|
/// используют `units: "%ftp"`). Перевод %FTP -> ватты делается на этапе
|
|
|
|
|
|
|
|
/// исполнения, зная FTP.
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
|
|
|
|
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
|
|
|
|
|
|
pub enum PowerTarget {
|
|
|
|
|
|
|
|
/// Фиксированная мощность, % FTP.
|
|
|
|
|
|
|
|
Fixed { percent: f64 },
|
|
|
|
|
|
|
|
/// Линейный ramp от `from` до `to` % FTP за время шага (`ramp: true`).
|
|
|
|
|
|
|
|
Ramp { from: f64, to: f64 },
|
|
|
|
|
|
|
|
/// Свободный диапазон `low..high` % FTP (не ramp) — гонщик едет внутри.
|
|
|
|
|
|
|
|
Range { low: f64, high: f64 },
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Один шаг воркаута до разворачивания повторов.
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
|
|
|
|
|
|
pub struct WorkoutStep {
|
|
|
|
|
|
|
|
pub text: Option<String>,
|
|
|
|
|
|
|
|
pub duration_secs: u32,
|
|
|
|
|
|
|
|
pub power: Option<PowerTarget>,
|
|
|
|
|
|
|
|
pub cadence_rpm: Option<u16>,
|
|
|
|
|
|
|
|
/// Сколько раз повторить (для блока с `children`); для листа — 1.
|
|
|
|
|
|
|
|
pub repeats: u32,
|
|
|
|
|
|
|
|
pub children: Vec<WorkoutStep>,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Развёрнутый линейный сегмент — атом исполнения.
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
|
|
|
|
|
|
pub struct Segment {
|
|
|
|
|
|
|
|
pub text: Option<String>,
|
|
|
|
|
|
|
|
pub duration_secs: u32,
|
|
|
|
|
|
|
|
pub power: Option<PowerTarget>,
|
|
|
|
|
|
|
|
pub cadence_rpm: Option<u16>,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Воркаут целиком.
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
|
|
|
|
pub struct Workout {
|
|
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
|
|
pub start_date_local: Option<String>,
|
|
|
|
|
|
|
|
pub icu_training_load: Option<f64>,
|
|
|
|
|
|
|
|
pub steps: Vec<WorkoutStep>,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Сырые serde-структуры (повторяют формат /events intervals.icu) =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
|
|
|
struct RawEvent {
|
|
|
|
|
|
|
|
name: Option<String>,
|
|
|
|
|
|
|
|
#[serde(rename = "start_date_local")]
|
|
|
|
|
|
|
|
start_date_local: Option<String>,
|
|
|
|
|
|
|
|
#[serde(rename = "icu_training_load")]
|
|
|
|
|
|
|
|
icu_training_load: Option<f64>,
|
|
|
|
|
|
|
|
workout_doc: Option<RawWorkoutDoc>,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
|
|
|
struct RawWorkoutDoc {
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
steps: Vec<RawStep>,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
|
|
|
struct RawStep {
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
text: Option<String>,
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
duration: Option<u32>,
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
ramp: bool,
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
reps: Option<u32>,
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
power: Option<RawPower>,
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
cadence: Option<RawCadence>,
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
steps: Vec<RawStep>,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
|
|
|
struct RawPower {
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
value: Option<f64>,
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
start: Option<f64>,
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
end: Option<f64>,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
|
|
|
struct RawCadence {
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
value: Option<f64>,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Ошибки =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
|
|
|
|
|
|
pub enum WorkoutError {
|
|
|
|
|
|
|
|
#[error("invalid workout JSON: {0}")]
|
|
|
|
|
|
|
|
Json(#[from] serde_json::Error),
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Парсинг =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Разбирает event-JSON intervals.icu в `Workout`.
|
|
|
|
|
|
|
|
pub fn parse_workout(json: &str) -> Result<Workout, WorkoutError> {
|
|
|
|
|
|
|
|
let raw: RawEvent = serde_json::from_str(json)?;
|
|
|
|
|
|
|
|
let steps = raw
|
|
|
|
|
|
|
|
.workout_doc
|
|
|
|
|
|
|
|
.map(|d| d.steps)
|
|
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
|
|
.map(convert_step)
|
|
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
Ok(Workout {
|
|
|
|
|
|
|
|
name: raw.name.unwrap_or_default(),
|
|
|
|
|
|
|
|
start_date_local: raw.start_date_local,
|
|
|
|
|
|
|
|
icu_training_load: raw.icu_training_load,
|
|
|
|
|
|
|
|
steps,
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fn convert_step(raw: RawStep) -> WorkoutStep {
|
|
|
|
|
|
|
|
WorkoutStep {
|
|
|
|
|
|
|
|
text: raw.text,
|
|
|
|
|
|
|
|
duration_secs: raw.duration.unwrap_or(0),
|
|
|
|
|
|
|
|
power: raw.power.and_then(|p| convert_power(p, raw.ramp)),
|
|
|
|
|
|
|
|
cadence_rpm: raw.cadence.and_then(|c| c.value.map(|v| v.round() as u16)),
|
|
|
|
|
|
|
|
repeats: raw.reps.unwrap_or(1),
|
|
|
|
|
|
|
|
children: raw.steps.into_iter().map(convert_step).collect(),
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Переводит сырую мощность в `PowerTarget`. Флаг `ramp` (с шага) отличает
|
|
|
|
|
|
|
|
/// линейный ramp от свободного диапазона. Предполагаем `units == "%ftp"`
|
|
|
|
|
|
|
|
/// (все скачанные воркауты используют %ftp); абсолютные ватты не поддерживаются.
|
|
|
|
|
|
|
|
fn convert_power(p: RawPower, ramp: bool) -> Option<PowerTarget> {
|
|
|
|
|
|
|
|
if let Some(v) = p.value {
|
|
|
|
|
|
|
|
return Some(PowerTarget::Fixed { percent: v });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
match (p.start, p.end) {
|
|
|
|
|
|
|
|
(Some(s), Some(e)) if ramp => Some(PowerTarget::Ramp { from: s, to: e }),
|
|
|
|
|
|
|
|
(Some(s), Some(e)) => Some(PowerTarget::Range { low: s, high: e }),
|
|
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Flatten =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Разворачивает шаги (включая вложенные `repeats`) в линейный список сегментов.
|
|
|
|
|
|
|
|
pub fn flatten(steps: &[WorkoutStep]) -> Vec<Segment> {
|
|
|
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
|
|
|
for step in steps {
|
|
|
|
|
|
|
|
let reps = step.repeats.max(1) as usize;
|
|
|
|
|
|
|
|
for _ in 0..reps {
|
|
|
|
|
|
|
|
if step.children.is_empty() {
|
|
|
|
|
|
|
|
out.push(Segment {
|
|
|
|
|
|
|
|
text: step.text.clone(),
|
|
|
|
|
|
|
|
duration_secs: step.duration_secs,
|
|
|
|
|
|
|
|
power: step.power.clone(),
|
|
|
|
|
|
|
|
cadence_rpm: step.cadence_rpm,
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
out.extend(flatten(&step.children));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
out
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Суммарная длительность линейного списка сегментов, секунды.
|
|
|
|
|
|
|
|
pub fn total_duration_secs(segments: &[Segment]) -> u64 {
|
|
|
|
|
|
|
|
segments.iter().map(|s| s.duration_secs as u64).sum()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const EVENT: &str = r#"{
|
|
|
|
|
|
|
|
"name": "Test",
|
|
|
|
|
|
|
|
"start_date_local": "2026-10-28T09:00:00",
|
|
|
|
|
|
|
|
"icu_training_load": 91,
|
|
|
|
|
|
|
|
"workout_doc": {
|
|
|
|
|
|
|
|
"steps": [
|
|
|
|
|
|
|
|
{"ramp": true, "text": "Warmup", "power": {"units":"%ftp","start":45,"end":75}, "cadence": {"units":"rpm","value":85}, "duration": 600},
|
|
|
|
|
|
|
|
{"text": "Endurance", "power": {"units":"%ftp","start":65,"end":75}, "duration": 300},
|
|
|
|
|
|
|
|
{"text": "Primer", "power": {"units":"%ftp","value":95}, "cadence": {"units":"rpm","value":90}, "duration": 60},
|
|
|
|
|
|
|
|
{"reps": 4, "text": "Main", "steps": [
|
|
|
|
|
|
|
|
{"text": "Under", "power": {"units":"%ftp","value":90}, "duration": 180},
|
|
|
|
|
|
|
|
{"text": "Over", "power": {"units":"%ftp","value":105}, "duration": 120}
|
|
|
|
|
|
|
|
], "duration": 1200}
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}"#;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
|
|
fn parses_workout_metadata() {
|
|
|
|
|
|
|
|
let w = parse_workout(EVENT).unwrap();
|
|
|
|
|
|
|
|
assert_eq!(w.name, "Test");
|
|
|
|
|
|
|
|
assert_eq!(w.start_date_local.as_deref(), Some("2026-10-28T09:00:00"));
|
|
|
|
|
|
|
|
assert_eq!(w.icu_training_load, Some(91.0));
|
|
|
|
|
|
|
|
assert_eq!(w.steps.len(), 4);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
|
|
fn parses_power_targets() {
|
|
|
|
|
|
|
|
let w = parse_workout(EVENT).unwrap();
|
|
|
|
|
|
|
|
// Warmup -> ramp
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
|
|
w.steps[0].power,
|
|
|
|
|
|
|
|
Some(PowerTarget::Ramp { from: 45.0, to: 75.0 })
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
// Endurance (start/end, no ramp flag) -> range
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
|
|
w.steps[1].power,
|
|
|
|
|
|
|
|
Some(PowerTarget::Range { low: 65.0, high: 75.0 })
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
// Primer -> fixed
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
|
|
w.steps[2].power,
|
|
|
|
|
|
|
|
Some(PowerTarget::Fixed { percent: 95.0 })
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
// cadence: Warmup 85rpm
|
|
|
|
|
|
|
|
assert_eq!(w.steps[0].cadence_rpm, Some(85));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
|
|
fn flatten_expands_repeats() {
|
|
|
|
|
|
|
|
let w = parse_workout(EVENT).unwrap();
|
|
|
|
|
|
|
|
let segs = flatten(&w.steps);
|
|
|
|
|
|
|
|
// Warmup + Endurance + Primer + 4x(Under,Over) = 3 + 8 = 11
|
|
|
|
|
|
|
|
assert_eq!(segs.len(), 11);
|
|
|
|
|
|
|
|
// первая четвёрка после первых трёх шагов — Under/Over/Under/Over
|
|
|
|
|
|
|
|
let main = &segs[3..11];
|
|
|
|
|
|
|
|
assert_eq!(main.len(), 8);
|
|
|
|
|
|
|
|
assert_eq!(main[0].text.as_deref(), Some("Under"));
|
|
|
|
|
|
|
|
assert_eq!(main[1].text.as_deref(), Some("Over"));
|
|
|
|
|
|
|
|
assert_eq!(main[6].text.as_deref(), Some("Under"));
|
|
|
|
|
|
|
|
assert_eq!(main[7].text.as_deref(), Some("Over"));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
|
|
fn total_duration_matches() {
|
|
|
|
|
|
|
|
let w = parse_workout(EVENT).unwrap();
|
|
|
|
|
|
|
|
let segs = flatten(&w.steps);
|
|
|
|
|
|
|
|
// 600 + 300 + 60 + 4*(180+120) = 2160
|
|
|
|
|
|
|
|
assert_eq!(total_duration_secs(&segs), 2160);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
|
|
#[ignore]
|
|
|
|
|
|
|
|
fn parses_real_downloaded_workouts() {
|
|
|
|
|
|
|
|
let cases = [
|
|
|
|
|
|
|
|
("2026-10-28_threshold-over-under-2x20.json", 4380),
|
|
|
|
|
|
|
|
("2026-10-30_vo2max-5x4min.json", 3600),
|
|
|
|
|
|
|
|
("2026-11-01_long-z2-endurance.json", 7200),
|
|
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
for (file, expected_secs) in cases {
|
|
|
|
|
|
|
|
let path = format!("/home/dude/code/ridein/workouts/{file}");
|
|
|
|
|
|
|
|
let json = std::fs::read_to_string(&path).unwrap();
|
|
|
|
|
|
|
|
let w = parse_workout(&json).unwrap();
|
|
|
|
|
|
|
|
let segs = flatten(&w.steps);
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
|
|
total_duration_secs(&segs),
|
|
|
|
|
|
|
|
expected_secs,
|
|
|
|
|
|
|
|
"workout {file}: flat total mismatch"
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|