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 +4 -4
- data/CHANGELOG.md +354 -1
- data/README.md +319 -43
- data/calcpace.gemspec +9 -5
- data/lib/calcpace/age_grading.rb +15 -22
- data/lib/calcpace/cameron_predictor.rb +73 -27
- data/lib/calcpace/checker.rb +101 -21
- data/lib/calcpace/converter.rb +19 -14
- data/lib/calcpace/data/environmental_factors.yml +88 -8
- data/lib/calcpace/data/friend_2015_vo2max_percentiles.yml +49 -0
- data/lib/calcpace/data/mldr_2025_road.yml +21 -0
- data/lib/calcpace/data/mldr_2025_road_open_standards.yml +46 -0
- data/lib/calcpace/environmental_adjuster.rb +65 -31
- data/lib/calcpace/grade_adjusted_pace.rb +114 -0
- data/lib/calcpace/humidity.rb +137 -0
- data/lib/calcpace/personalized_predictor.rb +220 -0
- data/lib/calcpace/race_predictor.rb +7 -2
- data/lib/calcpace/race_splits.rb +7 -5
- data/lib/calcpace/track_calculator.rb +183 -10
- data/lib/calcpace/training_zones.rb +42 -11
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace/vo2max_estimator.rb +29 -4
- data/lib/calcpace/vo2max_norms.rb +121 -0
- data/lib/calcpace.rb +7 -0
- metadata +16 -9
- data/lib/calcpace/data/wma_2023_open_standards.yml +0 -40
- data/lib/calcpace/data/wma_2023_road.yml +0 -16
data/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Calcpace [](https://badge.fury.io/rb/calcpace)
|
|
2
2
|
|
|
3
|
-
A Ruby gem for runners: pace, time, and distance calculations, unit conversions, race predictions, GPS track analysis, age grading, VO2max estimation, and training zones.
|
|
3
|
+
A Ruby gem for runners: pace, time, and distance calculations, unit conversions, race predictions (including personalized ones), GPS track analysis with grade-adjusted pace, heat, humidity and altitude adjustments, age grading, VO2max estimation and norms, and training zones.
|
|
4
4
|
|
|
5
5
|
> **See it in action:** [calcpace.app](https://calcpace.app) — free running calculators, race predictors and a training log, all powered by this gem.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
|
-
gem 'calcpace', '~>
|
|
10
|
+
gem 'calcpace', '~> 2.0'
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
@@ -27,7 +27,7 @@ calc.pace(3665, 12) # => 305.4 (time / distance)
|
|
|
27
27
|
calc.time(210, 12) # => 2520 (pace × distance)
|
|
28
28
|
calc.distance(9660, 120) # => 80.5 (velocity × time)
|
|
29
29
|
|
|
30
|
-
# Clocktime input/output (HH:MM:SS or MM:SS)
|
|
30
|
+
# Clocktime input/output (HH:MM:SS or MM:SS; seconds below 60, and minutes too when hours are given)
|
|
31
31
|
calc.clock_pace('01:00:00', 10) # => "00:06:00"
|
|
32
32
|
calc.clock_time('00:05:31', 12.6) # => "01:09:30"
|
|
33
33
|
calc.checked_distance('01:21:32', '00:06:27') # => 12.64
|
|
@@ -37,8 +37,50 @@ calc.checked_distance('01:21:32', '00:06:27') # => 12.64
|
|
|
37
37
|
|
|
38
38
|
### Environmental Performance Adjustments
|
|
39
39
|
|
|
40
|
-
Adjust race performance based on heat and altitude. Calculations are based on scientific models
|
|
41
|
-
(
|
|
40
|
+
Adjust race performance based on heat, humidity and altitude. Calculations are based on scientific models
|
|
41
|
+
(El Helou et al. 2012 and Ely et al. 2007 for heat, the Australian Bureau of Meteorology's
|
|
42
|
+
simplified WBGT for humidity, NCAA standards for altitude).
|
|
43
|
+
|
|
44
|
+
- **Altitude**: no penalty up to 300 m, then a linear ramp to the first NCAA point
|
|
45
|
+
(914.4 m → 1.41%), the NCAA table up to 2438.4 m (5.90%), and an extrapolated
|
|
46
|
+
curve beyond it (3000 m → 7.92%, 3500 m → 9.97%, 4000 m → 12.2%, capped there).
|
|
47
|
+
São Paulo (760 m) gets ~1.06%.
|
|
48
|
+
- **Heat**: a 60-minute baseline `4.3 · ((T − 15) / 10)^1.5` (0% at 15 °C,
|
|
49
|
+
1.52% at 20 °C, 4.3% at 25 °C, 7.9% at 30 °C; extrapolated to 12.16% at
|
|
50
|
+
35 °C and capped there, so 35–40 °C and hotter all read like 35 °C), stored
|
|
51
|
+
as points every 2.5 °C, then
|
|
52
|
+
scaled by effort duration: 0.5× up to 30 min, 1.0× at 60 min, 1.76× at 3 h,
|
|
53
|
+
2.81× at 4 h and beyond (linear in between, so 1.38× at 2 h). The exponent
|
|
54
|
+
and the 3 h / 4 h points are fitted to El Helou et al. (2012, Table S3: eight
|
|
55
|
+
finisher groups, 2:41–4:54, time penalty against 15 °C at 20 and 25 °C,
|
|
56
|
+
which grows ~2.8× from 20 to 25 °C in every group); the derivation table is
|
|
57
|
+
in `lib/calcpace/data/environmental_factors.yml`. The 25 °C / 60-minute
|
|
58
|
+
anchor (4.3%) and the 30/60-minute factors have no direct published source.
|
|
59
|
+
- **Humidity** (optional): pass `humidity:` (relative humidity, %) or
|
|
60
|
+
`dew_point:` (in `temperature_unit`). Without either, the heat curve assumes
|
|
61
|
+
50% humidity. With one, the temperature is replaced by the effective
|
|
62
|
+
temperature that has the same simplified WBGT (`0.567·Ta + 0.393·e + 3.94`)
|
|
63
|
+
at 50% humidity, and `factors` reports it as `:effective_temperature_celsius`.
|
|
64
|
+
|
|
65
|
+
| Heat penalty (%) | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min |
|
|
66
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
67
|
+
| 20 °C | 0.76 | 1.52 | 2.1 | 2.68 | 4.27 | 4.27 |
|
|
68
|
+
| 25 °C | 2.15 | 4.3 | 5.93 | 7.57 | 12.08 | 12.08 |
|
|
69
|
+
| 30 °C | 3.95 | 7.9 | 10.9 | 13.9 | 22.2 | 22.2 |
|
|
70
|
+
| 35 °C | 6.08 | 12.16 | 16.78 | 21.4 | 34.17 | 34.17 |
|
|
71
|
+
| 40 °C | 6.08 | 12.16 | 16.78 | 21.4 | 34.17 | 34.17 |
|
|
72
|
+
|
|
73
|
+
| 30 °C at | Effective temperature | 60 min | 4 h |
|
|
74
|
+
| --- | --- | --- | --- |
|
|
75
|
+
| 30% RH | 26.69 °C | 5.46% | 15.34% |
|
|
76
|
+
| 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% |
|
|
77
|
+
| 70% RH | 33.07 °C | 10.46% | 29.39% |
|
|
78
|
+
| 90% RH | 35.94 °C (capped at 35 °C) | 12.16% | 34.17% |
|
|
79
|
+
|
|
80
|
+
Above ~25 °C the numbers are extrapolations of the fitted curve: the marathon
|
|
81
|
+
studies behind it have no data there (El Helou's hottest race was 25.2 °C). The
|
|
82
|
+
cap at 35 °C is a deliberate choice for the same reason: the uncapped curve gave
|
|
83
|
+
47.77% for 4 h at 40 °C.
|
|
42
84
|
|
|
43
85
|
```ruby
|
|
44
86
|
# Calculate penalty for 25°C and 2000m altitude (Defaults to 60-min effort)
|
|
@@ -50,25 +92,30 @@ penalty = calc.calculate_penalty(temperature: 25, altitude: 2000)
|
|
|
50
92
|
|
|
51
93
|
# Fahrenheit support
|
|
52
94
|
calc.calculate_penalty(temperature: 80, temperature_unit: :f)
|
|
53
|
-
# => { total_penalty_percent: 5.
|
|
95
|
+
# => { total_penalty_percent: 5.44, ... }
|
|
96
|
+
|
|
97
|
+
# Humidity: 30 °C at 90% hits like 35.94 °C at 50% (which reads like the 35 °C cap)
|
|
98
|
+
calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] # => 12.16
|
|
99
|
+
calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] # => 35.94
|
|
100
|
+
calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] # => 11.07
|
|
54
101
|
|
|
55
102
|
# Adjust a 3:30 marathon time (12600s) for these conditions (High exposure penalty)
|
|
56
103
|
result = calc.adjust_time(12600, temperature: 25, altitude: 2000)
|
|
57
104
|
# => {
|
|
58
105
|
# original_time: 12600,
|
|
59
|
-
# adjusted_time:
|
|
60
|
-
# adjusted_time_clock: "
|
|
61
|
-
# penalty_percent:
|
|
62
|
-
# factors: { heat:
|
|
106
|
+
# adjusted_time: 14382.9,
|
|
107
|
+
# adjusted_time_clock: "03:59:42",
|
|
108
|
+
# penalty_percent: 14.15,
|
|
109
|
+
# factors: { heat: 9.83, altitude: 4.32 }
|
|
63
110
|
# }
|
|
64
111
|
|
|
65
112
|
# Predicted adjusted times (Riegel formula)
|
|
66
113
|
calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 28)
|
|
67
|
-
# => { adjusted_time:
|
|
114
|
+
# => { adjusted_time: 2613.0, adjusted_time_clock: "00:43:33", penalty_percent: 4.44, ... }
|
|
68
115
|
|
|
69
116
|
# Predicted adjusted times (Cameron formula)
|
|
70
117
|
calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 80, temperature_unit: :f)
|
|
71
|
-
# => { adjusted_time:
|
|
118
|
+
# => { adjusted_time: 12400.47, adjusted_time_clock: "03:26:40", penalty_percent: 10.28, ... }
|
|
72
119
|
```
|
|
73
120
|
|
|
74
121
|
---
|
|
@@ -139,8 +186,10 @@ calc.race_splits('half_marathon', target_time: '01:30:00', split_distance: '5k')
|
|
|
139
186
|
# => ["00:21:20", "00:42:40", "01:03:59", "01:25:19", "01:30:00"]
|
|
140
187
|
|
|
141
188
|
# Strategies: :even (default), :negative (second half faster), :positive (first half faster)
|
|
189
|
+
# :negative runs the first half 1% slower than average pace and the second half 1% faster;
|
|
190
|
+
# :positive is the mirror image. A 3:00:00 marathon splits 1:30:54 + 1:29:06 (:negative).
|
|
142
191
|
calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative)
|
|
143
|
-
# => ["00:20:
|
|
192
|
+
# => ["00:20:12", "00:40:00"]
|
|
144
193
|
|
|
145
194
|
# The race may be a plain distance too; the last split is always the finish
|
|
146
195
|
calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k')
|
|
@@ -157,14 +206,22 @@ calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k')
|
|
|
157
206
|
calc.predict_time_clock('5k', '00:20:00', 'marathon') # => "03:11:49"
|
|
158
207
|
calc.predict_pace_clock('5k', '00:20:00', 'marathon') # => "00:04:32"
|
|
159
208
|
calc.equivalent_performance('10k', '00:42:00', '5k')
|
|
160
|
-
# => { time: 1208.
|
|
209
|
+
# => { time: 1208.6727903498331, time_clock: "00:20:08", pace: 241.73455806996662, pace_clock: "00:04:01" }
|
|
161
210
|
```
|
|
162
211
|
|
|
163
|
-
**Cameron formula** (
|
|
212
|
+
**Cameron formula** (Dave Cameron's velocity-ratio model, fitted to world bests from
|
|
213
|
+
800 m to the marathon — more conservative than Riegel when predicting the marathon
|
|
214
|
+
from shorter races):
|
|
215
|
+
|
|
216
|
+
`T2 = T1 × (D2/D1) × f(D1)/f(D2)`, with `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905`
|
|
217
|
+
and `d` in metres (distances are still passed in km or as race names).
|
|
218
|
+
Both distances must be at most `CameronPredictor::CAMERON_MAX_DISTANCE_KM` (100 km):
|
|
219
|
+
the model is fitted up to the marathon and breaks down far beyond it, so longer
|
|
220
|
+
distances raise `ArgumentError`.
|
|
164
221
|
|
|
165
222
|
```ruby
|
|
166
|
-
calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "
|
|
167
|
-
calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:
|
|
223
|
+
calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "03:16:46"
|
|
224
|
+
calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:39"
|
|
168
225
|
```
|
|
169
226
|
|
|
170
227
|
**Any distance, on either end.** Both formulas are arithmetic on two distances,
|
|
@@ -173,7 +230,7 @@ so neither end has to be a standard race:
|
|
|
173
230
|
```ruby
|
|
174
231
|
# From a 7.79 km club race in 26:59
|
|
175
232
|
calc.predict_time_clock(7.79, '00:26:59', 'half_marathon') # => "01:17:34"
|
|
176
|
-
calc.predict_time_cameron_clock(7.79, '00:26:59', 'half_marathon') # => "01:
|
|
233
|
+
calc.predict_time_cameron_clock(7.79, '00:26:59', 'half_marathon') # => "01:17:26"
|
|
177
234
|
|
|
178
235
|
# To an unnamed distance, and between two of them
|
|
179
236
|
calc.predict_time_clock('10k', '00:42:00', 15) # => "01:04:33"
|
|
@@ -198,6 +255,85 @@ the caller's, and it always was, standard race names included.
|
|
|
198
255
|
|
|
199
256
|
---
|
|
200
257
|
|
|
258
|
+
### Personalized Predictions
|
|
259
|
+
|
|
260
|
+
**Marathon from training volume** — Tanda (2011), no race result needed. The
|
|
261
|
+
inputs are the mean weekly distance and the mean training pace over the 8 weeks
|
|
262
|
+
ending one week before the race:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')
|
|
266
|
+
# => { time: 11981.88, time_clock: "03:19:41", pace: 283.96, pace_clock: "00:04:43",
|
|
267
|
+
# within_validated_range: true, out_of_range: [] }
|
|
268
|
+
|
|
269
|
+
calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30')
|
|
270
|
+
# => { time: 13158.72, time_clock: "03:39:18", pace: 311.86, pace_clock: "00:05:11",
|
|
271
|
+
# within_validated_range: false, out_of_range: [:weekly_distance, :marathon_time] }
|
|
272
|
+
|
|
273
|
+
# unit: :mi — weekly miles, pace per mile in and out
|
|
274
|
+
calc.predict_marathon_from_training(weekly_distance: 25, training_pace: '08:00', unit: :mi)[:pace_clock] # => "00:07:53"
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The equation is `Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P` (Pm marathon
|
|
278
|
+
pace in s/km, K km/week, P s/km). Training pace is the plain average of every
|
|
279
|
+
run — total time over total distance, warm-ups and easy days included — not the
|
|
280
|
+
pace of the hard sessions. The paper reports a standard error of about 4 minutes.
|
|
281
|
+
`weekly_distance` may be a number or a numeric string (`'60'`); `training_pace`
|
|
282
|
+
is seconds or an `MM:SS` / `HH:MM:SS` string.
|
|
283
|
+
|
|
284
|
+
It was fitted on 22 experienced runners (21 men) and 46 marathons, so it is only
|
|
285
|
+
validated inside that sample: 40.4–110.7 km/week, training pace 253.3–330.6 s/km (4:13–5:30/km),
|
|
286
|
+
finish 2:47–3:36. Outside it the prediction is still returned, and
|
|
287
|
+
`out_of_range` names what fell outside (`:weekly_distance`, `:training_pace`,
|
|
288
|
+
`:marathon_time`) — a warning, not an error. A low-volume runner at an easy pace
|
|
289
|
+
will usually see all three.
|
|
290
|
+
|
|
291
|
+
> G. Tanda, "Prediction of marathon performance time on the basis of training
|
|
292
|
+
> indices", *Journal of Human Sport and Exercise* 6(3):511–520, 2011.
|
|
293
|
+
> doi:10.4100/jhse.2011.63.05
|
|
294
|
+
|
|
295
|
+
**Personal Riegel exponent** — fit the fatigue factor to two of your own races
|
|
296
|
+
instead of the population 1.06:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') # => 1.0961
|
|
300
|
+
|
|
301
|
+
calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')
|
|
302
|
+
# => { time: 13083.04, time_clock: "03:38:03", exponent: 1.0961, raw_exponent: 1.0961, clamped: false }
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
The standard Riegel gives 3:32:39 from that half and 3:27:00 from that 10K; this
|
|
306
|
+
runner fades more than average, and the personal exponent says so.
|
|
307
|
+
|
|
308
|
+
When the target lies outside the two races, the prediction extrapolates from
|
|
309
|
+
whichever race is closer to it (in log-distance), with the exponent clamped to
|
|
310
|
+
1.01–1.20:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
calc.predict_time_personal('5k', '00:20:00', '10k', '00:50:00', 'half_marathon')
|
|
314
|
+
# => { time: 7348.5, time_clock: "02:02:28", exponent: 1.2, raw_exponent: 1.3219, clamped: true }
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
When the target lies between them, it interpolates along the curve through both
|
|
318
|
+
performances with the raw exponent, never clamped: the runner's own data
|
|
319
|
+
already brackets the answer, and the result does not depend on which race comes
|
|
320
|
+
first.
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
calc.predict_time_personal(5, 1200, 20, 3000, 10)[:time] # => 1897.37
|
|
324
|
+
calc.predict_time_personal(20, 3000, 5, 1200, 10)[:time] # => 1897.37
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
An exponent outside that range — or `clamped: true` — usually means one of the
|
|
328
|
+
two races was not an all-out effort, or was run on a course or day that does
|
|
329
|
+
not compare with the other — the raw exponent in an interpolation
|
|
330
|
+
(0.661 above) is worth the same suspicion. Both races may be names or distances
|
|
331
|
+
in km; two races at the same distance, or a target equal to one of them, raise
|
|
332
|
+
`ArgumentError`. Times are seconds or `HH:MM:SS` / `MM:SS` strings; anything
|
|
333
|
+
else raises `Calcpace::InvalidTimeFormatError`.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
201
337
|
### GPS Track Analysis
|
|
202
338
|
|
|
203
339
|
Accepts an array of hashes with `:lat`, `:lon`, and optionally `:ele` (metres) and `:time` (`Time`):
|
|
@@ -238,6 +374,62 @@ in both formats (`"-00:40"` / `"-0:40"`) rather than raising.
|
|
|
238
374
|
|
|
239
375
|
**Haversine formula** — great-circle distance on a sphere (R = 6,371 km). Accuracy: ~0.3% of GPS/WGS84. Best for running and cycling distances; not for geodetic surveying.
|
|
240
376
|
|
|
377
|
+
#### Grade-adjusted pace (GAP)
|
|
378
|
+
|
|
379
|
+
The flat-ground pace that costs the same energy as a pace run on a slope, from
|
|
380
|
+
the energy cost of running on gradients measured by **Minetti et al. (2002)**.
|
|
381
|
+
The grade is a fraction (rise over horizontal distance): `0.05` is 5% uphill,
|
|
382
|
+
`-0.05` is 5% downhill.
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
calc.grade_adjustment_factor(0.1) # => 1.6578372222222222 (a metre at +10% ≈ 1.66 flat metres)
|
|
386
|
+
calc.grade_adjustment_factor(-0.1) # => 0.5976961111111111
|
|
387
|
+
|
|
388
|
+
calc.grade_adjusted_pace(360, 0.1) # => 217.1503903847952 (s/km)
|
|
389
|
+
calc.grade_adjusted_pace_clock('06:00', 0.1) # => "00:03:37"
|
|
390
|
+
calc.grade_adjusted_pace_clock('06:00', 0.1, compact: true) # => "3:37"
|
|
391
|
+
calc.grade_adjusted_pace(480, 0.05, unit: :mi) # => 368.82127811700303 (s/mi)
|
|
392
|
+
|
|
393
|
+
# Per-split GAP for a GPS track: the track_splits fields plus :gap
|
|
394
|
+
calc.track_grade_adjusted_splits(points, 1.0)
|
|
395
|
+
# => [{ km: 1.0, elapsed: 415, pace: "06:55", gap: "06:50" },
|
|
396
|
+
# { km: 1.51, elapsed: 600, pace: "06:04", gap: "06:21" }]
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
| Grade | −10% | −5% | 0% | +5% | +10% |
|
|
400
|
+
|-------|------|-----|----|-----|------|
|
|
401
|
+
| Factor | 0.598 | 0.763 | 1.000 | 1.301 | 1.658 |
|
|
402
|
+
|
|
403
|
+
**Formula** (J·kg⁻¹·m⁻¹, R² = 0.999):
|
|
404
|
+
```
|
|
405
|
+
Cr(i) = 155.4·i⁵ − 30.4·i⁴ − 43.3·i³ + 46.3·i² + 19.5·i + 3.6
|
|
406
|
+
factor = Cr(i) / Cr(0)
|
|
407
|
+
GAP = pace / factor
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
- Grades are clamped to **±45%**, the range Minetti et al. measured; nothing
|
|
411
|
+
is extrapolated beyond it. Running is cheapest near −20% and gets dearer
|
|
412
|
+
again on steeper descents.
|
|
413
|
+
- It is a metabolic model: it does not see the muscular cost of long descents
|
|
414
|
+
or technical terrain, and field models fitted to heart rate (Strava's, for
|
|
415
|
+
instance) are gentler on steep climbs.
|
|
416
|
+
- `track_grade_adjusted_splits` leaves `track_splits` untouched: it returns the
|
|
417
|
+
same `:km`, `:elapsed` and `:pace` with `:gap` added (formatted like `:pace`,
|
|
418
|
+
`compact:` applies to both). GPS elevation is noisy, so grades are measured
|
|
419
|
+
over **grade segments of at least 100 m** of horizontal distance — read
|
|
420
|
+
between fixes a metre apart, ±2 m of jitter would be a ±400% grade. A short
|
|
421
|
+
leftover at the end of a stretch joins the segment before it. Stretches
|
|
422
|
+
between points without `:ele` (or with a NaN/infinite one) count as flat,
|
|
423
|
+
so a track with no elevation has `:gap` equal to `:pace`; so does a stretch
|
|
424
|
+
with elevation shorter than 100 m that has no full segment before it to
|
|
425
|
+
join (between missing fixes, or a whole track that short).
|
|
426
|
+
- Track distances are horizontal (Haversine), and the factor is applied to
|
|
427
|
+
them without the √(1 + grade²) slope-length correction — 0.5% at 10%.
|
|
428
|
+
- `estimate_detailed_vo2max` keeps its own flat elevation heuristic (100 m of
|
|
429
|
+
gain = 600 m of flat), so its numbers do not change.
|
|
430
|
+
|
|
431
|
+
*Minetti, A. E., Moia, C., Roi, G. S., Susta, D., & Ferretti, G. (2002). Energy cost of walking and running at extreme uphill and downhill slopes. Journal of Applied Physiology, 93(3), 1039–1046. https://doi.org/10.1152/japplphysiol.01177.2001*
|
|
432
|
+
|
|
241
433
|
---
|
|
242
434
|
|
|
243
435
|
### Age Grading (Road Races)
|
|
@@ -249,18 +441,18 @@ age factors and open standards.
|
|
|
249
441
|
result = calc.age_grade(10.0, '00:45:00', age: 55, sex: :male)
|
|
250
442
|
# numeric distances also accepted in miles: calc.age_grade(6.21371, '00:45:00', age: 55, sex: :male, distance_unit: :mi)
|
|
251
443
|
# => {
|
|
252
|
-
# age_grade_percent:
|
|
444
|
+
# age_grade_percent: 68.9,
|
|
253
445
|
# category: "Local Class",
|
|
254
|
-
# age_graded_time_seconds:
|
|
255
|
-
# age_graded_time_clock: "00:
|
|
256
|
-
# open_standard_seconds:
|
|
257
|
-
# open_standard_clock: "00:26:
|
|
258
|
-
# factor: 0.
|
|
259
|
-
# table_version: "
|
|
446
|
+
# age_graded_time_seconds: 2297.97,
|
|
447
|
+
# age_graded_time_clock: "00:38:17",
|
|
448
|
+
# open_standard_seconds: 1584.0,
|
|
449
|
+
# open_standard_clock: "00:26:24",
|
|
450
|
+
# factor: 0.8511,
|
|
451
|
+
# table_version: "MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"
|
|
260
452
|
# }
|
|
261
453
|
|
|
262
|
-
calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.
|
|
263
|
-
calc.age_grade_label(65.
|
|
454
|
+
calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.0
|
|
455
|
+
calc.age_grade_label(65.0) # => "Local Class"
|
|
264
456
|
```
|
|
265
457
|
|
|
266
458
|
`category` (and `age_grade_label`) returns one of:
|
|
@@ -276,7 +468,7 @@ calc.age_grade_label(65.2) # => "Local Clas
|
|
|
276
468
|
| 40–49.9% | Recreational |
|
|
277
469
|
| below 40% | Active Beginner |
|
|
278
470
|
|
|
279
|
-
The
|
|
471
|
+
The Alan Jones road tables are numeric age factors and open
|
|
280
472
|
standards only — they define no categories at all. The bands from Local Class
|
|
281
473
|
(60%) upward follow the USATF Masters / National Masters News convention; the
|
|
282
474
|
three bands below 60% are calcpace's own extension — most recreational
|
|
@@ -293,8 +485,8 @@ A numeric distance within **2%** of one of those is graded as that standard —
|
|
|
293
485
|
a GPS watch rarely reads a 5K as exactly 5.000 km:
|
|
294
486
|
|
|
295
487
|
```ruby
|
|
296
|
-
calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # =>
|
|
297
|
-
calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # =>
|
|
488
|
+
calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # => 54.1
|
|
489
|
+
calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 54.1
|
|
298
490
|
|
|
299
491
|
calc.age_grade(7.79, '00:26:59', age: 36, sex: :male)
|
|
300
492
|
# => ArgumentError: Unsupported distance 7.79km. Supported: 5.0, 10.0, 21.0975, 42.195 km
|
|
@@ -302,20 +494,32 @@ calc.age_grade(7.79, '00:26:59', age: 36, sex: :male)
|
|
|
302
494
|
|
|
303
495
|
That refusal is deliberate, and it is where age grading parts ways with the
|
|
304
496
|
predictors above. A prediction is a formula and works at any distance; an age
|
|
305
|
-
grade is a lookup in the
|
|
497
|
+
grade is a lookup in the road table, which publishes a factor per *specific*
|
|
306
498
|
distance. There is no world standard for 7.79 km, so there is no honest
|
|
307
499
|
percentage to return — interpolating one would produce a number with the look
|
|
308
500
|
of an official standard and none of the authority.
|
|
309
501
|
|
|
310
|
-
Age factors
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
502
|
+
Age factors and open standards come from Alan Jones' **2025 road** age-grading
|
|
503
|
+
tables, approved on 2025-01-10 by the USATF Masters Long Distance Running
|
|
504
|
+
Council — the standard for road races, the same tables behind Howard Grubb's
|
|
505
|
+
MLDR road calculator. The source spreadsheets are `MaleRoadStd2025.xlsx` and
|
|
506
|
+
`FemaleRoadStd2025.xlsx`, linked here at the commit the bundled data was
|
|
507
|
+
taken from:
|
|
508
|
+
https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files
|
|
509
|
+
The bundled data has one factor per year of age from 18 to 100 (older ages use
|
|
510
|
+
the age-100 factor) and lives in `lib/calcpace/data/mldr_2025_road.yml` (factors)
|
|
511
|
+
and `lib/calcpace/data/mldr_2025_road_open_standards.yml` (open standards and
|
|
512
|
+
category labels).
|
|
513
|
+
|
|
514
|
+
| Distance | Open standard (men) | Open standard (women) |
|
|
515
|
+
| --- | --- | --- |
|
|
516
|
+
| 5K | 12:49 | 13:54 |
|
|
517
|
+
| 10K | 26:24 | 28:46 |
|
|
518
|
+
| Half marathon | 57:31 | 1:02:52 |
|
|
519
|
+
| Marathon | 2:00:35 | 2:09:56 |
|
|
316
520
|
|
|
317
521
|
Field meanings:
|
|
318
|
-
- `age_graded_time_clock`: your result after applying the
|
|
522
|
+
- `age_graded_time_clock`: your result after applying the age factor (normalized performance time).
|
|
319
523
|
- `open_standard_clock`: the open standard reference time used to compute the percentage for that distance/sex.
|
|
320
524
|
- `age_grade_percent`: `(open_standard_seconds / age_graded_time_seconds) * 100`.
|
|
321
525
|
|
|
@@ -355,6 +559,49 @@ VO2max = VO2 / %VO2max
|
|
|
355
559
|
|
|
356
560
|
Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace.
|
|
357
561
|
|
|
562
|
+
#### By age and sex
|
|
563
|
+
|
|
564
|
+
The fixed thresholds above are the same for everyone. Give `vo2max_label` an
|
|
565
|
+
age and a sex and it reads the value against people of the same sex and age
|
|
566
|
+
decade instead, using the **FRIEND registry** percentiles of VO2max measured on
|
|
567
|
+
a treadmill (Kaminsky, Arena & Myers, 2015):
|
|
568
|
+
|
|
569
|
+
```ruby
|
|
570
|
+
calc.vo2max_label(45) # => "Good" (fixed thresholds, unchanged)
|
|
571
|
+
calc.vo2max_label(45, age: 25, sex: :male) # => "Fair"
|
|
572
|
+
calc.vo2max_label(45, age: 60, sex: :male) # => "Elite"
|
|
573
|
+
calc.vo2max_label(45, age: 25, sex: :female) # => "Very Good"
|
|
574
|
+
calc.vo2max_label(45, age: 60, sex: :female) # => "Elite"
|
|
575
|
+
|
|
576
|
+
calc.vo2max_percentile(45, age: 25, sex: :male) # => 40.5
|
|
577
|
+
calc.vo2max_percentile(45, age: 25, sex: :female) # => 75.7
|
|
578
|
+
calc.vo2max_percentile(45, age: 60, sex: :male) # => 95.0
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
| Percentile (same sex and age decade) | Level |
|
|
582
|
+
|--------------------------------------|-----------|
|
|
583
|
+
| ≥ 95th | Elite |
|
|
584
|
+
| 90th–94th | Excellent |
|
|
585
|
+
| 75th–89th | Very Good |
|
|
586
|
+
| 50th–74th | Good |
|
|
587
|
+
| 25th–49th | Fair |
|
|
588
|
+
| < 25th | Beginner |
|
|
589
|
+
|
|
590
|
+
- The cuts sit on percentiles the table publishes (5th, 10th, 25th, 50th,
|
|
591
|
+
75th, 90th, 95th), so a label never depends on interpolation. They are
|
|
592
|
+
calcpace's choice: FRIEND publishes percentiles, not labels.
|
|
593
|
+
- `vo2max_percentile` interpolates linearly between the published percentiles,
|
|
594
|
+
rounded to one decimal, and is bounded to the table: `5.0` means at or below
|
|
595
|
+
the 5th percentile, `95.0` at or above the 95th.
|
|
596
|
+
- Age decades (20–29 … 70–79) are used as published, without blending, so a
|
|
597
|
+
29- and a 30-year-old read different rows. Ages 18–19 use the 20–29 row and
|
|
598
|
+
80+ the 70–79 row; under 18 raises `ArgumentError`, as does a sex other than
|
|
599
|
+
male/female. Age and sex must be given together.
|
|
600
|
+
- The registry measured VO2max in a lab; a VO2max estimated from a race time
|
|
601
|
+
carries its own ±3–5 ml/kg/min on top.
|
|
602
|
+
|
|
603
|
+
*Kaminsky, L. A., Arena, R., & Myers, J. (2015). Reference Standards for Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: Data From the Fitness Registry and the Importance of Exercise National Database. Mayo Clinic Proceedings, 90(11), 1515–1523, Table 3 (rows "Men/Women from FRIEND"; 7,783 treadmill tests on adults free of known cardiovascular disease). https://doi.org/10.1016/j.mayocp.2015.07.026. The same table also lists the Cooper Clinic norms printed in ACSM's Guidelines for Exercise Testing and Prescription (9th ed., 2014); those are predicted from treadmill time rather than measured, and are not used here.*
|
|
604
|
+
|
|
358
605
|
#### Contextualized estimation
|
|
359
606
|
|
|
360
607
|
`estimate_detailed_vo2max` returns a richer result that accounts for elevation, heart rate, and formula reliability:
|
|
@@ -406,6 +653,7 @@ Personalized training paces (Daniels' Running Formula) and Karvonen heart-rate z
|
|
|
406
653
|
zones = calc.training_paces(50.0)
|
|
407
654
|
zones[:threshold].fast_clock # => "00:04:15" per km
|
|
408
655
|
zones[:easy].slow_clock # => "00:05:52" per km
|
|
656
|
+
zones[:marathon].fast_clock # => "00:04:31" per km (the VDOT-predicted marathon pace)
|
|
409
657
|
|
|
410
658
|
calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock # => "00:06:51" per mile
|
|
411
659
|
|
|
@@ -424,13 +672,21 @@ calc.hr_zones_from_max(hr_max: 190)
|
|
|
424
672
|
| Zone | %VO2max | Purpose |
|
|
425
673
|
|------|---------|---------|
|
|
426
674
|
| Easy | 59–74% | Base building, recovery |
|
|
427
|
-
| Marathon | 75
|
|
675
|
+
| Marathon | 75% – predicted marathon pace (0.800–0.849) | Marathon race pace |
|
|
428
676
|
| Threshold | 83–88% | Lactate threshold, tempo runs |
|
|
429
677
|
| Interval | 95–100% | VO2max development |
|
|
430
678
|
| Repetition | 105–110% | Speed and running economy |
|
|
431
679
|
|
|
432
680
|
Pace accuracy vs published VDOT tables: within a few seconds per km
|
|
433
|
-
(threshold matches exactly; easy band is a range heuristic).
|
|
681
|
+
(threshold matches exactly; easy band is a range heuristic). The fast end of the
|
|
682
|
+
marathon band is the marathon pace `predict_time_from_vo2max` gives for the same
|
|
683
|
+
VO2max (Daniels' M pace is the predicted marathon race pace); that prediction
|
|
684
|
+
covers VO2max 10–100, and outside it the race-pace intensity of the nearest bound
|
|
685
|
+
is used. That intensity is 0.800–0.849 of VO2max (0.805 at VO2max 30, 0.830 at 70),
|
|
686
|
+
so the marathon band stays slower than the threshold band below VO2max ~69.5, as in
|
|
687
|
+
Daniels. `TRAINING_INTENSITIES[:marathon][:high]` stays 0.84 as the nominal upper
|
|
688
|
+
bound; `PREDICTED_RACE_PACE_ZONES` lists the zones whose fast end is the predicted
|
|
689
|
+
race pace.
|
|
434
690
|
|
|
435
691
|
`unit:` sets the unit of the returned pace bands; `distance_unit:` sets the unit of a
|
|
436
692
|
numeric race distance you pass in. Combining `distance_unit:` with a race name raises
|
|
@@ -662,6 +918,24 @@ calc.convert_to_clocktime(3600) # => "01:00:00"
|
|
|
662
918
|
calc.check_time('01:00:00') # => nil (valid)
|
|
663
919
|
```
|
|
664
920
|
|
|
921
|
+
Every time or pace string the gem reads goes through `convert_to_seconds`, so
|
|
922
|
+
every method reads the same clocks, and every clock the gem writes is among them:
|
|
923
|
+
|
|
924
|
+
```ruby
|
|
925
|
+
calc.convert_to_seconds('75:00') # => 4500 (MM:SS keeps counting minutes)
|
|
926
|
+
calc.convert_to_seconds('400:00:00') # => 1440000 (any number of hours)
|
|
927
|
+
calc.convert_to_seconds('1 03:46:40') # => 100000 (convert_to_clocktime's day prefix)
|
|
928
|
+
calc.convert_to_seconds('-0:40') # => -40 (a backwards track_splits split)
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
Seconds must be two digits below 60, and so must minutes when hours are given;
|
|
932
|
+
after a day prefix the hours are two digits below 24. A leading `-` is the only
|
|
933
|
+
sign, blanks or surrounding whitespace are not trimmed, and the string must be
|
|
934
|
+
in a valid, ASCII-compatible encoding (UTF-8, not UTF-16). Anything else —
|
|
935
|
+
`'05:99'`, `'1:60:00'`, `'1 3:46:40'`, `' 05:00'`, `'abc'` — raises
|
|
936
|
+
`Calcpace::InvalidTimeFormatError`. A negative clock parses, but every method
|
|
937
|
+
that needs a positive time or pace rejects it with `Calcpace::NonPositiveInputError`.
|
|
938
|
+
|
|
665
939
|
`convert_to_clocktime` takes a `compact:` keyword for the format a runner reads
|
|
666
940
|
on a screen — no zero hour, no leading zero on the most significant component:
|
|
667
941
|
|
|
@@ -687,8 +961,10 @@ call without it returns exactly what it returned before.
|
|
|
687
961
|
|
|
688
962
|
All errors inherit from `Calcpace::Error`:
|
|
689
963
|
|
|
690
|
-
- `Calcpace::NonPositiveInputError` — numeric input is zero or
|
|
691
|
-
- `Calcpace::InvalidTimeFormatError` — time string not
|
|
964
|
+
- `Calcpace::NonPositiveInputError` — numeric input is zero, negative, NaN or infinite
|
|
965
|
+
- `Calcpace::InvalidTimeFormatError` — time string that is not a clock the gem writes
|
|
966
|
+
(`[-][D ]H:MM:SS` or `[-]M:SS`, see Other Utilities): seconds must be below 60, and
|
|
967
|
+
so must minutes when hours are given (`'05:99'` and `'1:60:00'` raise)
|
|
692
968
|
- `Calcpace::UnsupportedUnitError` — unknown conversion (`convert`) or unknown
|
|
693
969
|
`unit:` / `distance_unit:` keyword
|
|
694
970
|
- `Calcpace::InvalidDataError` — the bundled data table failed its load-time
|
|
@@ -705,7 +981,7 @@ unknown race names, unsupported age-grading distances, and invalid `age` / `sex`
|
|
|
705
981
|
bundle exec rake
|
|
706
982
|
```
|
|
707
983
|
|
|
708
|
-
Requires Ruby >= 3.
|
|
984
|
+
Requires Ruby >= 3.3.0. Tested with Ruby 3.3, 3.4, and 4.0.
|
|
709
985
|
|
|
710
986
|
## Contributing
|
|
711
987
|
|
data/calcpace.gemspec
CHANGED
|
@@ -8,12 +8,16 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ['João Gilberto Saraiva']
|
|
9
9
|
spec.email = ['joaogilberto@tuta.io']
|
|
10
10
|
|
|
11
|
-
spec.summary = 'Running calculations: pace, race predictions, GPS track analysis,
|
|
11
|
+
spec.summary = 'Running calculations: pace, race predictions, GPS track analysis, ' \
|
|
12
|
+
'heat and altitude adjustments, age grading, VO2max, and training zones.'
|
|
12
13
|
spec.description = 'Ruby gem for runners: pace, time, and distance calculations, ' \
|
|
13
|
-
'unit conversions (30+ units), race time predictions (Riegel
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
14
|
+
'unit conversions (30+ units), race time predictions (Riegel, Cameron, ' \
|
|
15
|
+
'personal Riegel exponent, and Tanda marathon from training volume), ' \
|
|
16
|
+
'GPS track analysis (Haversine, elevation gain, per-km splits, ' \
|
|
17
|
+
'Minetti grade-adjusted pace), heat, humidity, and altitude adjustments, ' \
|
|
18
|
+
'age grading (2025 road tables), VO2max estimation (Daniels & Gilbert) ' \
|
|
19
|
+
'with FRIEND age/sex norms, and personalized training zones ' \
|
|
20
|
+
'(Daniels paces & Karvonen heart-rate zones).'
|
|
17
21
|
spec.homepage = 'https://github.com/0jonjo/calcpace'
|
|
18
22
|
spec.metadata['source_code_uri'] = spec.homepage
|
|
19
23
|
spec.license = 'MIT'
|
data/lib/calcpace/age_grading.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
require_relative 'errors'
|
|
5
|
+
require_relative 'checker'
|
|
5
6
|
|
|
6
7
|
# Module for age-grading race performances with a versioned table
|
|
7
8
|
#
|
|
@@ -11,8 +12,14 @@ require_relative 'errors'
|
|
|
11
12
|
# Current scope:
|
|
12
13
|
# - Common road distances: 5K, 10K, half marathon, marathon
|
|
13
14
|
# - Sex: male/female
|
|
14
|
-
# - Age: 18
|
|
15
|
-
# - Data
|
|
15
|
+
# - Age: 18+, one factor per year up to 100 (older ages use the age-100 factor)
|
|
16
|
+
# - Data: Alan Jones' 2025 road age-grading tables, approved by the USATF
|
|
17
|
+
# Masters Long Distance Running Council (github.com/AlanLyttonJones/Age-Grade-Tables).
|
|
18
|
+
# The files are versioned and replaceable: factors in
|
|
19
|
+
# `lib/calcpace/data/mldr_2025_road.yml`, open standards and category labels in
|
|
20
|
+
# `lib/calcpace/data/mldr_2025_road_open_standards.yml`
|
|
21
|
+
#
|
|
22
|
+
# The WMA_DATA constant keeps its historical name: it now holds the road table
|
|
16
23
|
#
|
|
17
24
|
# Returned values include:
|
|
18
25
|
# - age grade percentage
|
|
@@ -21,8 +28,11 @@ require_relative 'errors'
|
|
|
21
28
|
# - performance category
|
|
22
29
|
# rubocop:disable Metrics/ModuleLength
|
|
23
30
|
module AgeGrading
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
# normalize_age / normalize_sex live in Checker, shared with Vo2maxNorms
|
|
32
|
+
include Checker
|
|
33
|
+
|
|
34
|
+
DATA_PATH = File.expand_path('data/mldr_2025_road.yml', __dir__).freeze
|
|
35
|
+
OPEN_STANDARDS_DATA_PATH = File.expand_path('data/mldr_2025_road_open_standards.yml', __dir__).freeze
|
|
26
36
|
WMA_DATA = YAML.safe_load_file(DATA_PATH, permitted_classes: [],
|
|
27
37
|
aliases: false).freeze
|
|
28
38
|
OPEN_STANDARDS_DATA = YAML.safe_load_file(OPEN_STANDARDS_DATA_PATH, permitted_classes: [],
|
|
@@ -60,7 +70,7 @@ module AgeGrading
|
|
|
60
70
|
# How far a distance may sit from a standard and still be graded as it. 2% is
|
|
61
71
|
# the same window calcpace.app uses to decide a run "is a 5K", so the gem and
|
|
62
72
|
# the site never disagree about the same run. It stays a matching tolerance,
|
|
63
|
-
# not an interpolation: a distance outside it has no
|
|
73
|
+
# not an interpolation: a distance outside it has no table factor and is refused
|
|
64
74
|
STANDARD_DISTANCE_TOLERANCE_RATIO = 0.02
|
|
65
75
|
|
|
66
76
|
# Floor for the window above, so a future shorter standard still matches
|
|
@@ -180,23 +190,6 @@ module AgeGrading
|
|
|
180
190
|
convert_to_seconds(time.to_s)
|
|
181
191
|
end
|
|
182
192
|
|
|
183
|
-
def normalize_age(age)
|
|
184
|
-
age_value = Integer(age)
|
|
185
|
-
rescue ArgumentError, TypeError
|
|
186
|
-
raise ArgumentError, 'Age must be an integer greater than or equal to 18'
|
|
187
|
-
else
|
|
188
|
-
raise ArgumentError, 'Age must be at least 18' if age_value < 18
|
|
189
|
-
|
|
190
|
-
age_value
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def normalize_sex(sex)
|
|
194
|
-
normalized = sex.to_s.strip.downcase.to_sym
|
|
195
|
-
return normalized if %i[male female].include?(normalized)
|
|
196
|
-
|
|
197
|
-
raise ArgumentError, "Sex must be 'male' or 'female'"
|
|
198
|
-
end
|
|
199
|
-
|
|
200
193
|
def interpolated_factor(sex, age, distance_m)
|
|
201
194
|
table = factor_table(sex, distance_m)
|
|
202
195
|
ages = table.keys.map(&:to_i).sort
|