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
10pub 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
19pub 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
28pub 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
39pub 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
50pub 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#[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
85pub struct AllStatsVisitor {
87 pub total_sets: u32,
88 pub total_reps: u32,
89 pub total_volume_kg: f64,
90 exercises: HashMap<String, ExerciseAccum>,
92 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
190pub 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
197fn 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 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); 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 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); 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 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 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 assert!((stats.total_volume_kg - 306.1746).abs() < 0.01);
550 }
551}