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 +4 -4
- data/CHANGELOG.md +28 -1
- data/README.md +38 -2
- data/lib/calcpace/converter.rb +2 -2
- data/lib/calcpace/fitness_predictor.rb +175 -0
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace/vo2max_estimator.rb +11 -0
- data/lib/calcpace.rb +2 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42ce231ceded47814ccd2f3a0e8ecb79d0e8572253533980a76e11a70afeb6f1
|
|
4
|
+
data.tar.gz: a2680a1f0607ef49d5178431b415cce7e0945a722ad3dbec8bb58b2c2a698131
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 [](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.
|
|
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
|
data/lib/calcpace/converter.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/calcpace/version.rb
CHANGED
|
@@ -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.
|
|
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
|