calcpace 1.18.0 → 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 +395 -11
- data/README.md +330 -46
- data/calcpace.gemspec +9 -5
- data/lib/calcpace/age_grading.rb +29 -24
- 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/errors.rb +3 -0
- 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 +8 -1
- metadata +16 -9
- data/lib/calcpace/data/wma_2023_open_standards.yml +0 -38
- 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,10 +468,16 @@ calc.age_grade_label(65.2) # => "Local Clas
|
|
|
276
468
|
| 40–49.9% | Recreational |
|
|
277
469
|
| below 40% | Active Beginner |
|
|
278
470
|
|
|
279
|
-
The
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
471
|
+
The Alan Jones road tables are numeric age factors and open
|
|
472
|
+
standards only — they define no categories at all. The bands from Local Class
|
|
473
|
+
(60%) upward follow the USATF Masters / National Masters News convention; the
|
|
474
|
+
three bands below 60% are calcpace's own extension — most recreational
|
|
475
|
+
runners land there, and one catch-all label for everybody under 60% tells
|
|
476
|
+
them nothing.
|
|
477
|
+
|
|
478
|
+
The bands apply to the percentage as reported by `age_grade`, rounded to one
|
|
479
|
+
decimal, so calling `age_grade_label` directly on an unrounded value can land
|
|
480
|
+
one band lower at an edge.
|
|
283
481
|
|
|
284
482
|
Supported distances: 5K, 10K, half marathon, marathon.
|
|
285
483
|
|
|
@@ -287,8 +485,8 @@ A numeric distance within **2%** of one of those is graded as that standard —
|
|
|
287
485
|
a GPS watch rarely reads a 5K as exactly 5.000 km:
|
|
288
486
|
|
|
289
487
|
```ruby
|
|
290
|
-
calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # =>
|
|
291
|
-
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
|
|
292
490
|
|
|
293
491
|
calc.age_grade(7.79, '00:26:59', age: 36, sex: :male)
|
|
294
492
|
# => ArgumentError: Unsupported distance 7.79km. Supported: 5.0, 10.0, 21.0975, 42.195 km
|
|
@@ -296,20 +494,32 @@ calc.age_grade(7.79, '00:26:59', age: 36, sex: :male)
|
|
|
296
494
|
|
|
297
495
|
That refusal is deliberate, and it is where age grading parts ways with the
|
|
298
496
|
predictors above. A prediction is a formula and works at any distance; an age
|
|
299
|
-
grade is a lookup in the
|
|
497
|
+
grade is a lookup in the road table, which publishes a factor per *specific*
|
|
300
498
|
distance. There is no world standard for 7.79 km, so there is no honest
|
|
301
499
|
percentage to return — interpolating one would produce a number with the look
|
|
302
500
|
of an official standard and none of the authority.
|
|
303
501
|
|
|
304
|
-
Age factors
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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 |
|
|
310
520
|
|
|
311
521
|
Field meanings:
|
|
312
|
-
- `age_graded_time_clock`: your result after applying the
|
|
522
|
+
- `age_graded_time_clock`: your result after applying the age factor (normalized performance time).
|
|
313
523
|
- `open_standard_clock`: the open standard reference time used to compute the percentage for that distance/sex.
|
|
314
524
|
- `age_grade_percent`: `(open_standard_seconds / age_graded_time_seconds) * 100`.
|
|
315
525
|
|
|
@@ -349,6 +559,49 @@ VO2max = VO2 / %VO2max
|
|
|
349
559
|
|
|
350
560
|
Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace.
|
|
351
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
|
+
|
|
352
605
|
#### Contextualized estimation
|
|
353
606
|
|
|
354
607
|
`estimate_detailed_vo2max` returns a richer result that accounts for elevation, heart rate, and formula reliability:
|
|
@@ -400,6 +653,7 @@ Personalized training paces (Daniels' Running Formula) and Karvonen heart-rate z
|
|
|
400
653
|
zones = calc.training_paces(50.0)
|
|
401
654
|
zones[:threshold].fast_clock # => "00:04:15" per km
|
|
402
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)
|
|
403
657
|
|
|
404
658
|
calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock # => "00:06:51" per mile
|
|
405
659
|
|
|
@@ -418,13 +672,21 @@ calc.hr_zones_from_max(hr_max: 190)
|
|
|
418
672
|
| Zone | %VO2max | Purpose |
|
|
419
673
|
|------|---------|---------|
|
|
420
674
|
| Easy | 59–74% | Base building, recovery |
|
|
421
|
-
| Marathon | 75
|
|
675
|
+
| Marathon | 75% – predicted marathon pace (0.800–0.849) | Marathon race pace |
|
|
422
676
|
| Threshold | 83–88% | Lactate threshold, tempo runs |
|
|
423
677
|
| Interval | 95–100% | VO2max development |
|
|
424
678
|
| Repetition | 105–110% | Speed and running economy |
|
|
425
679
|
|
|
426
680
|
Pace accuracy vs published VDOT tables: within a few seconds per km
|
|
427
|
-
(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.
|
|
428
690
|
|
|
429
691
|
`unit:` sets the unit of the returned pace bands; `distance_unit:` sets the unit of a
|
|
430
692
|
numeric race distance you pass in. Combining `distance_unit:` with a race name raises
|
|
@@ -656,6 +918,24 @@ calc.convert_to_clocktime(3600) # => "01:00:00"
|
|
|
656
918
|
calc.check_time('01:00:00') # => nil (valid)
|
|
657
919
|
```
|
|
658
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
|
+
|
|
659
939
|
`convert_to_clocktime` takes a `compact:` keyword for the format a runner reads
|
|
660
940
|
on a screen — no zero hour, no leading zero on the most significant component:
|
|
661
941
|
|
|
@@ -681,10 +961,14 @@ call without it returns exactly what it returned before.
|
|
|
681
961
|
|
|
682
962
|
All errors inherit from `Calcpace::Error`:
|
|
683
963
|
|
|
684
|
-
- `Calcpace::NonPositiveInputError` — numeric input is zero or
|
|
685
|
-
- `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)
|
|
686
968
|
- `Calcpace::UnsupportedUnitError` — unknown conversion (`convert`) or unknown
|
|
687
969
|
`unit:` / `distance_unit:` keyword
|
|
970
|
+
- `Calcpace::InvalidDataError` — the bundled data table failed its load-time
|
|
971
|
+
consistency check (raised on `require`, never for user input)
|
|
688
972
|
|
|
689
973
|
Argument validation that is not about units or numbers raises a plain `ArgumentError`:
|
|
690
974
|
unknown race names, unsupported age-grading distances, and invalid `age` / `sex` values.
|
|
@@ -697,7 +981,7 @@ unknown race names, unsupported age-grading distances, and invalid `age` / `sex`
|
|
|
697
981
|
bundle exec rake
|
|
698
982
|
```
|
|
699
983
|
|
|
700
|
-
Requires Ruby >= 3.
|
|
984
|
+
Requires Ruby >= 3.3.0. Tested with Ruby 3.3, 3.4, and 4.0.
|
|
701
985
|
|
|
702
986
|
## Contributing
|
|
703
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'
|