1"""One rep max and strength standards calculator.
2
3Pure offline strength math. No network, no API key, no login. Every result is
4computed from the input with fixed formulas and embedded reference tables, so
5the actor cannot break as long as Python runs.
6
7Calculation types (one row per calculation):
8 one-rep-max Estimate 1RM from a weight lifted for a number of reps,
9 using seven published formulas plus their average, and
10 return a percentage-of-1RM load table.
11 rep-max-table From a known 1RM, project the weight you should manage for
12 1 to 12 reps and the load at each training percentage.
13 strength-standards Classify a lift (bench, squat, deadlift, overhead press,
14 or any lift with custom ratios) into beginner, novice,
15 intermediate, advanced or elite using bodyweight ratios.
16 wilks-dots Score a powerlifting total (or a single lift) with the
17 Wilks, Wilks 2, DOTS and IPF GL point systems.
18 plate-loading Work out which plates to load per side of the bar to reach
19 a target weight, from an available plate set.
20 warmup Build a warmup ramp of sets leading to a working weight.
21"""
22
23from __future__ import annotations
24
25import math
26from apify import Actor
27
28LB_PER_KG = 2.2046226218487757
29
30
31
32
33
34
35def _round(value: float, ndigits: int = 2) -> float:
36 return round(float(value) + 0.0, ndigits)
37
38
39def _num(value, name: str) -> float:
40 if value is None or value == "":
41 raise ValueError(f"{name} is required")
42 try:
43 out = float(value)
44 except (TypeError, ValueError):
45 raise ValueError(f"{name} must be a number, got {value!r}")
46 if math.isnan(out) or math.isinf(out):
47 raise ValueError(f"{name} must be a finite number")
48 return out
49
50
51def _unit(value) -> str:
52 unit = str(value or "kg").strip().lower()
53 if unit in ("kg", "kgs", "kilogram", "kilograms", "metric"):
54 return "kg"
55 if unit in ("lb", "lbs", "pound", "pounds", "imperial"):
56 return "lb"
57 raise ValueError(f"unit must be kg or lb, got {value!r}")
58
59
60def _sex(value) -> str:
61 sex = str(value or "").strip().lower()
62 if sex in ("m", "male", "man", "men"):
63 return "male"
64 if sex in ("f", "female", "woman", "women"):
65 return "female"
66 raise ValueError("sex must be male or female")
67
68
69def _to_kg(weight: float, unit: str) -> float:
70 return weight if unit == "kg" else weight / LB_PER_KG
71
72
73
74
75
76
77
78_ORM_FORMULAS = {
79 "epley": lambda w, r: w * (1 + r / 30.0),
80 "brzycki": lambda w, r: w * 36.0 / (37.0 - r),
81 "lombardi": lambda w, r: w * (r ** 0.10),
82 "oconner": lambda w, r: w * (1 + r / 40.0),
83 "wathan": lambda w, r: 100.0 * w / (48.8 + 53.8 * math.exp(-0.075 * r)),
84 "lander": lambda w, r: 100.0 * w / (101.3 - 2.67123 * r),
85 "mayhew": lambda w, r: 100.0 * w / (52.2 + 41.9 * math.exp(-0.055 * r)),
86}
87
88
89_PERCENT_TABLE = {
90 1: 100, 2: 95, 3: 93, 4: 90, 5: 87, 6: 85,
91 7: 83, 8: 80, 9: 77, 10: 75, 11: 70, 12: 67,
92}
93
94
95def calc_one_rep_max(item: dict) -> dict:
96 unit = _unit(item.get("unit"))
97 weight = _num(item.get("weight"), "weight")
98 reps = _num(item.get("reps"), "reps")
99 if weight <= 0:
100 raise ValueError("weight must be greater than zero")
101 if reps < 1:
102 raise ValueError("reps must be at least 1")
103 if reps != int(reps):
104 raise ValueError("reps must be a whole number")
105 reps = int(reps)
106 if reps > 36:
107 raise ValueError("reps must be 36 or fewer for a meaningful estimate")
108
109 formulas = {}
110 if reps == 1:
111 for name in _ORM_FORMULAS:
112 formulas[name] = _round(weight)
113 else:
114 for name, fn in _ORM_FORMULAS.items():
115 try:
116 formulas[name] = _round(fn(weight, reps))
117 except (ZeroDivisionError, ValueError):
118 formulas[name] = None
119
120 valid = [v for v in formulas.values() if v is not None and v > 0]
121 average = _round(sum(valid) / len(valid)) if valid else None
122
123 accuracy = "high" if reps <= 10 else ("moderate" if reps <= 15 else "low")
124
125 percent_table = []
126 if average:
127 for rep_count in range(1, 13):
128 pct = _PERCENT_TABLE[rep_count]
129 percent_table.append({
130 "reps": rep_count,
131 "percentOf1RM": pct,
132 "weight": _round(average * pct / 100.0),
133 })
134
135 return {
136 "primary": f"Estimated 1RM {average} {unit}" if average else "no estimate",
137 "estimated1RM": average,
138 "unit": unit,
139 "input": f"{_round(weight)} {unit} x {reps} reps",
140 "repsUsed": reps,
141 "accuracy": accuracy,
142 "epley": formulas.get("epley"),
143 "brzycki": formulas.get("brzycki"),
144 "lombardi": formulas.get("lombardi"),
145 "oconner": formulas.get("oconner"),
146 "wathan": formulas.get("wathan"),
147 "lander": formulas.get("lander"),
148 "mayhew": formulas.get("mayhew"),
149 "percentTable": percent_table,
150 }
151
152
153
154
155
156
157def calc_rep_max_table(item: dict) -> dict:
158 unit = _unit(item.get("unit"))
159 one_rm = _num(item.get("oneRepMax"), "oneRepMax")
160 if one_rm <= 0:
161 raise ValueError("oneRepMax must be greater than zero")
162
163 rounding = item.get("roundTo")
164 rounding = _num(rounding, "roundTo") if rounding not in (None, "") else None
165
166 def maybe_round(value: float) -> float:
167 if rounding and rounding > 0:
168 return _round(round(value / rounding) * rounding)
169 return _round(value)
170
171
172
173 table = []
174 for reps in range(1, 13):
175 pct = _PERCENT_TABLE[reps]
176 pct_weight = maybe_round(one_rm * pct / 100.0)
177 epley_weight = maybe_round(one_rm / (1 + reps / 30.0))
178 table.append({
179 "reps": reps,
180 "percentOf1RM": pct,
181 "weightByPercent": pct_weight,
182 "weightByEpley": epley_weight,
183 })
184
185 return {
186 "primary": f"Rep max table for a {_round(one_rm)} {unit} 1RM",
187 "oneRepMax": _round(one_rm),
188 "unit": unit,
189 "input": f"1RM {_round(one_rm)} {unit}",
190 "repMaxTable": table,
191 }
192
193
194
195
196
197
198
199
200_STANDARDS = {
201 "bench": {
202 "male": (0.75, 1.00, 1.50, 2.00),
203 "female": (0.50, 0.65, 0.90, 1.25),
204 },
205 "squat": {
206 "male": (1.00, 1.50, 2.00, 2.75),
207 "female": (0.75, 1.15, 1.50, 2.00),
208 },
209 "deadlift": {
210 "male": (1.25, 1.75, 2.50, 3.00),
211 "female": (1.00, 1.35, 1.90, 2.50),
212 },
213 "overhead-press": {
214 "male": (0.50, 0.75, 1.00, 1.35),
215 "female": (0.35, 0.50, 0.70, 1.00),
216 },
217}
218
219_LIFT_ALIASES = {
220 "bench": "bench", "bench-press": "bench", "benchpress": "bench",
221 "squat": "squat", "back-squat": "squat",
222 "deadlift": "deadlift", "dead-lift": "deadlift", "dl": "deadlift",
223 "overhead-press": "overhead-press", "ohp": "overhead-press",
224 "overhead": "overhead-press", "press": "overhead-press",
225 "shoulder-press": "overhead-press", "military-press": "overhead-press",
226}
227
228_LEVEL_NAMES = ("beginner", "novice", "intermediate", "advanced", "elite")
229
230
231def calc_strength_standards(item: dict) -> dict:
232 unit = _unit(item.get("unit"))
233 sex = _sex(item.get("sex"))
234 bodyweight = _num(item.get("bodyweight"), "bodyweight")
235 lift_1rm = _num(item.get("oneRepMax"), "oneRepMax")
236 if bodyweight <= 0:
237 raise ValueError("bodyweight must be greater than zero")
238 if lift_1rm <= 0:
239 raise ValueError("oneRepMax must be greater than zero")
240
241 raw_lift = str(item.get("lift", "bench")).strip().lower()
242 custom = item.get("ratios")
243 if custom:
244 try:
245 thresholds = tuple(float(x) for x in custom)
246 except (TypeError, ValueError):
247 raise ValueError("ratios must be a list of four numbers")
248 if len(thresholds) != 4:
249 raise ValueError("ratios must have exactly four values")
250 lift = raw_lift or "custom"
251 else:
252 lift = _LIFT_ALIASES.get(raw_lift)
253 if lift is None:
254 raise ValueError(
255 "lift must be bench, squat, deadlift or overhead-press, "
256 "or supply a custom ratios list"
257 )
258 thresholds = _STANDARDS[lift][sex]
259
260 ratio = lift_1rm / bodyweight
261
262 level_index = 0
263 for i, t in enumerate(thresholds):
264 if ratio >= t:
265 level_index = i + 1
266 level = _LEVEL_NAMES[level_index]
267
268 level_weights = {
269 "novice": _round(thresholds[0] * bodyweight),
270 "intermediate": _round(thresholds[1] * bodyweight),
271 "advanced": _round(thresholds[2] * bodyweight),
272 "elite": _round(thresholds[3] * bodyweight),
273 }
274
275 next_level = None
276 next_weight = None
277 if level_index < 4:
278 next_level = _LEVEL_NAMES[level_index + 1]
279 next_weight = _round(thresholds[level_index] * bodyweight)
280
281 return {
282 "primary": f"{level.capitalize()} ({_round(ratio)}x bodyweight)",
283 "level": level,
284 "ratio": _round(ratio, 3),
285 "lift": lift,
286 "sex": sex,
287 "unit": unit,
288 "bodyweight": _round(bodyweight),
289 "oneRepMax": _round(lift_1rm),
290 "input": f"{lift} {_round(lift_1rm)} {unit} at {_round(bodyweight)} {unit} bodyweight",
291 "nextLevel": next_level,
292 "nextLevelWeight": next_weight,
293 "noviceWeight": level_weights["novice"],
294 "intermediateWeight": level_weights["intermediate"],
295 "advancedWeight": level_weights["advanced"],
296 "eliteWeight": level_weights["elite"],
297 }
298
299
300
301
302
303
304_WILKS_OLD = {
305 "male": (-216.0475144, 16.2606339, -0.002388645, -0.00113732,
306 7.01863e-6, -1.291e-8),
307 "female": (594.31747775582, -27.23842536447, 0.82112226871,
308 -0.00930733913, 4.731582e-5, -9.054e-8),
309}
310
311_WILKS2 = {
312 "male": (47.46178854, 8.472061379, 0.07369410346, -0.001395833811,
313 7.07665973070743e-6, -1.20804336482315e-8),
314 "female": (-125.4255398, 13.71219419, -0.03307250631, -0.001050400051,
315 9.38773881462799e-6, -2.3334613884954e-8),
316}
317
318_DOTS = {
319 "male": (-307.75076, 24.0900756, -0.1918759221, 0.0007391293,
320 -0.000001093705),
321 "female": (-57.96288, 13.6175032, -0.1126655495, 0.0005158568,
322 -0.0000010706659),
323}
324
325
326_IPF_GL = {
327 "male": (1199.72839, 1025.18162, 0.00921),
328 "female": (610.32796, 1045.59282, 0.03048),
329}
330
331
332def _poly_coeff(coeffs, x: float) -> float:
333 denom = 0.0
334 for i, c in enumerate(coeffs):
335 denom += c * (x ** i)
336 return 500.0 / denom
337
338
339def calc_wilks_dots(item: dict) -> dict:
340 unit = _unit(item.get("unit"))
341 sex = _sex(item.get("sex"))
342 bodyweight = _num(item.get("bodyweight"), "bodyweight")
343 total = _num(item.get("total"), "total")
344 if bodyweight <= 0:
345 raise ValueError("bodyweight must be greater than zero")
346 if total <= 0:
347 raise ValueError("total must be greater than zero")
348
349 bw_kg = _to_kg(bodyweight, unit)
350 total_kg = _to_kg(total, unit)
351
352 wilks = _round(_poly_coeff(_WILKS_OLD[sex], bw_kg) * total_kg)
353 wilks2 = _round(_poly_coeff(_WILKS2[sex], bw_kg) * total_kg)
354 dots = _round(_poly_coeff(_DOTS[sex], bw_kg) * total_kg)
355
356 a, b, c = _IPF_GL[sex]
357 ipf_gl = _round(100.0 * total_kg / (a - b * math.exp(-c * bw_kg)))
358
359 return {
360 "primary": f"Wilks {wilks}, DOTS {dots}, IPF GL {ipf_gl}",
361 "wilks": wilks,
362 "wilks2": wilks2,
363 "dots": dots,
364 "ipfGL": ipf_gl,
365 "sex": sex,
366 "unit": unit,
367 "bodyweight": _round(bodyweight),
368 "total": _round(total),
369 "bodyweightKg": _round(bw_kg),
370 "totalKg": _round(total_kg),
371 "input": f"{_round(total)} {unit} total at {_round(bodyweight)} {unit} bodyweight",
372 }
373
374
375
376
377
378
379_DEFAULT_PLATES = {
380 "kg": [25, 20, 15, 10, 5, 2.5, 1.25],
381 "lb": [45, 35, 25, 10, 5, 2.5],
382}
383_DEFAULT_BAR = {"kg": 20.0, "lb": 45.0}
384
385
386def calc_plate_loading(item: dict) -> dict:
387 unit = _unit(item.get("unit"))
388 target = _num(item.get("targetWeight"), "targetWeight")
389 if target <= 0:
390 raise ValueError("targetWeight must be greater than zero")
391
392 bar = item.get("barWeight")
393 bar = _num(bar, "barWeight") if bar not in (None, "") else _DEFAULT_BAR[unit]
394 if bar < 0:
395 raise ValueError("barWeight cannot be negative")
396
397 plates_input = item.get("availablePlates")
398 if plates_input:
399 try:
400 plates = sorted({float(p) for p in plates_input}, reverse=True)
401 except (TypeError, ValueError):
402 raise ValueError("availablePlates must be a list of numbers")
403 else:
404 plates = _DEFAULT_PLATES[unit]
405
406 if target < bar:
407 raise ValueError(
408 f"targetWeight {_round(target)} is below the bar weight {_round(bar)}"
409 )
410
411 per_side = (target - bar) / 2.0
412 remaining = per_side
413 loading = []
414 for plate in plates:
415 if plate <= 0:
416 continue
417 count = int(remaining // plate + 1e-9)
418 if count > 0:
419 loading.append({"plate": _round(plate), "count": count})
420 remaining -= count * plate
421
422 loaded_per_side = per_side - remaining
423 achieved = _round(bar + loaded_per_side * 2.0)
424 leftover = _round(remaining)
425
426 per_side_str = " + ".join(
427 f"{p['count']}x{p['plate']}" for p in loading
428 ) or "empty bar"
429
430 return {
431 "primary": f"Per side: {per_side_str}",
432 "unit": unit,
433 "targetWeight": _round(target),
434 "barWeight": _round(bar),
435 "achievableWeight": achieved,
436 "weightPerSide": _round(loaded_per_side),
437 "leftoverPerSide": leftover,
438 "exactMatch": leftover == 0,
439 "platesPerSide": loading,
440 "input": f"target {_round(target)} {unit}, bar {_round(bar)} {unit}",
441 }
442
443
444
445
446
447
448
449_WARMUP_SCHEME = [
450 (0, 8), (40, 5), (55, 5), (70, 3), (85, 2), (100, None),
451]
452
453
454def calc_warmup(item: dict) -> dict:
455 unit = _unit(item.get("unit"))
456 working = _num(item.get("workingWeight"), "workingWeight")
457 if working <= 0:
458 raise ValueError("workingWeight must be greater than zero")
459
460 work_reps = item.get("workingReps")
461 work_reps = int(_num(work_reps, "workingReps")) if work_reps not in (None, "") else 5
462
463 bar = item.get("barWeight")
464 bar = _num(bar, "barWeight") if bar not in (None, "") else _DEFAULT_BAR[unit]
465
466 rounding = item.get("roundTo")
467 rounding = _num(rounding, "roundTo") if rounding not in (None, "") else None
468
469 def maybe_round(value: float) -> float:
470 if rounding and rounding > 0:
471 return _round(round(value / rounding) * rounding)
472 return _round(value)
473
474 sets = []
475 for i, (pct, reps) in enumerate(_WARMUP_SCHEME):
476 if pct == 0:
477 weight = _round(bar)
478 label = "empty bar"
479 elif pct == 100:
480 weight = maybe_round(working)
481 reps = work_reps
482 label = "working set"
483 else:
484 weight = maybe_round(working * pct / 100.0)
485 label = f"{pct}%"
486 sets.append({
487 "set": i + 1,
488 "label": label,
489 "percentOfWorking": pct,
490 "weight": weight,
491 "reps": reps,
492 })
493
494 return {
495 "primary": f"Warmup to {maybe_round(working)} {unit} x {work_reps}",
496 "unit": unit,
497 "workingWeight": _round(working),
498 "workingReps": work_reps,
499 "barWeight": _round(bar),
500 "warmupSets": sets,
501 "input": f"working {_round(working)} {unit} x {work_reps}",
502 }
503
504
505
506
507
508
509_TYPE_ALIASES = {
510 "one-rep-max": "one-rep-max", "1rm": "one-rep-max", "onerepmax": "one-rep-max",
511 "orm": "one-rep-max", "one_rep_max": "one-rep-max", "estimate": "one-rep-max",
512 "rep-max-table": "rep-max-table", "repmaxtable": "rep-max-table",
513 "rep-max": "rep-max-table", "percent-table": "rep-max-table",
514 "rep_max_table": "rep-max-table", "percentages": "rep-max-table",
515 "strength-standards": "strength-standards", "standards": "strength-standards",
516 "strength-level": "strength-standards", "level": "strength-standards",
517 "strength_standards": "strength-standards",
518 "wilks-dots": "wilks-dots", "wilks": "wilks-dots", "dots": "wilks-dots",
519 "score": "wilks-dots", "ipf": "wilks-dots", "points": "wilks-dots",
520 "wilks_dots": "wilks-dots",
521 "plate-loading": "plate-loading", "plates": "plate-loading",
522 "plate": "plate-loading", "barbell": "plate-loading",
523 "plate_loading": "plate-loading",
524 "warmup": "warmup", "warm-up": "warmup", "warmup-sets": "warmup",
525}
526
527_DISPATCH = {
528 "one-rep-max": calc_one_rep_max,
529 "rep-max-table": calc_rep_max_table,
530 "strength-standards": calc_strength_standards,
531 "wilks-dots": calc_wilks_dots,
532 "plate-loading": calc_plate_loading,
533 "warmup": calc_warmup,
534}
535
536
537def process(item: dict) -> dict:
538 raw_type = str(item.get("type", "one-rep-max")).strip().lower()
539 calc_type = _TYPE_ALIASES.get(raw_type)
540 base = {
541 "calculationType": calc_type or raw_type,
542 "label": item.get("label", ""),
543 "ok": True,
544 "error": "",
545 }
546 if calc_type is None:
547 base["ok"] = False
548 base["error"] = (
549 f"unknown type {raw_type!r}. Use one-rep-max, rep-max-table, "
550 "strength-standards, wilks-dots, plate-loading or warmup"
551 )
552 return base
553 try:
554 result = _DISPATCH[calc_type](item)
555 base.update(result)
556 except ValueError as exc:
557 base["ok"] = False
558 base["error"] = str(exc)
559 except Exception as exc:
560 base["ok"] = False
561 base["error"] = f"unexpected error: {exc}"
562 return base
563
564
565_DEFAULT_CALCULATIONS = [
566 {"type": "one-rep-max", "label": "Bench 100kg x 5", "weight": 100, "reps": 5, "unit": "kg"},
567 {"type": "rep-max-table", "label": "Squat 1RM 140kg", "oneRepMax": 140, "unit": "kg", "roundTo": 2.5},
568 {"type": "strength-standards", "label": "Male deadlift", "lift": "deadlift",
569 "sex": "male", "bodyweight": 80, "oneRepMax": 200, "unit": "kg"},
570 {"type": "wilks-dots", "label": "Male 600kg total", "sex": "male",
571 "bodyweight": 90, "total": 600, "unit": "kg"},
572 {"type": "plate-loading", "label": "Load 142.5kg", "targetWeight": 142.5, "unit": "kg"},
573 {"type": "warmup", "label": "Warmup to 120kg", "workingWeight": 120, "workingReps": 5, "unit": "kg"},
574]
575
576
577async def main() -> None:
578 async with Actor:
579 actor_input = await Actor.get_input() or {}
580 calculations = actor_input.get("calculations")
581 if not calculations:
582 calculations = _DEFAULT_CALCULATIONS
583 Actor.log.info("No calculations supplied, using the prefilled examples.")
584
585 if not isinstance(calculations, list):
586 raise ValueError("calculations must be a list of objects")
587
588 results = []
589 for item in calculations:
590 if not isinstance(item, dict):
591 results.append({
592 "calculationType": "",
593 "ok": False,
594 "error": f"each calculation must be an object, got {item!r}",
595 })
596 continue
597 results.append(process(item))
598
599 await Actor.push_data(results)
600 ok = sum(1 for r in results if r.get("ok"))
601 Actor.log.info(f"Wrote {len(results)} rows, {ok} ok, {len(results) - ok} errors.")