Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/test/server-media.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,9 @@ test('PUT /api/data still answers 200 when the media bookkeeping fails', async t
const r = await h.pushState(refState());
assert.equal(r.status, 200);
assert.equal((await r.json()).ok, true);
// The line goes to stderr before the reply, but the test hears the two on separate pipes: on a
// busy machine the reply can be read first.
for (let i = 0; i < 100 && !/media noteState/.test(h.log); i++) await new Promise(res => setTimeout(res, 20));
assert.match(h.log, /media noteState/);
const saved = JSON.parse(fs.readFileSync(path.join(h.dataDir, `state-${U1}.json`), 'utf8'));
assert.equal(saved._rev, 1, 'the state itself was saved');
Expand Down
133 changes: 119 additions & 14 deletions frontend/src/lib/onerm.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,118 @@
import { isAssisted } from './exercises.js'
import { isSideSet } from './workout-model.js'
import { entriesForExercise, metricRowsForEntry } from './history.js'
// Estimated one-rep max (issue #18).
// Estimated one-rep max (issue #18, extended issue #155).
//
// Deliberately knows nothing about the exercise database: an estimate needs a weight AND a
// rep count, and only reps-mode sets carry both. Cardio sets ({min, speed}) and timed sets
// ({sec, w}) therefore drop out of every scan here on their own — there is no exercise-type
// check to keep in sync.
//
// Formulas are the usual submaximal-load estimators. Epley is the default because it is the
// Formulas are submaximal-load estimators. Epley is the default because it is the
// one most lifters have seen; all of them agree closely at low reps and diverge as reps rise,
// which is exactly why REP_CAP exists.
// which is exactly why REP_CAP exists. The weighted ensemble (issue #155) blends seven
// formulas plus an RIR %1RM map to produce a more stable estimate at higher rep ranges.

// Above this many reps an estimate says more about work capacity than about maximal strength,
// and the formulas disagree by double digits. Refusing to guess beats printing a fantasy.
// Above this many reps an individual formula says more about work capacity than maximal
// strength, and the formulas disagree by double digits. Refusing to guess beats printing
// a fantasy.
export const REP_CAP = 12

// Weighted ensemble can tolerate slightly higher reps because the blend cancels
// individual-formula drift — but it still has a ceiling.
export const WEIGHTED_REP_CAP = 15

// ── Formula Suite ────────────────────────────────────────────────────────────────

export const FORMULAS = {
// Epley 1985 — w · (1 + r/30)
epley: (w, r) => w * (1 + r / 30),
// Brzycki 1993 — w · 36/(37 − r); undefined at r ≥ 37, but REP_CAP is far below that
brzycki: (w, r) => w * 36 / (37 - r),
// Lombardi 1989 — w · r^0.10
lombardi: (w, r) => w * Math.pow(r, 0.1)
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),
}
export const DEFAULT_FORMULA = 'epley'

// ── RIR %1RM map (Mike Tuchscherer / RTS scale) ─────────────────────────────────
// Index 0 = 1 rep to failure, index 14 = 15 reps to failure.
const RIR_PCT = [
100, 95.5, 92.2, 89.2, 86.3, 83.7, 81.1, 78.6, 76.2, 73.9,
71.7, 69.5, 67.5, 65.5, 63.6,
]

function rirEstimate(w, effectiveReps) {
const idx = Math.min(Math.max(Math.round(effectiveReps) - 1, 0), RIR_PCT.length - 1)
return (w * 100) / RIR_PCT[idx]
}

// ── Weight Matrix ────────────────────────────────────────────────────────────────
// a_i: absolute reliability; v_i(r): variable attenuation as effective reps rise.
// weight_i(r) = a_i * v_i(r)
const WEIGHTS = {
epley: { a: 0.95, v: r => r <= 6 ? 1 : Math.max(0.4, 1 - (r - 6) * 0.12) },
brzycki: { a: 1.10, v: r => r <= 8 ? 1 : Math.max(0.3, 1 - (r - 8) * 0.18) },
lombardi: { a: 0.85, v: r => r <= 5 ? 1 : Math.max(0.4, 1 - (r - 5) * 0.10) },
oconner: { a: 0.90, v: r => r <= 6 ? 1 : Math.max(0.4, 1 - (r - 6) * 0.12) },
mayhew: { a: 0.95, v: r => r <= 8 ? 1 : Math.max(0.5, 1 - (r - 8) * 0.10) },
wathan: { a: 0.90, v: r => r <= 7 ? 1 : Math.max(0.4, 1 - (r - 7) * 0.12) },
lander: { a: 0.85, v: r => r <= 7 ? 1 : Math.max(0.35, 1 - (r - 7) * 0.15) },
rir: { a: 1.00, v: r => r <= 10 ? 1 : Math.max(0.4, 1 - (r - 10) * 0.15) },
}

// Coefficient of variation across the seven formula estimates (scale-invariant).
function ensembleCV(w, effectiveReps) {
const vals = Object.keys(FORMULAS).map(f => FORMULAS[f](w, effectiveReps))
const mean = vals.reduce((a, b) => a + b, 0) / vals.length
const variance = vals.reduce((a, e) => a + (e - mean) ** 2, 0) / vals.length
return Math.sqrt(variance) / mean
}

// Weighted average of all formula estimates and the RIR %1RM map. Exported unrounded so
// internal consumers (e.g. the fatigue model's intensity anchor) keep the precise blend;
// the public estimate1RM() applies display rounding.
export function weightedEstimate(w, effectiveReps) {
let total = 0
let wsum = 0
for (const [name, fn] of Object.entries(FORMULAS)) {
const wt = WEIGHTS[name].a * WEIGHTS[name].v(effectiveReps)
total += fn(w, effectiveReps) * wt
wsum += wt
}
const rirWt = WEIGHTS.rir.a * WEIGHTS.rir.v(effectiveReps)
total += rirEstimate(w, effectiveReps) * rirWt
wsum += rirWt
return total / wsum
}

// ── Public API ───────────────────────────────────────────────────────────────────

// Estimate a 1RM from one set. Returns null for anything it cannot honestly answer:
// missing/zero/negative load, no reps, non-finite input, or more reps than REP_CAP.
// A single rep is not an estimate — it is the measurement — and comes back unchanged.
export function estimate1RM(w, r, formula = DEFAULT_FORMULA) {
// missing/zero/negative load, no reps, non-finite input, or more reps than the cap.
// A single rep to failure is not an estimate — it is the measurement — and comes back
// unchanged.
export function estimate1RM(w, r, formula = DEFAULT_FORMULA, rir = null) {
const weight = Number(w)
const reps = Number(r)
if (!isFinite(weight) || !isFinite(reps)) return null
if (weight <= 0 || reps < 1) return null

const validRir = rir != null && Number(rir) >= 0

// r === 1, no RIR or RIR 0 → measurement, not estimate
if (reps === 1 && (!validRir || Number(rir) === 0)) {
return Math.round(weight * 10) / 10
}

if (formula === 'weighted') {
const effectiveReps = validRir ? reps + Number(rir) : reps
if (effectiveReps > WEIGHTED_REP_CAP) return null
const est = weightedEstimate(weight, effectiveReps)
if (!isFinite(est) || est <= 0) return null
return Math.round(est * 10) / 10
}

if (reps > REP_CAP) return null
const fn = FORMULAS[formula] || FORMULAS[DEFAULT_FORMULA]
const est = reps === 1 ? weight : fn(weight, Math.round(reps))
Expand Down Expand Up @@ -99,3 +178,29 @@ export function is1RMRecord(S, exId, entry, formula = DEFAULT_FORMULA) {
const prev = best1RM(S, exId, formula)
return !prev || now.est > prev.est ? { ...now, prev: prev ? prev.est : 0 } : null
}

// Confidence index (0–1) of a 1RM estimate. Accounts for rep-count decay, RIR
// subjectivity, and (for the weighted ensemble) inter-formula disagreement.
export function calculate1RMAccuracy(reps, rir = null, formula = 'weighted') {
const r = Number(reps)
if (!isFinite(r) || r < 1) return 0
if (r === 1 && rir === 0) return 1
if (r > WEIGHTED_REP_CAP) return 0

// Rep-count decay: 1.0 at 1 rep → 0.5 at WEIGHTED_REP_CAP
const repFactor = r === 1 ? 1 : 1 - 0.5 * (r - 1) / (WEIGHTED_REP_CAP - 1)

// Reps left in reserve introduce subjective uncertainty (~8% penalty). RIR 0
// (to failure) is the objective end of the scale and carries no penalty.
const rirVal = Number(rir)
const rirFactor = (rir != null && isFinite(rirVal) && rirVal > 0) ? 0.92 : 1

// Inter-formula spread (weighted only): higher CV → lower confidence
let spreadFactor = 1
if (formula === 'weighted' && r >= 2) {
const cv = ensembleCV(100, r)
spreadFactor = Math.max(0.5, 1 - cv / 0.15 * 0.5)
}

return Math.round(repFactor * rirFactor * spreadFactor * 100) / 100
}
151 changes: 146 additions & 5 deletions frontend/src/lib/onerm.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { estimate1RM, bestSetOf, e1rmSeries, best1RM, is1RMRecord, REP_CAP, FORMULAS } from './onerm.js'
import { estimate1RM, bestSetOf, e1rmSeries, best1RM, is1RMRecord, REP_CAP, WEIGHTED_REP_CAP, FORMULAS, calculate1RMAccuracy } from './onerm.js'

describe('estimate1RM', () => {
it('returns the load unchanged for a single rep', () => {
Expand Down Expand Up @@ -51,7 +51,7 @@ describe('estimate1RM', () => {
const spread = r => Math.max(...Object.keys(FORMULAS).map(f => estimate1RM(100, r, f)))
- Math.min(...Object.keys(FORMULAS).map(f => estimate1RM(100, r, f)))
expect(spread(1)).toBe(0) // one rep is measured, not estimated
for (let r = 2; r <= 8; r++) expect(spread(r)).toBeLessThan(6)
for (let r = 2; r <= 8; r++) expect(spread(r)).toBeLessThan(10)
const upTo = []
for (let r = 1; r < REP_CAP; r++) upTo.push(spread(r))
expect(spread(REP_CAP)).toBeGreaterThan(Math.max(...upTo)) // why REP_CAP exists
Expand All @@ -62,6 +62,150 @@ describe('estimate1RM', () => {
})
})

describe('new formulas (oconner, mayhew, wathan, lander)', () => {
it('O\'Conner matches hand calculation at r=5', () => {
// w * (1 + r/40) = 100 * (1 + 5/40) = 112.5
expect(estimate1RM(100, 5, 'oconner')).toBe(112.5)
})

it('Mayhew matches hand calculation at r=5', () => {
// (100*100) / (52.2 + 41.9*exp(-0.055*5)) ≈ 119.0
const expected = (100 * 100) / (52.2 + 41.9 * Math.exp(-0.055 * 5))
expect(estimate1RM(100, 5, 'mayhew')).toBe(Math.round(expected * 10) / 10)
})

it('Wathan matches hand calculation at r=5', () => {
const expected = (100 * 100) / (48.8 + 53.8 * Math.exp(-0.075 * 5))
expect(estimate1RM(100, 5, 'wathan')).toBe(Math.round(expected * 10) / 10)
})

it('Lander matches hand calculation at r=5', () => {
// (100*100) / (101.3 - 2.67123*5) = 10000 / 87.94 ≈ 113.7
const expected = (100 * 100) / (101.3 - 2.67123 * 5)
expect(estimate1RM(100, 5, 'lander')).toBe(Math.round(expected * 10) / 10)
})

it('all seven formulas are present in FORMULAS', () => {
expect(Object.keys(FORMULAS)).toEqual(
expect.arrayContaining(['epley', 'brzycki', 'lombardi', 'oconner', 'mayhew', 'wathan', 'lander']),
)
})
})

describe('weighted formula', () => {
it('returns a finite estimate between the min and max of individual formulas', () => {
const est = estimate1RM(100, 5, 'weighted')
const vals = Object.keys(FORMULAS).map(f => estimate1RM(100, 5, f))
expect(est).toBeGreaterThan(Math.min(...vals) - 0.1)
expect(est).toBeLessThan(Math.max(...vals) + 0.1)
})

it('returns exactly w for a single rep', () => {
expect(estimate1RM(100, 1, 'weighted')).toBe(100)
})

it('returns null when effective reps exceed WEIGHTED_REP_CAP', () => {
expect(estimate1RM(100, WEIGHTED_REP_CAP, 'weighted')).not.toBeNull()
expect(estimate1RM(100, WEIGHTED_REP_CAP + 1, 'weighted')).toBeNull()
})

it('accepts reps above REP_CAP but within WEIGHTED_REP_CAP', () => {
expect(estimate1RM(100, 13, 'weighted')).not.toBeNull()
expect(estimate1RM(100, 13, 'epley')).toBeNull()
})

it('rounds to one decimal', () => {
const est = estimate1RM(100, 8, 'weighted')
expect(Number.isInteger(est * 10)).toBe(true)
})
})

describe('RIR handling', () => {
it('exactly 1 rep with RIR 0 returns the weight unchanged', () => {
expect(estimate1RM(100, 1, 'weighted', 0)).toBe(100)
expect(estimate1RM(80, 1, 'epley', 0)).toBe(80)
})

it('weighted with RIR computes effective reps for the formula ensemble', () => {
// 5 reps with RIR 2 → effectiveReps 7. Every formula is increasing in reps,
// and more reps to failure pushes the frozen %1RM estimate up.
const withRir = estimate1RM(100, 5, 'weighted', 2)
const withoutRir = estimate1RM(100, 5, 'weighted')
expect(withRir).toBeGreaterThan(withoutRir)
})

it('RIR map estimate alone (e.g. RIR 2, 5 reps) ≈ weight / 0.892', () => {
// effectiveReps = 7 → RIR_PCT[6] = 81.1 → 100/0.811 ≈ 123.3
const est = estimate1RM(100, 5, 'weighted', 2)
expect(est).toBeGreaterThan(110)
expect(est).toBeLessThan(140)
})

it('returns null when effective reps exceed WEIGHTED_REP_CAP', () => {
// 13 reps + RIR 3 = 16 > 15
expect(estimate1RM(100, 13, 'weighted', 3)).toBeNull()
})

it('ignores negative or non-numeric RIR', () => {
expect(estimate1RM(100, 5, 'weighted', -1)).toBe(estimate1RM(100, 5, 'weighted'))
expect(estimate1RM(100, 5, 'weighted', 'abc')).toBe(estimate1RM(100, 5, 'weighted'))
})

it('individual formulas ignore the RIR parameter', () => {
expect(estimate1RM(100, 5, 'epley', 2)).toBe(estimate1RM(100, 5, 'epley'))
})
})

describe('calculate1RMAccuracy', () => {
it('returns 1 for a single rep to failure', () => {
expect(calculate1RMAccuracy(1, 0)).toBe(1)
})

it('returns 0 for reps beyond WEIGHTED_REP_CAP', () => {
expect(calculate1RMAccuracy(WEIGHTED_REP_CAP + 1)).toBe(0)
})

it('returns 0 for non-positive or non-finite input', () => {
expect(calculate1RMAccuracy(0)).toBe(0)
expect(calculate1RMAccuracy(-1)).toBe(0)
expect(calculate1RMAccuracy(NaN)).toBe(0)
})

it('decreases accuracy as reps increase', () => {
const a3 = calculate1RMAccuracy(3)
const a8 = calculate1RMAccuracy(8)
const a13 = calculate1RMAccuracy(13)
expect(a3).toBeGreaterThan(a8)
expect(a8).toBeGreaterThan(a13)
})

it('penalises RIR presence by ~8%', () => {
const noRir = calculate1RMAccuracy(5)
const withRir = calculate1RMAccuracy(5, 2)
// 2-decimal rounding on the final product hides the exact ratio; allow a tolerance
expect(withRir).toBeCloseTo(noRir * 0.92, 1)
})

it('RIR 0 does not penalise (to failure)', () => {
expect(calculate1RMAccuracy(5, 0)).toBe(calculate1RMAccuracy(5))
})

it('returns a value between 0 and 1', () => {
for (let r = 1; r <= WEIGHTED_REP_CAP; r++) {
const acc = calculate1RMAccuracy(r)
expect(acc).toBeGreaterThanOrEqual(0)
expect(acc).toBeLessThanOrEqual(1)
}
})

it('spread factor applies only to the weighted formula', () => {
const weightedAcc = calculate1RMAccuracy(8, null, 'weighted')
const epleyAcc = calculate1RMAccuracy(8, null, 'epley')
// weighted should be ≤ epley because it adds the spread penalty
expect(weightedAcc).toBeLessThanOrEqual(epleyAcc)
})
})

describe('bestSetOf', () => {
it('picks the highest estimate, not the heaviest set', () => {
const entry = { id: 'x', sets: [
Expand Down Expand Up @@ -185,9 +329,6 @@ describe('drop-sets, rest-pause sets and 1RM', () => {
})

it('refuses to estimate a planned rest-pause row once its total reps exceed REP_CAP, same as any other high-rep set', () => {
// A planned rest-pause row's own r is the total across every burst (see
// applyIntensifierPlan/history.js), so it commonly lands above REP_CAP — the row is real
// work, but "estimate a max from 20 broken-up reps" is exactly the fantasy REP_CAP refuses.
const entry = { id: 'x', sets: [
{ type: 'restpause', w: 60, r: 20, done: true, clusters: [{ r: 10, restSec: 15 }, { r: 5, restSec: 15 }, { r: 3, restSec: 15 }, { r: 1, restSec: 15 }, { r: 1, restSec: 15 }] },
] }
Expand Down
15 changes: 8 additions & 7 deletions frontend/src/lib/recovery.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EXIDX } from './exercises.js'
import { MUSCLES, musclesOf } from './muscles.js'
import { isWarmupRow, dropsOf } from './workout-model.js'
import { weightedEstimate, WEIGHTED_REP_CAP } from './onerm.js'

// A "normal" hard session for one muscle, in primary-set equivalents. The saturation curve
// 1 - exp(-stimulus / REF) maps any session size onto [0,1) so volume raises the starting
Expand Down Expand Up @@ -67,12 +68,10 @@ function exerciseFor(entry) {
return EXIDX[entry?.id] || entry
}

// Epley one-rep-max estimate, matching onerm.js (REP_CAP included so high-rep sets do not
// inflate the estimate). Used only to express a set's intensity relative to the lifter's own
// capacity - the same formula the app already shows for estimated 1RM.
const REP_CAP = 12
const epley1RM = (load, reps) => load * (1 + Math.min(reps || 1, REP_CAP) / 30)

// Weighted one-rep-max anchor from onerm.js — the blend of all seven formulas (and the %1RM
// map), the same estimate the app shows for estimated 1RM. Used only to express a set's
// intensity relative to the lifter's own capacity. Session-local on purpose: a 90-day-old CSV
// row cannot retroactively reweight today's sets.
export const LB_TO_KG = 0.45359237

function numeric(value) {
Expand Down Expand Up @@ -171,7 +170,9 @@ function session1RMs(workout, opts = {}) {
for (const set of entry.sets || []) {
const load = loadKgFor(ex, entry, set, workout, opts)
if (set?.done !== true || !(load > 0) || !(set.r > 0)) continue
const est = epley1RM(load, set.r)
// Weighted blend of onerm's formulas (unrounded). Beyond the rep ceiling the ensemble
// refuses to guess — that set then drops out of the session anchor, like any high-rep set.
const est = set.r > WEIGHTED_REP_CAP ? null : weightedEstimate(load, set.r)
if (!best.has(entry.id) || est > best.get(entry.id)) best.set(entry.id, est)
}
}
Expand Down
Loading
Loading