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.
@@ -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
- def collect_splits(points, split_km, compact:)
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
- points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) }
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
- def process_segment(point_a, point_b, split_km, state)
267
- segment_km = haversine_distance(dig_key(point_a, :lat), dig_key(point_a, :lon),
268
- dig_key(point_b, :lat), dig_key(point_b, :lon))
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[:splits] << {
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((last_time - state[:split_start_time]).round, remaining_km,
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.transform_values do |band|
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[:high], meters)
63
-
64
- PaceBand.new(
65
- slow_seconds: slow,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.18.1'
4
+ VERSION = '2.0.0'
5
5
  end
@@ -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 [ArgumentError] if value is not positive
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) #=> "Very Good"
104
- def vo2max_label(value)
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
- VO2MAX_LABELS.find { |entry| value.to_f >= entry[:min] }[:label]
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: 1.18.1
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 & Cameron), GPS track analysis (Haversine,
14
- elevation gain, per-km splits), age grading (WMA 2023), VO2max estimation (Daniels
15
- & Gilbert), and personalized training zones (Daniels paces & Karvonen heart-rate
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/wma_2023_open_standards.yml
44
- - lib/calcpace/data/wma_2023_road.yml
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.16
86
+ rubygems_version: 4.0.20
80
87
  specification_version: 4
81
- summary: 'Running calculations: pace, race predictions, GPS track analysis, VO2max,
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