From 40b3ea0022c039caa5e40c01cdadd6675081cced Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 23 May 2026 09:10:12 -0300 Subject: [PATCH 1/5] feat(vo2max): Add contextualized VO2max estimation (v1.9.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `estimate_detailed_vo2max` alongside the existing `estimate_vo2max` (unchanged, no breaking change). Returns a `Vo2maxResult` struct with: - `value`: VO2max adjusted for elevation gain using Naismith heuristic (100m gain = +600m equivalent flat distance) - `confidence`: :high / :medium / :low based on effort duration (Daniels & Gilbert optimal window: 5–60 min) - `sub_maximal`: true when avg HR < 85% of HRmax, downgrades confidence to :low - `adjusted_distance_km`: effective flat distance used in the calculation Also validates that hr_avg cannot exceed hr_max (raises Calcpace::Error). Label thresholds reference: Daniels (2014) Running Formula + ACSM guidelines. --- CHANGELOG.md | 9 ++++ README.md | 41 +++++++++++++++++ lib/calcpace/version.rb | 2 +- lib/calcpace/vo2max_estimator.rb | 55 ++++++++++++++++++++++ test/calcpace/test_vo2max_estimator.rb | 63 ++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c9013c..3f0cacd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.9.8] - 2026-05-23 + +### Added +- Contextualized VO2max estimation (`Vo2maxEstimator#estimate_detailed_vo2max`) + - Confidence Score based on effort duration (Daniels & Gilbert optimal window) + - Elevation Adjustment (Equivalent Flat Distance) using Naismith-based heuristic + - Sub-maximal effort detection via Heart Rate intensity validation (%HRmax) + - Structured result object (`Vo2maxResult`) with value, confidence, and metadata + ## [1.9.7] - 2026-05-16 ### Added diff --git a/README.md b/README.md index 380bb5f..9d77dc6 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,8 @@ calc.vo2max_label(51.9) # => "Very Good" | 30–39 | Fair | | < 30 | Beginner | +*Thresholds based on Daniels, J. (2014). Daniels' Running Formula (3rd ed.), consistent with ACSM guidelines and McArdle, Katch & Katch (2015) Exercise Physiology.* + **Formula:** ``` velocity (m/min) = distance_m / time_min @@ -231,6 +233,45 @@ VO2max = VO2 / %VO2max Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace. +#### Contextualized estimation (v1.9.8+) + +`estimate_detailed_vo2max` returns a richer result that accounts for elevation, heart rate, and formula reliability: + +```ruby +# Mountain 10K: 200 m elevation gain, avg HR 172, max HR 190 +result = calc.estimate_detailed_vo2max( + 10.0, '00:48:30', + elevation_gain_m: 200, + hr_avg: 172, + hr_max: 190 +) + +result.value # => 47.7 (corrected for 1.2 km of equivalent flat distance) +result.adjusted_distance_km # => 11.2 (10 km + 200 m × 6 flat-equivalent) +result.confidence # => :high (48 min is inside the 5–60 min optimal window) +result.sub_maximal # => false (172/190 = 90.5 % HRmax → maximal effort) + +calc.vo2max_label(result.value) # => "Good" + +# Compare: same effort ignoring elevation → underestimates VO2max +flat = calc.estimate_detailed_vo2max(10.0, '00:48:30') +flat.value # => 41.5 + +# Easy recovery run: sub-maximal effort flag + confidence downgrade +easy = calc.estimate_detailed_vo2max(10.0, '01:05:00', hr_avg: 135, hr_max: 190) +easy.sub_maximal # => true (135/190 = 71 % HRmax < 85 %) +easy.confidence # => :low (formula assumes race-pace effort) +easy.value # => 29.3 (underestimates real aerobic capacity) +``` + +| `confidence` | Effort duration | Notes | +|---|---|---| +| `:high` | 5–60 min | Daniels & Gilbert optimal window | +| `:medium` | 60–120 min | Muscular fatigue starts distorting the estimate | +| `:low` | < 5 min or > 120 min | Anaerobic / glycogen-depletion effects dominate | + +> If `hr_avg > hr_max`, a `Calcpace::Error` is raised (physiologically impossible input). + --- ### Other Utilities diff --git a/lib/calcpace/version.rb b/lib/calcpace/version.rb index b8d233a..dbe2b10 100644 --- a/lib/calcpace/version.rb +++ b/lib/calcpace/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Calcpace - VERSION = '1.9.7' + VERSION = '1.9.8' end diff --git a/lib/calcpace/vo2max_estimator.rb b/lib/calcpace/vo2max_estimator.rb index e87eb0d..e707c7c 100644 --- a/lib/calcpace/vo2max_estimator.rb +++ b/lib/calcpace/vo2max_estimator.rb @@ -14,6 +14,10 @@ # Accuracy: ±3–5 ml/kg/min vs laboratory testing. Best results with efforts # between 5 and 60 minutes at race pace (i.e. near-maximal effort). module Vo2maxEstimator + # Classification thresholds based on: + # Daniels, J. (2014). Daniels' Running Formula (3rd ed.). Human Kinetics. + # General ranges are consistent with ACSM guidelines and widely cited in + # exercise physiology literature (McArdle, Katch & Katch, 2015). VO2MAX_LABELS = [ { min: 70, label: 'Elite' }, { min: 60, label: 'Excellent' }, @@ -23,6 +27,9 @@ module Vo2maxEstimator { min: 0, label: 'Beginner' } ].freeze + # Represents a contextualized VO2max estimation result + Vo2maxResult = Struct.new(:value, :confidence, :sub_maximal, :adjusted_distance_km) + # Estimates VO2max from a race performance using Daniels & Gilbert formula # # @param distance_km [Numeric] race distance in kilometres (must be > 0) @@ -48,6 +55,44 @@ def estimate_vo2max(distance_km, time) (vo2 / pct_vo2max).round(1) end + # Estimates a detailed and contextualized VO2max + # + # @param distance_km [Numeric] race distance in kilometres + # @param time [String, Integer] finish time + # @param elevation_gain_m [Numeric] total elevation gain in metres + # @param hr_avg [Numeric] average heart rate during the effort + # @param hr_max [Numeric] athlete's maximum heart rate + # @return [Vo2maxResult] structured result with value and metadata + def estimate_detailed_vo2max(distance_km, time, elevation_gain_m: 0, hr_avg: nil, hr_max: nil) + # 1. Elevation adjustment (Naismith-based heuristic: 100m gain = +600m flat) + adjusted_distance_m = (distance_km.to_f * 1000) + (elevation_gain_m.to_f * 6.0) + adjusted_distance_km = adjusted_distance_m / 1000.0 + + # 2. Base VO2max calculation + vo2max_value = estimate_vo2max(adjusted_distance_km, time) + + # 3. Confidence based on effort duration + time_min = parse_time_minutes(time) + confidence = calculate_time_confidence(time_min) + + # 4. HR validation for sub-maximal effort + sub_maximal = false + raise Calcpace::Error, 'hr_avg cannot exceed hr_max' if hr_avg && hr_max && (hr_avg.to_f > hr_max.to_f) + + # If effort is below 85% of HRmax, it's considered sub-maximal + if hr_avg && hr_max && ((hr_avg.to_f / hr_max) < 0.85) + sub_maximal = true + confidence = :low + end + + Vo2maxResult.new( + value: vo2max_value, + confidence: confidence, + sub_maximal: sub_maximal, + adjusted_distance_km: adjusted_distance_km.round(2) + ) + end + # Returns a descriptive label for a given VO2max value # # @param value [Numeric] VO2max in ml/kg/min @@ -64,6 +109,16 @@ def vo2max_label(value) private + def calculate_time_confidence(time_min) + if time_min.between?(5, 60) + :high + elsif time_min > 60 && time_min <= 120 + :medium + else + :low + end + end + def vo2_at_velocity(velocity) -4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2)) end diff --git a/test/calcpace/test_vo2max_estimator.rb b/test/calcpace/test_vo2max_estimator.rb index e11441b..545ffcb 100644 --- a/test/calcpace/test_vo2max_estimator.rb +++ b/test/calcpace/test_vo2max_estimator.rb @@ -90,4 +90,67 @@ def test_estimate_and_label_integrate_for_10k_in_40min vo2max = @calc.estimate_vo2max(10.0, '00:40:00') assert_equal 'Very Good', @calc.vo2max_label(vo2max) end + + # --- estimate_detailed_vo2max --- + + def test_detailed_vo2max_returns_struct_with_correct_data + result = @calc.estimate_detailed_vo2max(10.0, '00:40:00') + assert_respond_to result, :value + assert_respond_to result, :confidence + assert_respond_to result, :sub_maximal + assert_respond_to result, :adjusted_distance_km + assert_equal 51.9, result.value + assert_equal :high, result.confidence + assert_equal false, result.sub_maximal + assert_equal 10.0, result.adjusted_distance_km + end + + def test_detailed_vo2max_confidence_high_for_10k + result = @calc.estimate_detailed_vo2max(10.0, '00:40:00') + assert_equal :high, result.confidence + end + + def test_detailed_vo2max_confidence_medium_for_half_marathon + result = @calc.estimate_detailed_vo2max(21.0975, '01:40:00') + assert_equal :medium, result.confidence + end + + def test_detailed_vo2max_confidence_low_for_marathon + result = @calc.estimate_detailed_vo2max(42.195, '04:00:00') + assert_equal :low, result.confidence + end + + def test_detailed_vo2max_elevation_adjustment_increases_value + flat_result = @calc.estimate_detailed_vo2max(10.0, '00:40:00') + hilly_result = @calc.estimate_detailed_vo2max(10.0, '00:40:00', elevation_gain_m: 100) + + assert hilly_result.value > flat_result.value + assert_equal 10.6, hilly_result.adjusted_distance_km # 10km + 100m * 6 = 10.6km + end + + def test_detailed_vo2max_sub_maximal_detection + # HR intensity = 140 / 200 = 70% (< 85%) + result = @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 140, hr_max: 200) + assert_equal true, result.sub_maximal + assert_equal :low, result.confidence + end + + def test_detailed_vo2max_maximal_effort_detection + # HR intensity = 180 / 200 = 90% (> 85%) + result = @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 180, hr_max: 200) + assert_equal false, result.sub_maximal + assert_equal :high, result.confidence + end + + def test_detailed_vo2max_confidence_low_for_short_effort + # < 5 min has high anaerobic contribution — outside Daniels & Gilbert optimal window + result = @calc.estimate_detailed_vo2max(1.0, '00:04:00') + assert_equal :low, result.confidence + end + + def test_detailed_vo2max_raises_when_hr_avg_exceeds_hr_max + assert_raises(Calcpace::Error) do + @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 210, hr_max: 200) + end + end end From 1418d2c20a7951c0e08a636d60c186b0be24fbd4 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 23 May 2026 11:11:51 -0300 Subject: [PATCH 2/5] feat(vo2max): add detailed contextualized estimation v1.9.8 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9d77dc6..c2e1c94 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ VO2max = VO2 / %VO2max Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace. -#### Contextualized estimation (v1.9.8+) +#### Contextualized estimation `estimate_detailed_vo2max` returns a richer result that accounts for elevation, heart rate, and formula reliability: From fe9341a9aa34e308679e5171a91e1665cc2fcd88 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 23 May 2026 11:19:49 -0300 Subject: [PATCH 3/5] fix(vo2max): address review feedback on HR validation and docs --- README.md | 2 +- lib/calcpace/vo2max_estimator.rb | 18 +++++++++++++----- test/calcpace/test_vo2max_estimator.rb | 13 +++++++++---- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c2e1c94..a829f0d 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ easy.value # => 29.3 (underestimates real aerobic capacity) | `confidence` | Effort duration | Notes | |---|---|---| | `:high` | 5–60 min | Daniels & Gilbert optimal window | -| `:medium` | 60–120 min | Muscular fatigue starts distorting the estimate | +| `:medium` | > 60–120 min | Muscular fatigue starts distorting the estimate | | `:low` | < 5 min or > 120 min | Anaerobic / glycogen-depletion effects dominate | > If `hr_avg > hr_max`, a `Calcpace::Error` is raised (physiologically impossible input). diff --git a/lib/calcpace/vo2max_estimator.rb b/lib/calcpace/vo2max_estimator.rb index e707c7c..de2b074 100644 --- a/lib/calcpace/vo2max_estimator.rb +++ b/lib/calcpace/vo2max_estimator.rb @@ -77,12 +77,20 @@ def estimate_detailed_vo2max(distance_km, time, elevation_gain_m: 0, hr_avg: nil # 4. HR validation for sub-maximal effort sub_maximal = false - raise Calcpace::Error, 'hr_avg cannot exceed hr_max' if hr_avg && hr_max && (hr_avg.to_f > hr_max.to_f) + if hr_avg && hr_max + check_positive(hr_avg, 'Average heart rate') + check_positive(hr_max, 'Maximum heart rate') - # If effort is below 85% of HRmax, it's considered sub-maximal - if hr_avg && hr_max && ((hr_avg.to_f / hr_max) < 0.85) - sub_maximal = true - confidence = :low + avg = hr_avg.to_f + max = hr_max.to_f + + raise Calcpace::Error, "Average heart rate (#{avg}) cannot exceed maximum heart rate (#{max})" if avg > max + + # If effort is below 85% of HRmax, it's considered sub-maximal + if (avg / max) < 0.85 + sub_maximal = true + confidence = :low + end end Vo2maxResult.new( diff --git a/test/calcpace/test_vo2max_estimator.rb b/test/calcpace/test_vo2max_estimator.rb index 545ffcb..7f543da 100644 --- a/test/calcpace/test_vo2max_estimator.rb +++ b/test/calcpace/test_vo2max_estimator.rb @@ -142,10 +142,9 @@ def test_detailed_vo2max_maximal_effort_detection assert_equal :high, result.confidence end - def test_detailed_vo2max_confidence_low_for_short_effort - # < 5 min has high anaerobic contribution — outside Daniels & Gilbert optimal window - result = @calc.estimate_detailed_vo2max(1.0, '00:04:00') - assert_equal :low, result.confidence + def test_detailed_vo2max_raises_for_invalid_hr_values + assert_raises(Calcpace::NonPositiveInputError) { @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 0, hr_max: 200) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 150, hr_max: 0) } end def test_detailed_vo2max_raises_when_hr_avg_exceeds_hr_max @@ -153,4 +152,10 @@ def test_detailed_vo2max_raises_when_hr_avg_exceeds_hr_max @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 210, hr_max: 200) end end + + def test_detailed_vo2max_confidence_low_for_short_effort + # < 5 min has high anaerobic contribution + result = @calc.estimate_detailed_vo2max(1.0, '00:04:00') + assert_equal :low, result.confidence + end end From 9ca57e318acfe75326c9707151423a7010d455ec Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 23 May 2026 11:29:40 -0300 Subject: [PATCH 4/5] refactor(vo2max): address rubocop metrics and style offenses --- lib/calcpace/vo2max_estimator.rb | 57 +++++++++++++++----------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/lib/calcpace/vo2max_estimator.rb b/lib/calcpace/vo2max_estimator.rb index de2b074..0d24f73 100644 --- a/lib/calcpace/vo2max_estimator.rb +++ b/lib/calcpace/vo2max_estimator.rb @@ -64,40 +64,18 @@ def estimate_vo2max(distance_km, time) # @param hr_max [Numeric] athlete's maximum heart rate # @return [Vo2maxResult] structured result with value and metadata def estimate_detailed_vo2max(distance_km, time, elevation_gain_m: 0, hr_avg: nil, hr_max: nil) - # 1. Elevation adjustment (Naismith-based heuristic: 100m gain = +600m flat) - adjusted_distance_m = (distance_km.to_f * 1000) + (elevation_gain_m.to_f * 6.0) - adjusted_distance_km = adjusted_distance_m / 1000.0 + adj_dist_km = adjusted_distance_for_vo2(distance_km, elevation_gain_m) + vo2max_val = estimate_vo2max(adj_dist_km, time) + confidence = calculate_time_confidence(parse_time_minutes(time)) - # 2. Base VO2max calculation - vo2max_value = estimate_vo2max(adjusted_distance_km, time) - - # 3. Confidence based on effort duration - time_min = parse_time_minutes(time) - confidence = calculate_time_confidence(time_min) - - # 4. HR validation for sub-maximal effort - sub_maximal = false - if hr_avg && hr_max - check_positive(hr_avg, 'Average heart rate') - check_positive(hr_max, 'Maximum heart rate') - - avg = hr_avg.to_f - max = hr_max.to_f - - raise Calcpace::Error, "Average heart rate (#{avg}) cannot exceed maximum heart rate (#{max})" if avg > max - - # If effort is below 85% of HRmax, it's considered sub-maximal - if (avg / max) < 0.85 - sub_maximal = true - confidence = :low - end - end + hr_data = validate_and_analyze_hr(hr_avg, hr_max) + confidence = :low if hr_data[:sub_maximal] Vo2maxResult.new( - value: vo2max_value, + value: vo2max_val, confidence: confidence, - sub_maximal: sub_maximal, - adjusted_distance_km: adjusted_distance_km.round(2) + sub_maximal: hr_data[:sub_maximal], + adjusted_distance_km: adj_dist_km.round(2) ) end @@ -117,6 +95,25 @@ def vo2max_label(value) private + def adjusted_distance_for_vo2(distance_km, elevation_gain_m) + # Naismith-based heuristic: 100m gain = +600m flat + ((distance_km.to_f * 1000) + (elevation_gain_m.to_f * 6.0)) / 1000.0 + end + + def validate_and_analyze_hr(hr_avg, hr_max) + return { sub_maximal: false } unless hr_avg && hr_max + + check_positive(hr_avg, 'Average heart rate') + check_positive(hr_max, 'Maximum heart rate') + + avg = hr_avg.to_f + max = hr_max.to_f + + raise Calcpace::Error, "Average heart rate (#{avg}) cannot exceed maximum heart rate (#{max})" if avg > max + + { sub_maximal: (avg / max) < 0.85 } + end + def calculate_time_confidence(time_min) if time_min.between?(5, 60) :high From 16e04f8b5046c7e0fbfe3eb3186e184a65cc7352 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 23 May 2026 12:27:48 -0300 Subject: [PATCH 5/5] fix(vo2max): validate contextualized estimator inputs --- README.md | 4 +++- lib/calcpace/vo2max_estimator.rb | 12 ++++++++++++ test/calcpace/test_vo2max_estimator.rb | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a829f0d..60a43be 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Ruby gem for running and cycling calculations: pace, time, distance, unit conv ## Installation ```ruby -gem 'calcpace', '~> 1.9.7' +gem 'calcpace', '~> 1.9.8' ``` ## Usage @@ -271,6 +271,8 @@ easy.value # => 29.3 (underestimates real aerobic capacity) | `:low` | < 5 min or > 120 min | Anaerobic / glycogen-depletion effects dominate | > If `hr_avg > hr_max`, a `Calcpace::Error` is raised (physiologically impossible input). +> If you provide heart rate data, both `hr_avg` and `hr_max` must be present. +> `elevation_gain_m` must be zero or positive. --- diff --git a/lib/calcpace/vo2max_estimator.rb b/lib/calcpace/vo2max_estimator.rb index 0d24f73..5fa70bf 100644 --- a/lib/calcpace/vo2max_estimator.rb +++ b/lib/calcpace/vo2max_estimator.rb @@ -96,11 +96,17 @@ def vo2max_label(value) private def adjusted_distance_for_vo2(distance_km, elevation_gain_m) + check_non_negative(elevation_gain_m, 'Elevation gain') + # Naismith-based heuristic: 100m gain = +600m flat ((distance_km.to_f * 1000) + (elevation_gain_m.to_f * 6.0)) / 1000.0 end def validate_and_analyze_hr(hr_avg, hr_max) + if hr_avg.nil? ^ hr_max.nil? + raise Calcpace::Error, 'Average heart rate and maximum heart rate must be provided together' + end + return { sub_maximal: false } unless hr_avg && hr_max check_positive(hr_avg, 'Average heart rate') @@ -124,6 +130,12 @@ def calculate_time_confidence(time_min) end end + def check_non_negative(number, name = 'Input') + return if number.is_a?(Numeric) && number >= 0 + + raise Calcpace::Error, "#{name} must be zero or a positive number" + end + def vo2_at_velocity(velocity) -4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2)) end diff --git a/test/calcpace/test_vo2max_estimator.rb b/test/calcpace/test_vo2max_estimator.rb index 7f543da..b7fdc41 100644 --- a/test/calcpace/test_vo2max_estimator.rb +++ b/test/calcpace/test_vo2max_estimator.rb @@ -153,6 +153,22 @@ def test_detailed_vo2max_raises_when_hr_avg_exceeds_hr_max end end + def test_detailed_vo2max_raises_when_only_one_hr_value_is_provided + assert_raises(Calcpace::Error) do + @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 170) + end + + assert_raises(Calcpace::Error) do + @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_max: 190) + end + end + + def test_detailed_vo2max_raises_for_negative_elevation_gain + assert_raises(Calcpace::Error) do + @calc.estimate_detailed_vo2max(10.0, '00:40:00', elevation_gain_m: -100) + end + end + def test_detailed_vo2max_confidence_low_for_short_effort # < 5 min has high anaerobic contribution result = @calc.estimate_detailed_vo2max(1.0, '00:04:00')