Skip to main content

syntax_workout_core/
stats.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::measure::{Measure, WeightUnit};
6use crate::node::{Node, NodeKind};
7use crate::intensity::Intensity;
8use crate::visit::Visit;
9
10/// Epley formula: 1RM = weight * (1 + reps / 30)
11pub fn estimate_1rm(weight: f64, reps: u32) -> f64 {
12    if reps <= 1 {
13        weight
14    } else {
15        weight * (1.0 + reps as f64 / 30.0)
16    }
17}
18
19/// Counts all Set (Leaf) nodes in the tree.
20pub struct SetCounter(pub u32);
21
22impl Visit for SetCounter {
23    fn visit_leaf(&mut self, _measures: &[Measure], _intensity: Option<&Intensity>, _ancestors: &[&Node]) {
24        self.0 += 1;
25    }
26}
27
28/// Sums weight * reps across all sets, normalised to kg.
29pub struct VolumeCalculator(pub f64);
30
31impl Visit for VolumeCalculator {
32    fn visit_leaf(&mut self, measures: &[Measure], _intensity: Option<&Intensity>, _ancestors: &[&Node]) {
33        if let (Some(weight_kg), Some(reps)) = (extract_weight_kg(measures), extract_reps(measures)) {
34            self.0 += weight_kg * reps as f64;
35        }
36    }
37}
38
39/// Sums all reps across all sets.
40pub struct RepCounter(pub u32);
41
42impl Visit for RepCounter {
43    fn visit_leaf(&mut self, measures: &[Measure], _intensity: Option<&Intensity>, _ancestors: &[&Node]) {
44        if let Some(reps) = extract_reps(measures) {
45            self.0 += reps;
46        }
47    }
48}
49
50/// Volume for a specific exercise name only.
51pub struct ExerciseVolumeCalculator {
52    pub exercise_name: String,
53    pub volume: f64,
54}
55
56impl Visit for ExerciseVolumeCalculator {
57    fn visit_leaf(&mut self, measures: &[Measure], _intensity: Option<&Intensity>, ancestors: &[&Node]) {
58        let parent_exercise = find_parent_exercise_name(ancestors);
59        if parent_exercise == Some(self.exercise_name.as_str()) {
60            if let (Some(weight_kg), Some(reps)) = (extract_weight_kg(measures), extract_reps(measures)) {
61                self.volume += weight_kg * reps as f64;
62            }
63        }
64    }
65}
66
67/// Combined stats result -- computed in a single tree walk.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TreeStats {
70    pub total_sets: u32,
71    pub total_reps: u32,
72    pub total_volume_kg: f64,
73    pub exercises: Vec<ExerciseStats>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ExerciseStats {
78    pub name: String,
79    pub sets: u32,
80    pub reps: u32,
81    pub volume_kg: f64,
82    pub estimated_1rm: Option<f64>,
83}
84
85/// Single-pass visitor that collects all stats.
86pub struct AllStatsVisitor {
87    pub total_sets: u32,
88    pub total_reps: u32,
89    pub total_volume_kg: f64,
90    /// exercise name -> accumulated stats
91    exercises: HashMap<String, ExerciseAccum>,
92    /// preserve insertion order
93    exercise_order: Vec<String>,
94}
95
96struct ExerciseAccum {
97    sets: u32,
98    reps: u32,
99    volume_kg: f64,
100    best_1rm: Option<f64>,
101}
102
103impl AllStatsVisitor {
104    pub fn new() -> Self {
105        Self {
106            total_sets: 0,
107            total_reps: 0,
108            total_volume_kg: 0.0,
109            exercises: HashMap::new(),
110            exercise_order: Vec::new(),
111        }
112    }
113
114    pub fn into_tree_stats(self) -> TreeStats {
115        let exercises = self
116            .exercise_order
117            .iter()
118            .filter_map(|name| {
119                self.exercises.get(name).map(|acc| ExerciseStats {
120                    name: name.clone(),
121                    sets: acc.sets,
122                    reps: acc.reps,
123                    volume_kg: acc.volume_kg,
124                    estimated_1rm: acc.best_1rm,
125                })
126            })
127            .collect();
128
129        TreeStats {
130            total_sets: self.total_sets,
131            total_reps: self.total_reps,
132            total_volume_kg: self.total_volume_kg,
133            exercises,
134        }
135    }
136}
137
138impl Visit for AllStatsVisitor {
139    fn visit_leaf(&mut self, measures: &[Measure], _intensity: Option<&Intensity>, ancestors: &[&Node]) {
140        self.total_sets += 1;
141
142        let reps = extract_reps(measures);
143        let weight_kg = extract_weight_kg(measures);
144
145        if let Some(r) = reps {
146            self.total_reps += r;
147        }
148
149        let set_volume = match (weight_kg, reps) {
150            (Some(w), Some(r)) => w * r as f64,
151            _ => 0.0,
152        };
153        self.total_volume_kg += set_volume;
154
155        let set_1rm = match (weight_kg, reps) {
156            (Some(w), Some(r)) if w > 0.0 && r > 0 => Some(estimate_1rm(w, r)),
157            _ => None,
158        };
159
160        if let Some(exercise_name) = find_parent_exercise_name(ancestors) {
161            let name = exercise_name.to_string();
162            if !self.exercises.contains_key(&name) {
163                self.exercise_order.push(name.clone());
164                self.exercises.insert(
165                    name.clone(),
166                    ExerciseAccum {
167                        sets: 0,
168                        reps: 0,
169                        volume_kg: 0.0,
170                        best_1rm: None,
171                    },
172                );
173            }
174            let acc = self.exercises.get_mut(&name).unwrap();
175            acc.sets += 1;
176            if let Some(r) = reps {
177                acc.reps += r;
178            }
179            acc.volume_kg += set_volume;
180            if let Some(rm) = set_1rm {
181                acc.best_1rm = Some(match acc.best_1rm {
182                    Some(prev) if prev >= rm => prev,
183                    _ => rm,
184                });
185            }
186        }
187    }
188}
189
190/// Compute all stats for a workout tree in a single pass.
191pub fn compute_tree_stats(root: &Node) -> TreeStats {
192    let mut visitor = AllStatsVisitor::new();
193    visitor.visit_tree(root);
194    visitor.into_tree_stats()
195}
196
197// ── helpers ──────────────────────────────────────────────────────────
198
199fn extract_weight_kg(measures: &[Measure]) -> Option<f64> {
200    measures.iter().find_map(|m| match m {
201        Measure::Weight { amount, unit } => {
202            let kg = match unit {
203                WeightUnit::Kg => *amount,
204                WeightUnit::Lbs => *amount * 0.453592,
205            };
206            Some(kg)
207        }
208        _ => None,
209    })
210}
211
212fn extract_reps(measures: &[Measure]) -> Option<u32> {
213    measures.iter().find_map(|m| match m {
214        Measure::Reps(r) => Some(*r),
215        _ => None,
216    })
217}
218
219fn find_parent_exercise_name<'a>(ancestors: &[&'a Node]) -> Option<&'a str> {
220    ancestors
221        .iter()
222        .rev()
223        .find(|n| matches!(n.kind, NodeKind::Exercise))
224        .and_then(|n| n.name.as_deref())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::execution_mode::ExecutionMode;
231    use crate::node::*;
232    use std::collections::BTreeMap;
233
234    #[test]
235    fn epley_formula_basic() {
236        let result = estimate_1rm(80.0, 5);
237        // 80 * (1 + 5/30) = 80 * 7/6 = 93.333...
238        assert!((result - 93.333).abs() < 0.01);
239    }
240
241    #[test]
242    fn epley_formula_single_rep() {
243        assert_eq!(estimate_1rm(100.0, 1), 100.0);
244    }
245
246    fn make_straight_sets() -> Node {
247        let make_set = |id: &str, weight: f64, reps: u32| Node {
248            id: NodeId::from_string(id),
249            kind: NodeKind::Set,
250            name: None,
251            children: vec![],
252            payload: NodePayload::Leaf {
253                measures: vec![
254                    Measure::Weight { amount: weight, unit: WeightUnit::Kg },
255                    Measure::Reps(reps),
256                ],
257                intensity: None,
258            },
259            metadata: BTreeMap::new(),
260        };
261
262        let exercise = Node {
263            id: NodeId::from_string("ex-bench"),
264            kind: NodeKind::Exercise,
265            name: Some("Bench Press".into()),
266            children: vec![
267                make_set("s1", 80.0, 5),
268                make_set("s2", 80.0, 5),
269                make_set("s3", 80.0, 4),
270            ],
271            payload: NodePayload::Exercise {
272                measures: vec![],
273                intensity: None,
274                rest_seconds: Some(180.0),
275            },
276            metadata: BTreeMap::new(),
277        };
278
279        let block = Node {
280            id: NodeId::from_string("blk-1"),
281            kind: NodeKind::Block,
282            name: None,
283            children: vec![exercise],
284            payload: NodePayload::Block {
285                execution_mode: ExecutionMode::Sequential,
286                rest_seconds: None,
287            },
288            metadata: BTreeMap::new(),
289        };
290
291        Node {
292            id: NodeId::from_string("sess-1"),
293            kind: NodeKind::Session,
294            name: Some("Push Day A".into()),
295            children: vec![block],
296            payload: NodePayload::Temporal { rest_seconds: None },
297            metadata: BTreeMap::new(),
298        }
299    }
300
301    fn make_superset() -> Node {
302        let make_set = |id: &str, weight: f64, reps: u32| Node {
303            id: NodeId::from_string(id),
304            kind: NodeKind::Set,
305            name: None,
306            children: vec![],
307            payload: NodePayload::Leaf {
308                measures: vec![
309                    Measure::Weight { amount: weight, unit: WeightUnit::Kg },
310                    Measure::Reps(reps),
311                ],
312                intensity: None,
313            },
314            metadata: BTreeMap::new(),
315        };
316
317        let ex_row = Node {
318            id: NodeId::from_string("ex-row"),
319            kind: NodeKind::Exercise,
320            name: Some("DB Row".into()),
321            children: vec![make_set("s1", 30.0, 10), make_set("s2", 30.0, 10)],
322            payload: NodePayload::Exercise {
323                measures: vec![],
324                intensity: None,
325                rest_seconds: None,
326            },
327            metadata: BTreeMap::new(),
328        };
329
330        let ex_press = Node {
331            id: NodeId::from_string("ex-press"),
332            kind: NodeKind::Exercise,
333            name: Some("Incline Press".into()),
334            children: vec![make_set("s3", 25.0, 12), make_set("s4", 25.0, 12)],
335            payload: NodePayload::Exercise {
336                measures: vec![],
337                intensity: None,
338                rest_seconds: None,
339            },
340            metadata: BTreeMap::new(),
341        };
342
343        let block = Node {
344            id: NodeId::from_string("blk-a"),
345            kind: NodeKind::Block,
346            name: Some("Superset A".into()),
347            children: vec![ex_row, ex_press],
348            payload: NodePayload::Block {
349                execution_mode: ExecutionMode::Parallel,
350                rest_seconds: Some(90.0),
351            },
352            metadata: BTreeMap::new(),
353        };
354
355        Node {
356            id: NodeId::from_string("sess-1"),
357            kind: NodeKind::Session,
358            name: Some("Upper Hypertrophy".into()),
359            children: vec![block],
360            payload: NodePayload::Temporal { rest_seconds: None },
361            metadata: BTreeMap::new(),
362        }
363    }
364
365    fn make_easy_run() -> Node {
366        let set = Node {
367            id: NodeId::from_string("s1"),
368            kind: NodeKind::Set,
369            name: None,
370            children: vec![],
371            payload: NodePayload::Leaf {
372                measures: vec![
373                    Measure::Distance {
374                        amount: 5.0,
375                        unit: crate::measure::DistanceUnit::Kilometers,
376                    },
377                    Measure::Duration { seconds: 1650.0 },
378                ],
379                intensity: None,
380            },
381            metadata: BTreeMap::new(),
382        };
383
384        let exercise = Node {
385            id: NodeId::from_string("ex-run"),
386            kind: NodeKind::Exercise,
387            name: Some("Easy Run".into()),
388            children: vec![set],
389            payload: NodePayload::Exercise {
390                measures: vec![],
391                intensity: None,
392                rest_seconds: None,
393            },
394            metadata: BTreeMap::new(),
395        };
396
397        let block = Node {
398            id: NodeId::from_string("blk-1"),
399            kind: NodeKind::Block,
400            name: None,
401            children: vec![exercise],
402            payload: NodePayload::Block {
403                execution_mode: ExecutionMode::Sequential,
404                rest_seconds: None,
405            },
406            metadata: BTreeMap::new(),
407        };
408
409        Node {
410            id: NodeId::from_string("sess-1"),
411            kind: NodeKind::Session,
412            name: Some("Easy Run".into()),
413            children: vec![block],
414            payload: NodePayload::Temporal { rest_seconds: None },
415            metadata: BTreeMap::new(),
416        }
417    }
418
419    #[test]
420    fn straight_sets_stats() {
421        let root = make_straight_sets();
422        let stats = compute_tree_stats(&root);
423
424        assert_eq!(stats.total_sets, 3);
425        assert_eq!(stats.total_reps, 14); // 5+5+4
426        // volume: 80*5 + 80*5 + 80*4 = 400+400+320 = 1120
427        assert!((stats.total_volume_kg - 1120.0).abs() < 0.01);
428
429        assert_eq!(stats.exercises.len(), 1);
430        let bench = &stats.exercises[0];
431        assert_eq!(bench.name, "Bench Press");
432        assert_eq!(bench.sets, 3);
433        assert_eq!(bench.reps, 14);
434        assert!((bench.volume_kg - 1120.0).abs() < 0.01);
435        // best 1RM from 80kg x 5 reps = 93.33
436        assert!(bench.estimated_1rm.is_some());
437        assert!((bench.estimated_1rm.unwrap() - 93.333).abs() < 0.01);
438    }
439
440    #[test]
441    fn superset_stats() {
442        let root = make_superset();
443        let stats = compute_tree_stats(&root);
444
445        assert_eq!(stats.total_sets, 4);
446        assert_eq!(stats.total_reps, 44); // 10+10+12+12
447        // volume: 30*10 + 30*10 + 25*12 + 25*12 = 300+300+300+300 = 1200
448        assert!((stats.total_volume_kg - 1200.0).abs() < 0.01);
449
450        assert_eq!(stats.exercises.len(), 2);
451
452        let row = &stats.exercises[0];
453        assert_eq!(row.name, "DB Row");
454        assert_eq!(row.sets, 2);
455        assert_eq!(row.reps, 20);
456        assert!((row.volume_kg - 600.0).abs() < 0.01);
457
458        let press = &stats.exercises[1];
459        assert_eq!(press.name, "Incline Press");
460        assert_eq!(press.sets, 2);
461        assert_eq!(press.reps, 24);
462        assert!((press.volume_kg - 600.0).abs() < 0.01);
463    }
464
465    #[test]
466    fn endurance_has_zero_volume() {
467        let root = make_easy_run();
468        let stats = compute_tree_stats(&root);
469
470        assert_eq!(stats.total_sets, 1);
471        assert_eq!(stats.total_reps, 0);
472        assert!((stats.total_volume_kg - 0.0).abs() < 0.01);
473    }
474
475    #[test]
476    fn all_stats_visitor_matches_individual_visitors() {
477        let root = make_straight_sets();
478
479        // Individual visitors
480        let mut set_counter = SetCounter(0);
481        set_counter.visit_tree(&root);
482
483        let mut vol_calc = VolumeCalculator(0.0);
484        vol_calc.visit_tree(&root);
485
486        let mut rep_counter = RepCounter(0);
487        rep_counter.visit_tree(&root);
488
489        // Combined visitor
490        let stats = compute_tree_stats(&root);
491
492        assert_eq!(stats.total_sets, set_counter.0);
493        assert_eq!(stats.total_reps, rep_counter.0);
494        assert!((stats.total_volume_kg - vol_calc.0).abs() < 0.001);
495    }
496
497    #[test]
498    fn exercise_volume_calculator() {
499        let root = make_superset();
500        let mut calc = ExerciseVolumeCalculator {
501            exercise_name: "DB Row".to_string(),
502            volume: 0.0,
503        };
504        calc.visit_tree(&root);
505        assert!((calc.volume - 600.0).abs() < 0.01);
506    }
507
508    #[test]
509    fn lbs_to_kg_conversion() {
510        let set = Node {
511            id: NodeId::from_string("s1"),
512            kind: NodeKind::Set,
513            name: None,
514            children: vec![],
515            payload: NodePayload::Leaf {
516                measures: vec![
517                    Measure::Weight { amount: 135.0, unit: WeightUnit::Lbs },
518                    Measure::Reps(5),
519                ],
520                intensity: None,
521            },
522            metadata: BTreeMap::new(),
523        };
524
525        let exercise = Node {
526            id: NodeId::from_string("ex"),
527            kind: NodeKind::Exercise,
528            name: Some("Squat".into()),
529            children: vec![set],
530            payload: NodePayload::Exercise {
531                measures: vec![],
532                intensity: None,
533                rest_seconds: None,
534            },
535            metadata: BTreeMap::new(),
536        };
537
538        let root = Node {
539            id: NodeId::from_string("sess"),
540            kind: NodeKind::Session,
541            name: None,
542            children: vec![exercise],
543            payload: NodePayload::Temporal { rest_seconds: None },
544            metadata: BTreeMap::new(),
545        };
546
547        let stats = compute_tree_stats(&root);
548        // 135 lbs = 61.23492 kg, * 5 reps = 306.1746
549        assert!((stats.total_volume_kg - 306.1746).abs() < 0.01);
550    }
551}