From 18b9bf8b65ad21e416426b8447d40e086f0e56c2 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 07:59:48 -0300 Subject: [PATCH 1/5] feat: add training paces derived from VO2max (Daniels) --- lib/calcpace.rb | 2 + lib/calcpace/training_zones.rb | 66 ++++++++++++++++++++++++++++ test/calcpace/test_training_zones.rb | 61 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 lib/calcpace/training_zones.rb create mode 100644 test/calcpace/test_training_zones.rb diff --git a/lib/calcpace.rb b/lib/calcpace.rb index 2ef28be..b8a7c3e 100644 --- a/lib/calcpace.rb +++ b/lib/calcpace.rb @@ -13,6 +13,7 @@ require_relative 'calcpace/race_predictor' require_relative 'calcpace/race_splits' require_relative 'calcpace/track_calculator' +require_relative 'calcpace/training_zones' require_relative 'calcpace/vo2max_estimator' # Calcpace - A Ruby gem for pace, distance, and time calculations @@ -49,6 +50,7 @@ class Calcpace include RacePredictor include RaceSplits include TrackCalculator + include TrainingZones include Vo2maxEstimator # Creates a new Calcpace instance diff --git a/lib/calcpace/training_zones.rb b/lib/calcpace/training_zones.rb new file mode 100644 index 0000000..4947dc6 --- /dev/null +++ b/lib/calcpace/training_zones.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Module for deriving personalized training zones from VO2max +# +# Training paces invert the Daniels & Gilbert (1979) velocity equation: +# VO2 = -4.60 + 0.182258 * v + 0.000104 * v² (v in m/min) +# Solving the quadratic for v at a target %VO2max gives the running +# velocity for each training intensity (Daniels' Running Formula). +# +# Heart rate zones use the Karvonen method (Heart Rate Reserve): +# target = hr_rest + pct * (hr_max - hr_rest) +module TrainingZones + # Training intensities as fraction of VO2max (Daniels' Running Formula) + TRAINING_INTENSITIES = { + easy: { low: 0.59, high: 0.74 }, + marathon: { low: 0.75, high: 0.84 }, + threshold: { low: 0.83, high: 0.88 }, + interval: { low: 0.95, high: 1.00 }, + repetition: { low: 1.05, high: 1.10 } + }.freeze + + # A pace band for one training zone (paces per kilometre). + # slow = lower-intensity end of the band, fast = higher-intensity end. + PaceBand = Struct.new(:slow_seconds, :fast_seconds, :slow_clock, :fast_clock) + + # Derives training pace bands from a VO2max value + # + # @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0) + # @return [Hash{Symbol => PaceBand}] keys: :easy, :marathon, :threshold, + # :interval, :repetition — paces per km + # @raise [Calcpace::NonPositiveInputError] if vo2max is not positive + # + # @example + # calc.training_paces(50.0)[:threshold].fast_clock #=> "00:04:15" + def training_paces(vo2max) + check_positive(vo2max.to_f, 'VO2max') + + TRAINING_INTENSITIES.transform_values do |band| + slow = pace_seconds_at_pct(vo2max.to_f, band[:low]) + fast = pace_seconds_at_pct(vo2max.to_f, band[:high]) + + PaceBand.new( + slow_seconds: slow, + fast_seconds: fast, + slow_clock: convert_to_clocktime(slow), + fast_clock: convert_to_clocktime(fast) + ) + end + end + + private + + # Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2 + def velocity_at_vo2(vo2) + a = 0.000104 + b = 0.182258 + c = -(4.60 + vo2) + + (-b + Math.sqrt((b**2) - (4 * a * c))) / (2 * a) + end + + def pace_seconds_at_pct(vo2max, pct) + velocity = velocity_at_vo2(vo2max * pct) + (60_000.0 / velocity).round + end +end diff --git a/test/calcpace/test_training_zones.rb b/test/calcpace/test_training_zones.rb new file mode 100644 index 0000000..41c1672 --- /dev/null +++ b/test/calcpace/test_training_zones.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Tests for TrainingZones module — Daniels training paces and Karvonen HR zones +class TestTrainingZones < CalcpaceTest + # --- training_paces --- + def test_training_paces_returns_all_five_zones + zones = @calc.training_paces(50.0) + + assert_equal %i[easy marathon threshold interval repetition], zones.keys + end + + def test_threshold_fast_pace_matches_daniels_vdot_table + zones = @calc.training_paces(50.0) + + # VDOT 50 → T-pace 04:15/km in Daniels' official table + assert_in_delta 255, zones[:threshold].fast_seconds, 2 + assert_equal '00:04:15', zones[:threshold].fast_clock + end + + def test_easy_band_for_vo2max_fifty + zones = @calc.training_paces(50.0) + + assert_in_delta 352, zones[:easy].slow_seconds, 2 # ~05:52/km (59% VO2max) + assert_in_delta 294, zones[:easy].fast_seconds, 2 # ~04:54/km (74% VO2max) + end + + def test_interval_band_for_vo2max_fifty + zones = @calc.training_paces(50.0) + + assert_in_delta 240, zones[:interval].slow_seconds, 2 # 95% → ~04:00/km + assert_in_delta 230, zones[:interval].fast_seconds, 2 # 100% → ~03:50/km + end + + def test_repetition_band_for_vo2max_fifty + zones = @calc.training_paces(50.0) + + assert_in_delta 221, zones[:repetition].slow_seconds, 2 # 105% → ~03:41/km + assert_in_delta 213, zones[:repetition].fast_seconds, 2 # 110% → ~03:33/km + end + + def test_higher_vo2max_yields_faster_paces + slower = @calc.training_paces(40.0) + faster = @calc.training_paces(60.0) + + assert_operator faster[:easy].slow_seconds, :<, slower[:easy].slow_seconds + assert_operator faster[:interval].fast_seconds, :<, slower[:interval].fast_seconds + end + + def test_slow_is_always_slower_than_fast_within_each_band + @calc.training_paces(50.0).each_value do |band| + assert_operator band.slow_seconds, :>, band.fast_seconds + end + end + + def test_training_paces_rejects_non_positive_vo2max + assert_raises(Calcpace::NonPositiveInputError) { @calc.training_paces(0) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.training_paces(-10) } + end +end From e50d92fe05b3f6d70f98f757a656791e8d9fa7aa Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 08:00:38 -0300 Subject: [PATCH 2/5] feat: derive training paces directly from a race result --- lib/calcpace/training_zones.rb | 17 +++++++++++++++++ test/calcpace/test_training_zones.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lib/calcpace/training_zones.rb b/lib/calcpace/training_zones.rb index 4947dc6..a217ea2 100644 --- a/lib/calcpace/training_zones.rb +++ b/lib/calcpace/training_zones.rb @@ -48,6 +48,23 @@ def training_paces(vo2max) end end + # Derives training pace bands from a recent race result + # + # Convenience wrapper: estimates VO2max via Daniels & Gilbert + # (see Vo2maxEstimator#estimate_vo2max) and derives the bands from it. + # + # @param distance_km [Numeric] race distance in kilometres (must be > 0) + # @param time [String, Integer] finish time as "HH:MM:SS" / "MM:SS" or total seconds + # @return [Hash{Symbol => PaceBand}] same shape as #training_paces + # @raise [Calcpace::NonPositiveInputError] if distance or time are not positive + # @raise [Calcpace::InvalidTimeFormatError] if time string is malformed + # + # @example + # calc.training_paces_from_race(10.0, '00:40:00')[:easy].slow_clock #=> "00:05:41" + def training_paces_from_race(distance_km, time) + training_paces(estimate_vo2max(distance_km, time)) + end + private # Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2 diff --git a/test/calcpace/test_training_zones.rb b/test/calcpace/test_training_zones.rb index 41c1672..9d98fc4 100644 --- a/test/calcpace/test_training_zones.rb +++ b/test/calcpace/test_training_zones.rb @@ -58,4 +58,30 @@ def test_training_paces_rejects_non_positive_vo2max assert_raises(Calcpace::NonPositiveInputError) { @calc.training_paces(0) } assert_raises(Calcpace::NonPositiveInputError) { @calc.training_paces(-10) } end + + # --- training_paces_from_race --- + def test_training_paces_from_race_delegates_to_vo2max_estimation + # 10k in 40:00 → VO2max 51.9 (value already validated in test_vo2max_estimator.rb) + from_race = @calc.training_paces_from_race(10.0, '00:40:00') + from_vo2 = @calc.training_paces(51.9) + + assert_equal from_vo2[:threshold].fast_seconds, from_race[:threshold].fast_seconds + assert_equal from_vo2[:easy].slow_clock, from_race[:easy].slow_clock + end + + def test_training_paces_from_race_accepts_seconds_input + from_clock = @calc.training_paces_from_race(10.0, '00:40:00') + from_seconds = @calc.training_paces_from_race(10.0, 2400) + + assert_equal from_clock[:interval].fast_seconds, from_seconds[:interval].fast_seconds + end + + def test_training_paces_from_race_propagates_input_errors + assert_raises(Calcpace::NonPositiveInputError) do + @calc.training_paces_from_race(0, '00:40:00') + end + assert_raises(Calcpace::InvalidTimeFormatError) do + @calc.training_paces_from_race(10.0, 'banana') + end + end end From 571295b15106251690912d1971e5f3fb850ef1b4 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 08:02:30 -0300 Subject: [PATCH 3/5] feat: add Karvonen heart-rate training zones --- lib/calcpace/training_zones.rb | 40 ++++++++++++++++++++++++++++ test/calcpace/test_training_zones.rb | 36 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/lib/calcpace/training_zones.rb b/lib/calcpace/training_zones.rb index a217ea2..f3dc30b 100644 --- a/lib/calcpace/training_zones.rb +++ b/lib/calcpace/training_zones.rb @@ -23,6 +23,12 @@ module TrainingZones # slow = lower-intensity end of the band, fast = higher-intensity end. PaceBand = Struct.new(:slow_seconds, :fast_seconds, :slow_clock, :fast_clock) + # Heart-rate zone boundaries as fractions of Heart Rate Reserve (Karvonen) + HR_ZONE_BOUNDARIES = [0.50, 0.60, 0.70, 0.80, 0.90, 1.00].freeze + + # One heart-rate training zone (1 = recovery … 5 = maximal) + HrZone = Struct.new(:zone, :min_bpm, :max_bpm) + # Derives training pace bands from a VO2max value # # @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0) @@ -65,8 +71,42 @@ def training_paces_from_race(distance_km, time) training_paces(estimate_vo2max(distance_km, time)) end + # Computes the five Karvonen heart-rate training zones + # + # target_bpm = hr_rest + pct * (hr_max - hr_rest) + # + # @param hr_max [Numeric] maximum heart rate in bpm (must be > 0) + # @param hr_rest [Numeric] resting heart rate in bpm (must be > 0 and < hr_max) + # @return [Array] five contiguous zones from Z1 (50–60% HRR) to Z5 (90–100% HRR) + # @raise [Calcpace::NonPositiveInputError] if any rate is not positive + # @raise [Calcpace::Error] if hr_rest >= hr_max + # + # @example + # calc.hr_zones(hr_max: 190, hr_rest: 55).last.max_bpm #=> 190 + def hr_zones(hr_max:, hr_rest:) + max = hr_max.to_f + rest = hr_rest.to_f + check_heart_rates(max, rest) + + reserve = max - rest + points = HR_ZONE_BOUNDARIES.map { |pct| (rest + (pct * reserve)).round } + + points.each_cons(2).with_index(1).map do |(min_bpm, max_bpm), zone| + HrZone.new(zone: zone, min_bpm: min_bpm, max_bpm: max_bpm) + end + end + private + def check_heart_rates(hr_max, hr_rest) + check_positive(hr_max, 'Maximum heart rate') + check_positive(hr_rest, 'Resting heart rate') + return if hr_rest < hr_max + + raise Calcpace::Error, + "Resting heart rate (#{hr_rest}) must be lower than maximum heart rate (#{hr_max})" + end + # Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2 def velocity_at_vo2(vo2) a = 0.000104 diff --git a/test/calcpace/test_training_zones.rb b/test/calcpace/test_training_zones.rb index 9d98fc4..26fca83 100644 --- a/test/calcpace/test_training_zones.rb +++ b/test/calcpace/test_training_zones.rb @@ -84,4 +84,40 @@ def test_training_paces_from_race_propagates_input_errors @calc.training_paces_from_race(10.0, 'banana') end end + + # --- hr_zones --- + def test_hr_zones_returns_five_karvonen_zones + zones = @calc.hr_zones(hr_max: 190, hr_rest: 55) + + assert_equal 5, zones.size + assert_equal (1..5).to_a, zones.map(&:zone) + end + + def test_hr_zones_karvonen_values + zones = @calc.hr_zones(hr_max: 190, hr_rest: 55) # reserve = 135 + + assert_equal 123, zones[0].min_bpm # 55 + 0.50*135 = 122.5 → 123 + assert_equal 136, zones[0].max_bpm # 55 + 0.60*135 + assert_equal 163, zones[3].min_bpm # 55 + 0.80*135 + assert_equal 177, zones[3].max_bpm # 55 + 0.90*135 = 176.5 → 177 + assert_equal 190, zones[4].max_bpm # Z5 ends at max heart rate + end + + def test_hr_zones_are_contiguous + zones = @calc.hr_zones(hr_max: 185, hr_rest: 60) + + zones.each_cons(2) do |prev, nxt| + assert_equal prev.max_bpm, nxt.min_bpm + end + end + + def test_hr_zones_rejects_rest_greater_or_equal_to_max + error = assert_raises(Calcpace::Error) { @calc.hr_zones(hr_max: 150, hr_rest: 150) } + assert_match(/resting heart rate/i, error.message) + end + + def test_hr_zones_rejects_non_positive_values + assert_raises(Calcpace::NonPositiveInputError) { @calc.hr_zones(hr_max: 0, hr_rest: 55) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.hr_zones(hr_max: 190, hr_rest: -5) } + end end From 76a308bb0553260fe7a2b181c57738436b0760da Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 08:06:39 -0300 Subject: [PATCH 4/5] chore: bump version to 1.10.0 and document training zones --- CHANGELOG.md | 13 ++++++++++++- README.md | 34 +++++++++++++++++++++++++++++++--- calcpace.gemspec | 3 ++- lib/calcpace/training_zones.rb | 2 +- lib/calcpace/version.rb | 2 +- 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e74300..db294bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.10.0] - 2026-07-11 + +### Added +- Training Zones module (`TrainingZones`) + - `training_paces(vo2max)`: personalized Easy/Marathon/Threshold/Interval/Repetition + pace bands per km, inverting the Daniels & Gilbert velocity equation + - `training_paces_from_race(distance_km, time)`: pace bands straight from a race result + - `hr_zones(hr_max:, hr_rest:)`: five Karvonen (Heart Rate Reserve) heart-rate zones + - Structured results (`PaceBand`, `HrZone`) with seconds and clock formats + ## [1.9.10] - 2026-07-09 ### Changed @@ -205,7 +215,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See git history for changes in earlier versions. -[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.9.6...HEAD +[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.10.0...HEAD +[1.10.0]: https://github.com/0jonjo/calcpace/compare/v1.9.10...v1.10.0 [1.9.6]: https://github.com/0jonjo/calcpace/compare/v1.9.5...v1.9.6 [1.9.5]: https://github.com/0jonjo/calcpace/compare/v1.9.4...v1.9.5 [1.6.0]: https://github.com/0jonjo/calcpace/releases/tag/v1.6.0 diff --git a/README.md b/README.md index 64e3126..6605b53 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# 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) +# 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) -A Ruby gem for running and cycling calculations: pace, time, distance, unit conversions, race predictions, GPS track analysis, and VO2max estimation. +A Ruby gem for running and cycling calculations: pace, time, distance, unit conversions, race predictions, GPS track analysis, VO2max estimation, and training zones. ## Installation ```ruby -gem 'calcpace', '~> 1.9.8' +gem 'calcpace', '~> 1.10.0' ``` ## Usage @@ -276,6 +276,34 @@ easy.value # => 29.3 (underestimates real aerobic capacity) --- +### Training Zones + +Personalized training paces (Daniels' Running Formula) and Karvonen heart-rate zones: + +```ruby +zones = calc.training_paces(50.0) +zones[:threshold].fast_clock # => "00:04:15" per km +zones[:easy].slow_clock # => "00:05:52" per km + +calc.training_paces_from_race(10.0, '00:40:00') # from a recent race result + +calc.hr_zones(hr_max: 190, hr_rest: 55) +# => [#, ... zone=5, max_bpm=190] +``` + +| Zone | %VO2max | Purpose | +|------|---------|---------| +| Easy | 59–74% | Base building, recovery | +| Marathon | 75–84% | Marathon race pace | +| Threshold | 83–88% | Lactate threshold, tempo runs | +| Interval | 95–100% | VO2max development | +| Repetition | 105–110% | Speed and running economy | + +Pace accuracy vs published VDOT tables: within a few seconds per km +(threshold matches exactly; easy band is a range heuristic). + +--- + ### Other Utilities ```ruby diff --git a/calcpace.gemspec b/calcpace.gemspec index facf4ed..0f4bb80 100644 --- a/calcpace.gemspec +++ b/calcpace.gemspec @@ -12,7 +12,8 @@ Gem::Specification.new do |spec| spec.description = 'Ruby gem for running and cycling calculations: pace, time, distance, ' \ 'unit conversions (30+ units), race predictions (Riegel & Cameron), ' \ 'GPS track analysis (Haversine, elevation gain, per-km splits), ' \ - 'and VO2max estimation (Daniels & Gilbert).' + 'VO2max estimation (Daniels & Gilbert), and training zones ' \ + '(Daniels paces & Karvonen heart-rate zones).' spec.homepage = 'https://github.com/0jonjo/calcpace' spec.metadata['source_code_uri'] = spec.homepage spec.license = 'MIT' diff --git a/lib/calcpace/training_zones.rb b/lib/calcpace/training_zones.rb index f3dc30b..5124a26 100644 --- a/lib/calcpace/training_zones.rb +++ b/lib/calcpace/training_zones.rb @@ -66,7 +66,7 @@ def training_paces(vo2max) # @raise [Calcpace::InvalidTimeFormatError] if time string is malformed # # @example - # calc.training_paces_from_race(10.0, '00:40:00')[:easy].slow_clock #=> "00:05:41" + # calc.training_paces_from_race(10.0, '00:40:00')[:easy].slow_clock #=> "00:05:42" def training_paces_from_race(distance_km, time) training_paces(estimate_vo2max(distance_km, time)) end diff --git a/lib/calcpace/version.rb b/lib/calcpace/version.rb index 6756319..7bdbcaf 100644 --- a/lib/calcpace/version.rb +++ b/lib/calcpace/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Calcpace - VERSION = '1.9.10' + VERSION = '1.10.0' end From de4785f94dcdaf7a0b135e279ffb96bceec15408 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 08:10:02 -0300 Subject: [PATCH 5/5] docs: focus gem summary and description on running --- README.md | 2 +- calcpace.gemspec | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6605b53..a00fcc1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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) -A Ruby gem for running and cycling calculations: pace, time, distance, unit conversions, race predictions, GPS track analysis, VO2max estimation, and training zones. +A Ruby gem for runners: pace, time, and distance calculations, unit conversions, race predictions, GPS track analysis, age grading, VO2max estimation, and training zones. ## Installation diff --git a/calcpace.gemspec b/calcpace.gemspec index 0f4bb80..cc3f8b9 100644 --- a/calcpace.gemspec +++ b/calcpace.gemspec @@ -8,12 +8,12 @@ Gem::Specification.new do |spec| spec.authors = ['João Gilberto Saraiva'] spec.email = ['joaogilberto@tuta.io'] - spec.summary = 'Pace, distance, GPS track analysis, and VO2max calculations for runners and cyclists.' - spec.description = 'Ruby gem for running and cycling calculations: pace, time, distance, ' \ - 'unit conversions (30+ units), race predictions (Riegel & Cameron), ' \ + spec.summary = 'Running calculations: pace, race predictions, GPS track analysis, VO2max, and training zones.' + spec.description = 'Ruby gem for runners: pace, time, and distance calculations, ' \ + 'unit conversions (30+ units), race time predictions (Riegel & Cameron), ' \ 'GPS track analysis (Haversine, elevation gain, per-km splits), ' \ - 'VO2max estimation (Daniels & Gilbert), and training zones ' \ - '(Daniels paces & Karvonen heart-rate zones).' + 'age grading (WMA 2023), VO2max estimation (Daniels & Gilbert), and ' \ + 'personalized training zones (Daniels paces & Karvonen heart-rate zones).' spec.homepage = 'https://github.com/0jonjo/calcpace' spec.metadata['source_code_uri'] = spec.homepage spec.license = 'MIT'