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 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})}")