calcpace 1.18.1 → 2.1.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 +375 -1
- data/README.md +347 -43
- data/calcpace.gemspec +10 -6
- 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/errors.rb +7 -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_effort.rb +43 -0
- data/lib/calcpace/vo2max_estimator.rb +61 -16
- data/lib/calcpace/vo2max_norms.rb +121 -0
- data/lib/calcpace.rb +8 -0
- metadata +17 -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,15 @@
|
|
|
1
|
+
<p align="center"><img src="https://raw.githubusercontent.com/0jonjo/calcpace/main/docs/logo.svg" alt="calcpace logo" width="128"></p>
|
|
2
|
+
|
|
1
3
|
# Calcpace [](https://badge.fury.io/rb/calcpace)
|
|
2
4
|
|
|
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.
|
|
5
|
+
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
6
|
|
|
5
7
|
> **See it in action:** [calcpace.app](https://calcpace.app) — free running calculators, race predictors and a training log, all powered by this gem.
|
|
6
8
|
|
|
7
9
|
## Installation
|
|
8
10
|
|
|
9
11
|
```ruby
|
|
10
|
-
gem 'calcpace', '~>
|
|
12
|
+
gem 'calcpace', '~> 2.1'
|
|
11
13
|
```
|
|
12
14
|
|
|
13
15
|
## Usage
|
|
@@ -27,7 +29,7 @@ calc.pace(3665, 12) # => 305.4 (time / distance)
|
|
|
27
29
|
calc.time(210, 12) # => 2520 (pace × distance)
|
|
28
30
|
calc.distance(9660, 120) # => 80.5 (velocity × time)
|
|
29
31
|
|
|
30
|
-
# Clocktime input/output (HH:MM:SS or MM:SS)
|
|
32
|
+
# Clocktime input/output (HH:MM:SS or MM:SS; seconds below 60, and minutes too when hours are given)
|
|
31
33
|
calc.clock_pace('01:00:00', 10) # => "00:06:00"
|
|
32
34
|
calc.clock_time('00:05:31', 12.6) # => "01:09:30"
|
|
33
35
|
calc.checked_distance('01:21:32', '00:06:27') # => 12.64
|
|
@@ -37,8 +39,50 @@ calc.checked_distance('01:21:32', '00:06:27') # => 12.64
|
|
|
37
39
|
|
|
38
40
|
### Environmental Performance Adjustments
|
|
39
41
|
|
|
40
|
-
Adjust race performance based on heat and altitude. Calculations are based on scientific models
|
|
41
|
-
(
|
|
42
|
+
Adjust race performance based on heat, humidity and altitude. Calculations are based on scientific models
|
|
43
|
+
(El Helou et al. 2012 and Ely et al. 2007 for heat, the Australian Bureau of Meteorology's
|
|
44
|
+
simplified WBGT for humidity, NCAA standards for altitude).
|
|
45
|
+
|
|
46
|
+
- **Altitude**: no penalty up to 300 m, then a linear ramp to the first NCAA point
|
|
47
|
+
(914.4 m → 1.41%), the NCAA table up to 2438.4 m (5.90%), and an extrapolated
|
|
48
|
+
curve beyond it (3000 m → 7.92%, 3500 m → 9.97%, 4000 m → 12.2%, capped there).
|
|
49
|
+
São Paulo (760 m) gets ~1.06%.
|
|
50
|
+
- **Heat**: a 60-minute baseline `4.3 · ((T − 15) / 10)^1.5` (0% at 15 °C,
|
|
51
|
+
1.52% at 20 °C, 4.3% at 25 °C, 7.9% at 30 °C; extrapolated to 12.16% at
|
|
52
|
+
35 °C and capped there, so 35–40 °C and hotter all read like 35 °C), stored
|
|
53
|
+
as points every 2.5 °C, then
|
|
54
|
+
scaled by effort duration: 0.5× up to 30 min, 1.0× at 60 min, 1.76× at 3 h,
|
|
55
|
+
2.81× at 4 h and beyond (linear in between, so 1.38× at 2 h). The exponent
|
|
56
|
+
and the 3 h / 4 h points are fitted to El Helou et al. (2012, Table S3: eight
|
|
57
|
+
finisher groups, 2:41–4:54, time penalty against 15 °C at 20 and 25 °C,
|
|
58
|
+
which grows ~2.8× from 20 to 25 °C in every group); the derivation table is
|
|
59
|
+
in `lib/calcpace/data/environmental_factors.yml`. The 25 °C / 60-minute
|
|
60
|
+
anchor (4.3%) and the 30/60-minute factors have no direct published source.
|
|
61
|
+
- **Humidity** (optional): pass `humidity:` (relative humidity, %) or
|
|
62
|
+
`dew_point:` (in `temperature_unit`). Without either, the heat curve assumes
|
|
63
|
+
50% humidity. With one, the temperature is replaced by the effective
|
|
64
|
+
temperature that has the same simplified WBGT (`0.567·Ta + 0.393·e + 3.94`)
|
|
65
|
+
at 50% humidity, and `factors` reports it as `:effective_temperature_celsius`.
|
|
66
|
+
|
|
67
|
+
| Heat penalty (%) | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min |
|
|
68
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
69
|
+
| 20 °C | 0.76 | 1.52 | 2.1 | 2.68 | 4.27 | 4.27 |
|
|
70
|
+
| 25 °C | 2.15 | 4.3 | 5.93 | 7.57 | 12.08 | 12.08 |
|
|
71
|
+
| 30 °C | 3.95 | 7.9 | 10.9 | 13.9 | 22.2 | 22.2 |
|
|
72
|
+
| 35 °C | 6.08 | 12.16 | 16.78 | 21.4 | 34.17 | 34.17 |
|
|
73
|
+
| 40 °C | 6.08 | 12.16 | 16.78 | 21.4 | 34.17 | 34.17 |
|
|
74
|
+
|
|
75
|
+
| 30 °C at | Effective temperature | 60 min | 4 h |
|
|
76
|
+
| --- | --- | --- | --- |
|
|
77
|
+
| 30% RH | 26.69 °C | 5.46% | 15.34% |
|
|
78
|
+
| 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% |
|
|
79
|
+
| 70% RH | 33.07 °C | 10.46% | 29.39% |
|
|
80
|
+
| 90% RH | 35.94 °C (capped at 35 °C) | 12.16% | 34.17% |
|
|
81
|
+
|
|
82
|
+
Above ~25 °C the numbers are extrapolations of the fitted curve: the marathon
|
|
83
|
+
studies behind it have no data there (El Helou's hottest race was 25.2 °C). The
|
|
84
|
+
cap at 35 °C is a deliberate choice for the same reason: the uncapped curve gave
|
|
85
|
+
47.77% for 4 h at 40 °C.
|
|
42
86
|
|
|
43
87
|
```ruby
|
|
44
88
|
# Calculate penalty for 25°C and 2000m altitude (Defaults to 60-min effort)
|
|
@@ -50,25 +94,30 @@ penalty = calc.calculate_penalty(temperature: 25, altitude: 2000)
|
|
|
50
94
|
|
|
51
95
|
# Fahrenheit support
|
|
52
96
|
calc.calculate_penalty(temperature: 80, temperature_unit: :f)
|
|
53
|
-
# => { total_penalty_percent: 5.
|
|
97
|
+
# => { total_penalty_percent: 5.44, ... }
|
|
98
|
+
|
|
99
|
+
# Humidity: 30 °C at 90% hits like 35.94 °C at 50% (which reads like the 35 °C cap)
|
|
100
|
+
calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] # => 12.16
|
|
101
|
+
calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] # => 35.94
|
|
102
|
+
calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] # => 11.07
|
|
54
103
|
|
|
55
104
|
# Adjust a 3:30 marathon time (12600s) for these conditions (High exposure penalty)
|
|
56
105
|
result = calc.adjust_time(12600, temperature: 25, altitude: 2000)
|
|
57
106
|
# => {
|
|
58
107
|
# original_time: 12600,
|
|
59
|
-
# adjusted_time:
|
|
60
|
-
# adjusted_time_clock: "
|
|
61
|
-
# penalty_percent:
|
|
62
|
-
# factors: { heat:
|
|
108
|
+
# adjusted_time: 14382.9,
|
|
109
|
+
# adjusted_time_clock: "03:59:42",
|
|
110
|
+
# penalty_percent: 14.15,
|
|
111
|
+
# factors: { heat: 9.83, altitude: 4.32 }
|
|
63
112
|
# }
|
|
64
113
|
|
|
65
114
|
# Predicted adjusted times (Riegel formula)
|
|
66
115
|
calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 28)
|
|
67
|
-
# => { adjusted_time:
|
|
116
|
+
# => { adjusted_time: 2613.0, adjusted_time_clock: "00:43:33", penalty_percent: 4.44, ... }
|
|
68
117
|
|
|
69
118
|
# Predicted adjusted times (Cameron formula)
|
|
70
119
|
calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 80, temperature_unit: :f)
|
|
71
|
-
# => { adjusted_time:
|
|
120
|
+
# => { adjusted_time: 12400.47, adjusted_time_clock: "03:26:40", penalty_percent: 10.28, ... }
|
|
72
121
|
```
|
|
73
122
|
|
|
74
123
|
---
|
|
@@ -139,8 +188,10 @@ calc.race_splits('half_marathon', target_time: '01:30:00', split_distance: '5k')
|
|
|
139
188
|
# => ["00:21:20", "00:42:40", "01:03:59", "01:25:19", "01:30:00"]
|
|
140
189
|
|
|
141
190
|
# Strategies: :even (default), :negative (second half faster), :positive (first half faster)
|
|
191
|
+
# :negative runs the first half 1% slower than average pace and the second half 1% faster;
|
|
192
|
+
# :positive is the mirror image. A 3:00:00 marathon splits 1:30:54 + 1:29:06 (:negative).
|
|
142
193
|
calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative)
|
|
143
|
-
# => ["00:20:
|
|
194
|
+
# => ["00:20:12", "00:40:00"]
|
|
144
195
|
|
|
145
196
|
# The race may be a plain distance too; the last split is always the finish
|
|
146
197
|
calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k')
|
|
@@ -157,14 +208,22 @@ calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k')
|
|
|
157
208
|
calc.predict_time_clock('5k', '00:20:00', 'marathon') # => "03:11:49"
|
|
158
209
|
calc.predict_pace_clock('5k', '00:20:00', 'marathon') # => "00:04:32"
|
|
159
210
|
calc.equivalent_performance('10k', '00:42:00', '5k')
|
|
160
|
-
# => { time: 1208.
|
|
211
|
+
# => { time: 1208.6727903498331, time_clock: "00:20:08", pace: 241.73455806996662, pace_clock: "00:04:01" }
|
|
161
212
|
```
|
|
162
213
|
|
|
163
|
-
**Cameron formula** (
|
|
214
|
+
**Cameron formula** (Dave Cameron's velocity-ratio model, fitted to world bests from
|
|
215
|
+
800 m to the marathon — more conservative than Riegel when predicting the marathon
|
|
216
|
+
from shorter races):
|
|
217
|
+
|
|
218
|
+
`T2 = T1 × (D2/D1) × f(D1)/f(D2)`, with `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905`
|
|
219
|
+
and `d` in metres (distances are still passed in km or as race names).
|
|
220
|
+
Both distances must be at most `CameronPredictor::CAMERON_MAX_DISTANCE_KM` (100 km):
|
|
221
|
+
the model is fitted up to the marathon and breaks down far beyond it, so longer
|
|
222
|
+
distances raise `ArgumentError`.
|
|
164
223
|
|
|
165
224
|
```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:
|
|
225
|
+
calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "03:16:46"
|
|
226
|
+
calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:39"
|
|
168
227
|
```
|
|
169
228
|
|
|
170
229
|
**Any distance, on either end.** Both formulas are arithmetic on two distances,
|
|
@@ -173,7 +232,7 @@ so neither end has to be a standard race:
|
|
|
173
232
|
```ruby
|
|
174
233
|
# From a 7.79 km club race in 26:59
|
|
175
234
|
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:
|
|
235
|
+
calc.predict_time_cameron_clock(7.79, '00:26:59', 'half_marathon') # => "01:17:26"
|
|
177
236
|
|
|
178
237
|
# To an unnamed distance, and between two of them
|
|
179
238
|
calc.predict_time_clock('10k', '00:42:00', 15) # => "01:04:33"
|
|
@@ -198,6 +257,85 @@ the caller's, and it always was, standard race names included.
|
|
|
198
257
|
|
|
199
258
|
---
|
|
200
259
|
|
|
260
|
+
### Personalized Predictions
|
|
261
|
+
|
|
262
|
+
**Marathon from training volume** — Tanda (2011), no race result needed. The
|
|
263
|
+
inputs are the mean weekly distance and the mean training pace over the 8 weeks
|
|
264
|
+
ending one week before the race:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')
|
|
268
|
+
# => { time: 11981.88, time_clock: "03:19:41", pace: 283.96, pace_clock: "00:04:43",
|
|
269
|
+
# within_validated_range: true, out_of_range: [] }
|
|
270
|
+
|
|
271
|
+
calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30')
|
|
272
|
+
# => { time: 13158.72, time_clock: "03:39:18", pace: 311.86, pace_clock: "00:05:11",
|
|
273
|
+
# within_validated_range: false, out_of_range: [:weekly_distance, :marathon_time] }
|
|
274
|
+
|
|
275
|
+
# unit: :mi — weekly miles, pace per mile in and out
|
|
276
|
+
calc.predict_marathon_from_training(weekly_distance: 25, training_pace: '08:00', unit: :mi)[:pace_clock] # => "00:07:53"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The equation is `Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P` (Pm marathon
|
|
280
|
+
pace in s/km, K km/week, P s/km). Training pace is the plain average of every
|
|
281
|
+
run — total time over total distance, warm-ups and easy days included — not the
|
|
282
|
+
pace of the hard sessions. The paper reports a standard error of about 4 minutes.
|
|
283
|
+
`weekly_distance` may be a number or a numeric string (`'60'`); `training_pace`
|
|
284
|
+
is seconds or an `MM:SS` / `HH:MM:SS` string.
|
|
285
|
+
|
|
286
|
+
It was fitted on 22 experienced runners (21 men) and 46 marathons, so it is only
|
|
287
|
+
validated inside that sample: 40.4–110.7 km/week, training pace 253.3–330.6 s/km (4:13–5:30/km),
|
|
288
|
+
finish 2:47–3:36. Outside it the prediction is still returned, and
|
|
289
|
+
`out_of_range` names what fell outside (`:weekly_distance`, `:training_pace`,
|
|
290
|
+
`:marathon_time`) — a warning, not an error. A low-volume runner at an easy pace
|
|
291
|
+
will usually see all three.
|
|
292
|
+
|
|
293
|
+
> G. Tanda, "Prediction of marathon performance time on the basis of training
|
|
294
|
+
> indices", *Journal of Human Sport and Exercise* 6(3):511–520, 2011.
|
|
295
|
+
> doi:10.4100/jhse.2011.63.05
|
|
296
|
+
|
|
297
|
+
**Personal Riegel exponent** — fit the fatigue factor to two of your own races
|
|
298
|
+
instead of the population 1.06:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') # => 1.0961
|
|
302
|
+
|
|
303
|
+
calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')
|
|
304
|
+
# => { time: 13083.04, time_clock: "03:38:03", exponent: 1.0961, raw_exponent: 1.0961, clamped: false }
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The standard Riegel gives 3:32:39 from that half and 3:27:00 from that 10K; this
|
|
308
|
+
runner fades more than average, and the personal exponent says so.
|
|
309
|
+
|
|
310
|
+
When the target lies outside the two races, the prediction extrapolates from
|
|
311
|
+
whichever race is closer to it (in log-distance), with the exponent clamped to
|
|
312
|
+
1.01–1.20:
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
calc.predict_time_personal('5k', '00:20:00', '10k', '00:50:00', 'half_marathon')
|
|
316
|
+
# => { time: 7348.5, time_clock: "02:02:28", exponent: 1.2, raw_exponent: 1.3219, clamped: true }
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
When the target lies between them, it interpolates along the curve through both
|
|
320
|
+
performances with the raw exponent, never clamped: the runner's own data
|
|
321
|
+
already brackets the answer, and the result does not depend on which race comes
|
|
322
|
+
first.
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
calc.predict_time_personal(5, 1200, 20, 3000, 10)[:time] # => 1897.37
|
|
326
|
+
calc.predict_time_personal(20, 3000, 5, 1200, 10)[:time] # => 1897.37
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
An exponent outside that range — or `clamped: true` — usually means one of the
|
|
330
|
+
two races was not an all-out effort, or was run on a course or day that does
|
|
331
|
+
not compare with the other — the raw exponent in an interpolation
|
|
332
|
+
(0.661 above) is worth the same suspicion. Both races may be names or distances
|
|
333
|
+
in km; two races at the same distance, or a target equal to one of them, raise
|
|
334
|
+
`ArgumentError`. Times are seconds or `HH:MM:SS` / `MM:SS` strings; anything
|
|
335
|
+
else raises `Calcpace::InvalidTimeFormatError`.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
201
339
|
### GPS Track Analysis
|
|
202
340
|
|
|
203
341
|
Accepts an array of hashes with `:lat`, `:lon`, and optionally `:ele` (metres) and `:time` (`Time`):
|
|
@@ -238,6 +376,62 @@ in both formats (`"-00:40"` / `"-0:40"`) rather than raising.
|
|
|
238
376
|
|
|
239
377
|
**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
378
|
|
|
379
|
+
#### Grade-adjusted pace (GAP)
|
|
380
|
+
|
|
381
|
+
The flat-ground pace that costs the same energy as a pace run on a slope, from
|
|
382
|
+
the energy cost of running on gradients measured by **Minetti et al. (2002)**.
|
|
383
|
+
The grade is a fraction (rise over horizontal distance): `0.05` is 5% uphill,
|
|
384
|
+
`-0.05` is 5% downhill.
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
calc.grade_adjustment_factor(0.1) # => 1.6578372222222222 (a metre at +10% ≈ 1.66 flat metres)
|
|
388
|
+
calc.grade_adjustment_factor(-0.1) # => 0.5976961111111111
|
|
389
|
+
|
|
390
|
+
calc.grade_adjusted_pace(360, 0.1) # => 217.1503903847952 (s/km)
|
|
391
|
+
calc.grade_adjusted_pace_clock('06:00', 0.1) # => "00:03:37"
|
|
392
|
+
calc.grade_adjusted_pace_clock('06:00', 0.1, compact: true) # => "3:37"
|
|
393
|
+
calc.grade_adjusted_pace(480, 0.05, unit: :mi) # => 368.82127811700303 (s/mi)
|
|
394
|
+
|
|
395
|
+
# Per-split GAP for a GPS track: the track_splits fields plus :gap
|
|
396
|
+
calc.track_grade_adjusted_splits(points, 1.0)
|
|
397
|
+
# => [{ km: 1.0, elapsed: 415, pace: "06:55", gap: "06:50" },
|
|
398
|
+
# { km: 1.51, elapsed: 600, pace: "06:04", gap: "06:21" }]
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
| Grade | −10% | −5% | 0% | +5% | +10% |
|
|
402
|
+
|-------|------|-----|----|-----|------|
|
|
403
|
+
| Factor | 0.598 | 0.763 | 1.000 | 1.301 | 1.658 |
|
|
404
|
+
|
|
405
|
+
**Formula** (J·kg⁻¹·m⁻¹, R² = 0.999):
|
|
406
|
+
```
|
|
407
|
+
Cr(i) = 155.4·i⁵ − 30.4·i⁴ − 43.3·i³ + 46.3·i² + 19.5·i + 3.6
|
|
408
|
+
factor = Cr(i) / Cr(0)
|
|
409
|
+
GAP = pace / factor
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
- Grades are clamped to **±45%**, the range Minetti et al. measured; nothing
|
|
413
|
+
is extrapolated beyond it. Running is cheapest near −20% and gets dearer
|
|
414
|
+
again on steeper descents.
|
|
415
|
+
- It is a metabolic model: it does not see the muscular cost of long descents
|
|
416
|
+
or technical terrain, and field models fitted to heart rate (Strava's, for
|
|
417
|
+
instance) are gentler on steep climbs.
|
|
418
|
+
- `track_grade_adjusted_splits` leaves `track_splits` untouched: it returns the
|
|
419
|
+
same `:km`, `:elapsed` and `:pace` with `:gap` added (formatted like `:pace`,
|
|
420
|
+
`compact:` applies to both). GPS elevation is noisy, so grades are measured
|
|
421
|
+
over **grade segments of at least 100 m** of horizontal distance — read
|
|
422
|
+
between fixes a metre apart, ±2 m of jitter would be a ±400% grade. A short
|
|
423
|
+
leftover at the end of a stretch joins the segment before it. Stretches
|
|
424
|
+
between points without `:ele` (or with a NaN/infinite one) count as flat,
|
|
425
|
+
so a track with no elevation has `:gap` equal to `:pace`; so does a stretch
|
|
426
|
+
with elevation shorter than 100 m that has no full segment before it to
|
|
427
|
+
join (between missing fixes, or a whole track that short).
|
|
428
|
+
- Track distances are horizontal (Haversine), and the factor is applied to
|
|
429
|
+
them without the √(1 + grade²) slope-length correction — 0.5% at 10%.
|
|
430
|
+
- `estimate_detailed_vo2max` keeps its own flat elevation heuristic (100 m of
|
|
431
|
+
gain = 600 m of flat), so its numbers do not change.
|
|
432
|
+
|
|
433
|
+
*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*
|
|
434
|
+
|
|
241
435
|
---
|
|
242
436
|
|
|
243
437
|
### Age Grading (Road Races)
|
|
@@ -249,18 +443,18 @@ age factors and open standards.
|
|
|
249
443
|
result = calc.age_grade(10.0, '00:45:00', age: 55, sex: :male)
|
|
250
444
|
# numeric distances also accepted in miles: calc.age_grade(6.21371, '00:45:00', age: 55, sex: :male, distance_unit: :mi)
|
|
251
445
|
# => {
|
|
252
|
-
# age_grade_percent:
|
|
446
|
+
# age_grade_percent: 68.9,
|
|
253
447
|
# 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: "
|
|
448
|
+
# age_graded_time_seconds: 2297.97,
|
|
449
|
+
# age_graded_time_clock: "00:38:17",
|
|
450
|
+
# open_standard_seconds: 1584.0,
|
|
451
|
+
# open_standard_clock: "00:26:24",
|
|
452
|
+
# factor: 0.8511,
|
|
453
|
+
# table_version: "MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"
|
|
260
454
|
# }
|
|
261
455
|
|
|
262
|
-
calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.
|
|
263
|
-
calc.age_grade_label(65.
|
|
456
|
+
calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.0
|
|
457
|
+
calc.age_grade_label(65.0) # => "Local Class"
|
|
264
458
|
```
|
|
265
459
|
|
|
266
460
|
`category` (and `age_grade_label`) returns one of:
|
|
@@ -276,7 +470,7 @@ calc.age_grade_label(65.2) # => "Local Clas
|
|
|
276
470
|
| 40–49.9% | Recreational |
|
|
277
471
|
| below 40% | Active Beginner |
|
|
278
472
|
|
|
279
|
-
The
|
|
473
|
+
The Alan Jones road tables are numeric age factors and open
|
|
280
474
|
standards only — they define no categories at all. The bands from Local Class
|
|
281
475
|
(60%) upward follow the USATF Masters / National Masters News convention; the
|
|
282
476
|
three bands below 60% are calcpace's own extension — most recreational
|
|
@@ -293,8 +487,8 @@ A numeric distance within **2%** of one of those is graded as that standard —
|
|
|
293
487
|
a GPS watch rarely reads a 5K as exactly 5.000 km:
|
|
294
488
|
|
|
295
489
|
```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) # =>
|
|
490
|
+
calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # => 54.1
|
|
491
|
+
calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 54.1
|
|
298
492
|
|
|
299
493
|
calc.age_grade(7.79, '00:26:59', age: 36, sex: :male)
|
|
300
494
|
# => ArgumentError: Unsupported distance 7.79km. Supported: 5.0, 10.0, 21.0975, 42.195 km
|
|
@@ -302,20 +496,32 @@ calc.age_grade(7.79, '00:26:59', age: 36, sex: :male)
|
|
|
302
496
|
|
|
303
497
|
That refusal is deliberate, and it is where age grading parts ways with the
|
|
304
498
|
predictors above. A prediction is a formula and works at any distance; an age
|
|
305
|
-
grade is a lookup in the
|
|
499
|
+
grade is a lookup in the road table, which publishes a factor per *specific*
|
|
306
500
|
distance. There is no world standard for 7.79 km, so there is no honest
|
|
307
501
|
percentage to return — interpolating one would produce a number with the look
|
|
308
502
|
of an official standard and none of the authority.
|
|
309
503
|
|
|
310
|
-
Age factors
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
504
|
+
Age factors and open standards come from Alan Jones' **2025 road** age-grading
|
|
505
|
+
tables, approved on 2025-01-10 by the USATF Masters Long Distance Running
|
|
506
|
+
Council — the standard for road races, the same tables behind Howard Grubb's
|
|
507
|
+
MLDR road calculator. The source spreadsheets are `MaleRoadStd2025.xlsx` and
|
|
508
|
+
`FemaleRoadStd2025.xlsx`, linked here at the commit the bundled data was
|
|
509
|
+
taken from:
|
|
510
|
+
https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files
|
|
511
|
+
The bundled data has one factor per year of age from 18 to 100 (older ages use
|
|
512
|
+
the age-100 factor) and lives in `lib/calcpace/data/mldr_2025_road.yml` (factors)
|
|
513
|
+
and `lib/calcpace/data/mldr_2025_road_open_standards.yml` (open standards and
|
|
514
|
+
category labels).
|
|
515
|
+
|
|
516
|
+
| Distance | Open standard (men) | Open standard (women) |
|
|
517
|
+
| --- | --- | --- |
|
|
518
|
+
| 5K | 12:49 | 13:54 |
|
|
519
|
+
| 10K | 26:24 | 28:46 |
|
|
520
|
+
| Half marathon | 57:31 | 1:02:52 |
|
|
521
|
+
| Marathon | 2:00:35 | 2:09:56 |
|
|
316
522
|
|
|
317
523
|
Field meanings:
|
|
318
|
-
- `age_graded_time_clock`: your result after applying the
|
|
524
|
+
- `age_graded_time_clock`: your result after applying the age factor (normalized performance time).
|
|
319
525
|
- `open_standard_clock`: the open standard reference time used to compute the percentage for that distance/sex.
|
|
320
526
|
- `age_grade_percent`: `(open_standard_seconds / age_graded_time_seconds) * 100`.
|
|
321
527
|
|
|
@@ -355,6 +561,49 @@ VO2max = VO2 / %VO2max
|
|
|
355
561
|
|
|
356
562
|
Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace.
|
|
357
563
|
|
|
564
|
+
#### By age and sex
|
|
565
|
+
|
|
566
|
+
The fixed thresholds above are the same for everyone. Give `vo2max_label` an
|
|
567
|
+
age and a sex and it reads the value against people of the same sex and age
|
|
568
|
+
decade instead, using the **FRIEND registry** percentiles of VO2max measured on
|
|
569
|
+
a treadmill (Kaminsky, Arena & Myers, 2015):
|
|
570
|
+
|
|
571
|
+
```ruby
|
|
572
|
+
calc.vo2max_label(45) # => "Good" (fixed thresholds, unchanged)
|
|
573
|
+
calc.vo2max_label(45, age: 25, sex: :male) # => "Fair"
|
|
574
|
+
calc.vo2max_label(45, age: 60, sex: :male) # => "Elite"
|
|
575
|
+
calc.vo2max_label(45, age: 25, sex: :female) # => "Very Good"
|
|
576
|
+
calc.vo2max_label(45, age: 60, sex: :female) # => "Elite"
|
|
577
|
+
|
|
578
|
+
calc.vo2max_percentile(45, age: 25, sex: :male) # => 40.5
|
|
579
|
+
calc.vo2max_percentile(45, age: 25, sex: :female) # => 75.7
|
|
580
|
+
calc.vo2max_percentile(45, age: 60, sex: :male) # => 95.0
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
| Percentile (same sex and age decade) | Level |
|
|
584
|
+
|--------------------------------------|-----------|
|
|
585
|
+
| ≥ 95th | Elite |
|
|
586
|
+
| 90th–94th | Excellent |
|
|
587
|
+
| 75th–89th | Very Good |
|
|
588
|
+
| 50th–74th | Good |
|
|
589
|
+
| 25th–49th | Fair |
|
|
590
|
+
| < 25th | Beginner |
|
|
591
|
+
|
|
592
|
+
- The cuts sit on percentiles the table publishes (5th, 10th, 25th, 50th,
|
|
593
|
+
75th, 90th, 95th), so a label never depends on interpolation. They are
|
|
594
|
+
calcpace's choice: FRIEND publishes percentiles, not labels.
|
|
595
|
+
- `vo2max_percentile` interpolates linearly between the published percentiles,
|
|
596
|
+
rounded to one decimal, and is bounded to the table: `5.0` means at or below
|
|
597
|
+
the 5th percentile, `95.0` at or above the 95th.
|
|
598
|
+
- Age decades (20–29 … 70–79) are used as published, without blending, so a
|
|
599
|
+
29- and a 30-year-old read different rows. Ages 18–19 use the 20–29 row and
|
|
600
|
+
80+ the 70–79 row; under 18 raises `ArgumentError`, as does a sex other than
|
|
601
|
+
male/female. Age and sex must be given together.
|
|
602
|
+
- The registry measured VO2max in a lab; a VO2max estimated from a race time
|
|
603
|
+
carries its own ±3–5 ml/kg/min on top.
|
|
604
|
+
|
|
605
|
+
*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.*
|
|
606
|
+
|
|
358
607
|
#### Contextualized estimation
|
|
359
608
|
|
|
360
609
|
`estimate_detailed_vo2max` returns a richer result that accounts for elevation, heart rate, and formula reliability:
|
|
@@ -396,6 +645,32 @@ easy.value # => 29.3 (underestimates real aerobic capacity)
|
|
|
396
645
|
> If you provide heart rate data, both `hr_avg` and `hr_max` must be present.
|
|
397
646
|
> `elevation_gain_m` must be zero or positive.
|
|
398
647
|
|
|
648
|
+
`result.basis` says what set the confidence: `:duration` (the table above), `:heart_rate` (average HR under 85 % of max) or `:declared` (the `effort:` below).
|
|
649
|
+
|
|
650
|
+
#### Effort
|
|
651
|
+
|
|
652
|
+
The formula assumes an all-out effort, and duration is only a proxy for that. When the runner knows how hard it was, pass `effort:` — one of `Calcpace::EFFORT_LEVELS`, easiest first (a Symbol or a String):
|
|
653
|
+
|
|
654
|
+
| `effort` | How it felt | `confidence` | `sub_maximal` |
|
|
655
|
+
|---|---|---|---|
|
|
656
|
+
| `:easy` | Could talk freely | `:low` | `true` |
|
|
657
|
+
| `:moderate` | Short sentences, still comfortable | `:low` | `true` |
|
|
658
|
+
| `:hard` | A few words at a time | duration and heart-rate rules, as without `effort:` | |
|
|
659
|
+
| `:max` | Everything I had: a race or a test | `:high` from 5 min on, however long it took (unless HR < 85 %) | `false` (unless HR < 85 %) |
|
|
660
|
+
| `nil` (default) | — | exactly as in 2.0.0 | |
|
|
661
|
+
|
|
662
|
+
```ruby
|
|
663
|
+
calc.estimate_detailed_vo2max(42.195, '03:30:00').confidence # => :low (over 2 h)
|
|
664
|
+
calc.estimate_detailed_vo2max(42.195, '03:30:00', effort: :max).confidence # => :high (a race says it was all-out)
|
|
665
|
+
calc.estimate_detailed_vo2max(10.0, '00:55:00', effort: :easy).sub_maximal # => true
|
|
666
|
+
calc.estimate_detailed_vo2max(10.0, '00:55:00', effort: :easy).basis # => :declared
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
- **The value never changes.** An easy run still gives the number the formula gives, which then reads as "at least this much"; only `confidence`, `sub_maximal` and `basis` move.
|
|
670
|
+
- An easy or moderate effort is sub-maximal whatever the heart rate says. Heart rate under 85 % of max still overrules a declared `:max` (`basis: :heart_rate`), and a `:max` under five minutes keeps the duration rule, where the formula itself is unreliable.
|
|
671
|
+
- Without heart rate, `:hard` and `:max` are not sub-maximal: nothing says they were.
|
|
672
|
+
- An unknown effort raises `Calcpace::InvalidEffortError`.
|
|
673
|
+
|
|
399
674
|
---
|
|
400
675
|
|
|
401
676
|
### Training Zones
|
|
@@ -406,6 +681,7 @@ Personalized training paces (Daniels' Running Formula) and Karvonen heart-rate z
|
|
|
406
681
|
zones = calc.training_paces(50.0)
|
|
407
682
|
zones[:threshold].fast_clock # => "00:04:15" per km
|
|
408
683
|
zones[:easy].slow_clock # => "00:05:52" per km
|
|
684
|
+
zones[:marathon].fast_clock # => "00:04:31" per km (the VDOT-predicted marathon pace)
|
|
409
685
|
|
|
410
686
|
calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock # => "00:06:51" per mile
|
|
411
687
|
|
|
@@ -424,13 +700,21 @@ calc.hr_zones_from_max(hr_max: 190)
|
|
|
424
700
|
| Zone | %VO2max | Purpose |
|
|
425
701
|
|------|---------|---------|
|
|
426
702
|
| Easy | 59–74% | Base building, recovery |
|
|
427
|
-
| Marathon | 75
|
|
703
|
+
| Marathon | 75% – predicted marathon pace (0.800–0.849) | Marathon race pace |
|
|
428
704
|
| Threshold | 83–88% | Lactate threshold, tempo runs |
|
|
429
705
|
| Interval | 95–100% | VO2max development |
|
|
430
706
|
| Repetition | 105–110% | Speed and running economy |
|
|
431
707
|
|
|
432
708
|
Pace accuracy vs published VDOT tables: within a few seconds per km
|
|
433
|
-
(threshold matches exactly; easy band is a range heuristic).
|
|
709
|
+
(threshold matches exactly; easy band is a range heuristic). The fast end of the
|
|
710
|
+
marathon band is the marathon pace `predict_time_from_vo2max` gives for the same
|
|
711
|
+
VO2max (Daniels' M pace is the predicted marathon race pace); that prediction
|
|
712
|
+
covers VO2max 10–100, and outside it the race-pace intensity of the nearest bound
|
|
713
|
+
is used. That intensity is 0.800–0.849 of VO2max (0.805 at VO2max 30, 0.830 at 70),
|
|
714
|
+
so the marathon band stays slower than the threshold band below VO2max ~69.5, as in
|
|
715
|
+
Daniels. `TRAINING_INTENSITIES[:marathon][:high]` stays 0.84 as the nominal upper
|
|
716
|
+
bound; `PREDICTED_RACE_PACE_ZONES` lists the zones whose fast end is the predicted
|
|
717
|
+
race pace.
|
|
434
718
|
|
|
435
719
|
`unit:` sets the unit of the returned pace bands; `distance_unit:` sets the unit of a
|
|
436
720
|
numeric race distance you pass in. Combining `distance_unit:` with a race name raises
|
|
@@ -662,6 +946,24 @@ calc.convert_to_clocktime(3600) # => "01:00:00"
|
|
|
662
946
|
calc.check_time('01:00:00') # => nil (valid)
|
|
663
947
|
```
|
|
664
948
|
|
|
949
|
+
Every time or pace string the gem reads goes through `convert_to_seconds`, so
|
|
950
|
+
every method reads the same clocks, and every clock the gem writes is among them:
|
|
951
|
+
|
|
952
|
+
```ruby
|
|
953
|
+
calc.convert_to_seconds('75:00') # => 4500 (MM:SS keeps counting minutes)
|
|
954
|
+
calc.convert_to_seconds('400:00:00') # => 1440000 (any number of hours)
|
|
955
|
+
calc.convert_to_seconds('1 03:46:40') # => 100000 (convert_to_clocktime's day prefix)
|
|
956
|
+
calc.convert_to_seconds('-0:40') # => -40 (a backwards track_splits split)
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
Seconds must be two digits below 60, and so must minutes when hours are given;
|
|
960
|
+
after a day prefix the hours are two digits below 24. A leading `-` is the only
|
|
961
|
+
sign, blanks or surrounding whitespace are not trimmed, and the string must be
|
|
962
|
+
in a valid, ASCII-compatible encoding (UTF-8, not UTF-16). Anything else —
|
|
963
|
+
`'05:99'`, `'1:60:00'`, `'1 3:46:40'`, `' 05:00'`, `'abc'` — raises
|
|
964
|
+
`Calcpace::InvalidTimeFormatError`. A negative clock parses, but every method
|
|
965
|
+
that needs a positive time or pace rejects it with `Calcpace::NonPositiveInputError`.
|
|
966
|
+
|
|
665
967
|
`convert_to_clocktime` takes a `compact:` keyword for the format a runner reads
|
|
666
968
|
on a screen — no zero hour, no leading zero on the most significant component:
|
|
667
969
|
|
|
@@ -687,8 +989,10 @@ call without it returns exactly what it returned before.
|
|
|
687
989
|
|
|
688
990
|
All errors inherit from `Calcpace::Error`:
|
|
689
991
|
|
|
690
|
-
- `Calcpace::NonPositiveInputError` — numeric input is zero or
|
|
691
|
-
- `Calcpace::InvalidTimeFormatError` — time string not
|
|
992
|
+
- `Calcpace::NonPositiveInputError` — numeric input is zero, negative, NaN or infinite
|
|
993
|
+
- `Calcpace::InvalidTimeFormatError` — time string that is not a clock the gem writes
|
|
994
|
+
(`[-][D ]H:MM:SS` or `[-]M:SS`, see Other Utilities): seconds must be below 60, and
|
|
995
|
+
so must minutes when hours are given (`'05:99'` and `'1:60:00'` raise)
|
|
692
996
|
- `Calcpace::UnsupportedUnitError` — unknown conversion (`convert`) or unknown
|
|
693
997
|
`unit:` / `distance_unit:` keyword
|
|
694
998
|
- `Calcpace::InvalidDataError` — the bundled data table failed its load-time
|
|
@@ -705,7 +1009,7 @@ unknown race names, unsupported age-grading distances, and invalid `age` / `sex`
|
|
|
705
1009
|
bundle exec rake
|
|
706
1010
|
```
|
|
707
1011
|
|
|
708
|
-
Requires Ruby >= 3.
|
|
1012
|
+
Requires Ruby >= 3.3.0. Tested with Ruby 3.3, 3.4, and 4.0.
|
|
709
1013
|
|
|
710
1014
|
## Contributing
|
|
711
1015
|
|
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'
|
|
@@ -23,7 +27,7 @@ Gem::Specification.new do |spec|
|
|
|
23
27
|
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
24
28
|
|
|
25
29
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
26
|
-
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
30
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|docs)/}) }
|
|
27
31
|
end
|
|
28
32
|
spec.bindir = 'exe'
|
|
29
33
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|