calcpace 1.9.9 → 1.10.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: 690aa2af75618e2e2721e8459546019798dd11ed62aad22389339da03ca9aeb5
4
- data.tar.gz: b82e4c2c7bd29f62c95db7068952224baba43d271310b2cb6be2bac4d6ee0c73
3
+ metadata.gz: 6d89d16ea156af81fa6cd59976eebf680d435b38b41a65fcd46ee79ec2ea5fe6
4
+ data.tar.gz: b79d25c3f1da091543d1f1a930350bdd846ff15369f7e8476303e99aeccbcc79
5
5
  SHA512:
6
- metadata.gz: ab5ae1f4fdee60fd08886ec8ee6576f019200c7fa276cbcca847ef527d58e9d544a61c4f0680facf3a380c3e54dddf949851863849bc8669873ec6ae8770d6c0
7
- data.tar.gz: 71425df084bee6d3f2faaa367bdb69ace1a4f5033025ea0097124cb1d7368527b77f7706aed8d2c25644f036f4e6af63e581226aac04d54bfe022ba876bc8d9e
6
+ metadata.gz: 1582ebc32a95e1e7702881c0efc16da68768a6616382bb1fb8102e83c9be665ac3f8eb3524f1581f0ec82db1d05e1db2eedf2217b822f959a2dc62711e6b8aa5
7
+ data.tar.gz: 9b95fd1feacc28b87a6477788a76bf141651cfa0ee254d4923ccc44cb9f6af63feb666046835c01d3ff5ddd65fb1632ef5d7825b540933ea3295f1ae406f55d3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.10.0] - 2026-07-11
11
+
12
+ ### Added
13
+ - Training Zones module (`TrainingZones`)
14
+ - `training_paces(vo2max)`: personalized Easy/Marathon/Threshold/Interval/Repetition
15
+ pace bands per km, inverting the Daniels & Gilbert velocity equation
16
+ - `training_paces_from_race(distance_km, time)`: pace bands straight from a race result
17
+ - `hr_zones(hr_max:, hr_rest:)`: five Karvonen (Heart Rate Reserve) heart-rate zones
18
+ - Structured results (`PaceBand`, `HrZone`) with seconds and clock formats
19
+
20
+ ## [1.9.10] - 2026-07-09
21
+
22
+ ### Changed
23
+ - Refactor `TrackCalculator`: extract a single `dig_key` helper for symbol/string key access, removing duplicated fallback logic across coordinate, elevation, and time readers (no behavior change)
24
+
10
25
  ## [1.9.9] - 2026-06-17
11
26
 
12
27
  ### Changed
@@ -200,7 +215,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
200
215
 
201
216
  See git history for changes in earlier versions.
202
217
 
203
- [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.9.6...HEAD
218
+ [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.10.0...HEAD
219
+ [1.10.0]: https://github.com/0jonjo/calcpace/compare/v1.9.10...v1.10.0
204
220
  [1.9.6]: https://github.com/0jonjo/calcpace/compare/v1.9.5...v1.9.6
205
221
  [1.9.5]: https://github.com/0jonjo/calcpace/compare/v1.9.4...v1.9.5
206
222
  [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
@@ -276,6 +276,34 @@ easy.value # => 29.3 (underestimates real aerobic capacity)
276
276
 
277
277
  ---
278
278
 
279
+ ### Training Zones
280
+
281
+ Personalized training paces (Daniels' Running Formula) and Karvonen heart-rate zones:
282
+
283
+ ```ruby
284
+ zones = calc.training_paces(50.0)
285
+ zones[:threshold].fast_clock # => "00:04:15" per km
286
+ zones[:easy].slow_clock # => "00:05:52" per km
287
+
288
+ calc.training_paces_from_race(10.0, '00:40:00') # from a recent race result
289
+
290
+ calc.hr_zones(hr_max: 190, hr_rest: 55)
291
+ # => [#<struct zone=1, min_bpm=123, max_bpm=136>, ... zone=5, max_bpm=190]
292
+ ```
293
+
294
+ | Zone | %VO2max | Purpose |
295
+ |------|---------|---------|
296
+ | Easy | 59–74% | Base building, recovery |
297
+ | Marathon | 75–84% | Marathon race pace |
298
+ | Threshold | 83–88% | Lactate threshold, tempo runs |
299
+ | Interval | 95–100% | VO2max development |
300
+ | Repetition | 105–110% | Speed and running economy |
301
+
302
+ Pace accuracy vs published VDOT tables: within a few seconds per km
303
+ (threshold matches exactly; easy band is a range heuristic).
304
+
305
+ ---
306
+
279
307
  ### Other Utilities
280
308
 
281
309
  ```ruby
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'
@@ -74,8 +74,8 @@ module TrackCalculator
74
74
  return 0.0 if points.nil? || points.size < 2
75
75
 
76
76
  total = points.each_cons(2).sum do |a, b|
77
- haversine_distance(fetch_coord(a, :lat), fetch_coord(a, :lon),
78
- fetch_coord(b, :lat), fetch_coord(b, :lon))
77
+ haversine_distance(dig_key(a, :lat), dig_key(a, :lon),
78
+ dig_key(b, :lat), dig_key(b, :lon))
79
79
  end
80
80
 
81
81
  total.round(2)
@@ -180,25 +180,24 @@ module TrackCalculator
180
180
  end
181
181
  end
182
182
 
183
- def fetch_coord(point, key)
183
+ def dig_key(point, key)
184
184
  point[key] || point[key.to_s]
185
185
  end
186
186
 
187
187
  def fetch_ele(point)
188
- val = point[:ele] || point['ele']
189
- val&.to_f
188
+ dig_key(point, :ele)&.to_f
190
189
  end
191
190
 
192
191
  def validate_points_have_time(points)
193
192
  points.each_with_index do |pt, i|
194
- next if pt[:time] || pt['time']
193
+ next if dig_key(pt, :time)
195
194
 
196
195
  raise ArgumentError, "Point at index #{i} is missing :time key required for splits"
197
196
  end
198
197
  end
199
198
 
200
199
  def point_time(point)
201
- t = point[:time] || point['time']
200
+ t = dig_key(point, :time)
202
201
  t.respond_to?(:to_f) ? t.to_f : t
203
202
  end
204
203
 
@@ -228,8 +227,8 @@ module TrackCalculator
228
227
  end
229
228
 
230
229
  def process_segment(point_a, point_b, split_km, state)
231
- segment_km = haversine_distance(fetch_coord(point_a, :lat), fetch_coord(point_a, :lon),
232
- fetch_coord(point_b, :lat), fetch_coord(point_b, :lon))
230
+ segment_km = haversine_distance(dig_key(point_a, :lat), dig_key(point_a, :lon),
231
+ dig_key(point_b, :lat), dig_key(point_b, :lon))
233
232
  state[:accumulated_km] += segment_km
234
233
 
235
234
  while state[:accumulated_km] >= split_km * state[:split_number]
@@ -0,0 +1,123 @@
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).
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
+ # Heart-rate zone boundaries as fractions of Heart Rate Reserve (Karvonen)
27
+ HR_ZONE_BOUNDARIES = [0.50, 0.60, 0.70, 0.80, 0.90, 1.00].freeze
28
+
29
+ # One heart-rate training zone (1 = recovery … 5 = maximal)
30
+ HrZone = Struct.new(:zone, :min_bpm, :max_bpm)
31
+
32
+ # Derives training pace bands from a VO2max value
33
+ #
34
+ # @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0)
35
+ # @return [Hash{Symbol => PaceBand}] keys: :easy, :marathon, :threshold,
36
+ # :interval, :repetition — paces per km
37
+ # @raise [Calcpace::NonPositiveInputError] if vo2max is not positive
38
+ #
39
+ # @example
40
+ # calc.training_paces(50.0)[:threshold].fast_clock #=> "00:04:15"
41
+ def training_paces(vo2max)
42
+ check_positive(vo2max.to_f, 'VO2max')
43
+
44
+ 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])
47
+
48
+ PaceBand.new(
49
+ slow_seconds: slow,
50
+ fast_seconds: fast,
51
+ slow_clock: convert_to_clocktime(slow),
52
+ fast_clock: convert_to_clocktime(fast)
53
+ )
54
+ end
55
+ end
56
+
57
+ # Derives training pace bands from a recent race result
58
+ #
59
+ # Convenience wrapper: estimates VO2max via Daniels & Gilbert
60
+ # (see Vo2maxEstimator#estimate_vo2max) and derives the bands from it.
61
+ #
62
+ # @param distance_km [Numeric] race distance in kilometres (must be > 0)
63
+ # @param time [String, Integer] finish time as "HH:MM:SS" / "MM:SS" or total seconds
64
+ # @return [Hash{Symbol => PaceBand}] same shape as #training_paces
65
+ # @raise [Calcpace::NonPositiveInputError] if distance or time are not positive
66
+ # @raise [Calcpace::InvalidTimeFormatError] if time string is malformed
67
+ #
68
+ # @example
69
+ # 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))
72
+ end
73
+
74
+ # Computes the five Karvonen heart-rate training zones
75
+ #
76
+ # target_bpm = hr_rest + pct * (hr_max - hr_rest)
77
+ #
78
+ # @param hr_max [Numeric] maximum heart rate in bpm (must be > 0)
79
+ # @param hr_rest [Numeric] resting heart rate in bpm (must be > 0 and < hr_max)
80
+ # @return [Array<HrZone>] five contiguous zones from Z1 (50–60% HRR) to Z5 (90–100% HRR)
81
+ # @raise [Calcpace::NonPositiveInputError] if any rate is not positive
82
+ # @raise [Calcpace::Error] if hr_rest >= hr_max
83
+ #
84
+ # @example
85
+ # calc.hr_zones(hr_max: 190, hr_rest: 55).last.max_bpm #=> 190
86
+ def hr_zones(hr_max:, hr_rest:)
87
+ max = hr_max.to_f
88
+ rest = hr_rest.to_f
89
+ check_heart_rates(max, rest)
90
+
91
+ reserve = max - rest
92
+ points = HR_ZONE_BOUNDARIES.map { |pct| (rest + (pct * reserve)).round }
93
+
94
+ points.each_cons(2).with_index(1).map do |(min_bpm, max_bpm), zone|
95
+ HrZone.new(zone: zone, min_bpm: min_bpm, max_bpm: max_bpm)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def check_heart_rates(hr_max, hr_rest)
102
+ check_positive(hr_max, 'Maximum heart rate')
103
+ check_positive(hr_rest, 'Resting heart rate')
104
+ return if hr_rest < hr_max
105
+
106
+ raise Calcpace::Error,
107
+ "Resting heart rate (#{hr_rest}) must be lower than maximum heart rate (#{hr_max})"
108
+ end
109
+
110
+ # Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2
111
+ def velocity_at_vo2(vo2)
112
+ a = 0.000104
113
+ b = 0.182258
114
+ c = -(4.60 + vo2)
115
+
116
+ (-b + Math.sqrt((b**2) - (4 * a * c))) / (2 * a)
117
+ end
118
+
119
+ def pace_seconds_at_pct(vo2max, pct)
120
+ velocity = velocity_at_vo2(vo2max * pct)
121
+ (60_000.0 / velocity).round
122
+ end
123
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.9.9'
4
+ VERSION = '1.10.0'
5
5
  end
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.9
4
+ version: 1.10.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
@@ -72,6 +75,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
75
  requirements: []
73
76
  rubygems_version: 4.0.10
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: []