[Feature Request] Multi-Formula 1RM Estimation with Reliability-Weighted Average

Labels: enhancement, training-engine, algorithm

1. Current Statea

In frontend/src/lib/onerm.js, openGym currently implements only three 1RM equations: Epley, Brzycki, and Lombardi.

  • Epley is hardcoded as the default formula.
  • Sets above REP_CAP = 12 are rejected.
  • Each formula has recognized structural biases: Epley progressively overestimates 1RM at moderate-to-high repetitions (> 6 reps), Brzycki diverges sharply near 10–12 reps, and Lombardi is overly conservative at low repetitions.

2. Motivation

Estimated 1RM is a critical metric in openGym: it directly powers percentage-based load resolution (%1RM), the in-session load calculator, and PR tracking. Relying on a single equation introduces systemic distortion. A multi-formula composite average weighted by empirical reliability and penalized for high reps creates a stable, smooth, and robust estimate across all rep ranges up to 15 reps.

3. Feature to Implement

  1. Extend Formula Suite:
    • Add 4 peer-reviewed submaximal equations: O'Conner, Mayhew, Wathan, and Lander.
  2. Reliability-Weighted Average (weightedEstimate):
    • At r = 1: Return the exact lifted weight (1RM = w).
    • For r > 1: Compute estimates across all 7 equations. Weight each formula by its empirical reliability factor a_i and apply a logarithmic rep penalty factor w_i = \frac{1}{\log_{10}(r) + 0.1} \cdot a_i.
    • Set the default formula in openGym to 'weighted'.
    • Extend the safe calculation cap to WEIGHTED_REP_CAP = 15.
  3. Preserve Legacy Signatures:
    • Preserve exact backwards compatibility for explicit calls (e.g. estimate1RM(w, r, 'epley')).

4. Technical Design & Code Changes

// In frontend/src/lib/onerm.js






export const FORMULAS = {
  epley:    (w, r) => w * (1 + r / 30),
  brzycki:  (w, r) => w * 36 / (37 - r),
  lombardi: (w, r) => w * Math.pow(r, 0.1),
  oconner:  (w, r) => w * (1 + r / 40),
  mayhew:   (w, r) => (w * 100) / (52.2 + 41.9 * Math.exp(-0.055 * r)),
  wathan:   (w, r) => (w * 100) / (48.8 + 53.8 * Math.exp(-0.075 * r)),
  lander:   (w, r) => (w * 100) / (101.3 - 2.67123 * r)
}

const RELIABILITY = {
  epley: 1.0, brzycki: 1.1, lombardi: 0.95,
  oconner: 0.9, mayhew: 1.05, wathan: 0.9, lander: 0.85
}

export const DEFAULT_FORMULA = 'weighted'
export const WEIGHTED_REP_CAP = 15

export function weightedEstimate(w, r) {
  const reps = Math.round(Number(r))
  const weight = Number(w)
  if (!weight || weight <= 0 || !reps || reps <= 0) return null
  if (reps === 1) return weight
  if (reps > WEIGHTED_REP_CAP) return null

  const repPenalty = 1 / (Math.abs(Math.log10(reps)) + 0.1)
  let sum = 0, sumW = 0

  for (const [name, fn] of Object.entries(FORMULAS)) {
    const est = fn(weight, reps)
    if (!Number.isFinite(est) || est <= 0) continue
    const coeff = repPenalty * (RELIABILITY[name] || 1)
    sum += coeff * est
    sumW += coeff
  }

  return sumW > 0 ? Math.round((sum / sumW) * 10) / 10 : null
}

export function estimate1RM(w, r, formula = DEFAULT_FORMULA) {
  if (formula === 'weighted') return weightedEstimate(w, r)
  const fn = FORMULAS[formula] || FORMULAS.epley
  return fn(w, r)
}
Edited by Giulio Leuci