//! Estimated one-rep-max (e1RM). //! //! Reps-alone formulas (Epley, Brzycki, Lander, Mayhew) all discard the //! effort signal we already collect. ripgrow logs RPE per set, so we can //! do better: convert RPE to "reps in reserve," add it to the reps //! performed to get a hypothetical reps-to-failure, then apply Epley to //! that. This is the Tuchscherer / RTS approach in closed form; it //! approximates the RTS percentage chart within a few percent and needs //! no lookup table. //! //! e1RM = load * (1 + (reps + rir) / EPLEY_DIVISOR) //! //! All the constants and the RIR mapping live in [`crate::heuristics`] //! so they're auditable in one place; this module only composes them. //! The return type is [`Estimate`], not `f64`, so callers cannot //! forget they're looking at a formula-derived number. use crate::heuristics::{E1RM_METHOD_LABEL, EPLEY_DIVISOR, rir_from_rpe}; use crate::values::{Estimate, Load, Reps, Rpe}; /// Estimated 1RM from a single set. `None` when the set carries no signal /// — warmup RPE (see `heuristics::rir_from_rpe`) or zero reps. pub fn estimate_e1rm_from_set(load: Load, reps: Reps, rpe: Rpe) -> Option> { if load.get() <= 0.0 || reps.get() <= 0 { return None; } let rir = rir_from_rpe(rpe)?; let reps_to_failure = (reps.get() + rir) as f64; let value = load.get() * (1.0 + reps_to_failure / EPLEY_DIVISOR); Some(Estimate::new(value, E1RM_METHOD_LABEL)) } /// Convenience wrapper over primitives for the DB layer, which reads /// f64/i32 columns and cannot construct the newtypes without additional /// error handling for stored-invalid rows. Returns the bare value; if /// you need the method label, use [`estimate_e1rm_from_set`]. /// /// Panics-free: any invariant violation from the raw inputs (negative /// load, out-of-range RPE) collapses to `None`, matching the pre-newtype /// behavior. pub fn estimate_e1rm(load: f64, reps: i32, rpe: i32) -> Option { let load = Load::new(load).ok()?; let reps = Reps::new(reps).ok()?; let rpe = Rpe::new(rpe).ok()?; estimate_e1rm_from_set(load, reps, rpe).map(|e| e.value) } #[cfg(test)] mod tests { use super::*; fn set(load: f64, reps: i32, rpe: i32) -> (Load, Reps, Rpe) { ( Load::new(load).unwrap(), Reps::new(reps).unwrap(), Rpe::new(rpe).unwrap(), ) } #[test] fn rpe_1_yields_none() { assert_eq!(estimate_e1rm(100.0, 5, 1), None); } #[test] fn max_effort_five_reps_matches_epley_at_zero_rir() { let (l, r, e) = set(100.0, 5, 5); let est = estimate_e1rm_from_set(l, r, e).unwrap(); assert!((est.value - 116.666_666_67).abs() < 1e-6); assert_eq!(est.method, E1RM_METHOD_LABEL); } #[test] fn easy_set_reads_higher_than_hard_set_at_same_load() { let hard = estimate_e1rm(100.0, 5, 5).unwrap(); let easy = estimate_e1rm(100.0, 5, 2).unwrap(); assert!(easy > hard); } #[test] fn rpe_3_five_reps_matches_epley_at_seven_reps_to_failure() { // 5 reps @ RPE 3 -> 2 RIR -> effectively a 7-rep max at this load. // 100 * (1 + 7/30) = 123.33 let e = estimate_e1rm(100.0, 5, 3).unwrap(); assert!((e - 123.333_333_33).abs() < 1e-6); } #[test] fn non_positive_or_out_of_range_inputs_return_none() { assert_eq!(estimate_e1rm(0.0, 5, 3), None); assert_eq!(estimate_e1rm(-1.0, 5, 3), None); assert_eq!(estimate_e1rm(100.0, 0, 3), None); assert_eq!(estimate_e1rm(100.0, -1, 3), None); assert_eq!(estimate_e1rm(100.0, 5, 0), None); assert_eq!(estimate_e1rm(100.0, 5, 6), None); } #[test] fn method_label_flows_through_estimate() { let (l, r, e) = set(100.0, 5, 3); let est = estimate_e1rm_from_set(l, r, e).unwrap(); assert!(est.method.contains("rir")); } }