calcpace 1.18.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +354 -1
- data/README.md +319 -43
- data/calcpace.gemspec +9 -5
- data/lib/calcpace/age_grading.rb +15 -22
- data/lib/calcpace/cameron_predictor.rb +73 -27
- data/lib/calcpace/checker.rb +101 -21
- data/lib/calcpace/converter.rb +19 -14
- data/lib/calcpace/data/environmental_factors.yml +88 -8
- data/lib/calcpace/data/friend_2015_vo2max_percentiles.yml +49 -0
- data/lib/calcpace/data/mldr_2025_road.yml +21 -0
- data/lib/calcpace/data/mldr_2025_road_open_standards.yml +46 -0
- data/lib/calcpace/environmental_adjuster.rb +65 -31
- data/lib/calcpace/grade_adjusted_pace.rb +114 -0
- data/lib/calcpace/humidity.rb +137 -0
- data/lib/calcpace/personalized_predictor.rb +220 -0
- data/lib/calcpace/race_predictor.rb +7 -2
- data/lib/calcpace/race_splits.rb +7 -5
- data/lib/calcpace/track_calculator.rb +183 -10
- data/lib/calcpace/training_zones.rb +42 -11
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace/vo2max_estimator.rb +29 -4
- data/lib/calcpace/vo2max_norms.rb +121 -0
- data/lib/calcpace.rb +7 -0
- metadata +16 -9
- data/lib/calcpace/data/wma_2023_open_standards.yml +0 -40
- data/lib/calcpace/data/wma_2023_road.yml +0 -16
|
@@ -27,6 +27,19 @@ module TrackCalculator
|
|
|
27
27
|
# Mean radius of the Earth in kilometers (IAU standard)
|
|
28
28
|
EARTH_RADIUS_KM = 6371.0
|
|
29
29
|
|
|
30
|
+
# Shortest horizontal distance a grade is measured over in
|
|
31
|
+
# #track_grade_adjusted_splits. GPS elevation is noisy — a few metres between
|
|
32
|
+
# consecutive fixes even from a barometric altimeter — so a grade read
|
|
33
|
+
# between points 1 m apart can be ±300% on flat ground, and the grade factor's
|
|
34
|
+
# curvature turns that symmetric noise into a fake climb. Over 100 m, ±1–2 m
|
|
35
|
+
# of noise is ±1–2% of grade, while a hill longer than a track straight still
|
|
36
|
+
# shows up.
|
|
37
|
+
GRADE_SEGMENT_MIN_KM = 0.1
|
|
38
|
+
|
|
39
|
+
# Slack on GRADE_SEGMENT_MIN_KM so ten 10 m steps, which sum to a hair under
|
|
40
|
+
# 0.1 km in floating point, still close a 100 m grade segment (1 mm)
|
|
41
|
+
GRADE_SEGMENT_TOLERANCE_KM = 1e-6
|
|
42
|
+
|
|
30
43
|
# Computes the great-circle distance between two GPS coordinates using
|
|
31
44
|
# the Haversine formula.
|
|
32
45
|
#
|
|
@@ -156,6 +169,49 @@ module TrackCalculator
|
|
|
156
169
|
collect_splits(points, split_km, compact: compact)
|
|
157
170
|
end
|
|
158
171
|
|
|
172
|
+
# Pace splits with a grade-adjusted pace (GAP) for each split.
|
|
173
|
+
#
|
|
174
|
+
# Each split is the hash #track_splits returns — same :km, :elapsed and :pace,
|
|
175
|
+
# split boundaries computed the same way — plus :gap, the flat-ground pace of
|
|
176
|
+
# equal effort for that split (Minetti et al., 2002; see GradeAdjustedPace).
|
|
177
|
+
# #track_splits itself is unchanged.
|
|
178
|
+
#
|
|
179
|
+
# How :gap is computed:
|
|
180
|
+
# - The track is cut into grade segments of at least GRADE_SEGMENT_MIN_KM
|
|
181
|
+
# (100 m) of horizontal distance, and each gets one grade: its net
|
|
182
|
+
# elevation change over its length. A leftover shorter than that at the end
|
|
183
|
+
# of a stretch is merged into the segment before it. Grades are clamped to
|
|
184
|
+
# ±45%, the range the model was measured on.
|
|
185
|
+
# - A stretch between points without :ele (or with a NaN/infinite one) is
|
|
186
|
+
# flat (factor 1.0), and a missing :ele ends the grade segment in progress.
|
|
187
|
+
# A stretch with elevation shorter than one grade segment and with no full
|
|
188
|
+
# segment before it to join — or a whole track that short — is flat too.
|
|
189
|
+
# - Distances are the horizontal (Haversine) ones; the factor is applied to
|
|
190
|
+
# them as is, without the √(1 + grade²) slope correction (0.5% at 10%).
|
|
191
|
+
# - Each split's distance is weighted by the grade factor of the segments it
|
|
192
|
+
# covers, and :gap is the split's time over that flat-equivalent distance.
|
|
193
|
+
# A split on flat ground, or with no elevation data, has :gap equal to :pace.
|
|
194
|
+
#
|
|
195
|
+
# @param points [Array<Hash>] points with :lat, :lon and :time keys, and
|
|
196
|
+
# optionally :ele (metres), as for #track_splits
|
|
197
|
+
# @param split_km [Numeric] split interval in kilometers (default: 1.0)
|
|
198
|
+
# @param compact [Boolean] when true, :pace and :gap use the compact display format
|
|
199
|
+
# @return [Array<Hash>] split hashes with :km, :elapsed, :pace and :gap
|
|
200
|
+
# (:gap formatted like :pace)
|
|
201
|
+
# @raise [ArgumentError] if split_km is not positive
|
|
202
|
+
# @raise [ArgumentError] if any point is missing a :time key
|
|
203
|
+
#
|
|
204
|
+
# @example a steady 5% climb at 5:00/km
|
|
205
|
+
# calc.track_grade_adjusted_splits(points, 1.0)
|
|
206
|
+
# #=> [{ km: 1.0, elapsed: 300, pace: "05:00", gap: "03:51" }, ...]
|
|
207
|
+
def track_grade_adjusted_splits(points, split_km = 1.0, compact: false)
|
|
208
|
+
raise ArgumentError, 'split_km must be positive' unless split_km.is_a?(Numeric) && split_km.positive?
|
|
209
|
+
return [] if points.nil? || points.size < 2
|
|
210
|
+
|
|
211
|
+
validate_points_have_time(points)
|
|
212
|
+
collect_splits(points, split_km, compact: compact, grade_factors: segment_grade_factors(points))
|
|
213
|
+
end
|
|
214
|
+
|
|
159
215
|
private
|
|
160
216
|
|
|
161
217
|
def haversine_km(lat1, lon1, lat2, lon2)
|
|
@@ -253,41 +309,56 @@ module TrackCalculator
|
|
|
253
309
|
format('%<min>02d:%<sec>02d', min: pace_seconds / 60, sec: pace_seconds % 60)
|
|
254
310
|
end
|
|
255
311
|
|
|
256
|
-
|
|
312
|
+
# grade_factors, when given, holds one grade factor per segment (see
|
|
313
|
+
# #segment_grade_factors) and turns on the :gap field; without it the splits
|
|
314
|
+
# are exactly the ones #track_splits has always returned.
|
|
315
|
+
def collect_splits(points, split_km, compact:, grade_factors: nil)
|
|
257
316
|
state = { splits: [], start_time: point_time(points.first),
|
|
258
317
|
split_start_time: point_time(points.first),
|
|
259
|
-
accumulated_km: 0.0, split_number: 1, compact: compact
|
|
318
|
+
accumulated_km: 0.0, split_number: 1, compact: compact,
|
|
319
|
+
grade_factors: grade_factors, flat_equivalent_km: 0.0 }
|
|
260
320
|
|
|
261
|
-
|
|
321
|
+
if grade_factors
|
|
322
|
+
points.each_cons(2).with_index { |(a, b), index| process_segment(a, b, split_km, state, index) }
|
|
323
|
+
else
|
|
324
|
+
points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) }
|
|
325
|
+
end
|
|
262
326
|
append_partial_split(points.last, split_km, state)
|
|
263
327
|
state[:splits]
|
|
264
328
|
end
|
|
265
329
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
330
|
+
# index is only given, and the segment only tracked, when computing :gap —
|
|
331
|
+
# track_splits keeps its original per-segment cost
|
|
332
|
+
def process_segment(point_a, point_b, split_km, state, index = nil)
|
|
333
|
+
segment_km = segment_distance_km(point_a, point_b)
|
|
269
334
|
state[:accumulated_km] += segment_km
|
|
335
|
+
state[:segment] = { assigned_km: 0.0, factor: state[:grade_factors].fetch(index) } if index
|
|
270
336
|
|
|
271
337
|
while state[:accumulated_km] >= split_km * state[:split_number]
|
|
272
338
|
record_split(point_a, point_b, segment_km, split_km, state)
|
|
273
339
|
end
|
|
340
|
+
|
|
341
|
+
accumulate_flat_equivalent(state, segment_km)
|
|
274
342
|
end
|
|
275
343
|
|
|
276
344
|
def record_split(point_a, point_b, segment_km, split_km, state)
|
|
277
345
|
offset = (split_km * state[:split_number]) - (state[:accumulated_km] - segment_km)
|
|
346
|
+
accumulate_flat_equivalent(state, offset)
|
|
278
347
|
boundary_time = interpolate_time(point_a, point_b, segment_km, offset)
|
|
279
348
|
state[:splits] << build_split_entry(boundary_time, split_km, state)
|
|
280
349
|
state[:split_start_time] = boundary_time
|
|
281
350
|
state[:split_number] += 1
|
|
351
|
+
state[:flat_equivalent_km] = 0.0
|
|
282
352
|
end
|
|
283
353
|
|
|
284
354
|
def build_split_entry(boundary_time, split_km, state)
|
|
285
355
|
split_elapsed = (boundary_time - state[:split_start_time]).round
|
|
286
|
-
{
|
|
356
|
+
entry = {
|
|
287
357
|
km: (split_km * state[:split_number]).round(2),
|
|
288
358
|
elapsed: (boundary_time - state[:start_time]).round,
|
|
289
359
|
pace: seconds_to_pace(split_elapsed, split_km, compact: state[:compact])
|
|
290
360
|
}
|
|
361
|
+
with_gap(entry, split_elapsed, state)
|
|
291
362
|
end
|
|
292
363
|
|
|
293
364
|
def append_partial_split(last_point, split_km, state)
|
|
@@ -295,11 +366,113 @@ module TrackCalculator
|
|
|
295
366
|
return unless remaining_km > 0.001
|
|
296
367
|
|
|
297
368
|
last_time = point_time(last_point)
|
|
298
|
-
state[:
|
|
369
|
+
split_elapsed = (last_time - state[:split_start_time]).round
|
|
370
|
+
entry = {
|
|
299
371
|
km: state[:accumulated_km].round(2),
|
|
300
372
|
elapsed: (last_time - state[:start_time]).round,
|
|
301
|
-
pace: seconds_to_pace(
|
|
302
|
-
compact: state[:compact])
|
|
373
|
+
pace: seconds_to_pace(split_elapsed, remaining_km, compact: state[:compact])
|
|
303
374
|
}
|
|
375
|
+
state[:splits] << with_gap(entry, split_elapsed, state)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def segment_distance_km(point_a, point_b)
|
|
379
|
+
haversine_distance(dig_key(point_a, :lat), dig_key(point_a, :lon),
|
|
380
|
+
dig_key(point_b, :lat), dig_key(point_b, :lon))
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Adds the flat-equivalent distance of the current segment up to
|
|
384
|
+
# distance_into_segment, for the part not yet credited to an earlier split
|
|
385
|
+
def accumulate_flat_equivalent(state, distance_into_segment)
|
|
386
|
+
return unless state[:grade_factors]
|
|
387
|
+
|
|
388
|
+
segment = state[:segment]
|
|
389
|
+
|
|
390
|
+
state[:flat_equivalent_km] += (distance_into_segment - segment[:assigned_km]) * segment[:factor]
|
|
391
|
+
segment[:assigned_km] = distance_into_segment
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def with_gap(entry, split_elapsed, state)
|
|
395
|
+
return entry unless state[:grade_factors]
|
|
396
|
+
|
|
397
|
+
entry.merge(gap: seconds_to_pace(split_elapsed, state[:flat_equivalent_km], compact: state[:compact]))
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# One grade factor per segment (consecutive point pair). Segments are grouped
|
|
401
|
+
# into grade segments of at least GRADE_SEGMENT_MIN_KM, each with one grade
|
|
402
|
+
# (net elevation change over horizontal distance); see
|
|
403
|
+
# #track_grade_adjusted_splits for the rules.
|
|
404
|
+
def segment_grade_factors(points)
|
|
405
|
+
factors = Array.new(points.size - 1, 1.0)
|
|
406
|
+
window = new_grade_window
|
|
407
|
+
|
|
408
|
+
points.each_cons(2).with_index do |(a, b), index|
|
|
409
|
+
window = extend_grade_window(window, factors, a, b, index)
|
|
410
|
+
end
|
|
411
|
+
close_grade_window(window, factors, final: true)
|
|
412
|
+
factors
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def new_grade_window(previous = nil)
|
|
416
|
+
{ indexes: [], km: 0.0, start_ele: nil, end_ele: nil, previous: previous }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def extend_grade_window(window, factors, point_a, point_b, index)
|
|
420
|
+
ele_a = finite_ele(point_a)
|
|
421
|
+
ele_b = finite_ele(point_b)
|
|
422
|
+
if ele_a.nil? || ele_b.nil?
|
|
423
|
+
close_grade_window(window, factors, final: true)
|
|
424
|
+
return new_grade_window
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
add_to_grade_window(window, index, segment_distance_km(point_a, point_b), ele_a, ele_b)
|
|
428
|
+
return window unless full_grade_window?(window)
|
|
429
|
+
|
|
430
|
+
close_grade_window(window, factors, final: false)
|
|
431
|
+
new_grade_window(window.except(:previous))
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Grade segments treat a NaN or infinite elevation like a missing one
|
|
435
|
+
def finite_ele(point)
|
|
436
|
+
ele = fetch_ele(point)
|
|
437
|
+
ele if ele&.finite?
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def add_to_grade_window(window, index, segment_km, ele_a, ele_b)
|
|
441
|
+
window[:start_ele] ||= ele_a
|
|
442
|
+
window[:end_ele] = ele_b
|
|
443
|
+
window[:indexes] << index
|
|
444
|
+
window[:km] += segment_km
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# A full window gets its own grade. A short leftover — the end of the track
|
|
448
|
+
# or of a stretch with elevation — is merged into the full window before it;
|
|
449
|
+
# with no full window before it (a stretch shorter than a grade segment
|
|
450
|
+
# between missing elevations, or a whole track that short) it stays flat
|
|
451
|
+
# rather than being graded over a few noisy metres.
|
|
452
|
+
def close_grade_window(window, factors, final:)
|
|
453
|
+
return if window[:indexes].empty?
|
|
454
|
+
|
|
455
|
+
unless full_grade_window?(window)
|
|
456
|
+
return unless final && window[:previous]
|
|
457
|
+
|
|
458
|
+
window = merge_grade_windows(window[:previous], window)
|
|
459
|
+
end
|
|
460
|
+
factor = grade_adjustment_factor(window_grade(window))
|
|
461
|
+
window[:indexes].each { |index| factors[index] = factor }
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def full_grade_window?(window)
|
|
465
|
+
window[:km] >= GRADE_SEGMENT_MIN_KM - GRADE_SEGMENT_TOLERANCE_KM
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def merge_grade_windows(previous, window)
|
|
469
|
+
{ indexes: previous[:indexes] + window[:indexes], km: previous[:km] + window[:km],
|
|
470
|
+
start_ele: previous[:start_ele], end_ele: window[:end_ele] }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def window_grade(window)
|
|
474
|
+
return 0.0 unless window[:km].positive?
|
|
475
|
+
|
|
476
|
+
(window[:end_ele] - window[:start_ele]) / (window[:km] * 1000.0)
|
|
304
477
|
end
|
|
305
478
|
end
|
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
#
|
|
10
10
|
# Heart rate zones use the Karvonen method (Heart Rate Reserve):
|
|
11
11
|
# target = hr_rest + pct * (hr_max - hr_rest)
|
|
12
|
+
#
|
|
13
|
+
# Not standalone: it is a part of Calcpace and calls into its siblings —
|
|
14
|
+
# Vo2maxEstimator (estimate_vo2max, vo2_at_velocity), FitnessPredictor
|
|
15
|
+
# (predict_time_from_vo2max, for the marathon band), PaceCalculator
|
|
16
|
+
# (race_distance), Converter and Checker.
|
|
12
17
|
module TrainingZones
|
|
13
|
-
# Training intensities as fraction of VO2max (Daniels' Running Formula)
|
|
18
|
+
# Training intensities as fraction of VO2max (Daniels' Running Formula).
|
|
19
|
+
# The marathon :high (0.84) is the nominal upper bound only: the band's fast
|
|
20
|
+
# end is the predicted race pace (see PREDICTED_RACE_PACE_ZONES).
|
|
14
21
|
TRAINING_INTENSITIES = {
|
|
15
22
|
easy: { low: 0.59, high: 0.74 },
|
|
16
23
|
marathon: { low: 0.75, high: 0.84 },
|
|
@@ -19,6 +26,14 @@ module TrainingZones
|
|
|
19
26
|
repetition: { low: 1.05, high: 1.10 }
|
|
20
27
|
}.freeze
|
|
21
28
|
|
|
29
|
+
# Zones whose fast end is the VDOT-predicted race pace instead of
|
|
30
|
+
# TRAINING_INTENSITIES[zone][:high]. Daniels' M pace is the runner's
|
|
31
|
+
# predicted marathon race pace (FitnessPredictor#predict_time_from_vo2max),
|
|
32
|
+
# which is 0.800–0.849 of VO2max across VO2max 10–100 (0.805 at 30, 0.830
|
|
33
|
+
# at 70). That keeps the marathon band slower than the threshold band
|
|
34
|
+
# (from 0.83) below VO2max ~69.5.
|
|
35
|
+
PREDICTED_RACE_PACE_ZONES = %i[marathon].freeze
|
|
36
|
+
|
|
22
37
|
# A pace band for one training zone (paces per kilometre or mile).
|
|
23
38
|
# slow = lower-intensity end of the band, fast = higher-intensity end.
|
|
24
39
|
PaceBand = Struct.new(:slow_seconds, :fast_seconds, :slow_clock, :fast_clock)
|
|
@@ -46,27 +61,27 @@ module TrainingZones
|
|
|
46
61
|
# @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0)
|
|
47
62
|
# @param unit [Symbol] pace unit — :km (default) or :mi
|
|
48
63
|
# @return [Hash{Symbol => PaceBand}] keys: :easy, :marathon, :threshold,
|
|
49
|
-
# :interval, :repetition — paces per chosen unit
|
|
64
|
+
# :interval, :repetition — paces per chosen unit. The marathon band runs
|
|
65
|
+
# from 75% of VO2max to the VDOT-predicted marathon pace; the prediction
|
|
66
|
+
# covers VO2max 10–100, and outside that range the race-pace intensity of
|
|
67
|
+
# the nearest bound is used
|
|
50
68
|
# @raise [Calcpace::NonPositiveInputError] if vo2max is not positive
|
|
51
69
|
# @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
|
|
52
70
|
#
|
|
53
71
|
# @example
|
|
54
72
|
# calc.training_paces(50.0)[:threshold].fast_clock #=> "00:04:15"
|
|
55
73
|
# calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock #=> "00:06:51"
|
|
74
|
+
# calc.training_paces(50.0)[:marathon].fast_clock #=> "00:04:31"
|
|
56
75
|
def training_paces(vo2max, unit: :km)
|
|
57
76
|
check_positive(vo2max.to_f, 'VO2max')
|
|
58
77
|
meters = pace_unit_meters(unit)
|
|
59
78
|
|
|
60
|
-
TRAINING_INTENSITIES.
|
|
79
|
+
TRAINING_INTENSITIES.to_h do |zone, band|
|
|
61
80
|
slow = pace_seconds_at_pct(vo2max.to_f, band[:low], meters)
|
|
62
|
-
fast = pace_seconds_at_pct(vo2max.to_f, band
|
|
63
|
-
|
|
64
|
-
PaceBand.new(
|
|
65
|
-
|
|
66
|
-
fast_seconds: fast,
|
|
67
|
-
slow_clock: convert_to_clocktime(slow),
|
|
68
|
-
fast_clock: convert_to_clocktime(fast)
|
|
69
|
-
)
|
|
81
|
+
fast = pace_seconds_at_pct(vo2max.to_f, fast_intensity(zone, band, vo2max.to_f), meters)
|
|
82
|
+
|
|
83
|
+
[zone, PaceBand.new(slow_seconds: slow, fast_seconds: fast,
|
|
84
|
+
slow_clock: convert_to_clocktime(slow), fast_clock: convert_to_clocktime(fast))]
|
|
70
85
|
end
|
|
71
86
|
end
|
|
72
87
|
|
|
@@ -336,6 +351,22 @@ module TrainingZones
|
|
|
336
351
|
"Resting heart rate (#{hr_rest}) must be lower than maximum heart rate (#{hr_max})"
|
|
337
352
|
end
|
|
338
353
|
|
|
354
|
+
def fast_intensity(zone, band, vo2max)
|
|
355
|
+
PREDICTED_RACE_PACE_ZONES.include?(zone) ? marathon_race_intensity(vo2max) : band[:high]
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Fraction of VO2max a runner holds at the VDOT-predicted marathon pace.
|
|
359
|
+
# The prediction only covers FitnessPredictor::SUPPORTED_VO2MAX_RANGE, so
|
|
360
|
+
# beyond it the fraction of the nearest bound is used (it barely moves
|
|
361
|
+
# there: 0.800 at VO2max 10, 0.849 at 100).
|
|
362
|
+
def marathon_race_intensity(vo2max)
|
|
363
|
+
range = FitnessPredictor::SUPPORTED_VO2MAX_RANGE
|
|
364
|
+
vo2 = vo2max.clamp(range.min, range.max)
|
|
365
|
+
seconds = predict_time_from_vo2max(vo2, 'marathon')
|
|
366
|
+
|
|
367
|
+
vo2_at_velocity(race_distance('marathon') * Converter::Distance::KM_TO_METERS * 60.0 / seconds) / vo2
|
|
368
|
+
end
|
|
369
|
+
|
|
339
370
|
# Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2
|
|
340
371
|
def velocity_at_vo2(vo2)
|
|
341
372
|
a = 0.000104
|
data/lib/calcpace/version.rb
CHANGED
|
@@ -61,6 +61,13 @@ module Vo2maxEstimator
|
|
|
61
61
|
|
|
62
62
|
# Estimates a detailed and contextualized VO2max
|
|
63
63
|
#
|
|
64
|
+
# Elevation is folded in with a flat heuristic — every 100 m of gain adds
|
|
65
|
+
# 600 m of equivalent flat distance — that ignores where the climbing is and
|
|
66
|
+
# gives nothing back for descents. It is kept as is so results stay
|
|
67
|
+
# comparable across versions. For a profile-aware view of a GPS track, see
|
|
68
|
+
# GradeAdjustedPace#grade_adjusted_pace and
|
|
69
|
+
# TrackCalculator#track_grade_adjusted_splits (Minetti et al., 2002).
|
|
70
|
+
#
|
|
64
71
|
# @param distance [Numeric] race distance, in kilometres by default or in
|
|
65
72
|
# the unit given by distance_unit
|
|
66
73
|
# @param time [String, Integer] finish time
|
|
@@ -95,16 +102,34 @@ module Vo2maxEstimator
|
|
|
95
102
|
|
|
96
103
|
# Returns a descriptive label for a given VO2max value
|
|
97
104
|
#
|
|
105
|
+
# Without age and sex, the label comes from fixed thresholds that are the same
|
|
106
|
+
# for everyone (see VO2MAX_LABELS) — exactly as it always has. With both, it
|
|
107
|
+
# comes from where the value sits among people of the same sex and age
|
|
108
|
+
# decade, measured on a treadmill in the FRIEND registry (see
|
|
109
|
+
# Vo2maxNorms#vo2max_percentile and Vo2maxNorms::VO2MAX_PERCENTILE_LABELS):
|
|
110
|
+
# the same 45 ml/kg/min is "Fair" for a 25-year-old man and "Elite" for a
|
|
111
|
+
# 60-year-old woman.
|
|
112
|
+
#
|
|
98
113
|
# @param value [Numeric] VO2max in ml/kg/min
|
|
114
|
+
# @param age [Integer, nil] age in years (18 or over, a fractional age is
|
|
115
|
+
# truncated); give it with sex
|
|
116
|
+
# @param sex [String, Symbol, nil] male or female; give it with age
|
|
99
117
|
# @return [String] label: "Beginner", "Fair", "Good", "Very Good", "Excellent", or "Elite"
|
|
100
|
-
# @raise [
|
|
118
|
+
# @raise [Calcpace::NonPositiveInputError] if value is not positive
|
|
119
|
+
# @raise [ArgumentError] if only one of age and sex is given, or either is invalid
|
|
101
120
|
#
|
|
102
121
|
# @example
|
|
103
|
-
# calc.vo2max_label(51.9)
|
|
104
|
-
|
|
122
|
+
# calc.vo2max_label(51.9) #=> "Very Good"
|
|
123
|
+
# calc.vo2max_label(45) #=> "Good"
|
|
124
|
+
# calc.vo2max_label(45, age: 25, sex: :male) #=> "Fair"
|
|
125
|
+
# calc.vo2max_label(45, age: 60, sex: :female) #=> "Elite"
|
|
126
|
+
def vo2max_label(value, age: nil, sex: nil)
|
|
105
127
|
check_positive(value.to_f, 'VO2max value')
|
|
128
|
+
return VO2MAX_LABELS.find { |entry| value.to_f >= entry[:min] }[:label] if age.nil? && sex.nil?
|
|
129
|
+
raise ArgumentError, 'Age and sex must be provided together' if age.nil? || sex.nil?
|
|
106
130
|
|
|
107
|
-
|
|
131
|
+
percentile = raw_vo2max_percentile(value.to_f, age, sex)
|
|
132
|
+
Vo2maxNorms::VO2MAX_PERCENTILE_LABELS.find { |entry| percentile >= entry[:min] }[:label]
|
|
108
133
|
end
|
|
109
134
|
|
|
110
135
|
private
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
require_relative 'checker'
|
|
6
|
+
|
|
7
|
+
# Module for reading a VO2max against people of the same age and sex
|
|
8
|
+
#
|
|
9
|
+
# Uses the FRIEND registry (Fitness Registry and the Importance of Exercise
|
|
10
|
+
# National Database) percentiles of VO2max measured by cardiopulmonary
|
|
11
|
+
# exercise testing in 7,783 treadmill tests on US adults free of known
|
|
12
|
+
# cardiovascular disease:
|
|
13
|
+
#
|
|
14
|
+
# Kaminsky, L. A., Arena, R., & Myers, J. (2015). Reference Standards for
|
|
15
|
+
# Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing:
|
|
16
|
+
# Data From the Fitness Registry and the Importance of Exercise National
|
|
17
|
+
# Database. Mayo Clinic Proceedings, 90(11), 1515–1523, Table 3.
|
|
18
|
+
# https://doi.org/10.1016/j.mayocp.2015.07.026
|
|
19
|
+
#
|
|
20
|
+
# The table is stored, with its provenance, in
|
|
21
|
+
# `lib/calcpace/data/friend_2015_vo2max_percentiles.yml`.
|
|
22
|
+
# Vo2maxEstimator#vo2max_label uses it when given age and sex.
|
|
23
|
+
module Vo2maxNorms
|
|
24
|
+
# check_positive, normalize_age and normalize_sex: the same rules as AgeGrading
|
|
25
|
+
include Checker
|
|
26
|
+
|
|
27
|
+
# Percentile norms by age and sex: FRIEND registry, measured treadmill VO2max
|
|
28
|
+
# (Kaminsky, Arena & Myers, Mayo Clin Proc 2015;90(11):1515–1523, Table 3).
|
|
29
|
+
# See the data file for the full provenance.
|
|
30
|
+
NORMS_DATA_PATH = File.expand_path('data/friend_2015_vo2max_percentiles.yml', __dir__).freeze
|
|
31
|
+
norms_data = YAML.safe_load_file(NORMS_DATA_PATH, permitted_classes: [], aliases: false)
|
|
32
|
+
VO2MAX_NORMS_VERSION = norms_data.fetch('meta').fetch('table_version').freeze
|
|
33
|
+
VO2MAX_NORM_PERCENTILES = norms_data.fetch('percentiles').map(&:to_f).freeze
|
|
34
|
+
VO2MAX_NORMS = %w[M F].to_h do |sex|
|
|
35
|
+
bands = norms_data.fetch(sex).to_h { |age, row| [Integer(age), row.map(&:to_f).freeze] }
|
|
36
|
+
[sex.freeze, bands.freeze]
|
|
37
|
+
end.freeze
|
|
38
|
+
|
|
39
|
+
VO2MAX_NORMS.each do |sex, bands|
|
|
40
|
+
bands.each do |age, row|
|
|
41
|
+
next if row.size == VO2MAX_NORM_PERCENTILES.size && row.each_cons(2).all? { |low, high| high > low }
|
|
42
|
+
|
|
43
|
+
raise Calcpace::InvalidDataError,
|
|
44
|
+
"VO2max norms #{sex} #{age}: expected #{VO2MAX_NORM_PERCENTILES.size} rising values, got #{row.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Percentile cut for each label when vo2max_label is given age and sex. The
|
|
49
|
+
# cuts sit on percentiles the table publishes, so a label never depends on
|
|
50
|
+
# interpolation: at or above the 95th percentile is Elite, the 90th
|
|
51
|
+
# Excellent, the 75th Very Good, the median Good, the 25th Fair, and below
|
|
52
|
+
# the 25th Beginner. These cuts are calcpace's choice — FRIEND publishes
|
|
53
|
+
# percentiles, not labels.
|
|
54
|
+
VO2MAX_PERCENTILE_LABELS = [
|
|
55
|
+
{ min: 95, label: 'Elite' },
|
|
56
|
+
{ min: 90, label: 'Excellent' },
|
|
57
|
+
{ min: 75, label: 'Very Good' },
|
|
58
|
+
{ min: 50, label: 'Good' },
|
|
59
|
+
{ min: 25, label: 'Fair' },
|
|
60
|
+
{ min: 0, label: 'Beginner' }
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
# Approximate percentile of a VO2max among people of the same sex and age
|
|
64
|
+
#
|
|
65
|
+
# Reads the FRIEND registry percentiles (Kaminsky, Arena & Myers, 2015,
|
|
66
|
+
# Table 3: 5th, 10th, 25th, 50th, 75th, 90th and 95th, by decade from 20–29 to
|
|
67
|
+
# 70–79) and interpolates linearly between the two published percentiles
|
|
68
|
+
# around the value. The registry measured VO2max in a lab; an estimate from a
|
|
69
|
+
# race time carries its own ±3–5 ml/kg/min on top.
|
|
70
|
+
#
|
|
71
|
+
# - Age decades are used as published, without blending across them, so a
|
|
72
|
+
# 29- and a 30-year-old read different rows. Ages 18–19 use the 20–29 row and
|
|
73
|
+
# 80 or over the 70–79 row; under 18 is rejected (adult norms do not apply).
|
|
74
|
+
# - The result is bounded to the table: 5.0 means at or below the 5th
|
|
75
|
+
# percentile, 95.0 at or above the 95th.
|
|
76
|
+
#
|
|
77
|
+
# @param value [Numeric] VO2max in ml/kg/min
|
|
78
|
+
# @param age [Integer] age in years (18 or over; a fractional age is
|
|
79
|
+
# truncated, as in AgeGrading)
|
|
80
|
+
# @param sex [String, Symbol] male or female
|
|
81
|
+
# @return [Float] percentile between 5.0 and 95.0, rounded to one decimal
|
|
82
|
+
# @raise [Calcpace::NonPositiveInputError] if value is not positive
|
|
83
|
+
# @raise [ArgumentError] if age is under 18 or not a number, or sex is not male/female
|
|
84
|
+
#
|
|
85
|
+
# @example
|
|
86
|
+
# calc.vo2max_percentile(48.0, age: 25, sex: :male) #=> 50.0
|
|
87
|
+
# calc.vo2max_percentile(45, age: 25, sex: :male) #=> 40.5
|
|
88
|
+
# calc.vo2max_percentile(45, age: 25, sex: :female) #=> 75.7
|
|
89
|
+
# calc.vo2max_percentile(45, age: 60, sex: :male) #=> 95.0
|
|
90
|
+
def vo2max_percentile(value, age:, sex:)
|
|
91
|
+
check_positive(value.to_f, 'VO2max value')
|
|
92
|
+
|
|
93
|
+
raw_vo2max_percentile(value.to_f, age, sex).round(1)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Unrounded percentile, so a label is decided against the published values
|
|
99
|
+
# themselves rather than a rounded reading of them. normalize_age and
|
|
100
|
+
# normalize_sex come from Checker — one rule for age and sex gem-wide.
|
|
101
|
+
def raw_vo2max_percentile(value, age, sex)
|
|
102
|
+
row = vo2max_norm_row(normalize_age(age), normalize_sex(sex))
|
|
103
|
+
return VO2MAX_NORM_PERCENTILES.first if value <= row.first
|
|
104
|
+
return VO2MAX_NORM_PERCENTILES.last if value >= row.last
|
|
105
|
+
|
|
106
|
+
upper = row.index { |norm| norm > value }
|
|
107
|
+
interpolate_percentile(row, upper, value)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def vo2max_norm_row(age, sex)
|
|
111
|
+
bands = VO2MAX_NORMS.fetch(sex == :male ? 'M' : 'F')
|
|
112
|
+
band = bands.keys.select { |lower| lower <= age }.max || bands.keys.min
|
|
113
|
+
bands.fetch(band)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def interpolate_percentile(row, upper, value)
|
|
117
|
+
low_norm, high_norm = row.values_at(upper - 1, upper)
|
|
118
|
+
low_pct, high_pct = VO2MAX_NORM_PERCENTILES.values_at(upper - 1, upper)
|
|
119
|
+
low_pct + ((high_pct - low_pct) * (value - low_norm) / (high_norm - low_norm))
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/calcpace.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'calcpace/version'
|
|
3
4
|
require_relative 'calcpace/errors'
|
|
4
5
|
require_relative 'calcpace/calculator'
|
|
5
6
|
require_relative 'calcpace/cameron_predictor'
|
|
@@ -9,15 +10,18 @@ require_relative 'calcpace/checker'
|
|
|
9
10
|
require_relative 'calcpace/converter'
|
|
10
11
|
require_relative 'calcpace/converter_chain'
|
|
11
12
|
require_relative 'calcpace/fitness_predictor'
|
|
13
|
+
require_relative 'calcpace/grade_adjusted_pace'
|
|
12
14
|
require_relative 'calcpace/lap_analyzer'
|
|
13
15
|
require_relative 'calcpace/pace_calculator'
|
|
14
16
|
require_relative 'calcpace/pace_converter'
|
|
17
|
+
require_relative 'calcpace/personalized_predictor'
|
|
15
18
|
require_relative 'calcpace/race_predictor'
|
|
16
19
|
require_relative 'calcpace/race_splits'
|
|
17
20
|
require_relative 'calcpace/stride_calculator'
|
|
18
21
|
require_relative 'calcpace/track_calculator'
|
|
19
22
|
require_relative 'calcpace/training_zones'
|
|
20
23
|
require_relative 'calcpace/vo2max_estimator'
|
|
24
|
+
require_relative 'calcpace/vo2max_norms'
|
|
21
25
|
|
|
22
26
|
# Calcpace - A Ruby gem for pace, distance, and time calculations
|
|
23
27
|
#
|
|
@@ -49,15 +53,18 @@ class Calcpace
|
|
|
49
53
|
include Converter
|
|
50
54
|
include ConverterChain
|
|
51
55
|
include FitnessPredictor
|
|
56
|
+
include GradeAdjustedPace
|
|
52
57
|
include LapAnalyzer
|
|
53
58
|
include PaceCalculator
|
|
54
59
|
include PaceConverter
|
|
60
|
+
include PersonalizedPredictor
|
|
55
61
|
include RacePredictor
|
|
56
62
|
include RaceSplits
|
|
57
63
|
include StrideCalculator
|
|
58
64
|
include TrackCalculator
|
|
59
65
|
include TrainingZones
|
|
60
66
|
include Vo2maxEstimator
|
|
67
|
+
include Vo2maxNorms
|
|
61
68
|
|
|
62
69
|
# Creates a new Calcpace instance
|
|
63
70
|
#
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: calcpace
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- João Gilberto Saraiva
|
|
@@ -10,9 +10,11 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
12
|
description: 'Ruby gem for runners: pace, time, and distance calculations, unit conversions
|
|
13
|
-
(30+ units), race time predictions (Riegel
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
(30+ units), race time predictions (Riegel, Cameron, personal Riegel exponent, and
|
|
14
|
+
Tanda marathon from training volume), GPS track analysis (Haversine, elevation gain,
|
|
15
|
+
per-km splits, Minetti grade-adjusted pace), heat, humidity, and altitude adjustments,
|
|
16
|
+
age grading (2025 road tables), VO2max estimation (Daniels & Gilbert) with FRIEND
|
|
17
|
+
age/sex norms, and personalized training zones (Daniels paces & Karvonen heart-rate
|
|
16
18
|
zones).'
|
|
17
19
|
email:
|
|
18
20
|
- joaogilberto@tuta.io
|
|
@@ -40,14 +42,18 @@ files:
|
|
|
40
42
|
- lib/calcpace/converter.rb
|
|
41
43
|
- lib/calcpace/converter_chain.rb
|
|
42
44
|
- lib/calcpace/data/environmental_factors.yml
|
|
43
|
-
- lib/calcpace/data/
|
|
44
|
-
- lib/calcpace/data/
|
|
45
|
+
- lib/calcpace/data/friend_2015_vo2max_percentiles.yml
|
|
46
|
+
- lib/calcpace/data/mldr_2025_road.yml
|
|
47
|
+
- lib/calcpace/data/mldr_2025_road_open_standards.yml
|
|
45
48
|
- lib/calcpace/environmental_adjuster.rb
|
|
46
49
|
- lib/calcpace/errors.rb
|
|
47
50
|
- lib/calcpace/fitness_predictor.rb
|
|
51
|
+
- lib/calcpace/grade_adjusted_pace.rb
|
|
52
|
+
- lib/calcpace/humidity.rb
|
|
48
53
|
- lib/calcpace/lap_analyzer.rb
|
|
49
54
|
- lib/calcpace/pace_calculator.rb
|
|
50
55
|
- lib/calcpace/pace_converter.rb
|
|
56
|
+
- lib/calcpace/personalized_predictor.rb
|
|
51
57
|
- lib/calcpace/race_predictor.rb
|
|
52
58
|
- lib/calcpace/race_splits.rb
|
|
53
59
|
- lib/calcpace/stride_calculator.rb
|
|
@@ -55,6 +61,7 @@ files:
|
|
|
55
61
|
- lib/calcpace/training_zones.rb
|
|
56
62
|
- lib/calcpace/version.rb
|
|
57
63
|
- lib/calcpace/vo2max_estimator.rb
|
|
64
|
+
- lib/calcpace/vo2max_norms.rb
|
|
58
65
|
homepage: https://github.com/0jonjo/calcpace
|
|
59
66
|
licenses:
|
|
60
67
|
- MIT
|
|
@@ -76,8 +83,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
76
83
|
- !ruby/object:Gem::Version
|
|
77
84
|
version: '0'
|
|
78
85
|
requirements: []
|
|
79
|
-
rubygems_version: 4.0.
|
|
86
|
+
rubygems_version: 4.0.20
|
|
80
87
|
specification_version: 4
|
|
81
|
-
summary: 'Running calculations: pace, race predictions, GPS track analysis,
|
|
82
|
-
and training zones.'
|
|
88
|
+
summary: 'Running calculations: pace, race predictions, GPS track analysis, heat and
|
|
89
|
+
altitude adjustments, age grading, VO2max, and training zones.'
|
|
83
90
|
test_files: []
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
meta:
|
|
2
|
-
source: "WMA/Masters Rankings age grading tables (2023)"
|
|
3
|
-
url: "https://howardgrubb.co.uk/athletics/wmatnf23.html"
|
|
4
|
-
table_version: "WMA_2023_ONE_YEAR_FACTORS_V1"
|
|
5
|
-
|
|
6
|
-
# The WMA / Alan Jones (Howard Grubb) tables are numeric age factors and open
|
|
7
|
-
# standards only — they define no categories at all. The bands from Local
|
|
8
|
-
# Class (60%) upward follow the USATF Masters / National Masters News
|
|
9
|
-
# convention; the three bands under 60% are Calcpace's own extension, added
|
|
10
|
-
# to give recreational runners a meaningful label instead of a single
|
|
11
|
-
# catch-all.
|
|
12
|
-
age_grade_classifications:
|
|
13
|
-
- min: 100.0
|
|
14
|
-
label: "Approximate World Record Level"
|
|
15
|
-
- min: 90.0
|
|
16
|
-
label: "World Class"
|
|
17
|
-
- min: 80.0
|
|
18
|
-
label: "National Class"
|
|
19
|
-
- min: 70.0
|
|
20
|
-
label: "Regional Class"
|
|
21
|
-
- min: 60.0
|
|
22
|
-
label: "Local Class"
|
|
23
|
-
- min: 50.0
|
|
24
|
-
label: "Intermediate"
|
|
25
|
-
- min: 40.0
|
|
26
|
-
label: "Recreational"
|
|
27
|
-
- min: 0.0
|
|
28
|
-
label: "Active Beginner"
|
|
29
|
-
|
|
30
|
-
open_standards_seconds:
|
|
31
|
-
M:
|
|
32
|
-
"5000": 755.0
|
|
33
|
-
"10000": 1571.0
|
|
34
|
-
"21097": 3451.0
|
|
35
|
-
"42195": 7269.0
|
|
36
|
-
F:
|
|
37
|
-
"5000": 846.0
|
|
38
|
-
"10000": 1741.0
|
|
39
|
-
"21097": 3772.0
|
|
40
|
-
"42195": 8044.0
|