calcpace 1.9.10 → 1.11.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: 0e8d5ca7f00eaacd53af3a268a695a80523a497eedf9caebbd8c5459ea261808
4
- data.tar.gz: 974e29530df057bcc3317c66d78343af96c30d246c076c51115a0bcfe93c0736
3
+ metadata.gz: 8bc8b750605e9b531f01c27f0839668a04f9acf1d0c45316cd8860761afffbae
4
+ data.tar.gz: ceb6a60b435735552b2fd7eb0e78ec131c73a9faa3ef22a84ce59dda20d6d375
5
5
  SHA512:
6
- metadata.gz: 30f1a094b09bb131ad43923f6cabda560c4bb64b811c6b621d6b87e92f9f53101f9ff94f5a5c62926c003b743b53b3a2967e387c06effef1fd463de2481a0f8d
7
- data.tar.gz: 38eb834c5acb4d18dc9729159733dc0ff72ec56d5e7391867c1182f1c2abe62dd690348afdee0686a5aa25f7570c3fe1e176d1cdd99671ad03e9c44eaa57cf7e
6
+ metadata.gz: 5b76dea0d30d9a98b6fa46364a1750f1d6051b342ae723205cf3e1b4560e3fc5ef568703df87c70cb74826434eedc78bcaa2d2cc57354b259521a371ecf9fd51
7
+ data.tar.gz: def67fc24bbab2e8d7275f2db2d9a2b8f724e9690b1a3bf40d2d329de919db1c6bf35dd37e2878b775ff943026a372b0eda3a5f27302ca980645ccf7e3c3f6f7
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,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.11.0] - 2026-07-25
11
+
12
+ ### Added
13
+ - Training zones improvements
14
+ - `training_paces` and `training_paces_from_race` accept `unit: :mi` for
15
+ pace bands per mile (default remains `:km`)
16
+ - `hr_zones_from_max(hr_max:)`: five heart-rate zones from maximum heart
17
+ rate only (%HRmax method) — fallback when resting heart rate is unknown
18
+ - `training_paces_from_race` accepts standard race names ('10k', 'marathon',
19
+ '5mile', ...) in addition to numeric kilometres, matching `predict_time`
20
+ and `race_pace`
21
+ - `distance_unit: :mi` keyword on `estimate_vo2max`, `estimate_detailed_vo2max`,
22
+ `age_grade`, `age_grade_percent`, and `training_paces_from_race` — numeric
23
+ distance inputs can now be given in miles (default remains kilometres)
24
+
25
+ ### Changed
26
+ - `training_paces_from_race` resolves non-numeric distances as race names. Strings
27
+ that v1.10.0 silently parsed with `to_f` change meaning: `'5mile'` was 5.0 km and
28
+ is now the 5-mile standard distance (8.04672 km). Numeric strings (`'10'`,
29
+ `'21.0975'`) keep working as before, in kilometres.
30
+ - `training_paces_from_race` with an unparseable distance (`nil`, `'banana'`) now
31
+ raises `ArgumentError` ("Unknown race: ...") instead of
32
+ `Calcpace::NonPositiveInputError`.
33
+ - Unknown `unit:` / `distance_unit:` values raise `Calcpace::UnsupportedUnitError`
34
+ (inherits from `Calcpace::Error`) instead of `ArgumentError`. Unit keywords are
35
+ now case-insensitive (`'MI'` works) and `nil` raises the same error instead of a
36
+ `NoMethodError`.
37
+ - `unit: :mi` pace bands are computed natively per mile instead of being converted
38
+ from the km bands, so they can differ by ±1 s from `pace_km_to_mi(km_band)` —
39
+ the native value is the one without double rounding.
40
+ - Every mile-based factor now derives from the exact international mile
41
+ (1 mi = 1609.344 m), which was previously truncated to 1.60934 in some places
42
+ and exact in others. Affected values move by ~2.5e-6 relative:
43
+ `convert(1, :mi_to_km)` 1.60934 → 1.609344, `convert(1, :km_to_mi)` 0.621371 →
44
+ 0.6213711922…, `convert(1, :mi_to_meters)` 1609.34 → 1609.344, the `mi_h`/`m_s`
45
+ speed pairs, and `list_races` entries `'1mile'` (1.609344) and `'10mile'`
46
+ (16.09344). Age-grading tolerance and pace bands now agree on mile length.
47
+ - Passing `distance_unit:` together with a race name (`training_paces_from_race('10k',
48
+ t, distance_unit: :mi)`, `age_grade('10k', …, distance_unit: :mi)`) raises
49
+ `ArgumentError` instead of silently ignoring the keyword — a standard race already
50
+ carries its own distance.
51
+ - Race-name lookup is normalized in one place: `' 10K '` and `:MARATHON` now resolve
52
+ everywhere (previously `PaceCalculator` did not strip whitespace), and `AgeGrading`
53
+ uses the same "Unknown race: …" message wording as the rest of the gem.
54
+
55
+ ### Fixed
56
+ - Age grading accepts mile distances as runners write them (`3.1`, `6.2`, `13.1`,
57
+ `26.2` with `distance_unit: :mi`); the previous 0.001 km match window only
58
+ accepted 6-decimal conversions.
59
+ - Unsupported age-grading distances report the input in the unit it was given
60
+ instead of always labelling it "km".
61
+ - `estimate_detailed_vo2max` rejects a non-positive distance even when
62
+ `elevation_gain_m` is positive (the elevation adjustment used to mask it).
63
+
64
+ ## [1.10.0] - 2026-07-11
65
+
66
+ ### Added
67
+ - Training Zones module (`TrainingZones`)
68
+ - `training_paces(vo2max)`: personalized Easy/Marathon/Threshold/Interval/Repetition
69
+ pace bands per km, inverting the Daniels & Gilbert velocity equation
70
+ - `training_paces_from_race(distance_km, time)`: pace bands straight from a race result
71
+ - `hr_zones(hr_max:, hr_rest:)`: five Karvonen (Heart Rate Reserve) heart-rate zones
72
+ - Structured results (`PaceBand`, `HrZone`) with seconds and clock formats
73
+
10
74
  ## [1.9.10] - 2026-07-09
11
75
 
12
76
  ### Changed
@@ -205,7 +269,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
205
269
 
206
270
  See git history for changes in earlier versions.
207
271
 
208
- [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.9.6...HEAD
272
+ [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.10.0...HEAD
273
+ [1.10.0]: https://github.com/0jonjo/calcpace/compare/v1.9.10...v1.10.0
209
274
  [1.9.6]: https://github.com/0jonjo/calcpace/compare/v1.9.5...v1.9.6
210
275
  [1.9.5]: https://github.com/0jonjo/calcpace/compare/v1.9.4...v1.9.5
211
276
  [1.6.0]: https://github.com/0jonjo/calcpace/releases/tag/v1.6.0
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.9.8&x2=0)](https://badge.fury.io/rb/calcpace)
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)
2
2
 
3
- A Ruby gem for running and cycling calculations: pace, time, distance, unit conversions, race predictions, GPS track analysis, and VO2max estimation.
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.9.8'
8
+ gem 'calcpace', '~> 1.10.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
  ```
@@ -276,6 +278,51 @@ easy.value # => 29.3 (underestimates real aerobic capacity)
276
278
 
277
279
  ---
278
280
 
281
+ ### Training Zones
282
+
283
+ Personalized training paces (Daniels' Running Formula) and Karvonen heart-rate zones:
284
+
285
+ ```ruby
286
+ zones = calc.training_paces(50.0)
287
+ zones[:threshold].fast_clock # => "00:04:15" per km
288
+ zones[:easy].slow_clock # => "00:05:52" per km
289
+
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
295
+
296
+ calc.hr_zones(hr_max: 190, hr_rest: 55)
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
302
+ ```
303
+
304
+ | Zone | %VO2max | Purpose |
305
+ |------|---------|---------|
306
+ | Easy | 59–74% | Base building, recovery |
307
+ | Marathon | 75–84% | Marathon race pace |
308
+ | Threshold | 83–88% | Lactate threshold, tempo runs |
309
+ | Interval | 95–100% | VO2max development |
310
+ | Repetition | 105–110% | Speed and running economy |
311
+
312
+ Pace accuracy vs published VDOT tables: within a few seconds per km
313
+ (threshold matches exactly; easy band is a range heuristic).
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
+
279
326
  ### Other Utilities
280
327
 
281
328
  ```ruby
@@ -292,6 +339,11 @@ All errors inherit from `Calcpace::Error`:
292
339
 
293
340
  - `Calcpace::NonPositiveInputError` — numeric input is zero or negative
294
341
  - `Calcpace::InvalidTimeFormatError` — time string not in `HH:MM:SS` or `MM:SS` format
342
+ - `Calcpace::UnsupportedUnitError` — unknown conversion (`convert`) or unknown
343
+ `unit:` / `distance_unit:` keyword
344
+
345
+ Argument validation that is not about units or numbers raises a plain `ArgumentError`:
346
+ unknown race names, unsupported age-grading distances, and invalid `age` / `sex` values.
295
347
 
296
348
  ---
297
349
 
data/calcpace.gemspec CHANGED
@@ -8,11 +8,12 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ['João Gilberto Saraiva']
9
9
  spec.email = ['joaogilberto@tuta.io']
10
10
 
11
- spec.summary = 'Pace, distance, GPS track analysis, and VO2max calculations for runners and cyclists.'
12
- spec.description = 'Ruby gem for running and cycling calculations: pace, time, distance, ' \
13
- 'unit conversions (30+ units), race predictions (Riegel & Cameron), ' \
11
+ spec.summary = 'Running calculations: pace, race predictions, GPS track analysis, VO2max, and training zones.'
12
+ spec.description = 'Ruby gem for runners: pace, time, and distance calculations, ' \
13
+ 'unit conversions (30+ units), race time predictions (Riegel & Cameron), ' \
14
14
  'GPS track analysis (Haversine, elevation gain, per-km splits), ' \
15
- 'and VO2max estimation (Daniels & Gilbert).'
15
+ 'age grading (WMA 2023), VO2max estimation (Daniels & Gilbert), and ' \
16
+ 'personalized training zones (Daniels paces & Karvonen heart-rate zones).'
16
17
  spec.homepage = 'https://github.com/0jonjo/calcpace'
17
18
  spec.metadata['source_code_uri'] = spec.homepage
18
19
  spec.license = 'MIT'
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module for deriving personalized training zones from VO2max
4
+ #
5
+ # Training paces invert the Daniels & Gilbert (1979) velocity equation:
6
+ # VO2 = -4.60 + 0.182258 * v + 0.000104 * v² (v in m/min)
7
+ # Solving the quadratic for v at a target %VO2max gives the running
8
+ # velocity for each training intensity (Daniels' Running Formula).
9
+ #
10
+ # Heart rate zones use the Karvonen method (Heart Rate Reserve):
11
+ # target = hr_rest + pct * (hr_max - hr_rest)
12
+ module TrainingZones
13
+ # Training intensities as fraction of VO2max (Daniels' Running Formula)
14
+ TRAINING_INTENSITIES = {
15
+ easy: { low: 0.59, high: 0.74 },
16
+ marathon: { low: 0.75, high: 0.84 },
17
+ threshold: { low: 0.83, high: 0.88 },
18
+ interval: { low: 0.95, high: 1.00 },
19
+ repetition: { low: 1.05, high: 1.10 }
20
+ }.freeze
21
+
22
+ # A pace band for one training zone (paces per kilometre or mile).
23
+ # slow = lower-intensity end of the band, fast = higher-intensity end.
24
+ PaceBand = Struct.new(:slow_seconds, :fast_seconds, :slow_clock, :fast_clock)
25
+
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
31
+ HR_ZONE_BOUNDARIES = [0.50, 0.60, 0.70, 0.80, 0.90, 1.00].freeze
32
+
33
+ # One heart-rate training zone (1 = recovery … 5 = maximal)
34
+ HrZone = Struct.new(:zone, :min_bpm, :max_bpm)
35
+
36
+ # Derives training pace bands from a VO2max value
37
+ #
38
+ # @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0)
39
+ # @param unit [Symbol] pace unit — :km (default) or :mi
40
+ # @return [Hash{Symbol => PaceBand}] keys: :easy, :marathon, :threshold,
41
+ # :interval, :repetition — paces per chosen unit
42
+ # @raise [Calcpace::NonPositiveInputError] if vo2max is not positive
43
+ # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
44
+ #
45
+ # @example
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)
49
+ check_positive(vo2max.to_f, 'VO2max')
50
+ meters = pace_unit_meters(unit)
51
+
52
+ TRAINING_INTENSITIES.transform_values do |band|
53
+ slow = pace_seconds_at_pct(vo2max.to_f, band[:low], meters)
54
+ fast = pace_seconds_at_pct(vo2max.to_f, band[:high], meters)
55
+
56
+ PaceBand.new(
57
+ slow_seconds: slow,
58
+ fast_seconds: fast,
59
+ slow_clock: convert_to_clocktime(slow),
60
+ fast_clock: convert_to_clocktime(fast)
61
+ )
62
+ end
63
+ end
64
+
65
+ # Derives training pace bands from a recent race result
66
+ #
67
+ # Convenience wrapper: estimates VO2max via Daniels & Gilbert
68
+ # (see Vo2maxEstimator#estimate_vo2max) and derives the bands from it.
69
+ #
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)
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
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
84
+ # @raise [Calcpace::NonPositiveInputError] if distance or time are not positive
85
+ # @raise [Calcpace::InvalidTimeFormatError] if time string is malformed
86
+ #
87
+ # @example
88
+ # calc.training_paces_from_race(10.0, '00:40:00')[:easy].slow_clock #=> "00:05:42"
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)
103
+ end
104
+
105
+ # Computes the five Karvonen heart-rate training zones
106
+ #
107
+ # target_bpm = hr_rest + pct * (hr_max - hr_rest)
108
+ #
109
+ # @param hr_max [Numeric] maximum heart rate in bpm (must be > 0)
110
+ # @param hr_rest [Numeric] resting heart rate in bpm (must be > 0 and < hr_max)
111
+ # @return [Array<HrZone>] five contiguous zones from Z1 (50–60% HRR) to Z5 (90–100% HRR)
112
+ # @raise [Calcpace::NonPositiveInputError] if any rate is not positive
113
+ # @raise [Calcpace::Error] if hr_rest >= hr_max
114
+ #
115
+ # @example
116
+ # calc.hr_zones(hr_max: 190, hr_rest: 55).last.max_bpm #=> 190
117
+ def hr_zones(hr_max:, hr_rest:)
118
+ max = hr_max.to_f
119
+ rest = hr_rest.to_f
120
+ check_heart_rates(max, rest)
121
+
122
+ reserve = max - rest
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')
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)
151
+ points.each_cons(2).with_index(1).map do |(min_bpm, max_bpm), zone|
152
+ HrZone.new(zone: zone, min_bpm: min_bpm, max_bpm: max_bpm)
153
+ end
154
+ end
155
+
156
+ def check_heart_rates(hr_max, hr_rest)
157
+ check_positive(hr_max, 'Maximum heart rate')
158
+ check_positive(hr_rest, 'Resting heart rate')
159
+ return if hr_rest < hr_max
160
+
161
+ raise Calcpace::Error,
162
+ "Resting heart rate (#{hr_rest}) must be lower than maximum heart rate (#{hr_max})"
163
+ end
164
+
165
+ # Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2
166
+ def velocity_at_vo2(vo2)
167
+ a = 0.000104
168
+ b = 0.182258
169
+ c = -(4.60 + vo2)
170
+
171
+ (-b + Math.sqrt((b**2) - (4 * a * c))) / (2 * a)
172
+ end
173
+
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)
181
+ velocity = velocity_at_vo2(vo2max * pct)
182
+ (meters * 60.0 / velocity).round
183
+ end
184
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.9.10'
4
+ VERSION = '1.11.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))
data/lib/calcpace.rb CHANGED
@@ -13,6 +13,7 @@ require_relative 'calcpace/pace_converter'
13
13
  require_relative 'calcpace/race_predictor'
14
14
  require_relative 'calcpace/race_splits'
15
15
  require_relative 'calcpace/track_calculator'
16
+ require_relative 'calcpace/training_zones'
16
17
  require_relative 'calcpace/vo2max_estimator'
17
18
 
18
19
  # Calcpace - A Ruby gem for pace, distance, and time calculations
@@ -49,6 +50,7 @@ class Calcpace
49
50
  include RacePredictor
50
51
  include RaceSplits
51
52
  include TrackCalculator
53
+ include TrainingZones
52
54
  include Vo2maxEstimator
53
55
 
54
56
  # Creates a new Calcpace instance
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.9.10
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - João Gilberto Saraiva
@@ -9,9 +9,11 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: 'Ruby gem for running and cycling calculations: pace, time, distance,
13
- unit conversions (30+ units), race predictions (Riegel & Cameron), GPS track analysis
14
- (Haversine, elevation gain, per-km splits), and VO2max estimation (Daniels & Gilbert).'
12
+ description: 'Ruby gem for runners: pace, time, and distance calculations, unit conversions
13
+ (30+ units), race time predictions (Riegel & Cameron), GPS track analysis (Haversine,
14
+ elevation gain, per-km splits), age grading (WMA 2023), VO2max estimation (Daniels
15
+ & Gilbert), and personalized training zones (Daniels paces & Karvonen heart-rate
16
+ zones).'
15
17
  email:
16
18
  - joaogilberto@tuta.io
17
19
  executables: []
@@ -47,6 +49,7 @@ files:
47
49
  - lib/calcpace/race_predictor.rb
48
50
  - lib/calcpace/race_splits.rb
49
51
  - lib/calcpace/track_calculator.rb
52
+ - lib/calcpace/training_zones.rb
50
53
  - lib/calcpace/version.rb
51
54
  - lib/calcpace/vo2max_estimator.rb
52
55
  homepage: https://github.com/0jonjo/calcpace
@@ -70,8 +73,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
73
  - !ruby/object:Gem::Version
71
74
  version: '0'
72
75
  requirements: []
73
- rubygems_version: 4.0.10
76
+ rubygems_version: 4.0.16
74
77
  specification_version: 4
75
- summary: Pace, distance, GPS track analysis, and VO2max calculations for runners and
76
- cyclists.
78
+ summary: 'Running calculations: pace, race predictions, GPS track analysis, VO2max,
79
+ and training zones.'
77
80
  test_files: []