Skip to main content

max / ripgrow

e1rm: rpe-adjusted estimator on top of existing set history Epley over reps + reps-in-reserve, mapped from ripgrow's 1-5 RPE (RPE 5 -> 0 RIR down to RPE 2 -> 3 RIR; RPE 1 is warmup, no signal). This uses the effort column the log screen already collects rather than discarding it the way rep-only formulas do. Best e1RM across history is rendered on the History detail line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 17:26 UTC
Commit: a61e87fa34a406513872c13059115535b14cfde9
Parent: 7d4ddc9
4 files changed, +161 insertions, -1 deletion
@@ -0,0 +1,100 @@
1 + //! Estimated one-rep-max (e1RM).
2 + //!
3 + //! Reps-alone formulas (Epley, Brzycki, Lander, Mayhew) all discard the
4 + //! effort signal we already collect. ripgrow logs RPE per set, so we can
5 + //! do better: convert RPE to "reps in reserve," add it to the reps
6 + //! performed to get a hypothetical reps-to-failure, then apply Epley to
7 + //! that. This is the Tuchscherer / RTS approach in closed form; it
8 + //! approximates the RTS percentage chart within a few percent and needs
9 + //! no lookup table.
10 + //!
11 + //! e1RM = load * (1 + (reps + rir) / 30)
12 + //!
13 + //! RIR mapping for ripgrow's 1-5 RPE scale:
14 + //! - RPE 5 = 0 RIR (a max effort or a missed rep)
15 + //! - RPE 4 = 1 RIR
16 + //! - RPE 3 = 2 RIR
17 + //! - RPE 2 = 3 RIR
18 + //! - RPE 1 = warmup, no signal about the ceiling; the estimator returns None.
19 + //!
20 + //! Swapping in the empirical RTS chart later is a private-function change.
21 +
22 + /// Reps in reserve implied by an RPE value on ripgrow's 1-5 scale.
23 + /// Returns `None` for values outside the domain including RPE 1 (warmup).
24 + pub fn rir_from_rpe(rpe: i32) -> Option<i32> {
25 + match rpe {
26 + 5 => Some(0),
27 + 4 => Some(1),
28 + 3 => Some(2),
29 + 2 => Some(3),
30 + _ => None,
31 + }
32 + }
33 +
34 + /// Estimated 1RM from a single set. `None` when the set carries no signal
35 + /// (warmup RPE, zero/negative reps, or non-positive load).
36 + pub fn estimate_e1rm(load: f64, reps: i32, rpe: i32) -> Option<f64> {
37 + if load <= 0.0 || reps <= 0 {
38 + return None;
39 + }
40 + let rir = rir_from_rpe(rpe)?;
41 + let reps_to_failure = (reps + rir) as f64;
42 + Some(load * (1.0 + reps_to_failure / 30.0))
43 + }
44 +
45 + #[cfg(test)]
46 + mod tests {
47 + use super::*;
48 +
49 + #[test]
50 + fn rir_map_covers_rpe_2_through_5() {
51 + assert_eq!(rir_from_rpe(5), Some(0));
52 + assert_eq!(rir_from_rpe(4), Some(1));
53 + assert_eq!(rir_from_rpe(3), Some(2));
54 + assert_eq!(rir_from_rpe(2), Some(3));
55 + }
56 +
57 + #[test]
58 + fn rpe_1_is_warmup_and_yields_none() {
59 + assert_eq!(rir_from_rpe(1), None);
60 + assert_eq!(estimate_e1rm(100.0, 5, 1), None);
61 + }
62 +
63 + #[test]
64 + fn max_effort_five_reps_matches_epley_at_zero_rir() {
65 + // 100 * (1 + 5/30) = 116.67
66 + let e = estimate_e1rm(100.0, 5, 5).unwrap();
67 + assert!((e - 116.666_666_67).abs() < 1e-6);
68 + }
69 +
70 + #[test]
71 + fn easy_set_reads_higher_than_hard_set_at_same_load() {
72 + // Same load, same reps: easier set implies more in the tank, so a
73 + // bigger 1RM ceiling.
74 + let hard = estimate_e1rm(100.0, 5, 5).unwrap();
75 + let easy = estimate_e1rm(100.0, 5, 2).unwrap();
76 + assert!(easy > hard);
77 + }
78 +
79 + #[test]
80 + fn rpe_3_five_reps_matches_epley_at_seven_reps_to_failure() {
81 + // 5 reps @ RPE 3 -> 2 RIR -> effectively a 7-rep max at this load.
82 + // 100 * (1 + 7/30) = 123.33
83 + let e = estimate_e1rm(100.0, 5, 3).unwrap();
84 + assert!((e - 123.333_333_33).abs() < 1e-6);
85 + }
86 +
87 + #[test]
88 + fn non_positive_inputs_return_none() {
89 + assert_eq!(estimate_e1rm(0.0, 5, 3), None);
90 + assert_eq!(estimate_e1rm(-1.0, 5, 3), None);
91 + assert_eq!(estimate_e1rm(100.0, 0, 3), None);
92 + assert_eq!(estimate_e1rm(100.0, -1, 3), None);
93 + }
94 +
95 + #[test]
96 + fn out_of_range_rpe_returns_none() {
97 + assert_eq!(estimate_e1rm(100.0, 5, 0), None);
98 + assert_eq!(estimate_e1rm(100.0, 5, 6), None);
99 + }
100 + }
@@ -5,6 +5,7 @@
5 5
6 6 pub mod db;
7 7 pub mod error;
8 + pub mod estimator;
8 9 pub mod generation;
9 10 pub mod profiles;
10 11 pub mod progression;
@@ -13,6 +14,7 @@ pub mod templates;
13 14
14 15 pub use db::{Db, Unit};
15 16 pub use error::Error;
17 + pub use estimator::{estimate_e1rm, rir_from_rpe};
16 18 pub use generation::{PickedSlot, Readiness};
17 19 pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify};
18 20 pub use progression::{Prescription, PrescriptionResult, SessionOutcome, State};
@@ -9,6 +9,7 @@ use rusqlite::params;
9 9
10 10 use crate::db::Db;
11 11 use crate::error::Error;
12 + use crate::estimator::estimate_e1rm;
12 13
13 14 #[derive(Debug, Clone, PartialEq)]
14 15 pub struct SessionSet {
@@ -156,6 +157,30 @@ impl Db {
156 157 Ok(out)
157 158 }
158 159
160 + /// Best e1RM across every set logged for `exercise_id`. `None` when
161 + /// the exercise has no sets, or when no set carries an e1RM signal
162 + /// (all sets at warmup RPE, for instance).
163 + pub fn best_e1rm(&self, exercise_id: i64) -> Result<Option<f64>, Error> {
164 + let mut stmt = self.conn().prepare(
165 + "SELECT load, reps, rpe FROM sets WHERE exercise_id = ?1",
166 + )?;
167 + let rows = stmt.query_map(params![exercise_id], |row| {
168 + Ok((
169 + row.get::<_, f64>(0)?,
170 + row.get::<_, i32>(1)?,
171 + row.get::<_, i32>(2)?,
172 + ))
173 + })?;
174 + let mut best: Option<f64> = None;
175 + for row in rows {
176 + let (load, reps, rpe) = row?;
177 + if let Some(e) = estimate_e1rm(load, reps, rpe) {
178 + best = Some(best.map_or(e, |b| b.max(e)));
179 + }
180 + }
181 + Ok(best)
182 + }
183 +
159 184 /// Delete a specific set row by id. Returns `NotFound` if it did not
160 185 /// exist. Subsequent `append_set` calls do NOT reuse the freed index;
161 186 /// gaps are fine for the state machine (which reads ordered by index).
@@ -243,6 +268,34 @@ mod tests {
243 268 }
244 269
245 270 #[test]
271 + fn best_e1rm_none_when_no_sets() {
272 + let (db, ex) = setup();
273 + assert_eq!(db.best_e1rm(ex).unwrap(), None);
274 + }
275 +
276 + #[test]
277 + fn best_e1rm_takes_max_across_sets() {
278 + let (db, ex) = setup();
279 + let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
280 + // 100 x 5 @ RPE 5 -> e1RM 116.67
281 + // 90 x 8 @ RPE 3 -> e1RM 90 * (1 + 10/30) = 120.0
282 + // 120 x 3 @ RPE 5 -> e1RM 132.0 <- winner
283 + db.append_set(ex, date, 100.0, 5, 5).unwrap();
284 + db.append_set(ex, date, 90.0, 8, 3).unwrap();
285 + db.append_set(ex, date, 120.0, 3, 5).unwrap();
286 + let e = db.best_e1rm(ex).unwrap().unwrap();
287 + assert!((e - 132.0).abs() < 1e-6);
288 + }
289 +
290 + #[test]
291 + fn best_e1rm_ignores_warmup_rpe_1_sets() {
292 + let (db, ex) = setup();
293 + let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
294 + db.append_set(ex, date, 200.0, 10, 1).unwrap();
295 + assert_eq!(db.best_e1rm(ex).unwrap(), None);
296 + }
297 +
298 + #[test]
246 299 fn delete_removes_and_gap_is_fine() {
247 300 let (db, ex) = setup();
248 301 let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
@@ -131,10 +131,15 @@ fn render_detail(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
131 131
132 132 let (header_area, spark_area, table_area) = split_detail(inner);
133 133
134 + let e1rm_str = match db.best_e1rm(ex.id).ok().flatten() {
135 + Some(v) => format!("{:.1} {}", v, ex.load_unit),
136 + None => "-".to_string(),
137 + };
134 138 let header = format!(
135 - "state: {} sessions: {}",
139 + "state: {} sessions: {} e1RM: {}",
136 140 state_str,
137 141 series.len(),
142 + e1rm_str,
138 143 );
139 144 frame.render_widget(Paragraph::new(header), header_area);
140 145