calcpace 1.11.0 → 1.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bc8b750605e9b531f01c27f0839668a04f9acf1d0c45316cd8860761afffbae
4
- data.tar.gz: ceb6a60b435735552b2fd7eb0e78ec131c73a9faa3ef22a84ce59dda20d6d375
3
+ metadata.gz: 42ce231ceded47814ccd2f3a0e8ecb79d0e8572253533980a76e11a70afeb6f1
4
+ data.tar.gz: a2680a1f0607ef49d5178431b415cce7e0945a722ad3dbec8bb58b2c2a698131
5
5
  SHA512:
6
- metadata.gz: 5b76dea0d30d9a98b6fa46364a1750f1d6051b342ae723205cf3e1b4560e3fc5ef568703df87c70cb74826434eedc78bcaa2d2cc57354b259521a371ecf9fd51
7
- data.tar.gz: def67fc24bbab2e8d7275f2db2d9a2b8f724e9690b1a3bf40d2d329de919db1c6bf35dd37e2878b775ff943026a372b0eda3a5f27302ca980645ccf7e3c3f6f7
6
+ metadata.gz: 836dd745def09ebc585241308d56298b986693c075b4676e12b1d95e57d4847f05b979d41cf203491dedd4e1228561163782c7c14886e4c75a9fe7f577c70820
7
+ data.tar.gz: 672cb17a7a142cc6f693c8b3c6d3597223e3391f8d9cd3c6c552259a1eb1a377805bdaa26c95adb8e8e56d3b2acde94da66a8c125d0044c02fee3a11fd13174c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.12.0] - 2026-08-01
11
+
12
+ ### Added
13
+ - Fitness predictor — race times from a VO2max value, the inverse of
14
+ `estimate_vo2max`
15
+ - `predict_time_from_vo2max(vo2max, race, distance_unit: nil)`: predicted
16
+ finish time in seconds. `race` accepts a standard race name ('5k',
17
+ 'marathon', '5mile', ...) or a numeric distance in kilometres (miles via
18
+ `distance_unit: :mi`), same semantics as `training_paces_from_race`
19
+ - `predict_time_from_vo2max_clock(...)`: same prediction as `HH:MM:SS`
20
+ - `race_times_from_vo2max(vo2max, races: nil, unit: :km)`: one call returns a
21
+ table of `time`, `time_clock`, `pace`, and `pace_clock` per race (default
22
+ races: 5k, 10k, half marathon, marathon; `unit: :mi` for paces per mile)
23
+ - VO2max inputs outside 10–100 ml/kg/min raise `ArgumentError`, where the
24
+ Daniels & Gilbert model stops being physiologically meaningful
25
+
26
+ Predictions come from bisecting the Daniels & Gilbert curve on the time axis
27
+ (it has no closed-form inverse), so
28
+ `estimate_vo2max(d, predict_time_from_vo2max(v, d))` returns `v` back. Times
29
+ match Daniels' published VDOT table within a few seconds for the shorter races
30
+ and about a minute for the marathon.
31
+
32
+ No existing behaviour changed: the Riegel (`predict_time`) and Cameron
33
+ predictors are untouched.
34
+
10
35
  ## [1.11.0] - 2026-07-25
11
36
 
12
37
  ### Added
@@ -269,7 +294,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
269
294
 
270
295
  See git history for changes in earlier versions.
271
296
 
272
- [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.10.0...HEAD
297
+ [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.12.0...HEAD
298
+ [1.12.0]: https://github.com/0jonjo/calcpace/compare/v1.11.0...v1.12.0
299
+ [1.11.0]: https://github.com/0jonjo/calcpace/compare/v1.10.0...v1.11.0
273
300
  [1.10.0]: https://github.com/0jonjo/calcpace/compare/v1.9.10...v1.10.0
274
301
  [1.9.6]: https://github.com/0jonjo/calcpace/compare/v1.9.5...v1.9.6
275
302
  [1.9.5]: https://github.com/0jonjo/calcpace/compare/v1.9.4...v1.9.5
data/README.md CHANGED
@@ -1,11 +1,11 @@
1
- # Calcpace [![Gem Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=rb&r=r&ts=1683906897&type=6e&v=1.10.0&x2=0)](https://badge.fury.io/rb/calcpace)
1
+ # Calcpace [![Gem Version](https://badge.fury.io/rb/calcpace.svg)](https://badge.fury.io/rb/calcpace)
2
2
 
3
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.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```ruby
8
- gem 'calcpace', '~> 1.10.0'
8
+ gem 'calcpace', '~> 1.12.0'
9
9
  ```
10
10
 
11
11
  ## Usage
@@ -323,6 +323,42 @@ pace bands, and age-grading tolerances agree to the metre.
323
323
 
324
324
  ---
325
325
 
326
+ ### Fitness Predictor (race times from VO2max)
327
+
328
+ The inverse of `estimate_vo2max`: what a given fitness is worth over a race.
329
+
330
+ ```ruby
331
+ calc.predict_time_from_vo2max(50, '5k') # => 1196.02 (seconds)
332
+ calc.predict_time_from_vo2max_clock(50, 'marathon') # => "03:10:39"
333
+
334
+ calc.predict_time_from_vo2max(50, 10.0) # numeric distance in km
335
+ calc.predict_time_from_vo2max_clock(50, 6.2, distance_unit: :mi) # => "00:41:13"
336
+
337
+ calc.race_times_from_vo2max(50)['10k']
338
+ # => { time: 2479.6, time_clock: "00:41:19", pace: 247.96, pace_clock: "00:04:07" }
339
+
340
+ calc.race_times_from_vo2max(50, races: %w[5k 10mile], unit: :mi)['5k']
341
+ # => { time: 1196.02, time_clock: "00:19:56", pace: 384.96, pace_clock: "00:06:24" }
342
+ ```
343
+
344
+ `race_times_from_vo2max` returns the whole table in one call — default races are
345
+ `5k`, `10k`, `half_marathon`, and `marathon`, and `unit:` sets the pace unit. In
346
+ `predict_time_from_vo2max`, `distance_unit:` sets the unit of a numeric distance;
347
+ combining it with a race name raises `ArgumentError`, as elsewhere in the gem.
348
+
349
+ The Daniels & Gilbert curve has no closed-form inverse, so the time is found by
350
+ bisection — which makes the round trip exact:
351
+
352
+ ```ruby
353
+ calc.estimate_vo2max(5.0, calc.predict_time_from_vo2max(50, '5k')) # => 50.0
354
+ ```
355
+
356
+ Predictions match Daniels' published VDOT table within a few seconds for the shorter
357
+ races and about a minute for the marathon. VO2max values outside 10–100 ml/kg/min
358
+ raise `ArgumentError` — beyond that range the model stops describing running.
359
+
360
+ ---
361
+
326
362
  ### Other Utilities
327
363
 
328
364
  ```ruby
@@ -106,8 +106,8 @@ module Converter
106
106
  # convert_to_clocktime(3600) #=> '01:00:00' (1 hour)
107
107
  # convert_to_clocktime(100000) #=> '1 03:46:40' (1 day, 3 hours, 46 minutes, 40 seconds)
108
108
  def convert_to_clocktime(seconds)
109
- days = seconds / 86_400
110
- format = days.to_i.positive? ? "#{days} %H:%M:%S" : '%H:%M:%S'
109
+ days = (seconds / 86_400).to_i
110
+ format = days.positive? ? "#{days} %H:%M:%S" : '%H:%M:%S'
111
111
  Time.at(seconds).utc.strftime(format)
112
112
  end
113
113
 
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module for predicting race performances from a VO2max value
4
+ #
5
+ # This is the inverse of Vo2maxEstimator#estimate_vo2max: instead of asking
6
+ # "what fitness does this race result imply?", it asks "what race result does
7
+ # this fitness imply?".
8
+ #
9
+ # The Daniels & Gilbert (1979) model cannot be inverted in closed form — the
10
+ # %VO2max term mixes two exponentials of time with a quadratic in velocity —
11
+ # so the finish time is found by bisection on the time axis. VO2max decreases
12
+ # monotonically with time for a fixed distance, which makes the search exact
13
+ # to within the tolerance and guarantees the round trip:
14
+ #
15
+ # estimate_vo2max(distance, predict_time_from_vo2max(vo2max, distance)) == vo2max
16
+ #
17
+ # Predicted times reproduce Daniels' published VDOT table within a few seconds
18
+ # for the shorter races and about a minute for the marathon.
19
+ module FitnessPredictor
20
+ # Range of VO2max values the model is meaningful for. Below it the effort is
21
+ # slower than a walk, above it faster than any human has run — in both cases
22
+ # the resulting "prediction" would be arithmetic, not physiology.
23
+ SUPPORTED_VO2MAX_RANGE = (10.0..100.0)
24
+
25
+ # Bisection bounds, as seconds per kilometre: from 1:00/km (well beyond world
26
+ # record pace) to 20:00/km (slower than walking). They bracket every VO2max
27
+ # in SUPPORTED_VO2MAX_RANGE at any distance.
28
+ FASTEST_PACE_SECONDS_PER_KM = 60.0
29
+ SLOWEST_PACE_SECONDS_PER_KM = 1200.0
30
+
31
+ # Search stops when the bracket is tighter than this many seconds or when the
32
+ # VO2max at the midpoint is this close to the target
33
+ TIME_TOLERANCE_SECONDS = 0.001
34
+ VO2MAX_TOLERANCE = 1e-6
35
+
36
+ # Races reported by #race_times_from_vo2max when none are given
37
+ DEFAULT_RACES = %w[5k 10k half_marathon marathon].freeze
38
+
39
+ # Predicts the finish time a given VO2max is worth over a given race
40
+ #
41
+ # @param vo2max [Numeric] VO2max in ml/kg/min (must be within SUPPORTED_VO2MAX_RANGE)
42
+ # @param race [Numeric, String, Symbol] race distance in kilometres (or in
43
+ # miles via distance_unit: :mi), either numeric or as a numeric string
44
+ # ('10', '21.0975'), or a standard race name ('5k', '10k', 'half_marathon',
45
+ # 'marathon', '1mile', '5mile', '10mile', '100k' — see
46
+ # PaceCalculator::RACE_DISTANCES)
47
+ # @param distance_unit [Symbol, nil] unit of a numeric race distance — :km
48
+ # (default) or :mi. Rejected when race is a race name: standard races
49
+ # already carry their own distance
50
+ # @return [Float] predicted finish time in seconds
51
+ # @raise [Calcpace::NonPositiveInputError] if vo2max or distance are not positive
52
+ # @raise [ArgumentError] if vo2max is outside the supported range, if a race
53
+ # name is not recognized, or if distance_unit is combined with a race name
54
+ # @raise [Calcpace::UnsupportedUnitError] if distance_unit is not :km or :mi
55
+ #
56
+ # @note estimate_vo2max can report values below 10 for very slow efforts
57
+ # (walking pace); those estimates are outside this predictor's supported
58
+ # range on purpose — a race plan built on them would be meaningless
59
+ #
60
+ # @example
61
+ # calc.predict_time_from_vo2max(50, '5k') #=> 1196.02 (≈19:56)
62
+ # calc.predict_time_from_vo2max(50, 'marathon') #=> 11439.74 (≈3:10:39)
63
+ # calc.predict_time_from_vo2max(50, 6.2, distance_unit: :mi)
64
+ def predict_time_from_vo2max(vo2max, race, distance_unit: nil)
65
+ target = validated_vo2max(vo2max)
66
+ distance_km = predicted_race_distance_km(race, distance_unit)
67
+ check_positive(distance_km, 'Distance')
68
+
69
+ solve_time_for_vo2max(target, distance_km)
70
+ end
71
+
72
+ # Predicts the finish time and returns it as a clock time string
73
+ #
74
+ # @param (see #predict_time_from_vo2max)
75
+ # @return [String] predicted finish time in HH:MM:SS format
76
+ #
77
+ # @example
78
+ # calc.predict_time_from_vo2max_clock(50, 'marathon') #=> '03:10:39'
79
+ def predict_time_from_vo2max_clock(vo2max, race, distance_unit: nil)
80
+ convert_to_clocktime(predict_time_from_vo2max(vo2max, race, distance_unit: distance_unit))
81
+ end
82
+
83
+ # Builds a full race-time table for one VO2max — one call per dashboard
84
+ #
85
+ # @param vo2max [Numeric] VO2max in ml/kg/min
86
+ # @param races [Array<String, Symbol>, nil] race names to report
87
+ # (default: 5k, 10k, half marathon, marathon)
88
+ # @param unit [Symbol] unit of the returned paces — :km (default) or :mi
89
+ # @return [Hash{String => Hash}] race name => { time: seconds,
90
+ # time_clock: 'HH:MM:SS', pace: seconds per unit, pace_clock: 'HH:MM:SS' }
91
+ # @raise [ArgumentError] if a race name is not recognized or vo2max is out of range
92
+ # @raise [Calcpace::NonPositiveInputError] if vo2max is not positive
93
+ # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
94
+ #
95
+ # @example
96
+ # calc.race_times_from_vo2max(50)['10k']
97
+ # #=> { time: 2479.6, time_clock: '00:41:19', pace: 247.96, pace_clock: '00:04:07' }
98
+ # calc.race_times_from_vo2max(50, races: %w[5k 10mile], unit: :mi)
99
+ def race_times_from_vo2max(vo2max, races: nil, unit: :km)
100
+ meters = pace_unit_meters(unit)
101
+ validated_vo2max(vo2max)
102
+
103
+ Array(races || DEFAULT_RACES).to_h do |race|
104
+ [normalize_race_key(race), race_time_entry(vo2max, race, meters)]
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def race_time_entry(vo2max, race, meters)
111
+ seconds = predict_time_from_vo2max(vo2max, race)
112
+ pace = (seconds / (race_distance(race) * Converter::Distance::KM_TO_METERS / meters)).round(2)
113
+
114
+ {
115
+ time: seconds,
116
+ time_clock: convert_to_clocktime(seconds),
117
+ pace: pace,
118
+ pace_clock: convert_to_clocktime(pace)
119
+ }
120
+ end
121
+
122
+ # Same distance semantics as TrainingZones#training_paces_from_race: numeric
123
+ # strings ('10') stay distances, only race names fall through to the lookup
124
+ def predicted_race_distance_km(race, distance_unit)
125
+ numeric = race.is_a?(Numeric) ? race : Float(race, exception: false)
126
+ return normalize_distance_km(numeric, distance_unit || :km) if numeric
127
+
128
+ reject_distance_unit_with_race_name!(distance_unit, race)
129
+ race_distance(race)
130
+ end
131
+
132
+ def validated_vo2max(vo2max)
133
+ value = vo2max.to_f
134
+ check_positive(value, 'VO2max')
135
+ return value if SUPPORTED_VO2MAX_RANGE.cover?(value)
136
+
137
+ raise ArgumentError,
138
+ "VO2max #{value} is outside the supported range " \
139
+ "(#{SUPPORTED_VO2MAX_RANGE.min}–#{SUPPORTED_VO2MAX_RANGE.max} ml/kg/min)"
140
+ end
141
+
142
+ # Bisects the time axis for the finish time whose VO2max equals the target
143
+ def solve_time_for_vo2max(target, distance_km)
144
+ low = distance_km * FASTEST_PACE_SECONDS_PER_KM
145
+ high = distance_km * SLOWEST_PACE_SECONDS_PER_KM
146
+ ensure_vo2max_reachable!(target, distance_km, low, high)
147
+
148
+ while high - low > TIME_TOLERANCE_SECONDS
149
+ mid = (low + high) / 2.0
150
+ value = raw_vo2max(distance_km, mid)
151
+ return mid.round(2) if (value - target).abs < VO2MAX_TOLERANCE
152
+
153
+ # VO2max falls as time grows: a midpoint fitter than the target means
154
+ # the answer is a slower time (raise low), otherwise a faster one
155
+ if value > target
156
+ low = mid
157
+ else
158
+ high = mid
159
+ end
160
+ end
161
+
162
+ ((low + high) / 2.0).round(2)
163
+ end
164
+
165
+ # Defensive bracket check: the search bounds cover the whole supported range
166
+ # at every distance, so this only fires if those constants are ever widened
167
+ def ensure_vo2max_reachable!(target, distance_km, low, high)
168
+ reachable = raw_vo2max(distance_km, high)..raw_vo2max(distance_km, low)
169
+ return if reachable.cover?(target)
170
+
171
+ raise ArgumentError,
172
+ "VO2max #{target} is not reachable over #{distance_km} km within the search bounds " \
173
+ "(#{reachable.begin.round(1)}–#{reachable.end.round(1)} ml/kg/min)"
174
+ end
175
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.11.0'
4
+ VERSION = '1.12.0'
5
5
  end
@@ -154,6 +154,17 @@ module Vo2maxEstimator
154
154
  -4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2))
155
155
  end
156
156
 
157
+ # Unrounded Daniels & Gilbert VO2max for a distance/time pair. Lives next to
158
+ # the formula it composes so the two cannot drift apart: FitnessPredictor
159
+ # bisects against this to guarantee an exact round trip with the public
160
+ # estimator, which only differs by rounding to one decimal.
161
+ def raw_vo2max(distance_km, seconds)
162
+ time_min = seconds / 60.0
163
+ velocity = distance_km * Converter::Distance::KM_TO_METERS / time_min
164
+
165
+ vo2_at_velocity(velocity) / percent_vo2max(time_min)
166
+ end
167
+
157
168
  def percent_vo2max(time_min)
158
169
  0.8 +
159
170
  (0.1894393 * Math.exp(-0.012778 * time_min)) +
data/lib/calcpace.rb CHANGED
@@ -8,6 +8,7 @@ require_relative 'calcpace/checker'
8
8
  require_relative 'calcpace/converter'
9
9
  require_relative 'calcpace/converter_chain'
10
10
  require_relative 'calcpace/errors'
11
+ require_relative 'calcpace/fitness_predictor'
11
12
  require_relative 'calcpace/pace_calculator'
12
13
  require_relative 'calcpace/pace_converter'
13
14
  require_relative 'calcpace/race_predictor'
@@ -45,6 +46,7 @@ class Calcpace
45
46
  include Checker
46
47
  include Converter
47
48
  include ConverterChain
49
+ include FitnessPredictor
48
50
  include PaceCalculator
49
51
  include PaceConverter
50
52
  include RacePredictor
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.11.0
4
+ version: 1.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - João Gilberto Saraiva
@@ -44,6 +44,7 @@ files:
44
44
  - lib/calcpace/data/wma_2023_road.yml
45
45
  - lib/calcpace/environmental_adjuster.rb
46
46
  - lib/calcpace/errors.rb
47
+ - lib/calcpace/fitness_predictor.rb
47
48
  - lib/calcpace/pace_calculator.rb
48
49
  - lib/calcpace/pace_converter.rb
49
50
  - lib/calcpace/race_predictor.rb