calcpace 1.10.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/.rubocop.yml +6 -0
- data/CHANGELOG.md +82 -1
- data/README.md +65 -5
- data/lib/calcpace/age_grading.rb +39 -17
- data/lib/calcpace/converter.rb +46 -15
- data/lib/calcpace/errors.rb +20 -4
- data/lib/calcpace/fitness_predictor.rb +175 -0
- data/lib/calcpace/pace_calculator.rb +13 -5
- data/lib/calcpace/training_zones.rb +76 -15
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace/vo2max_estimator.rb +31 -6
- data/lib/calcpace.rb +2 -0
- metadata +3 -2
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/.rubocop.yml
CHANGED
|
@@ -50,6 +50,12 @@ Metrics/AbcSize:
|
|
|
50
50
|
Exclude:
|
|
51
51
|
- 'test/**/*'
|
|
52
52
|
|
|
53
|
+
# Keyword arguments are self-documenting, so the ceiling is a little higher than
|
|
54
|
+
# the default 5 — but it stays a ceiling: kwargs still count, so a method growing
|
|
55
|
+
# to eight or ten of them is flagged.
|
|
56
|
+
Metrics/ParameterLists:
|
|
57
|
+
Max: 6
|
|
58
|
+
|
|
53
59
|
Metrics/BlockLength:
|
|
54
60
|
Exclude:
|
|
55
61
|
- 'test/**/*'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,85 @@ 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
|
+
|
|
35
|
+
## [1.11.0] - 2026-07-25
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- Training zones improvements
|
|
39
|
+
- `training_paces` and `training_paces_from_race` accept `unit: :mi` for
|
|
40
|
+
pace bands per mile (default remains `:km`)
|
|
41
|
+
- `hr_zones_from_max(hr_max:)`: five heart-rate zones from maximum heart
|
|
42
|
+
rate only (%HRmax method) — fallback when resting heart rate is unknown
|
|
43
|
+
- `training_paces_from_race` accepts standard race names ('10k', 'marathon',
|
|
44
|
+
'5mile', ...) in addition to numeric kilometres, matching `predict_time`
|
|
45
|
+
and `race_pace`
|
|
46
|
+
- `distance_unit: :mi` keyword on `estimate_vo2max`, `estimate_detailed_vo2max`,
|
|
47
|
+
`age_grade`, `age_grade_percent`, and `training_paces_from_race` — numeric
|
|
48
|
+
distance inputs can now be given in miles (default remains kilometres)
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
- `training_paces_from_race` resolves non-numeric distances as race names. Strings
|
|
52
|
+
that v1.10.0 silently parsed with `to_f` change meaning: `'5mile'` was 5.0 km and
|
|
53
|
+
is now the 5-mile standard distance (8.04672 km). Numeric strings (`'10'`,
|
|
54
|
+
`'21.0975'`) keep working as before, in kilometres.
|
|
55
|
+
- `training_paces_from_race` with an unparseable distance (`nil`, `'banana'`) now
|
|
56
|
+
raises `ArgumentError` ("Unknown race: ...") instead of
|
|
57
|
+
`Calcpace::NonPositiveInputError`.
|
|
58
|
+
- Unknown `unit:` / `distance_unit:` values raise `Calcpace::UnsupportedUnitError`
|
|
59
|
+
(inherits from `Calcpace::Error`) instead of `ArgumentError`. Unit keywords are
|
|
60
|
+
now case-insensitive (`'MI'` works) and `nil` raises the same error instead of a
|
|
61
|
+
`NoMethodError`.
|
|
62
|
+
- `unit: :mi` pace bands are computed natively per mile instead of being converted
|
|
63
|
+
from the km bands, so they can differ by ±1 s from `pace_km_to_mi(km_band)` —
|
|
64
|
+
the native value is the one without double rounding.
|
|
65
|
+
- Every mile-based factor now derives from the exact international mile
|
|
66
|
+
(1 mi = 1609.344 m), which was previously truncated to 1.60934 in some places
|
|
67
|
+
and exact in others. Affected values move by ~2.5e-6 relative:
|
|
68
|
+
`convert(1, :mi_to_km)` 1.60934 → 1.609344, `convert(1, :km_to_mi)` 0.621371 →
|
|
69
|
+
0.6213711922…, `convert(1, :mi_to_meters)` 1609.34 → 1609.344, the `mi_h`/`m_s`
|
|
70
|
+
speed pairs, and `list_races` entries `'1mile'` (1.609344) and `'10mile'`
|
|
71
|
+
(16.09344). Age-grading tolerance and pace bands now agree on mile length.
|
|
72
|
+
- Passing `distance_unit:` together with a race name (`training_paces_from_race('10k',
|
|
73
|
+
t, distance_unit: :mi)`, `age_grade('10k', …, distance_unit: :mi)`) raises
|
|
74
|
+
`ArgumentError` instead of silently ignoring the keyword — a standard race already
|
|
75
|
+
carries its own distance.
|
|
76
|
+
- Race-name lookup is normalized in one place: `' 10K '` and `:MARATHON` now resolve
|
|
77
|
+
everywhere (previously `PaceCalculator` did not strip whitespace), and `AgeGrading`
|
|
78
|
+
uses the same "Unknown race: …" message wording as the rest of the gem.
|
|
79
|
+
|
|
80
|
+
### Fixed
|
|
81
|
+
- Age grading accepts mile distances as runners write them (`3.1`, `6.2`, `13.1`,
|
|
82
|
+
`26.2` with `distance_unit: :mi`); the previous 0.001 km match window only
|
|
83
|
+
accepted 6-decimal conversions.
|
|
84
|
+
- Unsupported age-grading distances report the input in the unit it was given
|
|
85
|
+
instead of always labelling it "km".
|
|
86
|
+
- `estimate_detailed_vo2max` rejects a non-positive distance even when
|
|
87
|
+
`elevation_gain_m` is positive (the elevation adjustment used to mask it).
|
|
88
|
+
|
|
10
89
|
## [1.10.0] - 2026-07-11
|
|
11
90
|
|
|
12
91
|
### Added
|
|
@@ -215,7 +294,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
215
294
|
|
|
216
295
|
See git history for changes in earlier versions.
|
|
217
296
|
|
|
218
|
-
[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
|
|
219
300
|
[1.10.0]: https://github.com/0jonjo/calcpace/compare/v1.9.10...v1.10.0
|
|
220
301
|
[1.9.6]: https://github.com/0jonjo/calcpace/compare/v1.9.5...v1.9.6
|
|
221
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
|
|
@@ -76,8 +76,8 @@ calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 8
|
|
|
76
76
|
30+ units supported. String or symbol format:
|
|
77
77
|
|
|
78
78
|
```ruby
|
|
79
|
-
calc.convert(10, :km_to_mi) # => 6.
|
|
80
|
-
calc.convert(10, 'mi to km') # => 16.
|
|
79
|
+
calc.convert(10, :km_to_mi) # => 6.213711922...
|
|
80
|
+
calc.convert(10, 'mi to km') # => 16.09344
|
|
81
81
|
calc.convert(1, :m_s_to_km_h) # => 3.6
|
|
82
82
|
|
|
83
83
|
# Chain conversions
|
|
@@ -169,6 +169,7 @@ age factors and open standards.
|
|
|
169
169
|
|
|
170
170
|
```ruby
|
|
171
171
|
result = calc.age_grade(10.0, '00:45:00', age: 55, sex: :male)
|
|
172
|
+
# numeric distances also accepted in miles: calc.age_grade(6.21371, '00:45:00', age: 55, sex: :male, distance_unit: :mi)
|
|
172
173
|
# => {
|
|
173
174
|
# age_grade_percent: 64.6,
|
|
174
175
|
# category: "Local Class",
|
|
@@ -208,6 +209,7 @@ Estimate aerobic fitness from a race result using the **Daniels & Gilbert formul
|
|
|
208
209
|
calc.estimate_vo2max(10.0, '00:40:00') # => 51.9 ml/kg/min
|
|
209
210
|
calc.estimate_vo2max(42.195, '03:30:00') # => 44.8
|
|
210
211
|
calc.estimate_vo2max(5.0, 2400) # also accepts total seconds
|
|
212
|
+
calc.estimate_vo2max(6.21371, '00:40:00', distance_unit: :mi) # => 51.9 (miles input)
|
|
211
213
|
|
|
212
214
|
calc.vo2max_label(51.9) # => "Very Good"
|
|
213
215
|
```
|
|
@@ -285,10 +287,18 @@ zones = calc.training_paces(50.0)
|
|
|
285
287
|
zones[:threshold].fast_clock # => "00:04:15" per km
|
|
286
288
|
zones[:easy].slow_clock # => "00:05:52" per km
|
|
287
289
|
|
|
288
|
-
calc.
|
|
290
|
+
calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock # => "00:06:51" per mile
|
|
291
|
+
|
|
292
|
+
calc.training_paces_from_race(10.0, '00:40:00') # from a recent race result
|
|
293
|
+
calc.training_paces_from_race('5mile', '00:35:00', unit: :mi) # race names work too
|
|
294
|
+
calc.training_paces_from_race(6.2, '00:40:00', distance_unit: :mi, unit: :mi) # race distance in miles
|
|
289
295
|
|
|
290
296
|
calc.hr_zones(hr_max: 190, hr_rest: 55)
|
|
291
297
|
# => [#<struct zone=1, min_bpm=123, max_bpm=136>, ... zone=5, max_bpm=190]
|
|
298
|
+
|
|
299
|
+
calc.hr_zones_from_max(hr_max: 190)
|
|
300
|
+
# => [#<struct zone=1, min_bpm=95, max_bpm=114>, ... zone=5, max_bpm=190]
|
|
301
|
+
# %HRmax fallback — prefer hr_zones (Karvonen) when resting HR is known
|
|
292
302
|
```
|
|
293
303
|
|
|
294
304
|
| Zone | %VO2max | Purpose |
|
|
@@ -302,6 +312,51 @@ calc.hr_zones(hr_max: 190, hr_rest: 55)
|
|
|
302
312
|
Pace accuracy vs published VDOT tables: within a few seconds per km
|
|
303
313
|
(threshold matches exactly; easy band is a range heuristic).
|
|
304
314
|
|
|
315
|
+
`unit:` sets the unit of the returned pace bands; `distance_unit:` sets the unit of a
|
|
316
|
+
numeric race distance you pass in. Combining `distance_unit:` with a race name raises
|
|
317
|
+
`ArgumentError` — `'10k'` already carries its own distance. Mile bands are computed
|
|
318
|
+
natively (not converted from the km bands), so they can differ by ±1 s from
|
|
319
|
+
`pace_km_to_mi(km_band)`.
|
|
320
|
+
|
|
321
|
+
All mile factors derive from the exact international mile (1609.344 m), so distances,
|
|
322
|
+
pace bands, and age-grading tolerances agree to the metre.
|
|
323
|
+
|
|
324
|
+
---
|
|
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
|
+
|
|
305
360
|
---
|
|
306
361
|
|
|
307
362
|
### Other Utilities
|
|
@@ -320,6 +375,11 @@ All errors inherit from `Calcpace::Error`:
|
|
|
320
375
|
|
|
321
376
|
- `Calcpace::NonPositiveInputError` — numeric input is zero or negative
|
|
322
377
|
- `Calcpace::InvalidTimeFormatError` — time string not in `HH:MM:SS` or `MM:SS` format
|
|
378
|
+
- `Calcpace::UnsupportedUnitError` — unknown conversion (`convert`) or unknown
|
|
379
|
+
`unit:` / `distance_unit:` keyword
|
|
380
|
+
|
|
381
|
+
Argument validation that is not about units or numbers raises a plain `ArgumentError`:
|
|
382
|
+
unknown race names, unsupported age-grading distances, and invalid `age` / `sex` values.
|
|
323
383
|
|
|
324
384
|
---
|
|
325
385
|
|
data/lib/calcpace/age_grading.rb
CHANGED
|
@@ -49,14 +49,22 @@ module AgeGrading
|
|
|
49
49
|
|
|
50
50
|
# Returns a full age-grading report for a race performance
|
|
51
51
|
#
|
|
52
|
-
# @param
|
|
53
|
-
# (5.0, 10.0, 21.0975, 42.195) or race key (:5k, :10k, :half_marathon, :marathon)
|
|
52
|
+
# @param distance [Numeric, String, Symbol] race distance in kilometres
|
|
53
|
+
# (5.0, 10.0, 21.0975, 42.195) or race key (:5k, :10k, :half_marathon, :marathon);
|
|
54
|
+
# numeric input can also be given in miles via distance_unit: :mi
|
|
54
55
|
# @param time [String, Numeric] performance time as HH:MM:SS / MM:SS, or total seconds
|
|
55
56
|
# @param age [Integer] athlete age (must be >= 18)
|
|
56
57
|
# @param sex [String, Symbol] male or female
|
|
58
|
+
# @param distance_unit [Symbol] unit of a numeric distance input — :km (default) or :mi.
|
|
59
|
+
# Rejected when distance is a race key: standard races already carry their own
|
|
60
|
+
# distance, so the combination is always a caller mistake
|
|
57
61
|
# @return [Hash] age-grading result details
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
# @raise [ArgumentError] if the distance, race key, age or sex is not supported,
|
|
63
|
+
# or if distance_unit is combined with a race key
|
|
64
|
+
# @raise [Calcpace::UnsupportedUnitError] if distance_unit is not :km or :mi
|
|
65
|
+
# @raise [Calcpace::InvalidTimeFormatError] if time string is malformed
|
|
66
|
+
def age_grade(distance, time, age:, sex:, distance_unit: nil)
|
|
67
|
+
distance_m = normalize_distance(distance, distance_unit)
|
|
60
68
|
seconds = parse_time_seconds(time)
|
|
61
69
|
age_value = normalize_age(age)
|
|
62
70
|
sex_value = normalize_sex(sex)
|
|
@@ -83,13 +91,15 @@ module AgeGrading
|
|
|
83
91
|
|
|
84
92
|
# Returns only the age-grade percentage
|
|
85
93
|
#
|
|
86
|
-
# @param
|
|
94
|
+
# @param distance [Numeric, String, Symbol] race distance in kilometres or race key
|
|
87
95
|
# @param time [String, Numeric] performance time
|
|
88
96
|
# @param age [Integer] athlete age
|
|
89
97
|
# @param sex [String, Symbol] male or female
|
|
98
|
+
# @param distance_unit [Symbol] unit of a numeric distance input — :km (default) or :mi
|
|
99
|
+
# (rejected alongside race keys, see #age_grade)
|
|
90
100
|
# @return [Float] age-grade percentage
|
|
91
|
-
def age_grade_percent(
|
|
92
|
-
age_grade(
|
|
101
|
+
def age_grade_percent(distance, time, age:, sex:, distance_unit: nil)
|
|
102
|
+
age_grade(distance, time, age: age, sex: sex, distance_unit: distance_unit)[:age_grade_percent]
|
|
93
103
|
end
|
|
94
104
|
|
|
95
105
|
# Returns a descriptive label for an age-grade percentage
|
|
@@ -110,23 +120,35 @@ module AgeGrading
|
|
|
110
120
|
|
|
111
121
|
private
|
|
112
122
|
|
|
113
|
-
def normalize_distance(
|
|
114
|
-
if
|
|
115
|
-
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
raise ArgumentError,
|
|
119
|
-
"Unsupported race '#{distance_km}'. Supported: #{RACE_TO_METERS.keys.join(', ')}"
|
|
123
|
+
def normalize_distance(distance_input, distance_unit = nil)
|
|
124
|
+
if distance_input.is_a?(String) || distance_input.is_a?(Symbol)
|
|
125
|
+
reject_distance_unit_with_race_name!(distance_unit, distance_input)
|
|
126
|
+
return race_key_to_meters(distance_input)
|
|
120
127
|
end
|
|
121
128
|
|
|
122
|
-
distance =
|
|
129
|
+
distance = normalize_distance_km(distance_input, distance_unit || :km)
|
|
123
130
|
check_positive(distance, 'Distance')
|
|
124
131
|
|
|
125
|
-
match = SUPPORTED_DISTANCES_KM.find { |value| (distance
|
|
132
|
+
match = SUPPORTED_DISTANCES_KM.find { |value| standard_distance?(distance, value) }
|
|
126
133
|
return DISTANCE_TO_METERS.fetch(match) if match
|
|
127
134
|
|
|
128
135
|
raise ArgumentError,
|
|
129
|
-
"Unsupported distance #{
|
|
136
|
+
"Unsupported distance #{distance_input}#{(distance_unit || :km).to_s.downcase}. " \
|
|
137
|
+
"Supported: #{SUPPORTED_DISTANCES_KM.join(', ')} km"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def race_key_to_meters(race_input)
|
|
141
|
+
# normalize_race_key is PaceCalculator's — one lookup convention gem-wide
|
|
142
|
+
RACE_TO_METERS.fetch(normalize_race_key(race_input)) do
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"Unknown race: #{race_input}. Available races: #{RACE_TO_METERS.keys.join(', ')}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Runners write rounded distances (3.1 mi, 13.1 mi, 26.2 mi), so the match
|
|
149
|
+
# window is relative — 0.5% of the standard distance, never below 1 metre
|
|
150
|
+
def standard_distance?(distance, standard)
|
|
151
|
+
(distance - standard).abs <= [0.001, standard * 0.005].max
|
|
130
152
|
end
|
|
131
153
|
|
|
132
154
|
def parse_time_seconds(time)
|
data/lib/calcpace/converter.rb
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
# speed units (m/s, km/h, mi/h, knots, etc.).
|
|
8
8
|
module Converter
|
|
9
9
|
module Distance
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
# One international mile is exactly 1609.344 m. Every mile-based factor
|
|
11
|
+
# derives from this single value so that no two call sites — a pace band, a
|
|
12
|
+
# distance conversion, an age-grading tolerance — can disagree about how
|
|
13
|
+
# long a mile is.
|
|
14
|
+
MI_TO_KM = 1.609344
|
|
15
|
+
KM_TO_MI = 1 / MI_TO_KM
|
|
12
16
|
NAUTICAL_MI_TO_KM = 1.852
|
|
13
17
|
KM_TO_NAUTICAL_MI = 0.539957
|
|
14
18
|
METERS_TO_KM = 0.001
|
|
15
19
|
KM_TO_METERS = 1000
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
MI_TO_METERS = MI_TO_KM * 1000
|
|
21
|
+
METERS_TO_MI = 1 / MI_TO_METERS
|
|
18
22
|
METERS_TO_FEET = 3.28084
|
|
19
23
|
FEET_TO_METERS = 0.3048
|
|
20
24
|
METERS_TO_YARDS = 1.09361
|
|
@@ -28,30 +32,30 @@ module Converter
|
|
|
28
32
|
KM_TO_INCHES = 39_370.1
|
|
29
33
|
INCHES_TO_KM = 0.0000254
|
|
30
34
|
MI_TO_YARDS = 1760
|
|
31
|
-
YARDS_TO_MI = 0
|
|
35
|
+
YARDS_TO_MI = 1.0 / MI_TO_YARDS
|
|
32
36
|
MI_TO_FEET = 5280
|
|
33
|
-
FEET_TO_MI = 0
|
|
37
|
+
FEET_TO_MI = 1.0 / MI_TO_FEET
|
|
34
38
|
MI_TO_INCHES = 63_360
|
|
35
|
-
INCHES_TO_MI = 0
|
|
39
|
+
INCHES_TO_MI = 1.0 / MI_TO_INCHES
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
module Speed
|
|
39
43
|
M_S_TO_KM_H = 3.6
|
|
40
44
|
KM_H_TO_M_S = 0.277778
|
|
41
|
-
M_S_TO_MI_H =
|
|
42
|
-
MI_H_TO_M_S =
|
|
45
|
+
M_S_TO_MI_H = 3.6 / Distance::MI_TO_KM
|
|
46
|
+
MI_H_TO_M_S = Distance::MI_TO_METERS / 3600
|
|
43
47
|
M_S_TO_NAUTICAL_MI_H = 1.94384
|
|
44
48
|
NAUTICAL_MI_H_TO_M_S = 0.514444
|
|
45
49
|
M_S_TO_FEET_S = 3.28084
|
|
46
50
|
FEET_S_TO_M_S = 0.3048
|
|
47
51
|
M_S_TO_KNOTS = 1.94384
|
|
48
52
|
KNOTS_TO_M_S = 0.514444
|
|
49
|
-
KM_H_TO_MI_H =
|
|
50
|
-
MI_H_TO_KM_H =
|
|
53
|
+
KM_H_TO_MI_H = Distance::KM_TO_MI
|
|
54
|
+
MI_H_TO_KM_H = Distance::MI_TO_KM
|
|
51
55
|
KM_H_TO_NAUTICAL_MI_H = 0.539957
|
|
52
56
|
NAUTICAL_MI_H_TO_KM_H = 1.852
|
|
53
|
-
MI_H_TO_NAUTICAL_MI_H =
|
|
54
|
-
NAUTICAL_MI_H_TO_MI_H =
|
|
57
|
+
MI_H_TO_NAUTICAL_MI_H = Distance::MI_TO_KM / Distance::NAUTICAL_MI_TO_KM
|
|
58
|
+
NAUTICAL_MI_H_TO_MI_H = Distance::NAUTICAL_MI_TO_KM / Distance::MI_TO_KM
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
# Converts a value from one unit to another
|
|
@@ -102,8 +106,8 @@ module Converter
|
|
|
102
106
|
# convert_to_clocktime(3600) #=> '01:00:00' (1 hour)
|
|
103
107
|
# convert_to_clocktime(100000) #=> '1 03:46:40' (1 day, 3 hours, 46 minutes, 40 seconds)
|
|
104
108
|
def convert_to_clocktime(seconds)
|
|
105
|
-
days = seconds / 86_400
|
|
106
|
-
format = days.
|
|
109
|
+
days = (seconds / 86_400).to_i
|
|
110
|
+
format = days.positive? ? "#{days} %H:%M:%S" : '%H:%M:%S'
|
|
107
111
|
Time.at(seconds).utc.strftime(format)
|
|
108
112
|
end
|
|
109
113
|
|
|
@@ -139,8 +143,35 @@ module Converter
|
|
|
139
143
|
format_list(Distance.constants)
|
|
140
144
|
end
|
|
141
145
|
|
|
146
|
+
# Multipliers from a supported distance-input unit to kilometres
|
|
147
|
+
# (used by methods that accept a distance_unit: keyword)
|
|
148
|
+
DISTANCE_UNIT_TO_KM = { km: 1.0, mi: Distance::MI_TO_KM }.freeze
|
|
149
|
+
|
|
142
150
|
private
|
|
143
151
|
|
|
152
|
+
# Guards the "race name + distance_unit" combination. A standard race already
|
|
153
|
+
# carries its own distance, so the keyword can only be a caller mistake —
|
|
154
|
+
# better to say so than to ignore it silently.
|
|
155
|
+
#
|
|
156
|
+
# @raise [ArgumentError] if a distance_unit was given alongside a race name
|
|
157
|
+
def reject_distance_unit_with_race_name!(distance_unit, race)
|
|
158
|
+
return if distance_unit.nil?
|
|
159
|
+
|
|
160
|
+
raise ArgumentError,
|
|
161
|
+
"distance_unit: #{distance_unit.inspect} cannot be combined with the race name " \
|
|
162
|
+
"#{race.inspect} — a standard race already carries its own distance"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Normalizes a numeric distance input to kilometres
|
|
166
|
+
#
|
|
167
|
+
# @raise [Calcpace::UnsupportedUnitError] if distance_unit is not :km or :mi
|
|
168
|
+
def normalize_distance_km(value, distance_unit)
|
|
169
|
+
factor = DISTANCE_UNIT_TO_KM.fetch(distance_unit.to_s.downcase.to_sym) do
|
|
170
|
+
raise Calcpace::UnsupportedUnitError.new(distance_unit, supported: DISTANCE_UNIT_TO_KM.keys)
|
|
171
|
+
end
|
|
172
|
+
value.to_f * factor
|
|
173
|
+
end
|
|
174
|
+
|
|
144
175
|
def format_unit(unit)
|
|
145
176
|
unit.downcase.gsub(' ', '_').to_sym
|
|
146
177
|
end
|
data/lib/calcpace/errors.rb
CHANGED
|
@@ -20,11 +20,27 @@ class Calcpace
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
# Raised when an unsupported unit conversion is requested
|
|
23
|
+
# Raised when an unsupported unit or unit conversion is requested
|
|
24
|
+
#
|
|
25
|
+
# @example conversion pair
|
|
26
|
+
# raise UnsupportedUnitError, :km_to_furlong
|
|
27
|
+
# @example single unit, with the supported ones listed
|
|
28
|
+
# raise UnsupportedUnitError.new(:furlong, supported: %i[km mi])
|
|
24
29
|
class UnsupportedUnitError < Error
|
|
25
|
-
def initialize(unit = nil)
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
def initialize(unit = nil, supported: nil)
|
|
31
|
+
super(build_message(unit, supported))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_message(unit, supported)
|
|
37
|
+
return conversion_message(unit) unless supported
|
|
38
|
+
|
|
39
|
+
"Unsupported unit: #{unit.inspect}. Supported units: #{supported.map(&:inspect).join(', ')}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def conversion_message(unit)
|
|
43
|
+
unit ? "Unsupported unit conversion: #{unit}" : 'Unsupported unit conversion'
|
|
28
44
|
end
|
|
29
45
|
end
|
|
30
46
|
end
|
|
@@ -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
|
|
@@ -12,9 +12,9 @@ module PaceCalculator
|
|
|
12
12
|
'half_marathon' => 21.0975,
|
|
13
13
|
'marathon' => 42.195,
|
|
14
14
|
'100k' => 100.0,
|
|
15
|
-
'1mile' =>
|
|
16
|
-
'5mile' =>
|
|
17
|
-
'10mile' =>
|
|
15
|
+
'1mile' => Converter::Distance::MI_TO_KM,
|
|
16
|
+
'5mile' => 5 * Converter::Distance::MI_TO_KM,
|
|
17
|
+
'10mile' => 10 * Converter::Distance::MI_TO_KM
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
20
|
# Calculates the finish time for a race given a pace per kilometer
|
|
@@ -93,10 +93,18 @@ module PaceCalculator
|
|
|
93
93
|
# @return [Float] distance in kilometers
|
|
94
94
|
# @raise [ArgumentError] if race is not recognized
|
|
95
95
|
def race_distance(race)
|
|
96
|
-
|
|
97
|
-
RACE_DISTANCES.fetch(key) do
|
|
96
|
+
RACE_DISTANCES.fetch(normalize_race_key(race)) do
|
|
98
97
|
raise ArgumentError,
|
|
99
98
|
"Unknown race: #{race}. Available races: #{RACE_DISTANCES.keys.join(', ')}"
|
|
100
99
|
end
|
|
101
100
|
end
|
|
101
|
+
|
|
102
|
+
# Single normalization for every race-name lookup in the gem (here and in
|
|
103
|
+
# AgeGrading), so ' 10K ' and :marathon always resolve like '10k' and 'marathon'
|
|
104
|
+
#
|
|
105
|
+
# @param race [String, Symbol] race name in any case, with or without padding
|
|
106
|
+
# @return [String] normalized lookup key
|
|
107
|
+
def normalize_race_key(race)
|
|
108
|
+
race.to_s.strip.downcase
|
|
109
|
+
end
|
|
102
110
|
end
|
|
@@ -19,11 +19,15 @@ module TrainingZones
|
|
|
19
19
|
repetition: { low: 1.05, high: 1.10 }
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
-
# A pace band for one training zone (paces per kilometre).
|
|
22
|
+
# A pace band for one training zone (paces per kilometre or mile).
|
|
23
23
|
# slow = lower-intensity end of the band, fast = higher-intensity end.
|
|
24
24
|
PaceBand = Struct.new(:slow_seconds, :fast_seconds, :slow_clock, :fast_clock)
|
|
25
25
|
|
|
26
|
-
#
|
|
26
|
+
# Metres per pace unit — pace bands can be expressed per km or per mile
|
|
27
|
+
PACE_UNIT_METERS = { km: 1000.0, mi: Converter::Distance::MI_TO_METERS }.freeze
|
|
28
|
+
|
|
29
|
+
# Heart-rate zone boundaries as fractions of the range being split:
|
|
30
|
+
# Heart Rate Reserve in #hr_zones (Karvonen) and HRmax in #hr_zones_from_max
|
|
27
31
|
HR_ZONE_BOUNDARIES = [0.50, 0.60, 0.70, 0.80, 0.90, 1.00].freeze
|
|
28
32
|
|
|
29
33
|
# One heart-rate training zone (1 = recovery … 5 = maximal)
|
|
@@ -32,18 +36,22 @@ module TrainingZones
|
|
|
32
36
|
# Derives training pace bands from a VO2max value
|
|
33
37
|
#
|
|
34
38
|
# @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0)
|
|
39
|
+
# @param unit [Symbol] pace unit — :km (default) or :mi
|
|
35
40
|
# @return [Hash{Symbol => PaceBand}] keys: :easy, :marathon, :threshold,
|
|
36
|
-
# :interval, :repetition — paces per
|
|
41
|
+
# :interval, :repetition — paces per chosen unit
|
|
37
42
|
# @raise [Calcpace::NonPositiveInputError] if vo2max is not positive
|
|
43
|
+
# @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
|
|
38
44
|
#
|
|
39
45
|
# @example
|
|
40
|
-
# calc.training_paces(50.0)[:threshold].fast_clock
|
|
41
|
-
|
|
46
|
+
# calc.training_paces(50.0)[:threshold].fast_clock #=> "00:04:15"
|
|
47
|
+
# calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock #=> "00:06:51"
|
|
48
|
+
def training_paces(vo2max, unit: :km)
|
|
42
49
|
check_positive(vo2max.to_f, 'VO2max')
|
|
50
|
+
meters = pace_unit_meters(unit)
|
|
43
51
|
|
|
44
52
|
TRAINING_INTENSITIES.transform_values do |band|
|
|
45
|
-
slow = pace_seconds_at_pct(vo2max.to_f, band[:low])
|
|
46
|
-
fast = pace_seconds_at_pct(vo2max.to_f, band[:high])
|
|
53
|
+
slow = pace_seconds_at_pct(vo2max.to_f, band[:low], meters)
|
|
54
|
+
fast = pace_seconds_at_pct(vo2max.to_f, band[:high], meters)
|
|
47
55
|
|
|
48
56
|
PaceBand.new(
|
|
49
57
|
slow_seconds: slow,
|
|
@@ -59,16 +67,39 @@ module TrainingZones
|
|
|
59
67
|
# Convenience wrapper: estimates VO2max via Daniels & Gilbert
|
|
60
68
|
# (see Vo2maxEstimator#estimate_vo2max) and derives the bands from it.
|
|
61
69
|
#
|
|
62
|
-
# @param
|
|
70
|
+
# @param race [Numeric, String, Symbol] race distance in kilometres (or in
|
|
71
|
+
# miles via distance_unit: :mi), either numeric or as a numeric string
|
|
72
|
+
# ('10', '21.0975'), or a standard race name ('5k', '10k', 'half_marathon',
|
|
73
|
+
# 'marathon', '1mile', '5mile', '10mile', '100k' — see
|
|
74
|
+
# PaceCalculator::RACE_DISTANCES)
|
|
63
75
|
# @param time [String, Integer] finish time as "HH:MM:SS" / "MM:SS" or total seconds
|
|
76
|
+
# @param unit [Symbol] pace unit of the output bands — :km (default) or :mi
|
|
77
|
+
# @param distance_unit [Symbol] unit of a numeric race distance input — :km (default) or :mi.
|
|
78
|
+
# Rejected when race is a race name: standard races already carry their own
|
|
79
|
+
# distance, so the combination is always a caller mistake
|
|
64
80
|
# @return [Hash{Symbol => PaceBand}] same shape as #training_paces
|
|
81
|
+
# @raise [ArgumentError] if a race name is not recognized, or if distance_unit
|
|
82
|
+
# is combined with a race name
|
|
83
|
+
# @raise [Calcpace::UnsupportedUnitError] if unit or distance_unit is not :km or :mi
|
|
65
84
|
# @raise [Calcpace::NonPositiveInputError] if distance or time are not positive
|
|
66
85
|
# @raise [Calcpace::InvalidTimeFormatError] if time string is malformed
|
|
67
86
|
#
|
|
68
87
|
# @example
|
|
69
88
|
# calc.training_paces_from_race(10.0, '00:40:00')[:easy].slow_clock #=> "00:05:42"
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
# calc.training_paces_from_race('5mile', '00:35:00', unit: :mi)
|
|
90
|
+
# calc.training_paces_from_race(6.2, '00:40:00', distance_unit: :mi, unit: :mi)
|
|
91
|
+
def training_paces_from_race(race, time, unit: :km, distance_unit: nil)
|
|
92
|
+
# Numeric strings ('10') keep working as distances; only non-numeric input
|
|
93
|
+
# (race names) falls through to the RACE_DISTANCES lookup
|
|
94
|
+
numeric = race.is_a?(Numeric) ? race : Float(race, exception: false)
|
|
95
|
+
distance_km = if numeric
|
|
96
|
+
normalize_distance_km(numeric, distance_unit || :km)
|
|
97
|
+
else
|
|
98
|
+
reject_distance_unit_with_race_name!(distance_unit, race)
|
|
99
|
+
race_distance(race)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
training_paces(estimate_vo2max(distance_km, time), unit: unit)
|
|
72
103
|
end
|
|
73
104
|
|
|
74
105
|
# Computes the five Karvonen heart-rate training zones
|
|
@@ -89,15 +120,39 @@ module TrainingZones
|
|
|
89
120
|
check_heart_rates(max, rest)
|
|
90
121
|
|
|
91
122
|
reserve = max - rest
|
|
92
|
-
|
|
123
|
+
build_hr_zones(HR_ZONE_BOUNDARIES.map { |pct| (rest + (pct * reserve)).round })
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Computes five heart-rate zones from maximum heart rate only (%HRmax method)
|
|
127
|
+
#
|
|
128
|
+
# Fallback for athletes who don't know their resting heart rate — the
|
|
129
|
+
# classic percent-of-max model used as default by most sports watches:
|
|
130
|
+
# target_bpm = pct * hr_max
|
|
131
|
+
#
|
|
132
|
+
# Prefer #hr_zones (Karvonen) when resting heart rate is available.
|
|
133
|
+
#
|
|
134
|
+
# @param hr_max [Numeric] maximum heart rate in bpm (must be > 0)
|
|
135
|
+
# @return [Array<HrZone>] five contiguous zones from Z1 (50–60% HRmax) to Z5 (90–100% HRmax)
|
|
136
|
+
# @raise [Calcpace::NonPositiveInputError] if hr_max is not positive
|
|
137
|
+
#
|
|
138
|
+
# @example
|
|
139
|
+
# calc.hr_zones_from_max(hr_max: 190).first.min_bpm #=> 95
|
|
140
|
+
def hr_zones_from_max(hr_max:)
|
|
141
|
+
max = hr_max.to_f
|
|
142
|
+
check_positive(max, 'Maximum heart rate')
|
|
93
143
|
|
|
144
|
+
build_hr_zones(HR_ZONE_BOUNDARIES.map { |pct| (pct * max).round })
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Turns six ascending bpm boundary points into five contiguous HrZone structs
|
|
150
|
+
def build_hr_zones(points)
|
|
94
151
|
points.each_cons(2).with_index(1).map do |(min_bpm, max_bpm), zone|
|
|
95
152
|
HrZone.new(zone: zone, min_bpm: min_bpm, max_bpm: max_bpm)
|
|
96
153
|
end
|
|
97
154
|
end
|
|
98
155
|
|
|
99
|
-
private
|
|
100
|
-
|
|
101
156
|
def check_heart_rates(hr_max, hr_rest)
|
|
102
157
|
check_positive(hr_max, 'Maximum heart rate')
|
|
103
158
|
check_positive(hr_rest, 'Resting heart rate')
|
|
@@ -116,8 +171,14 @@ module TrainingZones
|
|
|
116
171
|
(-b + Math.sqrt((b**2) - (4 * a * c))) / (2 * a)
|
|
117
172
|
end
|
|
118
173
|
|
|
119
|
-
def
|
|
174
|
+
def pace_unit_meters(unit)
|
|
175
|
+
PACE_UNIT_METERS.fetch(unit.to_s.downcase.to_sym) do
|
|
176
|
+
raise Calcpace::UnsupportedUnitError.new(unit, supported: PACE_UNIT_METERS.keys)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def pace_seconds_at_pct(vo2max, pct, meters)
|
|
120
181
|
velocity = velocity_at_vo2(vo2max * pct)
|
|
121
|
-
(
|
|
182
|
+
(meters * 60.0 / velocity).round
|
|
122
183
|
end
|
|
123
184
|
end
|
data/lib/calcpace/version.rb
CHANGED
|
@@ -32,17 +32,21 @@ module Vo2maxEstimator
|
|
|
32
32
|
|
|
33
33
|
# Estimates VO2max from a race performance using Daniels & Gilbert formula
|
|
34
34
|
#
|
|
35
|
-
# @param
|
|
35
|
+
# @param distance [Numeric] race distance (must be > 0), in kilometres by
|
|
36
|
+
# default or in the unit given by distance_unit
|
|
36
37
|
# @param time [String, Integer] finish time as "HH:MM:SS" / "MM:SS", or total seconds (must be > 0)
|
|
38
|
+
# @param distance_unit [Symbol] unit of the distance input — :km (default) or :mi
|
|
37
39
|
# @return [Float] estimated VO2max in ml/kg/min, rounded to one decimal place
|
|
38
40
|
# @raise [Calcpace::NonPositiveInputError] if distance or time are not positive
|
|
39
41
|
# @raise [Calcpace::InvalidTimeFormatError] if time string is not in HH:MM:SS or MM:SS format
|
|
42
|
+
# @raise [Calcpace::UnsupportedUnitError] if distance_unit is not :km or :mi
|
|
40
43
|
#
|
|
41
44
|
# @example 10 km in 40:00 → ~51.9 ml/kg/min
|
|
42
45
|
# calc = Calcpace.new
|
|
43
|
-
# calc.estimate_vo2max(10.0, '00:40:00')
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
# calc.estimate_vo2max(10.0, '00:40:00') #=> 51.9
|
|
47
|
+
# calc.estimate_vo2max(6.21371, '00:40:00', distance_unit: :mi) #=> 51.9
|
|
48
|
+
def estimate_vo2max(distance, time, distance_unit: :km)
|
|
49
|
+
distance_m = normalize_distance_km(distance, distance_unit) * 1000
|
|
46
50
|
time_min = parse_time_minutes(time)
|
|
47
51
|
|
|
48
52
|
check_positive(distance_m, 'Distance')
|
|
@@ -57,13 +61,23 @@ module Vo2maxEstimator
|
|
|
57
61
|
|
|
58
62
|
# Estimates a detailed and contextualized VO2max
|
|
59
63
|
#
|
|
60
|
-
# @param
|
|
64
|
+
# @param distance [Numeric] race distance, in kilometres by default or in
|
|
65
|
+
# the unit given by distance_unit
|
|
61
66
|
# @param time [String, Integer] finish time
|
|
62
67
|
# @param elevation_gain_m [Numeric] total elevation gain in metres
|
|
63
68
|
# @param hr_avg [Numeric] average heart rate during the effort
|
|
64
69
|
# @param hr_max [Numeric] athlete's maximum heart rate
|
|
70
|
+
# @param distance_unit [Symbol] unit of the distance input — :km (default) or :mi
|
|
65
71
|
# @return [Vo2maxResult] structured result with value and metadata
|
|
66
|
-
|
|
72
|
+
# (adjusted_distance_km is always in kilometres)
|
|
73
|
+
# @raise [Calcpace::NonPositiveInputError] if distance or time are not positive
|
|
74
|
+
# @raise [Calcpace::UnsupportedUnitError] if distance_unit is not :km or :mi
|
|
75
|
+
def estimate_detailed_vo2max(distance, time, elevation_gain_m: 0, hr_avg: nil, hr_max: nil, distance_unit: :km)
|
|
76
|
+
distance_km = normalize_distance_km(distance, distance_unit)
|
|
77
|
+
# Validated before the elevation adjustment, which would otherwise turn a
|
|
78
|
+
# negative distance into a positive equivalent-flat one
|
|
79
|
+
check_positive(distance_km, 'Distance')
|
|
80
|
+
|
|
67
81
|
adj_dist_km = adjusted_distance_for_vo2(distance_km, elevation_gain_m)
|
|
68
82
|
vo2max_val = estimate_vo2max(adj_dist_km, time)
|
|
69
83
|
confidence = calculate_time_confidence(parse_time_minutes(time))
|
|
@@ -140,6 +154,17 @@ module Vo2maxEstimator
|
|
|
140
154
|
-4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2))
|
|
141
155
|
end
|
|
142
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
|
+
|
|
143
168
|
def percent_vo2max(time_min)
|
|
144
169
|
0.8 +
|
|
145
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
|
|
@@ -73,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
73
74
|
- !ruby/object:Gem::Version
|
|
74
75
|
version: '0'
|
|
75
76
|
requirements: []
|
|
76
|
-
rubygems_version: 4.0.
|
|
77
|
+
rubygems_version: 4.0.16
|
|
77
78
|
specification_version: 4
|
|
78
79
|
summary: 'Running calculations: pace, race predictions, GPS track analysis, VO2max,
|
|
79
80
|
and training zones.'
|