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 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
@@ -0,0 +1,8 @@
1
+ """Allows `python -m liftmath ...` in addition to the installed `liftmath` console script."""
2
+
3
+ import sys
4
+
5
+ from liftmath.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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
+ )