Skip to main content

liftmath

A gym calculator that works offline, on your phone, with no account. Open it, use it, close it.

CI PyPI License: GPL-3.0-or-later zero dependencies

liftmath web app: a 1RM estimate, a plate calculator with a barbell that loads itself, and Wilks/DOTS/IPF strength scores

https://munzzyy.github.io/liftmath/ - nothing to install, works offline once loaded, no account, no ads, no tracking. Everything below runs the same math as a Python library and a command-line tool, for people who'd rather script it.

Four tools:

  • 1RM: estimate a one-rep max from a set, with optional RPE/RIR, plus a percentage table down to 50% with loads rounded to your plates.
  • Plates: what to hang on the bar for a target weight, plus a warm-up ramp from the empty bar up to it.
  • Strength score: Wilks, DOTS, and IPF GL points to compare across bodyweights, plus where that DOTS score stands against OpenPowerlifting.
  • Records: powerlifting, strongman, grip sport, and track & field (world, US collegiate, US high-school), searchable by lift or event, sex, weight class, equipment, and level, with your own lift or mark shown as a percentage of the record.

The web app also has a rest timer, a Share button that builds a deep link back to your exact setup, and it remembers your unit/setup between visits. It used to do a lot more tools than this; it does fewer now, on purpose - every number traces back to a named formula or a cited record, and you can read the whole thing in a sitting. There's also a small lb/kg converter (convert) bolted on, since plates and strength score already needed exact unit conversion internally, and an import command that reads your own history out of a Strong or Hevy export and runs it through the 1RM math above - neither is a fifth tool, they're utilities that feed the ones already here.

The web app also has a rest timer (the header button): 60/90/120/180/300s presets or a custom value, a countdown ring, a vibrate + beep at zero, and it survives switching tabs or reloading - it's driven by a stored end time, not a running count, so backgrounding the tab can't drift it.

First run with no saved unit choice defaults to kg, except in the US, Liberia, and Myanmar (read from the browser's own language setting) - those three default to lb.

It's deep-linkable too: the URL hash reflects the active tab and its inputs (1RM, Plates, Score, Convert - Records' filters and Track's imported files aren't simple enough to encode), so a link someone sends you opens straight into their exact setup instead of the defaults. The Share button builds that link and hands it to whatever's available: the OS share sheet if there is one, a clipboard copy otherwise.

Pure Python standard library. No dependencies, no network calls, no accounts. Use it as a library you import or a command you run.

Android

The same app runs on Android from android/: the web app bundled into a small WebView wrapper with no internet permission, so it works with the phone in airplane mode. Share opens the system share sheet and the rest timer keeps the screen on. Signed APKs are on the releases page, and it has been submitted to F-Droid. To build it yourself, run ./gradlew assembleDebug inside android/.

Install

pipx install liftmath

Or with pip, into whatever environment you want it in:

pip install liftmath

Or from a clone, if you want to run the tests too:

git clone https://github.com/munzzyy/liftmath
cd liftmath
pip install -e .

Requires Python 3.10+. Nothing else.

Command line

Pass --json (before or after the subcommand) to any command for machine-readable output instead of text. Loads are unit-agnostic; pass --unit kg or --unit lb (default lb).

1RM

Estimate a one-rep max from a weight × reps set. No single formula is most accurate across every rep range, so it runs six published equations and reports the median consensus instead of picking one, dropping the curvilinear formulas past 12 reps where they're known to drift.

$ liftmath 1rm --weight 225 --reps 5
Estimated 1RM from 225lb x 5 reps
  No single formula is most accurate across every rep range, so this runs six and
  takes the CONSENSUS (median) instead of picking one. Sorted by value, not accuracy.
----------------------------------------------
  Brzycki    253.1lb
  O'Conner   253.1lb
  Lander     255.8lb
  Epley      262.5lb
  Lombardi   264.3lb
  Mayhew     267.8lb
----------------------------------------------
  CONSENSUS  259.2lb   (median; range 253.1-267.8)

If the set wasn't taken to failure, add --rpe (6-10, half steps) or --rir. RIR = 10 - RPE, and the reps in reserve get added to the reps performed before the formulas run, since a set stopped short of failure otherwise underestimates the true 1RM:

$ liftmath 1rm --weight 225 --reps 5 --rpe 9

Add --table for a 100%-to-50% percentage breakdown of the consensus, load rounded to what the default plate set can actually load and reps estimated at that load (Epley's inversion - see onerm.py's _epley_reps_at for why). The web 1RM tab shows the same table; tapping a row sends that load straight to Plates.

Plates

Which plates to load per side for a target barbell weight, largest first.

$ liftmath plates --target 315
Load 315lb on a 45lb bar:
  per side (135lb): 3x45

Home gym or travel kit with a finite set of plates? Pass --inventory with the exact per-side counts you have and it solves against that instead of assuming an unlimited supply (an exhaustive search, since greedy picks aren't optimal once supply runs out):

$ liftmath plates --target 405 --inventory 45x3,25x1,10x1
Load 405lb on a 45lb bar (from your inventory):
  per side (180lb): 3x45, 1x25, 1x10
  [!] can't make it exactly with this inventory - short 10lb/side.
      nearest achievable below: 385lb

There are also --bar, --plates (custom denominations), and --preset (womens, metric-no-45, both kg-only, so pass --unit kg with them) flags. Run liftmath plates --help.

Warm-up

A ramp from the empty bar up to a working weight - bar x10, then 40/60/80% of the target for 5/3/1 reps, each rounded to what your plates can load (steps that round to the same weight collapse into one row):

$ liftmath warmup --target 225
Warm-up ramp to 225lb:
   45.0lb x 10  empty bar
   90.0lb x 5   2x10, 1x2.5
  135.0lb x 3   1x45
  180.0lb x 1   1x45, 2x10, 1x2.5

The web Plates tab has the same ramp behind a "Warm-up ramp" toggle. --inventory's finite plate counts aren't supported here yet (same unlimited-supply assumption as --plates).

Strength score

Relative-strength scores from a total, bodyweight, and sex, so a lighter lifter and a heavier one can be compared on one scale.

$ liftmath standards --total 1200 --bodyweight 200 --sex male
Relative-strength scores - 1200lb total @ 200lb bodyweight (male):
  Each score below lets you compare lifters across bodyweights on one scale - Wilks
  and DOTS are two different curve-fit formulas for it; IPF GL is the federation's own.
----------------------------------------
  Wilks (2020)       415.78
  Wilks (original)   346.09
  DOTS               350.55
  IPF GL points       72.08
----------------------------------------
  Where you stand: higher DOTS than 48% of raw male lifters in OpenPowerlifting (276,025, as of 2026-09-19).
IPF GL uses classic (raw) powerlifting coefficients only. All four formulas are fit
to different samples and disagree slightly, especially at the extremes of the
bodyweight range - treat them as independent opinions, not a single ground truth.
Wilks-2020 is the IPF's current standard; original Wilks is kept for historical
comparison. [evidence tier] established as competition scoring CONVENTIONS (real
federation formulas fit to real competition samples), not evidence in the RCT sense.

All four are fit to different samples and disagree slightly, especially at the extremes of the bodyweight range. Treat them as independent opinions, not one ground truth.

"Where you stand" (add --equipped to compare against equipped lifters instead of raw) is a percentile lookup against best-DOTS-per-lifter in the same OpenPowerlifting snapshot, bucketed into 99 percentile breakpoints per sex/equipment group rather than shipping the full per-lifter list - see tools/build_records.py's compute_dots_percentiles for the row filters and why a 2-way raw/equipped split, not the 4-way equipment filter used elsewhere in this codebase. The web Score tab shows the same line, with a Raw/Equipped toggle.

Records

World records, bundled as a dated snapshot so lookups work offline like everything else here. Powerlifting records are computed from the OpenPowerlifting project's public-domain database of ~4M sanctioned meet results, in two scopes: all-time (heaviest ever done in any sanctioned federation) and tested (drug-tested meets only). Strongman and grip sport have no open database anywhere, so those records are hand-curated, each entry carrying its own citation URL.

$ liftmath records --sport powerlifting --lift deadlift --sex male --bodyweight 220 --equip raw --compare 500
Records matching your filters (snapshot of 2026-09-19):
  Powerlifting rows are computed from the OpenPowerlifting database - all-time =
  any sanctioned federation, tested = drug-tested meets only. Strongman, grip, and
  track & field are curated with per-entry citations (--json carries the sources).
------------------------------------------------------------------------------------
  [powerlifting] Deadlift 100 [traditional] M raw (all-time)
      433.5kg (956lb)    Krzysztof Wierzbicki, Poland  (Siberian Championships, 2020-03-08)
      @ 97.9kg bw
      your 500lb = 52.3% of this record (456lb to go)
  [powerlifting] Deadlift 100 [traditional] M raw (tested)
      400.5kg (883lb)    Hunter Olsen, USA  (Age Division Nationals, 2026-05-19)
      @ 96.9kg bw
      your 500lb = 56.6% of this record (383lb to go)
------------------------------------------------------------------------------------
Records move; a bundled snapshot can trail the current record. Official federation
lists (e.g. the IPF's) are curated separately and differ from all-time-in-the-data.

Pass --class directly (82.5, 140+, open) or --bodyweight and it resolves the class for you - in either weight-class scheme (--scheme traditional for the all-time-record convention, --scheme ipf for the IPF's current classes). --sport strongman and --sport grip cover the events with a real documented record (log lift, atlas stone, keg toss, Rolling Thunder, Apollon's Axle, two hands pinch, ...). --json carries the source URL, sanctioning body, and confidence grade for every curated entry. Powerlifting rows are the empirical maxima in the data, NOT any federation's official record list - federations curate those separately and reset them on rule changes.

Track & field gets three levels - --level world (World Athletics), --level college (US collegiate records), --level high-school (US national high-school records) - and the compare flag reads times the way they're written:

$ liftmath records --sport track --event 1500m --sex male --level world --compare 4:30
  [track] 1500 meters world M (official)
      3:26.00            Hicham El Guerrouj, Morocco  (Rome, Italy, 1998-07-14)
      your 4:30 = 76.3% of record pace (1:04.00 off)

Times are direction-aware (lower is better, so 100% always means record-equalling), and high-school throws use lighter implements than college/world - each such record's notes say so, because the levels are deliberately not cross-comparable.

Convert

A plain lb/kg conversion, using the exact international avoirdupois pound (1 lb = 0.45359237 kg), not a rounded approximation.

$ liftmath convert --weight 225 --unit lb
225lb = 102.06kg
(exact: 1lb = 0.45359237kg, the international avoirdupois pound)

Import

Read your own workout history out of a Strong or Hevy CSV export and get the two things a single logged set can't tell you: best estimated 1RM per exercise, and total tonnage (weight x reps) per week.

$ liftmath import --file strong_export.csv
Imported 214 sets from a strong export (2026-01-04 to 2026-06-30).
  9 distinct exercises.
----------------------------------------------
  Best estimated 1RM per exercise, most recent session:
  Bench Press (Barbell)          228.4lb  (2026-06-08)
  ...
----------------------------------------------
  Total tonnage (weight x reps) per week:
  2026-W23         2620lb
  2026-W24         2700lb
  ...

The export format is auto-detected from its header row; pass --source strong or --source hevy if you'd rather be explicit. Strong's own export has no weight-unit column in its most common (iOS) form - --unit is both what that's assumed to already be in, and what a Hevy export (always kg internally) gets converted to for display.

Switched apps partway through your training history? Pass --file more than once and they merge into one trend and tonnage view instead of two separate ones:

$ liftmath import --file strong_export.csv hevy_export.csv
Imported 340 sets from 2 files (strong, hevy) (2023-02-11 to 2026-06-30).
  14 distinct exercises.
----------------------------------------------
  Best estimated 1RM per exercise, most recent session:
  Bench Press (Barbell)          228.4lb  (2026-06-08)
  ...
----------------------------------------------
  Total tonnage (weight x reps) per week:
  2026-W23         2620lb
  2026-W24         2700lb
  ...

As a library

Every command is a thin wrapper around a plain function that returns a dataclass, so you can use the math directly:

from liftmath import estimate_one_rm, load_plates, score

est = estimate_one_rm(225, 5, unit="lb")
print(est.consensus)          # 259.17...

plates = load_plates(245, unit="lb")
print(plates.plates)          # [(45, 2), (10, 1)]

s = score(500, 90, "male")    # total kg, bodyweight kg, sex
print(s.wilks, s.dots, s.ipf_gl)

The full public API:

from liftmath import (
    estimate_one_rm,                                       # six-formula 1RM consensus
    load_plates, load_plates_from_inventory,               # plate loading (unlimited / finite)
    score,                                                 # all four scores at once
    wilks_score, wilks_original_score,                     # Wilks 2020 / original
    dots_score, ipf_gl_points,                             # DOTS / IPF GL points
    search_records, weight_class_for, percent_of_record,   # record lookup
    parse_mark, format_seconds,                            # "4:12.3" <-> seconds
    records_as_of,                                         # dataset snapshot date
    lbs_to_kg, kg_to_lbs, convert_weight,                  # exact lb/kg conversion
    parse_strong_csv, parse_hevy_csv,                      # Strong/Hevy CSV export -> WorkoutSet
    e1rm_trend, weekly_tonnage,                            # derived views over imported history
    to_dict, to_json,                                      # serialize any result
)

Every result is a plain dataclass. To serialize one (an API response, a log line, whatever) use to_dict/to_json rather than hand-rolling dataclasses.asdict(); they also carry over read-only properties like is_exact and exact that asdict() alone would drop:

from liftmath import estimate_one_rm, to_json
print(to_json(estimate_one_rm(225, 5, unit="lb")))

See the module docstrings in src/liftmath/ for the details: onerm.py, plates.py, standards.py, records.py, convert.py, imports.py.

Where the numbers come from

The 1RM formulas are Epley (1985), Brzycki (1993), Lombardi (1989), O'Conner et al. (1989), Lander (1985), and Mayhew et al. (1992). Which of these actually degrades worse at high rep counts is genuinely contested in the secondary literature, and onerm.py's docstring documents that openly rather than asserting an uncited fix. RPE/RIR (--rpe/--rir) uses the reps-in-reserve scale from Zourdos et al. (2016), J Strength Cond Res 30(1), 267-275.

The plate solver is a plain greedy largest-first pass for the unlimited case; the finite-inventory solver does an exhaustive bounded search instead, because greedy isn't optimal once you can run out of a plate (one 25 + one 25 beats grabbing the single 45 you own). plates.py explains why.

The strength scores come from the IPF's own published GL coefficients (May 2020), the original and 2020-revised Wilks formulas, and the DOTS formula introduced in 2019. These are competition scoring conventions: the actual formulas federations use, fit by regression to real competition samples, not "evidence" in the causal sense. The Wilks and DOTS coefficient tables are cross-checked against OpenPowerlifting's implementations; IPF GL came straight from the IPF's own coefficients PDF. standards.py has the full citations and a note on when the IPF table is due to refresh.

The records come in three layers. Powerlifting is computed by tools/build_records.py from the OpenPowerlifting project's bulk CSV (public domain, refreshed daily - openpowerlifting.org): the best sanctioned lift per sex, equipment, weight class (in both the traditional and IPF class schemes), and lift, in all-time and drug-tested scopes, with doping disqualifications excluded. Strongman and grip sport publish no machine-readable records at all, so tools/data/curated_records.json holds hand-verified entries - each with a citation URL, sanctioning body, and a confidence grade - and its header documents what was deliberately left out (events with no standardized implement, or marks with no verifiable source). Track & field lives in tools/data/track_records.json on the same terms: World Athletics world records plus the US collegiate and national high-school lists (Track & Field News convention), every entry cited, ratification-pending marks flagged. The bundle is a dated snapshot; records move, so tools/build_records.py's docstring explains how to regenerate it from a fresh CSV.

The lb/kg conversion uses the exact international avoirdupois pound (1 lb = 0.45359237 kg, fixed by the 1959 international yard-and-pound agreement), not a rounded approximation. It's the same factor standards.py was already converting with internally for its --unit lb handling.

Neither Strong nor Hevy publishes their CSV export schema - Strong's help center confirms export exists but not its columns, and Hevy's doesn't document its CSV at all. imports.py's column layout was built from real exports pulled from public workout-log-tooling repos plus a few independent open-source importers for cross-checking, not guessed from either app's marketing copy - its docstring says exactly what came from where, including that Strong's own schema has changed across app versions and differs between iOS and Android.

What this is not

This computes training math. It doesn't design your program, pick your exercises, or replace a coach who can watch you lift. Informational and educational only, not medical advice.

Tests

pip install -e ".[dev]"   # or: pip install pytest ruff
pytest
ruff check .

Every formula is pinned against hand-checked reference values in tests/. The web app's JavaScript math is parity-tested against the Python reference so the two never drift, and those tests run on Node 22+ with no npm packages at all:

python tools/gen_fixtures.py        # regenerate fixtures from the Python reference
node --test "tests/web/*.test.mjs"  # assert the JS math matches them

If you change any math, both engines need the change. CONTRIBUTING.md has the full rundown.

Contributing

Questions and contributions are welcome. See CONTRIBUTING.md.

License

GPL-3.0-or-later. You can use, study, change and share it. If you distribute a copy or a modified version, it has to stay under the GPL and come with its source. Releases up to 2.4.0 were under the Prosperity Public License 3.0.0.

Support

If liftmath runs your training math, sponsoring is what keeps it maintained.

Release files for liftmath 2.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for liftmath 2.5.0
File Size Uploaded
liftmath-2.5.0.tar.gz 161.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for liftmath 2.5.0
File Interpreter ABI Platform
liftmath-2.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 302.1 kB

Release files / liftmath-2.5.0.tar.gz

Download URL liftmath-2.5.0.tar.gz
Size 161.4 kB
Tags Source
SHA-256 checksum
How to use checksums
a2eb5f7406f47487c3e678a341cc7b8778313f21e016b08bbbbc15d0e92a015e
BLAKE2b-256 checksum
How to use checksums
cdfc038264112472d899f2294d67c7c0afda9a169b47cb1900d7ef752f0fd1ee
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / liftmath-2.5.0-py3-none-any.whl

Download URL liftmath-2.5.0-py3-none-any.whl
Size 140.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8458a71dfa7792063b258a6a434d5e65889f245c7d39fcc9e862fa966844ba0a
BLAKE2b-256 checksum
How to use checksums
6a6c7d6fe7626c7a19e278b63b2b1ccf7052b0af2e20a4a87779320f829d3d40
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

2.5.0 This release

2 release files

2.4.0

2 release files

2.3.0

2 release files

2.2.0

2 release files

2.1.0

2 release files

1.5.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page