mohitchahal.com
← all write-ups
Gym Ledger

Modelling muscle fatigue from logged sets

Two fading numbers per muscle, worked out from each logged set and rebuildable from history.

Role
Sole engineer
Stack
Express 5, TypeScript, PostgreSQL
Year
2026

What it’s for

The muscle map in Gym Ledger answers two questions a lifter has before a session: have I trained this muscle enough lately, and has it recovered from last time? Each muscle gets a 0–100% gauge for both, and the body heatmap is coloured from them.

The only input is what people already log: the exercise, whether a set was a warm-up, and reps in reserve (RIR), meaning how many more reps they could have done. I didn’t want to ask for a one-rep max or anything else most people don’t track.

How a set becomes a number

Every exercise lists the muscles it works and how much, from 0 to 100%, and that’s edited in the admin panel. A logged set gives each of those muscles a stimulus:

effectiveness = rir is null ? 0.6 : clamp(1 - rir / 5, 0, 1)
stimulus      = contribution × effectiveness × (warm-up ? 0.15 : 1)

A set taken to failure counts in full, and one with five or more reps left counts for nothing. It’s the “hard sets” way coaches count volume, applied per muscle. When someone doesn’t enter RIR, I treat the set as moderately hard (0.6) instead of ignoring it.

That stimulus feeds two numbers per muscle that fade at different speeds. Training has a half-life of 7 days and reflects the last week or two. Fatigue has a half-life of 48 hours and reflects the last couple of sessions.

Choices I made along the way

  • There’s no scheduled job. Each row keeps its value and when it was last updated, and decay is applied up to the current time whenever a set is logged or the map is read. Reads never write.
  • The stored numbers are a cache. Logged sets are the source of truth, and a script replays a user’s sets in order to rebuild the numbers exactly. Changing a constant means running that script, not writing a migration.
  • Logging a set always wins. The update runs after the set is saved, in its own try/catch, so a bug here gets logged and never stops anyone recording a workout.
  • A muscle’s gauges can be switched off in the admin panel, but both numbers keep being tracked underneath. Switching a gauge back on shows real history straight away, with nothing to backfill.
  • All the constants live in one config object, so tuning the model doesn’t mean hunting for numbers in the code.

The bug that gave the model a ceiling

At first there was no upper limit. Steady hard training kept pushing the stored training value up: after six weeks of about 16 hard sets a week it sat around 27, against 10 for “100%”. The gauge capped its display at 100%, so the excess was invisible. It showed up later instead. Exponential decay never actually reaches zero, so a muscle stayed lit on the heatmap for about six weeks after its last session.

I capped the stored value at 1.5 times the reference and made anything under 10% read as zero. A muscle you stop training now goes from about 75% to 38% to 19% and then off the map over roughly four weeks.

What it doesn’t do yet

  • Every muscle uses the same half-lives, though forearms and quads don’t recover at the same rate. Per-muscle half-lives would fit into the same formula.
  • It isn’t calibrated to the person, so a beginner and an experienced lifter fill the gauge at different rates.
  • It’s a training-load estimate, not a physiological measurement. I see it as a guide for planning the next session.