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.
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 [![Gem Version](https://badge.fury.io/rb/calcpace.svg)](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', '~> 1.18.1'
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
- (Matthew Ely 2007 for heat, NCAA standards for altitude).
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.03, ... }
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: 15176.7,
60
- # adjusted_time_clock: "04:12:56",
61
- # penalty_percent: 20.45,
62
- # factors: { heat: 16.13, altitude: 4.32 }
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: 2599.74, adjusted_time_clock: "00:43:19", penalty_percent: 3.91, ... }
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: 11585.88, adjusted_time_clock: "03:13:05", penalty_percent: 14.18, ... }
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:48", "00:40:00"]
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.67, time_clock: "00:20:08", pace: 241.73, pace_clock: "00:04:01" }
211
+ # => { time: 1208.6727903498331, time_clock: "00:20:08", pace: 241.73455806996662, pace_clock: "00:04:01" }
161
212
  ```
162
213
 
163
- **Cameron formula** (exponential correction — tends to be more conservative from short distances):
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') # => "02:57:34"
167
- calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:12"
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:13:44"
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: 69.0,
446
+ # age_grade_percent: 68.9,
253
447
  # 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"
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.2
263
- calc.age_grade_label(65.2) # => "Local Class"
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 WMA / Alan Jones (Howard Grubb) tables are numeric age factors and open
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) # => 51.9
297
- calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 51.9
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 WMA table, which publishes a factor per *specific*
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 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`).
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 WMA age factor (normalized performance time).
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–84% | Marathon race pace |
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 negative
691
- - `Calcpace::InvalidTimeFormatError` — time string not in `HH:MM:SS` or `MM:SS` format
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.2.0. Tested with Ruby 3.2, 3.3, 3.4, and 4.0.
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, 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'
@@ -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) }