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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d89d16ea156af81fa6cd59976eebf680d435b38b41a65fcd46ee79ec2ea5fe6
4
- data.tar.gz: b79d25c3f1da091543d1f1a930350bdd846ff15369f7e8476303e99aeccbcc79
3
+ metadata.gz: 42ce231ceded47814ccd2f3a0e8ecb79d0e8572253533980a76e11a70afeb6f1
4
+ data.tar.gz: a2680a1f0607ef49d5178431b415cce7e0945a722ad3dbec8bb58b2c2a698131
5
5
  SHA512:
6
- metadata.gz: 1582ebc32a95e1e7702881c0efc16da68768a6616382bb1fb8102e83c9be665ac3f8eb3524f1581f0ec82db1d05e1db2eedf2217b822f959a2dc62711e6b8aa5
7
- data.tar.gz: 9b95fd1feacc28b87a6477788a76bf141651cfa0ee254d4923ccc44cb9f6af63feb666046835c01d3ff5ddd65fb1632ef5d7825b540933ea3295f1ae406f55d3
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.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
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 [![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
@@ -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.21371
80
- calc.convert(10, 'mi to km') # => 16.0934
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.training_paces_from_race(10.0, '00:40:00') # from a recent race result
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
 
@@ -49,14 +49,22 @@ module AgeGrading
49
49
 
50
50
  # Returns a full age-grading report for a race performance
51
51
  #
52
- # @param distance_km [Numeric, String, Symbol] race distance in kilometres
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
- def age_grade(distance_km, time, age:, sex:)
59
- distance_m = normalize_distance(distance_km)
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 distance_km [Numeric] race distance in kilometres
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(distance_km, time, age:, sex:)
92
- age_grade(distance_km, time, age: age, sex: sex)[:age_grade_percent]
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(distance_km)
114
- if distance_km.is_a?(String) || distance_km.is_a?(Symbol)
115
- key = distance_km.to_s.strip.downcase
116
- return RACE_TO_METERS.fetch(key) if RACE_TO_METERS.key?(key)
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 = distance_km.to_f
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 - value).abs <= 0.001 }
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 #{distance_km}km. Supported: #{SUPPORTED_DISTANCES_KM.join(', ')}"
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)
@@ -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
- KM_TO_MI = 0.621371
11
- MI_TO_KM = 1.60934
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
- METERS_TO_MI = 0.000621371
17
- MI_TO_METERS = 1609.34
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.000568182
35
+ YARDS_TO_MI = 1.0 / MI_TO_YARDS
32
36
  MI_TO_FEET = 5280
33
- FEET_TO_MI = 0.000189394
37
+ FEET_TO_MI = 1.0 / MI_TO_FEET
34
38
  MI_TO_INCHES = 63_360
35
- INCHES_TO_MI = 0.0000157828
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 = 2.23694
42
- MI_H_TO_M_S = 0.44704
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 = 0.621371
50
- MI_H_TO_KM_H = 1.60934
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 = 0.868976
54
- NAUTICAL_MI_H_TO_MI_H = 1.15078
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.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'
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
@@ -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
- msg = unit ? "Unsupported unit conversion: #{unit}" : 'Unsupported unit conversion'
27
- super(msg)
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' => 1.60934,
16
- '5mile' => 8.04672,
17
- '10mile' => 16.0934
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
- key = race.to_s.downcase
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
- # Heart-rate zone boundaries as fractions of Heart Rate Reserve (Karvonen)
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 km
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 #=> "00:04:15"
41
- def training_paces(vo2max)
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 distance_km [Numeric] race distance in kilometres (must be > 0)
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
- def training_paces_from_race(distance_km, time)
71
- training_paces(estimate_vo2max(distance_km, time))
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
- points = HR_ZONE_BOUNDARIES.map { |pct| (rest + (pct * reserve)).round }
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 pace_seconds_at_pct(vo2max, pct)
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
- (60_000.0 / velocity).round
182
+ (meters * 60.0 / velocity).round
122
183
  end
123
184
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.10.0'
4
+ VERSION = '1.12.0'
5
5
  end
@@ -32,17 +32,21 @@ module Vo2maxEstimator
32
32
 
33
33
  # Estimates VO2max from a race performance using Daniels & Gilbert formula
34
34
  #
35
- # @param distance_km [Numeric] race distance in kilometres (must be > 0)
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') #=> 51.9
44
- def estimate_vo2max(distance_km, time)
45
- distance_m = distance_km.to_f * 1000
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 distance_km [Numeric] race distance in kilometres
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
- def estimate_detailed_vo2max(distance_km, time, elevation_gain_m: 0, hr_avg: nil, hr_max: nil)
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.10.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
@@ -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.10
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.'