feat: workout model, JSON parser and flatten (phase 1)

pull/4/head
lambda 6 days ago
parent 94cfe8f102
commit a08b3ed76c
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -4,6 +4,7 @@ mod device_repo;
mod state; mod state;
mod telemetry; mod telemetry;
mod commands; mod commands;
mod workout;
use std::pin::Pin; use std::pin::Pin;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};

@ -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"
);
}
}
}
Loading…
Cancel
Save