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.
data/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # Calcpace [![Gem Version](https://badge.fury.io/rb/calcpace.svg)](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', '~> 1.18.1'
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
- (Matthew Ely 2007 for heat, NCAA standards for altitude).
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.03, ... }
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: 15176.7,
60
- # adjusted_time_clock: "04:12:56",
61
- # penalty_percent: 20.45,
62
- # factors: { heat: 16.13, altitude: 4.32 }
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: 2599.74, adjusted_time_clock: "00:43:19", penalty_percent: 3.91, ... }
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: 11585.88, adjusted_time_clock: "03:13:05", penalty_percent: 14.18, ... }
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:48", "00:40:00"]
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.67, time_clock: "00:20:08", pace: 241.73, pace_clock: "00:04:01" }
209
+ # => { time: 1208.6727903498331, time_clock: "00:20:08", pace: 241.73455806996662, pace_clock: "00:04:01" }
161
210
  ```
162
211
 
163
- **Cameron formula** (exponential correction — tends to be more conservative from short distances):
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') # => "02:57:34"
167
- calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:12"
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:13:44"
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: 69.0,
444
+ # age_grade_percent: 68.9,
253
445
  # category: "Local Class",
254
- # age_graded_time_seconds: 2278.26,
255
- # age_graded_time_clock: "00:37:58",
256
- # open_standard_seconds: 1571.0,
257
- # open_standard_clock: "00:26:11",
258
- # factor: 0.8438,
259
- # table_version: "WMA_2023_ONE_YEAR_FACTORS_V1"
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.2
263
- calc.age_grade_label(65.2) # => "Local Class"
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 WMA / Alan Jones (Howard Grubb) tables are numeric age factors and open
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) # => 51.9
297
- calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 51.9
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 WMA table, which publishes a factor per *specific*
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 are based on WMA 2023 one-year age grading tables:
311
- https://world-masters-athletics.org/documents/competition-rules/
312
-
313
- Open standards used in `open_standard_seconds` / `open_standard_clock` are loaded
314
- from the bundled WMA 2023 open standards dataset
315
- (`lib/calcpace/data/wma_2023_open_standards.yml`).
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 WMA age factor (normalized performance time).
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–84% | Marathon race pace |
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 negative
691
- - `Calcpace::InvalidTimeFormatError` — time string not in `HH:MM:SS` or `MM:SS` format
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.2.0. Tested with Ruby 3.2, 3.3, 3.4, and 4.0.
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, VO2max, and training zones.'
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 & Cameron), ' \
14
- 'GPS track analysis (Haversine, elevation gain, per-km splits), ' \
15
- 'age grading (WMA 2023), VO2max estimation (Daniels & Gilbert), and ' \
16
- 'personalized training zones (Daniels paces & Karvonen heart-rate zones).'
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'
@@ -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 file is versioned and replaceable (`lib/calcpace/data/wma_2023_road.yml`)
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
- DATA_PATH = File.expand_path('data/wma_2023_road.yml', __dir__).freeze
25
- OPEN_STANDARDS_DATA_PATH = File.expand_path('data/wma_2023_open_standards.yml', __dir__).freeze
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 WMA factor and is refused
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