calcpace 1.18.1 → 2.0.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70f0253d8a81513d2f48082bc39c96a1c9c324fc29ffb7f64ffde0590e5134c7
4
- data.tar.gz: 2add58bc0d0f904e1e4cf6e08379e495bdeef2ee74bdcb068b973e4e4f0c995a
3
+ metadata.gz: d60f32c2e712a9f2c4ab9a1a8724ac9b932838acab9bbe1db333f557d0cf1797
4
+ data.tar.gz: 6bd621feb8849df93ab197bbe77795da67878ddfa46ee493acd8e2ffe53a651e
5
5
  SHA512:
6
- metadata.gz: 9abfbeae4e5cccd4c9634f0abcfa7bc51a29d317f47db86aceffd4afe04b17a629f0075440cc3729fd0893d614b007d16d3f5318ee3e4c671226832fb2373327
7
- data.tar.gz: 8d6f1e3e2cd0e10a0436bbdd9ea7ebfc1f0a6973d2e69a83c0419c17ce0ae12c554790d1f80b5fedf2ad047ec6c5092c03bb58710da2bdc76434b71ac9ccc071
6
+ metadata.gz: f6b76162fc0e1e575c1c1fc8d06c782cf603775c374c1a75dc5bacab50e2958a99c18ba47e429b65bc154f72d12d127e7c0fae99f9d59cd4dc2d3a54541fe335
7
+ data.tar.gz: 260a67baea8e765b473261c0c24bfc12af6672404713f92bec266c070c5c0ae5beaa7da4ef9b3714d9286f868158fddca5c74e9bb0da182d78bd393947191fdb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,358 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.0] - 2026-10-02
11
+
12
+ A major release. Four physiological models gave unrealistic numbers and now
13
+ give different ones (Cameron prediction, altitude, race splits, heat), the
14
+ marathon pace band and age grading move to their proper sources, input
15
+ validation is stricter, and there are new personalized predictions,
16
+ humidity, grade-adjusted pace and VO2max norms. Method names, signatures and
17
+ return shapes are unchanged except where listed under Breaking; the
18
+ `environmental_factors.yml` keys are unchanged.
19
+
20
+ ### Breaking
21
+ - **Invalid clocks raise, everywhere.** Every method that takes a time or pace
22
+ string now validates it: `convert_to_seconds` itself, and so `checked_*`,
23
+ `race_time`/`race_pace` (and `_clock`), `race_splits` (`target_time:`),
24
+ `convert_pace`, `pace_km_to_mi`, `pace_mi_to_km`,
25
+ `predict_time`/`predict_pace` (and `_clock`), `equivalent_performance`,
26
+ `predict_time_adjusted`, every Cameron method, `riegel_exponent`,
27
+ `predict_time_personal`, `predict_marathon_from_training`,
28
+ `estimate_vo2max`, `estimate_detailed_vo2max`, `training_paces_from_race`,
29
+ `age_grade`, `stride_length`, `cadence_for_stride` and `grade_adjusted_pace`
30
+ (and `_clock`). Before, `check_time` accepted any two digits per field
31
+ (`'05:99'`, `'1:60:00'`), and the methods that did not call it turned such
32
+ strings — or garbage like `'abc'` — into the wrong number of seconds,
33
+ `convert_to_seconds` returning `0` for anything it could not split and
34
+ dropping the sign of `'-0:40'`.
35
+ - Accepted — every clock the gem writes (`Checker::CLOCK_FORMAT`):
36
+ `H:MM:SS` with any number of hour digits (`'400:00:00'`); `M:SS` with any
37
+ number of minute digits, counting past the hour (`'75:00'`, `'123:45'`); the
38
+ `'D HH:MM:SS'` day prefix of `convert_to_clocktime` (`'1 03:46:40'`); and a
39
+ leading `-` (`track_splits` writes `'-0:40'` for a backwards split;
40
+ `convert_to_seconds` returns `-40`). A few clocks the gem never writes
41
+ parse too, such as `'-1 03:46:40'`, `'0:00:00'` or `'000000000:00'`.
42
+ - Rejected with `Calcpace::InvalidTimeFormatError`: seconds that are not two
43
+ digits below 60; minutes that are not two digits below 60 when hours are
44
+ given (`'1:60:00'`, `'1:5:00'`); after a day prefix, hours that are not two
45
+ digits below 24 (`'1 3:46:40'`, `'1 24:00:00'`); a `+` sign, blanks,
46
+ surrounding whitespace, non-ASCII digits, more than three fields, strings
47
+ in an invalid or non-ASCII-compatible encoding (broken UTF-8, UTF-16,
48
+ UTF-32), and anything that is not a String.
49
+ - A negative clock parses, but every method that needs a positive time or
50
+ pace rejects it with `Calcpace::NonPositiveInputError`, as it does a
51
+ clock too large for a Float (see Fixed). Numeric (seconds) inputs are
52
+ unaffected.
53
+ - **Cameron predictions are limited to 100 km.** `predict_time_cameron`,
54
+ `predict_time_cameron_clock`, `predict_pace_cameron`,
55
+ `predict_pace_cameron_clock` and `predict_time_cameron_adjusted` raise
56
+ `ArgumentError` when either distance exceeds `CAMERON_MAX_DISTANCE_KM`
57
+ (100 km, which keeps the standard `'100k'` race usable). Cameron's model is
58
+ fitted from 800 m to the marathon and its `f(d)` crosses zero near 445 km:
59
+ without the limit a 500 km target got a negative time.
60
+ - **Removed `CameronPredictor::CAMERON_A`, `CAMERON_B` and `CAMERON_C`.** They
61
+ described the wrong formula (see Changed). The model's constants are now
62
+ `CAMERON_CONSTANT`, `CAMERON_LINEAR_COEFFICIENT`, `CAMERON_POWER_COEFFICIENT`
63
+ and `CAMERON_POWER_EXPONENT`.
64
+ - **`AgeGrading::WMA_DATA` lost its track keys and changed its age range.**
65
+ Each sex now holds only the road distances the gem grades — `"5000"`,
66
+ `"10000"`, `"21097"` and `"42195"`; code that read `WMA_DATA["M"]["1500"]`
67
+ (or `"3000"`) gets `nil` / `KeyError`. Its factor tables run from age 18 to
68
+ 100 (they were 30–110). `table_version` is now
69
+ `"MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"` (was
70
+ `"WMA_2023_ONE_YEAR_FACTORS_V1"`), and the data files were renamed to
71
+ `lib/calcpace/data/mldr_2025_road.yml` and
72
+ `lib/calcpace/data/mldr_2025_road_open_standards.yml`. `DATA_PATH`,
73
+ `OPEN_STANDARDS_DATA_PATH`, `WMA_DATA`, `OPEN_STANDARDS_DATA` and
74
+ `TABLE_VERSION` keep their names.
75
+
76
+ ### Changed (numbers)
77
+ - **Cameron prediction uses Dave Cameron's actual model.** The previous
78
+ constants (`a + b·e^(−d/c)` with a = 0.000495, b = 0.000985, c = 1.4485)
79
+ were not Cameron's formula and were far more optimistic than Riegel for the
80
+ marathon, while the real model is more conservative. The Cameron methods now
81
+ use his velocity-ratio function with distances in metres, as in his own
82
+ metric version (t-and-f mailing list, 20 Jun 2001) and the had2know.org
83
+ calculator: `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905`,
84
+ `T2 = T1 · (D2/D1) · f(D1)/f(D2)`. Distances are still passed in km or as
85
+ race names.
86
+ - **Altitude no longer jumps at 914 m and no longer stops at 2438 m.** The
87
+ penalty starts at 300 m (new `300: 0.0` point) and ramps linearly to the
88
+ first NCAA point (914.4 m → 1.41%) instead of jumping from 0% at 914 m to
89
+ 1.41% at 915 m. The NCAA points up to 2438.4 m (5.90%) are unchanged. Above
90
+ it, where everything used to be capped at 5.90%, three points come from a
91
+ quadratic fit to the NCAA table (`p = 0.3647·x² + 1.9482·x`,
92
+ `x = km − 0.3`): 3000 m → 7.92%, 3500 m → 9.97%, 4000 m → 12.2% (capped
93
+ there). The redundant `0: 0.0` point is gone.
94
+ - **Negative and positive race splits are ±1% per half instead of ±4%.** A
95
+ 3:00:00 marathon with `strategy: :negative` used to go through halfway in
96
+ 1:33:36 (a 7-minute negative split); it now splits 1:30:54 + 1:29:06.
97
+ - **Heat: base curve and duration factor fitted jointly to marathon data, and
98
+ capped at 35 °C.** The 60-minute base was 2.8 / 4.3 / 6.5% at
99
+ 20 / 25 / 30 °C, flat from 30 °C up (35 °C and 40 °C read like 30 °C), and
100
+ the duration factor was 1.0× (60 min) → 3.0× (3 h) → 4.5× (4 h), its 3 h
101
+ point justified by Ely 2007 percentages that the paper's abstract does not
102
+ contain. Both are now fitted to El Helou et al. (2012, PLoS One
103
+ 7(5):e37407, Table S3; 1.79 M finishers of six majors, 2001–2010):
104
+ 1. speed loss at 15, 20 and 25 °C, straight line between the table's points
105
+ (each group's optimum −10 … +20 °C);
106
+ 2. time penalty against 15 °C, where the gem's curve is zero:
107
+ `P = ((1 − loss15) / (1 − lossT) − 1) × 100`;
108
+ 3. **base shape**: P25/P20 is 2.70–2.97 in every group, so `P ∝ (T − 15)^p`
109
+ with `p = log2(P25/P20)`; the sex-weighted mean (each sex half the
110
+ weight) is 1.497 → **1.5**. Base = `4.3 · ((T − 15)/10)^1.5`, keeping the
111
+ 25 °C / 60-minute anchor of 4.3%, stored every 2.5 °C from 15 to 40 °C
112
+ (linear interpolation stays within 0.08 points of the curve): 0, 0.54,
113
+ 1.52, 2.79, 4.3, 6.01, 7.9, 9.95, 12.16, then 12.16 and 12.16 at 37.5
114
+ and 40 °C. **Capped at 35 °C**: 35–40 °C and hotter all read like 35 °C,
115
+ because above 25 °C the curve is an extrapolation (El Helou's hottest
116
+ race was 25.2 °C) and the uncapped law (14.51 at 37.5 °C, 17.0 at 40 °C)
117
+ gave 47.77% for 4 h at 40 °C;
118
+ 4. ratio = P ÷ base(T); finish time = 42195 m ÷ the group's speed at its
119
+ optimum;
120
+ 5. **duration factor**: weighted least squares over the 15 ratios (men P1 at
121
+ 25 °C is beyond the table), each sex half the weight, 0.5× (≤30 min) and
122
+ 1.0× (60 min) kept, 180 and 240 min free, flat after 240: 1.761 / 2.814 →
123
+ **1.76× at 3 h, 2.81× at 4 h** (1.38× at 2 h on the straight line). A
124
+ free 150-min point cut the weighted residual by 1%; a free 210-min point
125
+ made the curve non-monotonic (2.97 > 2.73 at 240). Neither was kept.
126
+
127
+ | Group | Finish | loss@15 | loss@20 | loss@25 | P20 | P25 | P25/P20 | ratio @20 | ratio @25 |
128
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
129
+ | men P1 | 2:41 | 1.88 | 3.93 | n/a | 2.14 | n/a | n/a | 1.41 | n/a |
130
+ | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 2.89 | 1.59 | 1.63 |
131
+ | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 2.78 | 2.93 | 2.89 |
132
+ | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 2.86 | 3.40 | 3.43 |
133
+ | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 2.70 | 1.99 | 1.90 |
134
+ | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 2.73 | 2.23 | 2.16 |
135
+ | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 2.97 | 3.57 | 3.74 |
136
+ | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 2.78 | 2.33 | 2.29 |
137
+
138
+ Losses and P in %. With the new base each group's ratio is almost the same
139
+ at 20 and 25 °C, so `base(T) × duration_factor(seconds)` fits; the weighted
140
+ residual in penalty points drops from 18.3 (linear base, 1.24×/2.18×) to
141
+ 8.8, and what remains is mostly sex (see Known limitations). Ely et al.
142
+ (2007) stays a qualitative source (top men 1.7 / 2.5 / 3.3 / 4.5% off the
143
+ course record across WBGT 5–10 … 20–25 °C, i.e. +2.8 points); the model
144
+ gives a 2:10 effort 4.03% at 22.5 °C against 0% at 7.5 °C, a little above
145
+ that for elite runners. The duration points live in the new
146
+ `EnvironmentalAdjuster::HEAT_DURATION_FACTORS`; `duration_factor(time_seconds)`
147
+ is unchanged.
148
+
149
+ | Heat penalty (%), 1.18.1 → 2.0.0 | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min |
150
+ | --- | --- | --- | --- | --- | --- | --- |
151
+ | 20 °C | 1.4 → 0.76 | 2.8 → 1.52 | 5.6 → 2.1 | 8.4 → 2.68 | 12.6 → 4.27 | 12.6 → 4.27 |
152
+ | 25 °C | 2.15 → 2.15 | 4.3 → 4.3 | 8.6 → 5.93 | 12.9 → 7.57 | 19.35 → 12.08 | 19.35 → 12.08 |
153
+ | 30 °C | 3.25 → 3.95 | 6.5 → 7.9 | 13.0 → 10.9 | 19.5 → 13.9 | 29.25 → 22.2 | 29.25 → 22.2 |
154
+ | 35 °C | 3.25 → 6.08 | 6.5 → 12.16 | 13.0 → 16.78 | 19.5 → 21.4 | 29.25 → 34.17 | 29.25 → 34.17 |
155
+ | 40 °C | 3.25 → 6.08 | 6.5 → 12.16 | 13.0 → 16.78 | 19.5 → 21.4 | 29.25 → 34.17 | 29.25 → 34.17 |
156
+
157
+ - **The marathon pace band ends at the runner's predicted marathon pace.**
158
+ Daniels' M pace is the predicted marathon race pace, but
159
+ `training_paces(50)[:marathon]` ran from 4:50 to 4:25/km (75–84% VO2max)
160
+ while the VDOT marathon prediction for VO2max 50 is 3:10:39, 4:31/km. The
161
+ fast end now comes from `predict_time_from_vo2max(vo2max, 'marathon')`
162
+ (0.800–0.849 of VO2max across VO2max 10–100; 0.805 at 30, 0.830 at 70); the
163
+ slow end stays at 75%. Beyond VO2max 10–100 the race-pace fraction of the
164
+ nearest bound is used, so `training_paces` still accepts any positive
165
+ VO2max. `TRAINING_INTENSITIES` stays all-numeric
166
+ (`marathon: { low: 0.75, high: 0.84 }`, the nominal upper bound); the new
167
+ `PREDICTED_RACE_PACE_ZONES` (`%i[marathon]`) names the zones whose fast end
168
+ is the predicted race pace. The marathon and threshold bands no longer
169
+ overlap below VO2max ~69.5 (the threshold band starts at 0.83): M pace is
170
+ slower than T pace, as in Daniels. At VO2max 70 they touch (3:24).
171
+
172
+ | VO2max | M band before | M band after | Predicted marathon pace |
173
+ | --- | --- | --- | --- |
174
+ | 30 | 7:15–6:38/km | 7:15–6:52/km | 6:52/km |
175
+ | 40 | 5:47–5:17/km | 5:47–5:27/km | 5:27/km |
176
+ | 50 | 4:50–4:25/km | 4:50–4:31/km | 4:31/km |
177
+ | 60 | 4:11–3:49/km | 4:11–3:52/km | 3:52/km |
178
+ | 70 | 3:41–3:22/km | 3:41–3:24/km | 3:24/km |
179
+
180
+ - **Age grading uses the 2025 road tables.** Age factors and open standards
181
+ come from Alan Jones' 2025 road age-grading tables, approved on 2025-01-10
182
+ by the USATF Masters Long Distance Running Council
183
+ ([source spreadsheets](https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files):
184
+ `MaleRoadStd2025.xlsx`, `FemaleRoadStd2025.xlsx`). The previous data,
185
+ despite the `wma_2023_road.yml` name, came from the WMA 2023 **track and
186
+ field** tables: track open standards (5000 m 12:35 / 14:06, 10 000 m
187
+ 26:11 / 29:01), an outdated women's marathon standard (2:14:04) and no
188
+ factors under age 30, so road age grades were off by about 1–3%. New open
189
+ standards — men: 5K 12:49, 10K 26:24, half 57:31, marathon 2:00:35; women:
190
+ 5K 13:54, 10K 28:46, half 1:02:52, marathon 2:09:56. There is one factor
191
+ per year of age from 18 to 100: runners under 30 get the table's real
192
+ factors instead of 1.0 (a male 18-year-old at 5K: 0.9995; a female
193
+ 30-year-old at 5K: 0.9959), and ages 101 and over use the age-100 factor.
194
+ Category labels are unchanged.
195
+
196
+ | Case | 1.18.1 (WMA 2023 track) | 2.0.0 (2025 road) | Official 2025 |
197
+ | --- | --- | --- | --- |
198
+ | Male 40, marathon 3:30:00 | 58.8% (factor 0.9804) | 58.7% (0.9783) | 58.7% |
199
+ | Female 50, marathon 4:00:00 | 62.7% (0.8915) | 60.2% (0.8998) | 60.2% |
200
+ | Female 30, 5K 25:00 | 56.4% (1.0) | 55.8% (0.9959) | 55.8% |
201
+ | Male 60, half 1:50:00 | 63.3% (0.8264) | 64.7% (0.8082) | 64.7% |
202
+ | Male 55, 10K 45:00 | 69.0% (0.8438) | 68.9% (0.8511) | 68.9% |
203
+
204
+ "Official" is age standard / time from the spreadsheets' `AgeStdSec` sheet,
205
+ at one decimal.
206
+
207
+ Summary of the other models:
208
+
209
+ | Case | 1.18.1 | 2.0.0 |
210
+ | --- | --- | --- |
211
+ | Cameron 10K 42:00 → marathon | 02:57:34 | 03:16:46 (Riegel 03:13:12) |
212
+ | Cameron 5K 20:00 → marathon | 02:59:25 | 03:15:11 (Riegel 03:11:49) |
213
+ | Cameron 5K 20:00 → 10K | 00:42:26 | 00:41:39 |
214
+ | Cameron 7.79 km 26:59 → half marathon | 01:13:44 | 01:17:26 |
215
+ | Altitude 500 m | 0.0% | 0.46% |
216
+ | Altitude 760 m (São Paulo) | 0.0% | 1.06% |
217
+ | Altitude 914 m / 915 m | 0.0% / 1.41% | 1.41% / 1.41% |
218
+ | Altitude 2800 m | 5.9% | 7.2% |
219
+ | Altitude 3600 m | 5.9% | 10.42% |
220
+ | Heat 20 °C, 60 min | 2.8% | 1.52% |
221
+ | Heat 30 °C, 60 min | 6.5% | 7.9% |
222
+ | Heat 35 °C / 40 °C, 60 min | 6.5% | 12.16% |
223
+ | Heat 25 °C, 2 h / 3 h / 4 h | 8.6% / 12.9% / 19.35% | 5.93% / 7.57% / 12.08% |
224
+ | Heat 30 °C, 4 h | 29.25% | 22.2% |
225
+ | Heat 35 °C / 40 °C, 4 h | 29.25% | 34.17% |
226
+ | Marathon band, VO2max 50 | 4:50–4:25/km | 4:50–4:31/km |
227
+ | Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 |
228
+ | Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 |
229
+
230
+ ### Added
231
+ - **Humidity in the heat penalty.** `calculate_penalty` — and therefore
232
+ `adjust_time`, `normalize_time`, `predict_time_adjusted` and
233
+ `predict_time_cameron_adjusted`, which forward their options — accepts
234
+ `humidity:` (relative humidity, 0–100 %) or `dew_point:` (in
235
+ `temperature_unit`). 30 °C dry and 30 °C at 80% (typical of coastal Brazil)
236
+ used to get the same penalty. The temperature is replaced by an effective
237
+ temperature: the air temperature that, at `REFERENCE_HUMIDITY` (50%), has
238
+ the same simplified WBGT (Australian Bureau of Meteorology:
239
+ `WBGT = 0.567·Ta + 0.393·e + 3.94`, `e` = vapour pressure in hPa) as the
240
+ real temperature and humidity, solved exactly by bisection. The heat curve
241
+ is read at the unrounded effective temperature, so `humidity: 50` gives
242
+ exactly the temperature-only numbers: 50% is the humidity at which that
243
+ WBGT equals the air temperature between 20 °C and 35 °C (51–56%), i.e. how
244
+ the temperature-only points already read. `factors` gains
245
+ `:effective_temperature_celsius` (rounded to 2 decimals) when humidity or
246
+ dew point is given. `ArgumentError` for humidity outside 0–100, NaN,
247
+ Complex or non-numeric; a dew point above the temperature, below −100 °C or
248
+ not a finite real number; both keywords together; or either without a
249
+ finite temperature.
250
+
251
+ | 30 °C at | Effective temperature | 60 min | 4 h |
252
+ | --- | --- | --- | --- |
253
+ | 30% RH | 26.69 °C | 5.46% | 15.34% |
254
+ | 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% |
255
+ | 70% RH | 33.07 °C | 10.46% | 29.39% |
256
+ | 90% RH | 35.94 °C (capped at 35 °C) | 12.16% | 34.17% |
257
+
258
+ - **Marathon from training volume.**
259
+ `predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km)`
260
+ predicts a marathon from the mean weekly distance and mean training pace of
261
+ the 8 weeks before the race, with Tanda (2011), *Journal of Human Sport and
262
+ Exercise* 6(3):511–520: `Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P`.
263
+ Returns `:time`, `:time_clock`, `:pace`, `:pace_clock` (in `unit`, `:km` or
264
+ `:mi`), `:within_validated_range` and `:out_of_range`, which lists any of
265
+ `:weekly_distance` (sample: 40.4–110.7 km/week), `:training_pace`
266
+ (253.3–330.6 s/km) and `:marathon_time` (167–216 min) that fall outside the
267
+ paper's sample. Out of range is a flag, not an error.
268
+
269
+ ```ruby
270
+ calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:time_clock] # => "03:19:41"
271
+ ```
272
+ - **Personal Riegel exponent.** `riegel_exponent(race1, time1, race2, time2)`
273
+ fits `ln(t2/t1) / ln(d2/d1)` to two performances, and
274
+ `predict_time_personal(race1, time1, race2, time2, to_race)` predicts with
275
+ it. A target between the two races is interpolated along the curve through
276
+ both, with the raw exponent (never clamped, independent of argument order);
277
+ a target outside the pair is extrapolated from the closer performance in
278
+ log-distance, with the exponent clamped to 1.01–1.20. Returns `:time`,
279
+ `:time_clock`, `:exponent`, `:raw_exponent` and `:clamped`; an exponent
280
+ that needed clamping usually means one of the races was not all-out.
281
+
282
+ ```ruby
283
+ calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock] # => "03:38:03"
284
+ ```
285
+ - **Grade-adjusted pace** from the energy cost of running on gradients of
286
+ Minetti et al. (2002), J Appl Physiol 93:1039–1046:
287
+ `grade_adjustment_factor(grade)` (Cr(i)/Cr(0), grade as a fraction, clamped
288
+ to the measured ±0.45), `grade_adjusted_pace(pace, grade, unit: :km)` and
289
+ `grade_adjusted_pace_clock(pace, grade, unit: :km, compact: false)`.
290
+ - **`track_grade_adjusted_splits(points, split_km = 1.0, compact: false)`**:
291
+ the `track_splits` splits with a `:gap` pace per split, computed segment by
292
+ segment from `:ele`. Grades are measured over segments of at least 100 m of
293
+ horizontal distance so GPS elevation noise does not become fake climbing;
294
+ stretches without `:ele` (or with a non-finite one), and stretches with
295
+ elevation too short to grade, are flat. `track_splits` output is unchanged.
296
+ - **VO2max norms by age and sex** from the FRIEND registry (Kaminsky, Arena &
297
+ Myers, Mayo Clin Proc 2015;90(11):1515–1523, Table 3: treadmill, measured
298
+ VO2max), stored in `lib/calcpace/data/friend_2015_vo2max_percentiles.yml`:
299
+ - `vo2max_label(value, age: nil, sex: nil)` — optional keywords; with both,
300
+ the label comes from the percentile among the same sex and age decade
301
+ (≥95th Elite, ≥90th Excellent, ≥75th Very Good, ≥50th Good, ≥25th Fair,
302
+ else Beginner). Without them the fixed thresholds and labels are
303
+ unchanged.
304
+ - `vo2max_percentile(value, age:, sex:)` — linearly interpolated percentile,
305
+ bounded to the table's 5–95. Ages 18–19 use the 20–29 row and 80+ the
306
+ 70–79 row; under 18 is rejected.
307
+ - New public constants (removed ones are under Breaking):
308
+ - `CameronPredictor`: `CAMERON_CONSTANT`, `CAMERON_LINEAR_COEFFICIENT`,
309
+ `CAMERON_POWER_COEFFICIENT`, `CAMERON_POWER_EXPONENT`,
310
+ `CAMERON_MAX_DISTANCE_KM`.
311
+ - `Checker::CLOCK_FORMAT` — the clock grammar every time string is read with.
312
+ - `EnvironmentalAdjuster`: `HEAT_DURATION_FACTORS`, `REFERENCE_HUMIDITY`, and
313
+ the `EnvironmentalAdjuster::Humidity` module with `REFERENCE_HUMIDITY`,
314
+ `WBGT_TEMPERATURE_COEFFICIENT`, `WBGT_VAPOUR_PRESSURE_COEFFICIENT`,
315
+ `MIN_DEW_POINT_CELSIUS`, `BRACKET_CELSIUS` and `BISECTION_STEPS`.
316
+ - `GradeAdjustedPace`: `MINETTI_RUNNING_COEFFICIENTS`, `MINETTI_GRADE_RANGE`.
317
+ - `PersonalizedPredictor`: `TANDA_INTERCEPT`, `TANDA_VOLUME_AMPLITUDE`,
318
+ `TANDA_VOLUME_DECAY`, `TANDA_PACE_SLOPE`, `TANDA_MARATHON_KM`,
319
+ `TANDA_WEEKLY_DISTANCE_RANGE_KM`, `TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM`,
320
+ `TANDA_MARATHON_TIME_RANGE_SECONDS`, `PERSONAL_EXPONENT_RANGE`.
321
+ - `TrackCalculator`: `GRADE_SEGMENT_MIN_KM`, `GRADE_SEGMENT_TOLERANCE_KM`.
322
+ - `TrainingZones::PREDICTED_RACE_PACE_ZONES`.
323
+ - `Vo2maxNorms`: `NORMS_DATA_PATH`, `VO2MAX_NORMS`, `VO2MAX_NORMS_VERSION`,
324
+ `VO2MAX_NORM_PERCENTILES`, `VO2MAX_PERCENTILE_LABELS`.
325
+
326
+ ### Fixed
327
+ - `check_positive` let `Float::INFINITY` through, so every method guarded by it
328
+ accepted an infinite distance or time: an infinite weekly distance became a
329
+ finite (and fast) marathon prediction, an infinite pace a `FloatDomainError`
330
+ far from the input. Infinity now raises `Calcpace::NonPositiveInputError`
331
+ ("must be a finite positive number"), like zero, negatives and NaN already
332
+ did, and so does an Integer too large for a Float (e.g. a clock with
333
+ hundreds of hour digits), which used to become `Infinity` or a
334
+ `FloatDomainError` inside the formulas.
335
+ - `Calcpace::VERSION` is defined after `require 'calcpace'`; before, it only
336
+ existed once the gemspec had been loaded.
337
+ - The `vo2max_label` docstring now documents the error it actually raises for
338
+ a non-positive value (`Calcpace::NonPositiveInputError`, not
339
+ `ArgumentError`). Behaviour is unchanged.
340
+
341
+ ### Known limitations
342
+ - **The heat model has no sex term.** One curve serves everyone, and El Helou
343
+ et al. (2012) Table S3 shows men slowing more than women in the heat: at
344
+ 25 °C the model reads men's slower groups low (men's median 11.9% vs 14.76%
345
+ observed, Q3 12.08% vs 16.10%) and women's high (women's median 12.08% vs
346
+ 9.27%, Q1 12.08% vs 8.16%).
347
+ - **Heat above ~25 °C is extrapolated.** The marathon data behind the curve
348
+ stops at 25.2 °C; the 35 °C cap is a deliberate choice, not a measurement.
349
+ The 25 °C / 60-minute anchor (4.3%) and the 30- and 60-minute duration
350
+ factors have no direct published source.
351
+ - **The size of the humidity adjustment rests on the WBGT index, not on race
352
+ data.** The marathon outcome studies (El Helou 2012, Vihma 2010) found no
353
+ humidity effect independent of temperature.
354
+ - **Tanda's equation is validated only inside its sample** (22 experienced
355
+ runners, 21 of them men): outside it the prediction is still returned and
356
+ `out_of_range` says what fell outside.
357
+ - **Grade-adjusted pace is a metabolic model.** It does not see the muscular
358
+ cost of long descents or technical terrain, and the factor is applied to
359
+ horizontal (Haversine) distance without the √(1 + grade²) slope-length
360
+ correction (0.5% at 10%).
361
+
10
362
  ## [1.18.1] - 2026-09-06
11
363
 
12
364
  ### Fixed
@@ -649,7 +1001,8 @@ predictors are untouched.
649
1001
 
650
1002
  See git history for changes in earlier versions.
651
1003
 
652
- [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.18.1...HEAD
1004
+ [Unreleased]: https://github.com/0jonjo/calcpace/compare/v2.0.0...HEAD
1005
+ [2.0.0]: https://github.com/0jonjo/calcpace/compare/v1.18.1...v2.0.0
653
1006
  [1.18.1]: https://github.com/0jonjo/calcpace/compare/v1.18.0...v1.18.1
654
1007
  [1.18.0]: https://github.com/0jonjo/calcpace/compare/v1.17.0...v1.18.0
655
1008
  [1.17.0]: https://github.com/0jonjo/calcpace/compare/v1.16.0...v1.17.0