liftmath 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- liftmath/__init__.py +253 -0
- liftmath/__main__.py +8 -0
- liftmath/_serialize.py +47 -0
- liftmath/attempts.py +128 -0
- liftmath/bodycomp.py +190 -0
- liftmath/bodyweight.py +136 -0
- liftmath/bulkcut.py +139 -0
- liftmath/cli.py +1694 -0
- liftmath/clubs.py +154 -0
- liftmath/gainrate.py +160 -0
- liftmath/glossary.py +354 -0
- liftmath/loads.py +113 -0
- liftmath/macros.py +350 -0
- liftmath/mesocycle.py +73 -0
- liftmath/onerm.py +170 -0
- liftmath/plates.py +272 -0
- liftmath/pr.py +73 -0
- liftmath/prilepin.py +316 -0
- liftmath/program.py +165 -0
- liftmath/progression.py +131 -0
- liftmath/rpe.py +145 -0
- liftmath/sessionload.py +150 -0
- liftmath/skinfold.py +224 -0
- liftmath/standards.py +260 -0
- liftmath/symmetry.py +182 -0
- liftmath/templates.py +471 -0
- liftmath/tiers.py +300 -0
- liftmath/tonnage.py +97 -0
- liftmath/volume.py +167 -0
- liftmath/warmup.py +50 -0
- liftmath-1.5.0.dist-info/METADATA +818 -0
- liftmath-1.5.0.dist-info/RECORD +36 -0
- liftmath-1.5.0.dist-info/WHEEL +5 -0
- liftmath-1.5.0.dist-info/entry_points.txt +2 -0
- liftmath-1.5.0.dist-info/licenses/LICENSE +57 -0
- liftmath-1.5.0.dist-info/top_level.txt +1 -0
liftmath/__init__.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""liftmath - evidence-based strength training math, pure Python stdlib.
|
|
2
|
+
|
|
3
|
+
This package is the calculator, not the coach. It estimates one-rep maxes
|
|
4
|
+
(with an RPE/RIR axis, and a weighted-bodyweight-movement variant for
|
|
5
|
+
pull-ups/dips), builds percentage-based load charts, looks up and audits
|
|
6
|
+
weekly volume landmarks per muscle group, ramps a mesocycle, tracks double
|
|
7
|
+
progression, sets protein/calorie/macro targets (including a lean-mass-based
|
|
8
|
+
Cunningham TDEE alternative and bulk/cut rate targets), computes body
|
|
9
|
+
composition (FFMI, Navy tape body-fat %, Jackson-Pollock skinfold + Siri),
|
|
10
|
+
tracks session load/monotony/strain and tonnage (volume-load), scores
|
|
11
|
+
relative strength (Wilks original + 2020, DOTS, IPF GL, McCulloch age
|
|
12
|
+
adjustment), scores squat/bench/deadlift lift-ratio symmetry, computes
|
|
13
|
+
training maxes and named program templates (5/3/1, GZCLP, nSuns), does
|
|
14
|
+
plate/warm-up math (including a finite-inventory plate solver), looks up
|
|
15
|
+
Prilepin's table and INOL, recommends powerlifting meet attempts, detects
|
|
16
|
+
e1RM PRs, tracks gym-culture milestones (clubs), and estimates muscle-gain
|
|
17
|
+
rate. Every formula and heuristic is cited in the module it lives in, with
|
|
18
|
+
an explicit evidence-tier note (established / emerging / speculative /
|
|
19
|
+
practitioner consensus) wherever the provenance isn't a straightforward
|
|
20
|
+
peer-reviewed finding. A plain-English glossary (`glossary.py`) defines
|
|
21
|
+
every piece of jargon this package uses, beginner-friendly first and
|
|
22
|
+
technical second.
|
|
23
|
+
|
|
24
|
+
None of this is medical or nutrition advice. See the README.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from liftmath._serialize import to_dict, to_json
|
|
28
|
+
from liftmath.attempts import (
|
|
29
|
+
OPENER_PCT,
|
|
30
|
+
OPENER_RANGE_PCT,
|
|
31
|
+
SECOND_PCT,
|
|
32
|
+
SECOND_RANGE_PCT,
|
|
33
|
+
THIRD_PCT,
|
|
34
|
+
AttemptSelection,
|
|
35
|
+
attempt_selection,
|
|
36
|
+
)
|
|
37
|
+
from liftmath.bodycomp import FfmiResult, NavyBodyFatResult, ffmi, navy_body_fat
|
|
38
|
+
from liftmath.bodyweight import MOVEMENTS, WeightedBodyweightEstimate, weighted_bodyweight_one_rm
|
|
39
|
+
from liftmath.bulkcut import TIERS, RateTarget, rate_target
|
|
40
|
+
from liftmath.clubs import CULTURE_CAVEAT, ClubProgress, ClubsReport, evaluate_clubs
|
|
41
|
+
from liftmath.gainrate import (
|
|
42
|
+
ARAGON_HELMS_MONTHLY_PCT_BW,
|
|
43
|
+
LEVELS,
|
|
44
|
+
MCDONALD_YEARLY_LB,
|
|
45
|
+
GainRateEstimate,
|
|
46
|
+
gain_rate,
|
|
47
|
+
)
|
|
48
|
+
from liftmath.glossary import GLOSSARY, Term, glossary_entry
|
|
49
|
+
from liftmath.loads import load_chart, pct_to_reps, reps_to_pct, target_load
|
|
50
|
+
from liftmath.macros import CunninghamTdee, MacroTargets, cunningham_tdee, macro_targets
|
|
51
|
+
from liftmath.mesocycle import ramp_mesocycle
|
|
52
|
+
from liftmath.onerm import OneRmEstimate, estimate_one_rm
|
|
53
|
+
from liftmath.plates import InventoryPlateLoad, PlateLoad, load_plates, load_plates_from_inventory
|
|
54
|
+
from liftmath.pr import PrCheck, check_pr
|
|
55
|
+
from liftmath.prilepin import (
|
|
56
|
+
PRILEPIN_CAVEAT,
|
|
57
|
+
ZONES,
|
|
58
|
+
InolGroup,
|
|
59
|
+
InolResult,
|
|
60
|
+
PrilepinZone,
|
|
61
|
+
SchemeEvaluation,
|
|
62
|
+
classify_weekly_inol,
|
|
63
|
+
classify_workout_inol,
|
|
64
|
+
evaluate_scheme,
|
|
65
|
+
inol_of_set,
|
|
66
|
+
inol_total,
|
|
67
|
+
zone_for_pct,
|
|
68
|
+
)
|
|
69
|
+
from liftmath.program import ExerciseSet, audit_program
|
|
70
|
+
from liftmath.progression import ProgressionStep, next_progression_step
|
|
71
|
+
from liftmath.rpe import (
|
|
72
|
+
RepsRpeEstimate,
|
|
73
|
+
RpeEstimate,
|
|
74
|
+
pct_1rm_from_reps_and_rir,
|
|
75
|
+
pct_1rm_from_reps_and_rpe,
|
|
76
|
+
rir_to_rpe,
|
|
77
|
+
rpe_from_reps_and_pct,
|
|
78
|
+
rpe_to_rir,
|
|
79
|
+
)
|
|
80
|
+
from liftmath.sessionload import WeeklyLoad, session_load, weekly_load
|
|
81
|
+
from liftmath.skinfold import (
|
|
82
|
+
SkinfoldResult,
|
|
83
|
+
jackson_pollock_men_3site,
|
|
84
|
+
jackson_pollock_men_7site,
|
|
85
|
+
jackson_pollock_women_3site,
|
|
86
|
+
jackson_pollock_women_7site,
|
|
87
|
+
siri_bodyfat_pct,
|
|
88
|
+
)
|
|
89
|
+
from liftmath.standards import (
|
|
90
|
+
MastersScore,
|
|
91
|
+
StrengthScore,
|
|
92
|
+
dots_score,
|
|
93
|
+
ipf_gl_points,
|
|
94
|
+
mcculloch_coefficient,
|
|
95
|
+
mcculloch_score,
|
|
96
|
+
score,
|
|
97
|
+
wilks_original_score,
|
|
98
|
+
wilks_score,
|
|
99
|
+
)
|
|
100
|
+
from liftmath.symmetry import EXPECTED_RATIOS, LiftRatio, SymmetryReport, score_symmetry
|
|
101
|
+
from liftmath.templates import (
|
|
102
|
+
T1_STAGES,
|
|
103
|
+
T2_STAGES,
|
|
104
|
+
GzclpSession,
|
|
105
|
+
NsunsDay,
|
|
106
|
+
ProgramSet,
|
|
107
|
+
ProgramWeek,
|
|
108
|
+
TrainingMax,
|
|
109
|
+
gzclp_next_session,
|
|
110
|
+
nsuns_day,
|
|
111
|
+
program_531,
|
|
112
|
+
round_to_increment,
|
|
113
|
+
training_max,
|
|
114
|
+
)
|
|
115
|
+
from liftmath.tiers import (
|
|
116
|
+
MEN_TOTAL_KG,
|
|
117
|
+
TIER_NAMES,
|
|
118
|
+
WOMEN_TOTAL_KG,
|
|
119
|
+
TierResult,
|
|
120
|
+
TierThresholds,
|
|
121
|
+
classify_tier,
|
|
122
|
+
thresholds_at_bodyweight,
|
|
123
|
+
)
|
|
124
|
+
from liftmath.tonnage import TonnageReport, TonnageSet, session_tonnage
|
|
125
|
+
from liftmath.volume import LANDMARKS, MUSCLES, band_for, describe_band, resolve_muscle
|
|
126
|
+
from liftmath.warmup import warmup_ramp
|
|
127
|
+
|
|
128
|
+
__version__ = "1.5.0"
|
|
129
|
+
|
|
130
|
+
__all__ = [
|
|
131
|
+
"estimate_one_rm",
|
|
132
|
+
"OneRmEstimate",
|
|
133
|
+
"pct_to_reps",
|
|
134
|
+
"reps_to_pct",
|
|
135
|
+
"load_chart",
|
|
136
|
+
"target_load",
|
|
137
|
+
"LANDMARKS",
|
|
138
|
+
"MUSCLES",
|
|
139
|
+
"band_for",
|
|
140
|
+
"describe_band",
|
|
141
|
+
"resolve_muscle",
|
|
142
|
+
"audit_program",
|
|
143
|
+
"ExerciseSet",
|
|
144
|
+
"ramp_mesocycle",
|
|
145
|
+
"macro_targets",
|
|
146
|
+
"MacroTargets",
|
|
147
|
+
"cunningham_tdee",
|
|
148
|
+
"CunninghamTdee",
|
|
149
|
+
"load_plates",
|
|
150
|
+
"PlateLoad",
|
|
151
|
+
"load_plates_from_inventory",
|
|
152
|
+
"InventoryPlateLoad",
|
|
153
|
+
"warmup_ramp",
|
|
154
|
+
"score",
|
|
155
|
+
"StrengthScore",
|
|
156
|
+
"wilks_score",
|
|
157
|
+
"wilks_original_score",
|
|
158
|
+
"dots_score",
|
|
159
|
+
"ipf_gl_points",
|
|
160
|
+
"mcculloch_coefficient",
|
|
161
|
+
"mcculloch_score",
|
|
162
|
+
"MastersScore",
|
|
163
|
+
"ffmi",
|
|
164
|
+
"FfmiResult",
|
|
165
|
+
"navy_body_fat",
|
|
166
|
+
"NavyBodyFatResult",
|
|
167
|
+
"session_load",
|
|
168
|
+
"weekly_load",
|
|
169
|
+
"WeeklyLoad",
|
|
170
|
+
"weighted_bodyweight_one_rm",
|
|
171
|
+
"WeightedBodyweightEstimate",
|
|
172
|
+
"MOVEMENTS",
|
|
173
|
+
"next_progression_step",
|
|
174
|
+
"ProgressionStep",
|
|
175
|
+
"rate_target",
|
|
176
|
+
"RateTarget",
|
|
177
|
+
"TIERS",
|
|
178
|
+
"pct_1rm_from_reps_and_rpe",
|
|
179
|
+
"pct_1rm_from_reps_and_rir",
|
|
180
|
+
"rpe_from_reps_and_pct",
|
|
181
|
+
"rpe_to_rir",
|
|
182
|
+
"rir_to_rpe",
|
|
183
|
+
"RpeEstimate",
|
|
184
|
+
"RepsRpeEstimate",
|
|
185
|
+
"score_symmetry",
|
|
186
|
+
"SymmetryReport",
|
|
187
|
+
"LiftRatio",
|
|
188
|
+
"EXPECTED_RATIOS",
|
|
189
|
+
"round_to_increment",
|
|
190
|
+
"training_max",
|
|
191
|
+
"TrainingMax",
|
|
192
|
+
"program_531",
|
|
193
|
+
"ProgramWeek",
|
|
194
|
+
"ProgramSet",
|
|
195
|
+
"gzclp_next_session",
|
|
196
|
+
"GzclpSession",
|
|
197
|
+
"T1_STAGES",
|
|
198
|
+
"T2_STAGES",
|
|
199
|
+
"nsuns_day",
|
|
200
|
+
"NsunsDay",
|
|
201
|
+
"thresholds_at_bodyweight",
|
|
202
|
+
"classify_tier",
|
|
203
|
+
"TierThresholds",
|
|
204
|
+
"TierResult",
|
|
205
|
+
"TIER_NAMES",
|
|
206
|
+
"MEN_TOTAL_KG",
|
|
207
|
+
"WOMEN_TOTAL_KG",
|
|
208
|
+
"to_dict",
|
|
209
|
+
"to_json",
|
|
210
|
+
"GLOSSARY",
|
|
211
|
+
"Term",
|
|
212
|
+
"glossary_entry",
|
|
213
|
+
"zone_for_pct",
|
|
214
|
+
"evaluate_scheme",
|
|
215
|
+
"PrilepinZone",
|
|
216
|
+
"SchemeEvaluation",
|
|
217
|
+
"ZONES",
|
|
218
|
+
"inol_of_set",
|
|
219
|
+
"inol_total",
|
|
220
|
+
"classify_workout_inol",
|
|
221
|
+
"classify_weekly_inol",
|
|
222
|
+
"InolGroup",
|
|
223
|
+
"InolResult",
|
|
224
|
+
"PRILEPIN_CAVEAT",
|
|
225
|
+
"attempt_selection",
|
|
226
|
+
"AttemptSelection",
|
|
227
|
+
"OPENER_PCT",
|
|
228
|
+
"SECOND_PCT",
|
|
229
|
+
"THIRD_PCT",
|
|
230
|
+
"OPENER_RANGE_PCT",
|
|
231
|
+
"SECOND_RANGE_PCT",
|
|
232
|
+
"jackson_pollock_men_3site",
|
|
233
|
+
"jackson_pollock_men_7site",
|
|
234
|
+
"jackson_pollock_women_3site",
|
|
235
|
+
"jackson_pollock_women_7site",
|
|
236
|
+
"siri_bodyfat_pct",
|
|
237
|
+
"SkinfoldResult",
|
|
238
|
+
"session_tonnage",
|
|
239
|
+
"TonnageSet",
|
|
240
|
+
"TonnageReport",
|
|
241
|
+
"check_pr",
|
|
242
|
+
"PrCheck",
|
|
243
|
+
"evaluate_clubs",
|
|
244
|
+
"ClubProgress",
|
|
245
|
+
"ClubsReport",
|
|
246
|
+
"CULTURE_CAVEAT",
|
|
247
|
+
"gain_rate",
|
|
248
|
+
"GainRateEstimate",
|
|
249
|
+
"LEVELS",
|
|
250
|
+
"ARAGON_HELMS_MONTHLY_PCT_BW",
|
|
251
|
+
"MCDONALD_YEARLY_LB",
|
|
252
|
+
"__version__",
|
|
253
|
+
]
|
liftmath/__main__.py
ADDED
liftmath/_serialize.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shared dataclass -> dict/JSON helper for the public result types.
|
|
2
|
+
|
|
3
|
+
Every public function in liftmath returns a plain @dataclass (OneRmEstimate,
|
|
4
|
+
MacroTargets, PlateLoad, LoadChart, ...). This module turns any of them into a
|
|
5
|
+
plain dict (or a JSON string) suitable for logging, an API response, or the
|
|
6
|
+
CLI's --json flag, without callers having to hand-roll dataclasses.asdict()
|
|
7
|
+
and remember to include the read-only @property values (like PlateLoad.exact
|
|
8
|
+
or OneRmEstimate.is_exact) that asdict() alone would drop.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_dict(obj: Any) -> Any:
|
|
19
|
+
"""Recursively convert a dataclass (or a list/dict/tuple of them) to plain dicts.
|
|
20
|
+
|
|
21
|
+
Nested dataclasses, lists, dicts, and tuples are all handled. Read-only
|
|
22
|
+
properties defined on a dataclass (e.g. `is_exact`, `exact`, `achievable`)
|
|
23
|
+
are included alongside its fields so the JSON output carries the same
|
|
24
|
+
derived info the human-readable CLI text does. Tuples are converted to
|
|
25
|
+
lists (JSON has no tuple type).
|
|
26
|
+
"""
|
|
27
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
28
|
+
result = {f.name: to_dict(getattr(obj, f.name)) for f in dataclasses.fields(obj)}
|
|
29
|
+
for name in dir(type(obj)):
|
|
30
|
+
attr = getattr(type(obj), name, None)
|
|
31
|
+
if isinstance(attr, property) and name not in result:
|
|
32
|
+
result[name] = to_dict(getattr(obj, name))
|
|
33
|
+
return result
|
|
34
|
+
if isinstance(obj, dict):
|
|
35
|
+
return {k: to_dict(v) for k, v in obj.items()}
|
|
36
|
+
if isinstance(obj, (list, tuple)):
|
|
37
|
+
return [to_dict(v) for v in obj]
|
|
38
|
+
return obj
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def to_json(obj: Any, **kwargs: Any) -> str:
|
|
42
|
+
"""Serialize a dataclass (or nested structure of them) straight to a JSON string.
|
|
43
|
+
|
|
44
|
+
Extra keyword arguments are passed through to `json.dumps` (e.g. `indent=2`).
|
|
45
|
+
"""
|
|
46
|
+
kwargs.setdefault("indent", 2)
|
|
47
|
+
return json.dumps(to_dict(obj), **kwargs)
|
liftmath/attempts.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Powerlifting meet attempt selection: opener/second/third as a % of your goal third.
|
|
2
|
+
|
|
3
|
+
Two numbers, shown side by side rather than picked as one "correct" answer:
|
|
4
|
+
HEADLINE (peer-reviewed): opener ~91% / second ~96% / third 100% of the
|
|
5
|
+
goal third-attempt weight.
|
|
6
|
+
COACH-CONSENSUS RANGE: opener 88-93%, second 93-97% - the wider band
|
|
7
|
+
practitioner sources actually work within, alongside the headline
|
|
8
|
+
study numbers.
|
|
9
|
+
|
|
10
|
+
Sources:
|
|
11
|
+
Travis, S.K., Zourdos, M.C., Bazyler, C.D. (2021). Weight Selection
|
|
12
|
+
Attempts of Elite Classic Powerlifters. Perceptual and Motor Skills,
|
|
13
|
+
128(1), 507-521. DOI: 10.1177/0031512520967608. Lifters (66 men, 43
|
|
14
|
+
women) who successfully completed ALL NINE attempts at an IPF
|
|
15
|
+
Classic World Championship, 2012-2019: opener (A1) ~= 91% of the
|
|
16
|
+
eventual third attempt (A3); A1->A2 jump ~= +5%; A2->A3 jump ~= +3%.
|
|
17
|
+
Net: opener ~91% of A3, second ~96%, third = 100% by definition.
|
|
18
|
+
van den Hoek, D.J. et al. (2022). What are the odds? Identifying factors
|
|
19
|
+
related to competitive success in powerlifting. BMC Sports Science,
|
|
20
|
+
Medicine and Rehabilitation, 14, 110. DOI: 10.1186/s13102-022-00505-2.
|
|
21
|
+
10,599 Australian competition entries (2010-2019) - a fully
|
|
22
|
+
independent dataset/federation from Travis et al. Doesn't publish
|
|
23
|
+
matching clean percentages, but independently supports the same
|
|
24
|
+
"aggressive-but-not-reckless attempt selection tracks with winning"
|
|
25
|
+
direction (winners' openers were heavier than non-winners' by a
|
|
26
|
+
meaningful margin across all three lifts, both sexes).
|
|
27
|
+
StrengthLog. "Powerlifting Competition Attempt Calculator & Meet
|
|
28
|
+
Strategy." strengthlog.com - cites the Travis et al. figures as its
|
|
29
|
+
basis, layered on coach Matt Gary's 2017 European Powerlifting
|
|
30
|
+
Conference presentation and the stated practices of coaches Boris
|
|
31
|
+
Sheiko, Bryce Lewis, and Alexander Eriksson; this is the source for
|
|
32
|
+
the coach-consensus range above.
|
|
33
|
+
|
|
34
|
+
Evidence grade: peer-reviewed for the headline %, cross-corroborated by a
|
|
35
|
+
second independent peer-reviewed dataset on outcome direction (not the exact
|
|
36
|
+
numbers), plus wide practitioner overlap - one of the better-sourced items
|
|
37
|
+
in this library.
|
|
38
|
+
|
|
39
|
+
Rounding: opener/second/third are rounded to the NEAREST achievable plate
|
|
40
|
+
increment (5lb / 2.5kg by default, via `templates.round_to_increment`) - an
|
|
41
|
+
implementation choice of this module, not part of either source (neither
|
|
42
|
+
publishes a rounding convention). Pass `increment` to override, or call
|
|
43
|
+
`templates.round_to_increment` yourself with `direction="down"` for a more
|
|
44
|
+
conservative (never-rounds-up) opener.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
|
|
51
|
+
from liftmath.templates import DEFAULT_INCREMENT, round_to_increment
|
|
52
|
+
|
|
53
|
+
# Travis, Zourdos & Bazyler (2021) headline percentages of the goal third attempt.
|
|
54
|
+
OPENER_PCT = 0.91
|
|
55
|
+
SECOND_PCT = 0.96
|
|
56
|
+
THIRD_PCT = 1.00
|
|
57
|
+
|
|
58
|
+
# Coach-consensus practitioner range (StrengthLog, citing Matt Gary / Boris
|
|
59
|
+
# Sheiko / Bryce Lewis / Alexander Eriksson) - see module docstring.
|
|
60
|
+
OPENER_RANGE_PCT = (0.88, 0.93)
|
|
61
|
+
SECOND_RANGE_PCT = (0.93, 0.97)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class AttemptSelection:
|
|
66
|
+
"""Opener/second/third recommendation for one lift, from a goal third attempt."""
|
|
67
|
+
|
|
68
|
+
lift: str
|
|
69
|
+
goal_third: float
|
|
70
|
+
unit: str
|
|
71
|
+
increment: float
|
|
72
|
+
opener: float
|
|
73
|
+
second: float
|
|
74
|
+
third: float
|
|
75
|
+
opener_range_low: float
|
|
76
|
+
opener_range_high: float
|
|
77
|
+
second_range_low: float
|
|
78
|
+
second_range_high: float
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def attempt_selection(
|
|
82
|
+
goal_third: float,
|
|
83
|
+
*,
|
|
84
|
+
lift: str = "lift",
|
|
85
|
+
unit: str = "lb",
|
|
86
|
+
increment: float | None = None,
|
|
87
|
+
) -> AttemptSelection:
|
|
88
|
+
"""Recommend opener/second/third attempts from a goal third-attempt weight.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
goal_third: the weight you're aiming to hit (or exceed) on your
|
|
92
|
+
THIRD attempt - every other attempt is computed as a % of it.
|
|
93
|
+
If you only have an e1RM, pass that (e.g. from `onerm.
|
|
94
|
+
estimate_one_rm(...).consensus`) as a reasonable stand-in.
|
|
95
|
+
lift: label only (e.g. "squat", "bench", "deadlift") - not validated
|
|
96
|
+
against a fixed list; call this once per lift.
|
|
97
|
+
unit: "lb" or "kg" - selects the default rounding increment.
|
|
98
|
+
increment: rounding increment; defaults to 5lb / 2.5kg (see
|
|
99
|
+
`templates.DEFAULT_INCREMENT`, the same defaults Wendler's
|
|
100
|
+
training max uses in this library).
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: if goal_third <= 0, or unit isn't "lb"/"kg" while
|
|
104
|
+
`increment` is left as its unit-based default.
|
|
105
|
+
"""
|
|
106
|
+
if goal_third <= 0:
|
|
107
|
+
raise ValueError("goal_third must be > 0")
|
|
108
|
+
if increment is None:
|
|
109
|
+
if unit not in DEFAULT_INCREMENT:
|
|
110
|
+
raise ValueError(f"unit must be one of {tuple(DEFAULT_INCREMENT)}, got {unit!r}")
|
|
111
|
+
increment = DEFAULT_INCREMENT[unit]
|
|
112
|
+
|
|
113
|
+
def rounded(pct: float) -> float:
|
|
114
|
+
return round_to_increment(goal_third * pct, increment, direction="nearest")
|
|
115
|
+
|
|
116
|
+
return AttemptSelection(
|
|
117
|
+
lift=lift,
|
|
118
|
+
goal_third=goal_third,
|
|
119
|
+
unit=unit,
|
|
120
|
+
increment=increment,
|
|
121
|
+
opener=rounded(OPENER_PCT),
|
|
122
|
+
second=rounded(SECOND_PCT),
|
|
123
|
+
third=rounded(THIRD_PCT),
|
|
124
|
+
opener_range_low=rounded(OPENER_RANGE_PCT[0]),
|
|
125
|
+
opener_range_high=rounded(OPENER_RANGE_PCT[1]),
|
|
126
|
+
second_range_low=rounded(SECOND_RANGE_PCT[0]),
|
|
127
|
+
second_range_high=rounded(SECOND_RANGE_PCT[1]),
|
|
128
|
+
)
|
liftmath/bodycomp.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Body composition: FFMI (Kouri 1995) and Navy tape-measure body-fat % (Hodgdon & Beckett 1984).
|
|
2
|
+
|
|
3
|
+
Both are stdlib-only (`math.log10`), field-expedient estimates - good for
|
|
4
|
+
tracking a trend over time, not a clinical body-composition reading.
|
|
5
|
+
|
|
6
|
+
Sources:
|
|
7
|
+
Kouri, E.M., Pope, H.G., Katz, D.L., Oliva, P. (1995). Fat-free mass
|
|
8
|
+
index in users and nonusers of anabolic-androgenic steroids.
|
|
9
|
+
Clinical Journal of Sport Medicine, 5(4), 223-228.
|
|
10
|
+
FFMI = lean_mass_kg / height_m^2. Normalized to a 1.80m reference
|
|
11
|
+
height: normalized_FFMI = FFMI + 6.3 * (1.80 - height_m).
|
|
12
|
+
Study population: 157 male athletes (74 non-users, 83 steroid
|
|
13
|
+
users). Non-users' normalized FFMI topped out at 25.0 in this
|
|
14
|
+
sample; historical (1939-1959) "Mr. America" winners averaged 25.4.
|
|
15
|
+
Evidence grade: established as a descriptive reference from a real
|
|
16
|
+
measured sample with a clear reported ceiling, but emerging/soft as
|
|
17
|
+
a hard "natural limit" claim - n=157, all-male, one era/population,
|
|
18
|
+
no cross-cultural or test-retest replication reported in the source,
|
|
19
|
+
and genetics/frame/measurement variance mean individuals can
|
|
20
|
+
legitimately sit above or below 25 without doping. Treat the 25.0
|
|
21
|
+
line as "a reference ceiling from one 1995 sample," not a law.
|
|
22
|
+
Hodgdon, J.A., Beckett, M.B. (1984). Prediction of percent body fat for
|
|
23
|
+
U.S. Navy men and women from body circumferences and height. Naval
|
|
24
|
+
Health Research Center, Report No. 84-11.
|
|
25
|
+
Men: BF% = 86.010*log10(waist-neck) - 70.041*log10(height) + 36.76
|
|
26
|
+
Women: BF% = 163.205*log10(waist+hip-neck) - 97.684*log10(height) - 78.387
|
|
27
|
+
All circumferences and height in inches. Waist at the navel, neck
|
|
28
|
+
below the larynx, hip at the widest point (women only).
|
|
29
|
+
Evidence grade: established as a field-expedient estimate -
|
|
30
|
+
validated against hydrostatic weighing at r~=0.90 in the source
|
|
31
|
+
study, but with a reported standard error of ~3-4 percentage points
|
|
32
|
+
vs. underwater weighing, so the output should be read as a band
|
|
33
|
+
("~18% +/- 3-4"), not a precise figure. That error band is a
|
|
34
|
+
SAMPLE-AVERAGE figure, not constant across the whole range: this is
|
|
35
|
+
a regression fit to the Navy's own sample, and like any regression
|
|
36
|
+
it fits worst at the tails of that sample - a circumference-based
|
|
37
|
+
estimate tracks fat mass least tightly on bodies that are already
|
|
38
|
+
very lean or unusually muscular (roughly under ~12% or over ~25%
|
|
39
|
+
body fat), where the reported +/-3-4 point band is optimistic.
|
|
40
|
+
`NavyBodyFatResult.less_reliable_at_extremes` flags this range;
|
|
41
|
+
treat the number there as a rough trend line, not even the usual
|
|
42
|
+
band.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import math
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
|
|
50
|
+
_FFMI_REFERENCE_HEIGHT_M = 1.80
|
|
51
|
+
_FFMI_NATURAL_CEILING = 25.0 # Kouri 1995 non-user sample ceiling, normalized FFMI
|
|
52
|
+
|
|
53
|
+
_SEXES = ("male", "female")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class FfmiResult:
|
|
58
|
+
"""Fat-free mass index for one height/weight/bodyfat combination."""
|
|
59
|
+
|
|
60
|
+
weight_kg: float
|
|
61
|
+
height_m: float
|
|
62
|
+
bodyfat_pct: float
|
|
63
|
+
lean_mass_kg: float
|
|
64
|
+
ffmi: float
|
|
65
|
+
normalized_ffmi: float
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def above_natural_reference_ceiling(self) -> bool:
|
|
69
|
+
"""True if normalized FFMI exceeds Kouri 1995's 25.0 non-user sample ceiling.
|
|
70
|
+
|
|
71
|
+
This is a reference point from one 1995 sample of 157 male athletes,
|
|
72
|
+
not a hard physiological law - individuals can legitimately sit above
|
|
73
|
+
or below it without doping. See module docstring.
|
|
74
|
+
"""
|
|
75
|
+
return self.normalized_ffmi > _FFMI_NATURAL_CEILING
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ffmi(weight_kg: float, height_m: float, bodyfat_pct: float) -> FfmiResult:
|
|
79
|
+
"""Compute FFMI and height-normalized FFMI (Kouri et al., 1995).
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
weight_kg: total bodyweight, kilograms.
|
|
83
|
+
height_m: height, meters.
|
|
84
|
+
bodyfat_pct: body-fat percentage as a whole number (e.g. 15 for 15%).
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: if weight_kg or height_m aren't positive, or bodyfat_pct
|
|
88
|
+
isn't in [0, 100).
|
|
89
|
+
"""
|
|
90
|
+
if weight_kg <= 0:
|
|
91
|
+
raise ValueError("weight_kg must be > 0")
|
|
92
|
+
if height_m <= 0:
|
|
93
|
+
raise ValueError("height_m must be > 0")
|
|
94
|
+
if not 0 <= bodyfat_pct < 100:
|
|
95
|
+
raise ValueError("bodyfat_pct must be in [0, 100)")
|
|
96
|
+
|
|
97
|
+
lean_mass_kg = weight_kg * (1 - bodyfat_pct / 100.0)
|
|
98
|
+
raw_ffmi = lean_mass_kg / height_m**2
|
|
99
|
+
normalized = raw_ffmi + 6.3 * (_FFMI_REFERENCE_HEIGHT_M - height_m)
|
|
100
|
+
|
|
101
|
+
return FfmiResult(
|
|
102
|
+
weight_kg=weight_kg,
|
|
103
|
+
height_m=height_m,
|
|
104
|
+
bodyfat_pct=bodyfat_pct,
|
|
105
|
+
lean_mass_kg=lean_mass_kg,
|
|
106
|
+
ffmi=raw_ffmi,
|
|
107
|
+
normalized_ffmi=normalized,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class NavyBodyFatResult:
|
|
113
|
+
"""Navy tape-measure body-fat % estimate (Hodgdon & Beckett 1984)."""
|
|
114
|
+
|
|
115
|
+
sex: str
|
|
116
|
+
height_in: float
|
|
117
|
+
neck_in: float
|
|
118
|
+
waist_in: float
|
|
119
|
+
hip_in: float | None
|
|
120
|
+
bodyfat_pct: float
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def error_band_pct(self) -> float:
|
|
124
|
+
"""Reported standard error vs. hydrostatic weighing, +/- percentage points."""
|
|
125
|
+
return 3.5
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def less_reliable_at_extremes(self) -> bool:
|
|
129
|
+
"""True below ~12% or above ~25% body fat, where this regression fits worst.
|
|
130
|
+
|
|
131
|
+
The Hodgdon & Beckett regression was fit across the Navy's own
|
|
132
|
+
sample and is least accurate at the tails of that fit - a very lean
|
|
133
|
+
or unusually muscular body pushes the real error wider than the
|
|
134
|
+
reported +/-3-4 point band (see module docstring). Read the result
|
|
135
|
+
as a rough trend line in this range, not the usual band.
|
|
136
|
+
"""
|
|
137
|
+
return self.bodyfat_pct < 12.0 or self.bodyfat_pct > 25.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def navy_body_fat(
|
|
141
|
+
sex: str,
|
|
142
|
+
height_in: float,
|
|
143
|
+
neck_in: float,
|
|
144
|
+
waist_in: float,
|
|
145
|
+
hip_in: float | None = None,
|
|
146
|
+
) -> NavyBodyFatResult:
|
|
147
|
+
"""Estimate body-fat % from circumference measurements (Hodgdon & Beckett, 1984).
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
sex: "male" or "female".
|
|
151
|
+
height_in: height, inches.
|
|
152
|
+
neck_in: neck circumference below the larynx, inches.
|
|
153
|
+
waist_in: waist circumference at the navel, inches.
|
|
154
|
+
hip_in: hip circumference at the widest point, inches. Required for
|
|
155
|
+
"female", ignored for "male".
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
ValueError: if sex isn't "male"/"female", any measurement isn't
|
|
159
|
+
positive, hip_in is missing for "female", or the log10 argument
|
|
160
|
+
would be non-positive (e.g. waist <= neck for men).
|
|
161
|
+
"""
|
|
162
|
+
if sex not in _SEXES:
|
|
163
|
+
raise ValueError(f"sex must be one of {_SEXES}, got {sex!r}")
|
|
164
|
+
for name, value in (("height_in", height_in), ("neck_in", neck_in), ("waist_in", waist_in)):
|
|
165
|
+
if value <= 0:
|
|
166
|
+
raise ValueError(f"{name} must be > 0")
|
|
167
|
+
|
|
168
|
+
if sex == "male":
|
|
169
|
+
span = waist_in - neck_in
|
|
170
|
+
if span <= 0:
|
|
171
|
+
raise ValueError("waist_in must be greater than neck_in")
|
|
172
|
+
bf = 86.010 * math.log10(span) - 70.041 * math.log10(height_in) + 36.76
|
|
173
|
+
else:
|
|
174
|
+
if hip_in is None:
|
|
175
|
+
raise ValueError("hip_in is required for sex='female'")
|
|
176
|
+
if hip_in <= 0:
|
|
177
|
+
raise ValueError("hip_in must be > 0")
|
|
178
|
+
span = waist_in + hip_in - neck_in
|
|
179
|
+
if span <= 0:
|
|
180
|
+
raise ValueError("waist_in + hip_in must be greater than neck_in")
|
|
181
|
+
bf = 163.205 * math.log10(span) - 97.684 * math.log10(height_in) - 78.387
|
|
182
|
+
|
|
183
|
+
return NavyBodyFatResult(
|
|
184
|
+
sex=sex,
|
|
185
|
+
height_in=height_in,
|
|
186
|
+
neck_in=neck_in,
|
|
187
|
+
waist_in=waist_in,
|
|
188
|
+
hip_in=hip_in,
|
|
189
|
+
bodyfat_pct=bf,
|
|
190
|
+
)
|