brrag 0.0.2__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.
- brrag/__init__.py +42 -0
- brrag/cli.py +161 -0
- brrag/core.py +273 -0
- brrag/data/mldr_road.json +17951 -0
- brrag/ui.py +72 -0
- brrag-0.0.2.dist-info/METADATA +200 -0
- brrag-0.0.2.dist-info/RECORD +11 -0
- brrag-0.0.2.dist-info/WHEEL +5 -0
- brrag-0.0.2.dist-info/entry_points.txt +2 -0
- brrag-0.0.2.dist-info/licenses/LICENSE +43 -0
- brrag-0.0.2.dist-info/top_level.txt +1 -0
brrag/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""BRRAG — Botha's Road Running Age Grading: USATF MLDR road tables.
|
|
2
|
+
|
|
3
|
+
Reference implementation of the published calculator's arithmetic.
|
|
4
|
+
|
|
5
|
+
>>> from brrag import table
|
|
6
|
+
>>> t = table(2025, "M")
|
|
7
|
+
>>> g = t.grade("M", 40, 40 * 60, dist_km=10)
|
|
8
|
+
>>> round(g.percent, 2)
|
|
9
|
+
68.49
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
try: # from the installed metadata; set by the git tag at build time
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
14
|
+
try:
|
|
15
|
+
__version__ = _dist_version("brrag")
|
|
16
|
+
except PackageNotFoundError: # running from an unbuilt source tree
|
|
17
|
+
__version__ = "0.0.0+unknown"
|
|
18
|
+
except ImportError: # pragma: no cover - Python < 3.8
|
|
19
|
+
__version__ = "0.0.0+unknown"
|
|
20
|
+
|
|
21
|
+
from .core import ( # noqa: F401
|
|
22
|
+
CALIBRATION_KM,
|
|
23
|
+
Event,
|
|
24
|
+
Grade,
|
|
25
|
+
Lookup,
|
|
26
|
+
OutOfRange,
|
|
27
|
+
Tables,
|
|
28
|
+
load_tables,
|
|
29
|
+
table,
|
|
30
|
+
)
|
|
31
|
+
from .ui import ( # noqa: F401
|
|
32
|
+
format_time,
|
|
33
|
+
parse_time,
|
|
34
|
+
ui_percent,
|
|
35
|
+
ui_seconds,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"__version__", "CALIBRATION_KM", "Event", "Grade", "Lookup", "OutOfRange", "Tables",
|
|
40
|
+
"load_tables", "table", "format_time", "parse_time", "ui_percent",
|
|
41
|
+
"ui_seconds",
|
|
42
|
+
]
|
brrag/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""brrag — grade a road-running performance by sex, age and distance.
|
|
3
|
+
|
|
4
|
+
Examples
|
|
5
|
+
--------
|
|
6
|
+
brrag 40:00 --age 40 --sex M --distance 10k
|
|
7
|
+
brrag 2:30:00 --age 75 --sex M --event Marathon --json
|
|
8
|
+
brrag 3:15:00 --age 52 --sex F --distance 42.195 --show-standard
|
|
9
|
+
brrag --list-events --sex F
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import re
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
21
|
+
|
|
22
|
+
from brrag import __version__ # noqa: E402
|
|
23
|
+
from brrag.core import DEFAULT_DATA, OutOfRange, load_tables # noqa: E402
|
|
24
|
+
from brrag.ui import format_time, parse_time, ui_percent # noqa: E402
|
|
25
|
+
|
|
26
|
+
MILE_KM = 1.609344
|
|
27
|
+
_KM_UNITS = {"k", "km", "kilometer", "kilometers", "kilometre", "kilometres"}
|
|
28
|
+
_MILE_UNITS = {"m", "mile", "miles"}
|
|
29
|
+
_DISTANCE_RE = re.compile(r"^(\d+(?:\.\d+)?)([a-z]*)$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_distance(text: str) -> float:
|
|
33
|
+
"""The distance grammar, the same one the Excel add-in uses.
|
|
34
|
+
|
|
35
|
+
<number> followed by k, km, kilometer(s), kilometre(s) -> kilometres
|
|
36
|
+
<number> followed by m, mile, miles -> miles
|
|
37
|
+
half marathon, marathon -> 21.0975, 42.195 km
|
|
38
|
+
a bare number is kilometres
|
|
39
|
+
|
|
40
|
+
Case is ignored, and every space, tab, newline and hyphen is removed before
|
|
41
|
+
comparison, so "10 K M", "Half-Marathon" and "6.2 miles" all work.
|
|
42
|
+
"""
|
|
43
|
+
key = "".join(ch for ch in text.lower() if not ch.isspace() and ch not in "-_")
|
|
44
|
+
if key == "halfmarathon":
|
|
45
|
+
return 21.0975
|
|
46
|
+
if key == "marathon":
|
|
47
|
+
return 42.195
|
|
48
|
+
m = _DISTANCE_RE.match(key)
|
|
49
|
+
if not m:
|
|
50
|
+
raise SystemExit(
|
|
51
|
+
f"unknown distance {text!r}. Use a number with a unit "
|
|
52
|
+
f"(10k, 10 km, 10 kilometres, 6.2 miles), half marathon, marathon, "
|
|
53
|
+
f"or a bare number of kilometres")
|
|
54
|
+
amount, unit = float(m.group(1)), m.group(2)
|
|
55
|
+
if amount <= 0:
|
|
56
|
+
raise SystemExit(f"distance must be positive, got {text!r}")
|
|
57
|
+
if unit in _KM_UNITS or unit == "":
|
|
58
|
+
return amount
|
|
59
|
+
if unit in _MILE_UNITS:
|
|
60
|
+
return amount * MILE_KM
|
|
61
|
+
raise SystemExit(
|
|
62
|
+
f"unknown unit {unit!r} in {text!r}. Use k, km, kilometer(s), "
|
|
63
|
+
f"kilometre(s), m, mile or miles")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: list[str] | None = None) -> int:
|
|
68
|
+
ap = argparse.ArgumentParser(
|
|
69
|
+
prog="brrag",
|
|
70
|
+
description="Age-grade a road-running performance (USATF MLDR tables).",
|
|
71
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
72
|
+
epilog=__doc__,
|
|
73
|
+
)
|
|
74
|
+
ap.add_argument("--version", action="version",
|
|
75
|
+
version=f"brrag {__version__}")
|
|
76
|
+
ap.add_argument("time", nargs="?", help="performance: h:mm:ss, mm:ss or seconds")
|
|
77
|
+
ap.add_argument("--age", type=float, help="age in years at race day (fractional ok)")
|
|
78
|
+
ap.add_argument("--sex", "-s", default="M", choices=["M", "F", "m", "f"],
|
|
79
|
+
help="M or F (default M)")
|
|
80
|
+
ap.add_argument("--distance", "-d", help="distance: 10k, 10 km, 6.2 miles, half marathon, "
|
|
81
|
+
"marathon, or a bare number of km")
|
|
82
|
+
ap.add_argument("--event", "-e", help="exact table event name, e.g. 10kmRoad")
|
|
83
|
+
ap.add_argument("--year", type=int, default=2025, help="table year (default 2025)")
|
|
84
|
+
ap.add_argument("--file", type=Path, default=DEFAULT_DATA, help="tables JSON")
|
|
85
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
86
|
+
ap.add_argument("--list-events", action="store_true", help="list table events")
|
|
87
|
+
ap.add_argument("--show-standard", action="store_true",
|
|
88
|
+
help="also show open/age standard and time at 100%%")
|
|
89
|
+
ap.add_argument("--ui-parity", action="store_true",
|
|
90
|
+
help="reproduce the web calculator's displayed percentage "
|
|
91
|
+
"(rounds the age standard first)")
|
|
92
|
+
args = ap.parse_args(argv)
|
|
93
|
+
|
|
94
|
+
tables = load_tables(args.file)
|
|
95
|
+
sex = args.sex.upper()
|
|
96
|
+
key = f"USATF MLDR {args.year} {sex}"
|
|
97
|
+
if key not in tables:
|
|
98
|
+
raise SystemExit(f"no table {key!r}; have: {sorted(tables)}")
|
|
99
|
+
t = tables[key]
|
|
100
|
+
|
|
101
|
+
if args.list_events:
|
|
102
|
+
rows = [{"event": e.name, "distance_km": e.distance_km,
|
|
103
|
+
"open_standard_s": e.standard_s,
|
|
104
|
+
"open_standard": format_time(e.standard_s, 0),
|
|
105
|
+
"calibration": e.is_calibration}
|
|
106
|
+
for e in t.events]
|
|
107
|
+
if args.json:
|
|
108
|
+
print(json.dumps(rows, indent=1))
|
|
109
|
+
else:
|
|
110
|
+
for r in rows:
|
|
111
|
+
tag = " (fitted)" if r["calibration"] else ""
|
|
112
|
+
print(f"{r['event']:<12} {r['distance_km']:>9.4f} km "
|
|
113
|
+
f"open {r['open_standard']:>9}{tag}")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
if args.time is None or args.age is None:
|
|
117
|
+
ap.error("a time and --age are required")
|
|
118
|
+
if args.distance is None and args.event is None:
|
|
119
|
+
ap.error("pass --distance or --event")
|
|
120
|
+
|
|
121
|
+
time_s = parse_time(args.time)
|
|
122
|
+
dist_km = parse_distance(args.distance) if args.distance else None
|
|
123
|
+
try:
|
|
124
|
+
g = t.grade(sex, args.age, time_s, dist_km=dist_km, event=args.event)
|
|
125
|
+
except OutOfRange as exc:
|
|
126
|
+
raise SystemExit(f"error: {exc}") from None
|
|
127
|
+
|
|
128
|
+
percent = ui_percent(time_s, g.age_standard_s) if args.ui_parity else g.percent
|
|
129
|
+
out = {
|
|
130
|
+
"sex": sex,
|
|
131
|
+
"age": args.age,
|
|
132
|
+
"year": args.year,
|
|
133
|
+
"event": args.event,
|
|
134
|
+
"distance_km": dist_km,
|
|
135
|
+
"time_s": time_s,
|
|
136
|
+
"time": format_time(time_s, 1),
|
|
137
|
+
"factor": round(g.factor, 4),
|
|
138
|
+
"age_graded_s": g.age_graded_s,
|
|
139
|
+
"age_graded": format_time(g.age_graded_s, 1),
|
|
140
|
+
"percent": round(percent, 2),
|
|
141
|
+
"open_standard_s": g.standard_s,
|
|
142
|
+
"open_standard": format_time(g.standard_s, 0),
|
|
143
|
+
"age_standard_s": g.age_standard_s,
|
|
144
|
+
"age_standard": format_time(g.age_standard_s, 1),
|
|
145
|
+
"ui_parity": args.ui_parity,
|
|
146
|
+
}
|
|
147
|
+
if args.json:
|
|
148
|
+
print(json.dumps(out, indent=1))
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
if args.show_standard:
|
|
152
|
+
print(f"open standard {out['open_standard']:>10}")
|
|
153
|
+
print(f"age standard {out['age_standard']:>10} (scores 100.0%)")
|
|
154
|
+
print(f"factor {out['factor']:>10.4f}")
|
|
155
|
+
print(f"age-graded {out['age_graded']:>10}")
|
|
156
|
+
print(f"age grade {out['percent']:>10.2f}%")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
sys.exit(main())
|
brrag/core.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Reference implementation of USATF MLDR road age-grading (2025 tables).
|
|
2
|
+
|
|
3
|
+
Mirrors the arithmetic of the published calculator
|
|
4
|
+
(https://www.howardgrubb.co.uk/athletics/mldrroad25.html) exactly, including
|
|
5
|
+
its rounding and clamping behaviour. See docs/03-grading-algorithm.md.
|
|
6
|
+
|
|
7
|
+
Public API
|
|
8
|
+
----------
|
|
9
|
+
load_tables(path) -> Tables
|
|
10
|
+
Tables.lookup(sex, age, dist_km) -> Lookup(factor, standard_s)
|
|
11
|
+
Tables.grade(sex, age, dist_km, time_s) -> Grade
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterable
|
|
21
|
+
|
|
22
|
+
def _default_data() -> Path:
|
|
23
|
+
"""Where the tables live.
|
|
24
|
+
|
|
25
|
+
An installed package carries its own copy (brrag/data/mldr_road.json); a
|
|
26
|
+
source checkout keeps them in the repo's data/ directory, derived from the
|
|
27
|
+
published calculator page by tools/extract_tables.py. The packaged copy wins
|
|
28
|
+
so a wheel never reads outside itself.
|
|
29
|
+
"""
|
|
30
|
+
packaged = Path(__file__).resolve().parent / "data" / "mldr_road.json"
|
|
31
|
+
if packaged.exists():
|
|
32
|
+
return packaged
|
|
33
|
+
return Path(__file__).resolve().parent.parent / "data" / "mldr_road.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
DEFAULT_DATA = _default_data()
|
|
37
|
+
|
|
38
|
+
#: Key ("calibration") distances: the only ones actually fitted. Everything
|
|
39
|
+
#: else in the published table is logarithmically interpolated from these.
|
|
40
|
+
CALIBRATION_KM = (5.0, 10.0, 21.0975, 42.195)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OutOfRange(ValueError):
|
|
44
|
+
"""Raised when the request falls outside what the tables can answer."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Event:
|
|
49
|
+
name: str
|
|
50
|
+
distance_km: float
|
|
51
|
+
standard_s: float
|
|
52
|
+
factors: tuple[float, ...]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_calibration(self) -> bool:
|
|
56
|
+
return any(math.isclose(self.distance_km, d, rel_tol=0, abs_tol=1e-6)
|
|
57
|
+
for d in CALIBRATION_KM)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Lookup:
|
|
62
|
+
factor: float
|
|
63
|
+
standard_s: float
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def age_standard_s(self) -> float:
|
|
67
|
+
"""Time that scores 100% for this age (the 'age standard')."""
|
|
68
|
+
return self.standard_s / self.factor
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Grade:
|
|
73
|
+
age_graded_s: float
|
|
74
|
+
percent: float
|
|
75
|
+
factor: float
|
|
76
|
+
standard_s: float
|
|
77
|
+
age_standard_s: float
|
|
78
|
+
predicted_at_standard_s: float # time that would score 100%
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Tables:
|
|
82
|
+
"""One year/authority/sex set of factors."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, meta: dict, ages: Iterable[int], events: Iterable[Event]):
|
|
85
|
+
self.meta = meta
|
|
86
|
+
self.ages = list(ages)
|
|
87
|
+
self.events = sorted(events, key=lambda e: e.distance_km)
|
|
88
|
+
self._by_name = {e.name.casefold(): e for e in self.events}
|
|
89
|
+
for ev in self.events:
|
|
90
|
+
if len(ev.factors) != len(self.ages):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"{ev.name}: {len(ev.factors)} factors, {len(self.ages)} ages"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# -- metadata --------------------------------------------------------
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
return (f"<Tables {self.meta.get('authority')} {self.meta.get('year')} "
|
|
98
|
+
f"{self.meta.get('sex')} {len(self.events)} events>")
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def event_names(self) -> list[str]:
|
|
102
|
+
return [e.name for e in self.events]
|
|
103
|
+
|
|
104
|
+
def event(self, name: str) -> Event:
|
|
105
|
+
try:
|
|
106
|
+
return self._by_name[name.casefold()]
|
|
107
|
+
except KeyError:
|
|
108
|
+
raise OutOfRange(f"unknown event {name!r}; known: {self.event_names}") from None
|
|
109
|
+
|
|
110
|
+
# -- age axis --------------------------------------------------------
|
|
111
|
+
def _bracket_age(self, age: float,
|
|
112
|
+
policy: str = "ui") -> tuple[int, int, float]:
|
|
113
|
+
"""Return (i0, i1, proportion) with the published clamping rules.
|
|
114
|
+
|
|
115
|
+
Ages at or below the first table age use the first column; ages at or
|
|
116
|
+
above the last use the last column. Between ages it is linear
|
|
117
|
+
interpolation — with one faithful quirk of the calculator, kept here
|
|
118
|
+
so results match it exactly:
|
|
119
|
+
|
|
120
|
+
the page clamps as soon as its search index reaches the last row,
|
|
121
|
+
so any age in (99, 100] is given the age-100 factors instead of
|
|
122
|
+
being interpolated between 99 and 100. The low end (<= 5) has no
|
|
123
|
+
such gap. `policy="linear"` interpolates normally instead.
|
|
124
|
+
"""
|
|
125
|
+
ages = self.ages
|
|
126
|
+
n = len(ages)
|
|
127
|
+
if policy == "linear":
|
|
128
|
+
if age <= ages[0]:
|
|
129
|
+
return 0, 0, 0.0
|
|
130
|
+
if age >= ages[-1]:
|
|
131
|
+
return n - 1, n - 1, 0.0
|
|
132
|
+
else: # "ui": what the JavaScript does
|
|
133
|
+
if age <= ages[0]:
|
|
134
|
+
return 0, 0, 0.0
|
|
135
|
+
if age > ages[-2]:
|
|
136
|
+
return n - 1, n - 1, 0.0
|
|
137
|
+
# first index with ages[i] >= age
|
|
138
|
+
i = 0
|
|
139
|
+
while i < n and ages[i] < age:
|
|
140
|
+
i += 1
|
|
141
|
+
i0, i1 = i - 1, i
|
|
142
|
+
span = ages[i1] - ages[i0]
|
|
143
|
+
prop = 0.0 if span == 0 else (age - ages[i0]) / span
|
|
144
|
+
return i0, i1, prop
|
|
145
|
+
|
|
146
|
+
# -- distance axis ---------------------------------------------------
|
|
147
|
+
def _bracket_distance(self, dist_km: float,
|
|
148
|
+
policy: str = "strict") -> tuple[int, int, float]:
|
|
149
|
+
"""Return (e0, e1, proportion) over the *listed* event distances.
|
|
150
|
+
|
|
151
|
+
Interpolation is LINEAR IN DISTANCE (not in log-distance) between the
|
|
152
|
+
two nearest published distances — this is what the calculator does.
|
|
153
|
+
|
|
154
|
+
Outside the published range the calculator yields no result at all:
|
|
155
|
+
distances below the shortest event (1 mile) and above the longest
|
|
156
|
+
(200 km) both leave its fields blank. `policy` chooses what happens
|
|
157
|
+
instead:
|
|
158
|
+
|
|
159
|
+
"strict" (default) — raise OutOfRange, matching the calculator's
|
|
160
|
+
refusal to answer
|
|
161
|
+
"clamp" — use the nearest end row (what most ports do)
|
|
162
|
+
"""
|
|
163
|
+
events = self.events
|
|
164
|
+
dmin, dmax = events[0].distance_km, events[-1].distance_km
|
|
165
|
+
if dist_km < dmin or dist_km > dmax:
|
|
166
|
+
if policy == "clamp":
|
|
167
|
+
return (0, 0, 0.0) if dist_km < dmin else \
|
|
168
|
+
(len(events) - 1, len(events) - 1, 0.0)
|
|
169
|
+
raise OutOfRange(
|
|
170
|
+
f"{dist_km} km is outside the published range "
|
|
171
|
+
f"{dmin}-{dmax} km; the calculator returns no result "
|
|
172
|
+
f"(pass policy='clamp' to use the nearest event instead)"
|
|
173
|
+
)
|
|
174
|
+
# last index whose distance <= dist_km
|
|
175
|
+
i = len(events) - 1
|
|
176
|
+
for j, ev in enumerate(events):
|
|
177
|
+
if ev.distance_km > dist_km:
|
|
178
|
+
i = j - 1
|
|
179
|
+
break
|
|
180
|
+
i = max(i, 0)
|
|
181
|
+
j = min(i + 1, len(events) - 1)
|
|
182
|
+
if j == i:
|
|
183
|
+
return i, i, 0.0
|
|
184
|
+
d0, d1 = events[i].distance_km, events[j].distance_km
|
|
185
|
+
prop = (dist_km - d0) / (d1 - d0)
|
|
186
|
+
return i, j, prop
|
|
187
|
+
|
|
188
|
+
# -- core lookup -----------------------------------------------------
|
|
189
|
+
def lookup(self, sex: str, age: float, dist_km: float | None = None,
|
|
190
|
+
event: str | None = None, policy: str = "strict",
|
|
191
|
+
age_policy: str = "ui") -> Lookup:
|
|
192
|
+
"""Bilinear lookup of (factor, open standard) in age and distance."""
|
|
193
|
+
if event is not None:
|
|
194
|
+
e0 = e1 = self.event(event)
|
|
195
|
+
pd = 0.0
|
|
196
|
+
elif dist_km is not None:
|
|
197
|
+
i0, i1, pd = self._bracket_distance(dist_km, policy=policy)
|
|
198
|
+
e0, e1 = self.events[i0], self.events[i1]
|
|
199
|
+
else:
|
|
200
|
+
raise TypeError("pass either event= or dist_km=")
|
|
201
|
+
|
|
202
|
+
ia, ib, pa = self._bracket_age(age, policy=age_policy)
|
|
203
|
+
|
|
204
|
+
f00 = e0.factors[ia]
|
|
205
|
+
f01 = e0.factors[ib]
|
|
206
|
+
f10 = e1.factors[ia]
|
|
207
|
+
f11 = e1.factors[ib]
|
|
208
|
+
factor = (1.0 - pd) * (pa * f01 + (1.0 - pa) * f00) + \
|
|
209
|
+
pd * (pa * f11 + (1.0 - pa) * f10)
|
|
210
|
+
standard = (1.0 - pd) * e0.standard_s + pd * e1.standard_s
|
|
211
|
+
return Lookup(factor=factor, standard_s=standard)
|
|
212
|
+
|
|
213
|
+
def grade(self, sex: str, age: float, time_s: float,
|
|
214
|
+
dist_km: float | None = None, event: str | None = None,
|
|
215
|
+
policy: str = "strict", age_policy: str = "ui") -> Grade:
|
|
216
|
+
"""Grade a performance. `time_s` in seconds; the only unit that matters."""
|
|
217
|
+
if time_s <= 0:
|
|
218
|
+
raise ValueError("time must be positive")
|
|
219
|
+
lk = self.lookup(sex, age, dist_km=dist_km, event=event, policy=policy,
|
|
220
|
+
age_policy=age_policy)
|
|
221
|
+
if lk.factor <= 0:
|
|
222
|
+
raise OutOfRange("non-positive age factor; performance not gradable")
|
|
223
|
+
astd = lk.age_standard_s
|
|
224
|
+
return Grade(
|
|
225
|
+
age_graded_s=time_s * lk.factor,
|
|
226
|
+
percent=100.0 * astd / time_s,
|
|
227
|
+
factor=lk.factor,
|
|
228
|
+
standard_s=lk.standard_s,
|
|
229
|
+
age_standard_s=astd,
|
|
230
|
+
predicted_at_standard_s=astd,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def time_for_percent(self, sex: str, age: float, percent: float,
|
|
234
|
+
dist_km: float | None = None,
|
|
235
|
+
event: str | None = None,
|
|
236
|
+
policy: str = "strict") -> float:
|
|
237
|
+
"""Inverse: the time that would score `percent` (the calculator's
|
|
238
|
+
'Result' button)."""
|
|
239
|
+
lk = self.lookup(sex, age, dist_km=dist_km, event=event, policy=policy)
|
|
240
|
+
return lk.age_standard_s / (percent / 100.0)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def load_tables(path: str | Path = DEFAULT_DATA) -> dict[str, Tables]:
|
|
244
|
+
"""Load every table in the JSON data file, keyed 'AUTHORITY YEAR SEX'."""
|
|
245
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
246
|
+
out: dict[str, Tables] = {}
|
|
247
|
+
for key, t in raw["tables"].items():
|
|
248
|
+
events = [
|
|
249
|
+
Event(name=e["event"], distance_km=e["distance_km"],
|
|
250
|
+
standard_s=e["open_standard_s"], factors=tuple(e["factors"]))
|
|
251
|
+
for e in t["events"]
|
|
252
|
+
]
|
|
253
|
+
out[key] = Tables(t, t["ages"], events)
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def table(year: int = 2025, sex: str = "M", authority: str | None = None,
|
|
258
|
+
path: str | Path = DEFAULT_DATA) -> Tables:
|
|
259
|
+
"""Convenience accessor: table(2025, "F"), table(2010, "M").
|
|
260
|
+
|
|
261
|
+
`authority` defaults to whichever one published that year - USATF MLDR for
|
|
262
|
+
2020 and 2025, WMA for 2010 and 2015 - so a caller needs only the year.
|
|
263
|
+
"""
|
|
264
|
+
tables = load_tables(path)
|
|
265
|
+
if authority is not None:
|
|
266
|
+
return tables[f"{authority} {year} {sex.upper()}"]
|
|
267
|
+
suffix = f" {year} {sex.upper()}"
|
|
268
|
+
for key, tbl in tables.items():
|
|
269
|
+
if key.endswith(suffix):
|
|
270
|
+
return tbl
|
|
271
|
+
raise OutOfRange(
|
|
272
|
+
f"no {sex.upper()} table for {year}; known editions: "
|
|
273
|
+
f"{sorted({k.rsplit(' ', 2)[1] for k in tables})}")
|