Skip to content

Commit 96bc1f4

Browse files
authored
feat(vo2max): Contextualized VO2max estimation (v1.9.8) (#76)
* feat(vo2max): Add contextualized VO2max estimation (v1.9.8) 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. * feat(vo2max): add detailed contextualized estimation v1.9.8 * fix(vo2max): address review feedback on HR validation and docs * refactor(vo2max): address rubocop metrics and style offenses * fix(vo2max): validate contextualized estimator inputs
1 parent 9a825f5 commit 96bc1f4

5 files changed

Lines changed: 210 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.9.8] - 2026-05-23
11+
12+
### Added
13+
- Contextualized VO2max estimation (`Vo2maxEstimator#estimate_detailed_vo2max`)
14+
- Confidence Score based on effort duration (Daniels & Gilbert optimal window)
15+
- Elevation Adjustment (Equivalent Flat Distance) using Naismith-based heuristic
16+
- Sub-maximal effort detection via Heart Rate intensity validation (%HRmax)
17+
- Structured result object (`Vo2maxResult`) with value, confidence, and metadata
18+
1019
## [1.9.7] - 2026-05-16
1120

1221
### Added

README.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ A Ruby gem for running and cycling calculations: pace, time, distance, unit conv
55
## Installation
66

77
```ruby
8-
gem 'calcpace', '~> 1.9.7'
8+
gem 'calcpace', '~> 1.9.8'
99
```
1010

1111
## Usage
@@ -221,6 +221,8 @@ calc.vo2max_label(51.9) # => "Very Good"
221221
| 30–39 | Fair |
222222
| < 30 | Beginner |
223223

224+
*Thresholds based on Daniels, J. (2014). Daniels' Running Formula (3rd ed.), consistent with ACSM guidelines and McArdle, Katch & Katch (2015) Exercise Physiology.*
225+
224226
**Formula:**
225227
```
226228
velocity (m/min) = distance_m / time_min
@@ -231,6 +233,47 @@ VO2max = VO2 / %VO2max
231233

232234
Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace.
233235

236+
#### Contextualized estimation
237+
238+
`estimate_detailed_vo2max` returns a richer result that accounts for elevation, heart rate, and formula reliability:
239+
240+
```ruby
241+
# Mountain 10K: 200 m elevation gain, avg HR 172, max HR 190
242+
result = calc.estimate_detailed_vo2max(
243+
10.0, '00:48:30',
244+
elevation_gain_m: 200,
245+
hr_avg: 172,
246+
hr_max: 190
247+
)
248+
249+
result.value # => 47.7 (corrected for 1.2 km of equivalent flat distance)
250+
result.adjusted_distance_km # => 11.2 (10 km + 200 m × 6 flat-equivalent)
251+
result.confidence # => :high (48 min is inside the 5–60 min optimal window)
252+
result.sub_maximal # => false (172/190 = 90.5 % HRmax → maximal effort)
253+
254+
calc.vo2max_label(result.value) # => "Good"
255+
256+
# Compare: same effort ignoring elevation → underestimates VO2max
257+
flat = calc.estimate_detailed_vo2max(10.0, '00:48:30')
258+
flat.value # => 41.5
259+
260+
# Easy recovery run: sub-maximal effort flag + confidence downgrade
261+
easy = calc.estimate_detailed_vo2max(10.0, '01:05:00', hr_avg: 135, hr_max: 190)
262+
easy.sub_maximal # => true (135/190 = 71 % HRmax < 85 %)
263+
easy.confidence # => :low (formula assumes race-pace effort)
264+
easy.value # => 29.3 (underestimates real aerobic capacity)
265+
```
266+
267+
| `confidence` | Effort duration | Notes |
268+
|---|---|---|
269+
| `:high` | 5–60 min | Daniels & Gilbert optimal window |
270+
| `:medium` | > 60–120 min | Muscular fatigue starts distorting the estimate |
271+
| `:low` | < 5 min or > 120 min | Anaerobic / glycogen-depletion effects dominate |
272+
273+
> If `hr_avg > hr_max`, a `Calcpace::Error` is raised (physiologically impossible input).
274+
> If you provide heart rate data, both `hr_avg` and `hr_max` must be present.
275+
> `elevation_gain_m` must be zero or positive.
276+
234277
---
235278

236279
### Other Utilities

lib/calcpace/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
class Calcpace
4-
VERSION = '1.9.7'
4+
VERSION = '1.9.8'
55
end

lib/calcpace/vo2max_estimator.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
# Accuracy: ±3–5 ml/kg/min vs laboratory testing. Best results with efforts
1515
# between 5 and 60 minutes at race pace (i.e. near-maximal effort).
1616
module Vo2maxEstimator
17+
# Classification thresholds based on:
18+
# Daniels, J. (2014). Daniels' Running Formula (3rd ed.). Human Kinetics.
19+
# General ranges are consistent with ACSM guidelines and widely cited in
20+
# exercise physiology literature (McArdle, Katch & Katch, 2015).
1721
VO2MAX_LABELS = [
1822
{ min: 70, label: 'Elite' },
1923
{ min: 60, label: 'Excellent' },
@@ -23,6 +27,9 @@ module Vo2maxEstimator
2327
{ min: 0, label: 'Beginner' }
2428
].freeze
2529

30+
# Represents a contextualized VO2max estimation result
31+
Vo2maxResult = Struct.new(:value, :confidence, :sub_maximal, :adjusted_distance_km)
32+
2633
# Estimates VO2max from a race performance using Daniels & Gilbert formula
2734
#
2835
# @param distance_km [Numeric] race distance in kilometres (must be > 0)
@@ -48,6 +55,30 @@ def estimate_vo2max(distance_km, time)
4855
(vo2 / pct_vo2max).round(1)
4956
end
5057

58+
# Estimates a detailed and contextualized VO2max
59+
#
60+
# @param distance_km [Numeric] race distance in kilometres
61+
# @param time [String, Integer] finish time
62+
# @param elevation_gain_m [Numeric] total elevation gain in metres
63+
# @param hr_avg [Numeric] average heart rate during the effort
64+
# @param hr_max [Numeric] athlete's maximum heart rate
65+
# @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)
67+
adj_dist_km = adjusted_distance_for_vo2(distance_km, elevation_gain_m)
68+
vo2max_val = estimate_vo2max(adj_dist_km, time)
69+
confidence = calculate_time_confidence(parse_time_minutes(time))
70+
71+
hr_data = validate_and_analyze_hr(hr_avg, hr_max)
72+
confidence = :low if hr_data[:sub_maximal]
73+
74+
Vo2maxResult.new(
75+
value: vo2max_val,
76+
confidence: confidence,
77+
sub_maximal: hr_data[:sub_maximal],
78+
adjusted_distance_km: adj_dist_km.round(2)
79+
)
80+
end
81+
5182
# Returns a descriptive label for a given VO2max value
5283
#
5384
# @param value [Numeric] VO2max in ml/kg/min
@@ -64,6 +95,47 @@ def vo2max_label(value)
6495

6596
private
6697

98+
def adjusted_distance_for_vo2(distance_km, elevation_gain_m)
99+
check_non_negative(elevation_gain_m, 'Elevation gain')
100+
101+
# Naismith-based heuristic: 100m gain = +600m flat
102+
((distance_km.to_f * 1000) + (elevation_gain_m.to_f * 6.0)) / 1000.0
103+
end
104+
105+
def validate_and_analyze_hr(hr_avg, hr_max)
106+
if hr_avg.nil? ^ hr_max.nil?
107+
raise Calcpace::Error, 'Average heart rate and maximum heart rate must be provided together'
108+
end
109+
110+
return { sub_maximal: false } unless hr_avg && hr_max
111+
112+
check_positive(hr_avg, 'Average heart rate')
113+
check_positive(hr_max, 'Maximum heart rate')
114+
115+
avg = hr_avg.to_f
116+
max = hr_max.to_f
117+
118+
raise Calcpace::Error, "Average heart rate (#{avg}) cannot exceed maximum heart rate (#{max})" if avg > max
119+
120+
{ sub_maximal: (avg / max) < 0.85 }
121+
end
122+
123+
def calculate_time_confidence(time_min)
124+
if time_min.between?(5, 60)
125+
:high
126+
elsif time_min > 60 && time_min <= 120
127+
:medium
128+
else
129+
:low
130+
end
131+
end
132+
133+
def check_non_negative(number, name = 'Input')
134+
return if number.is_a?(Numeric) && number >= 0
135+
136+
raise Calcpace::Error, "#{name} must be zero or a positive number"
137+
end
138+
67139
def vo2_at_velocity(velocity)
68140
-4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2))
69141
end

test/calcpace/test_vo2max_estimator.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,88 @@ def test_estimate_and_label_integrate_for_10k_in_40min
9090
vo2max = @calc.estimate_vo2max(10.0, '00:40:00')
9191
assert_equal 'Very Good', @calc.vo2max_label(vo2max)
9292
end
93+
94+
# --- estimate_detailed_vo2max ---
95+
96+
def test_detailed_vo2max_returns_struct_with_correct_data
97+
result = @calc.estimate_detailed_vo2max(10.0, '00:40:00')
98+
assert_respond_to result, :value
99+
assert_respond_to result, :confidence
100+
assert_respond_to result, :sub_maximal
101+
assert_respond_to result, :adjusted_distance_km
102+
assert_equal 51.9, result.value
103+
assert_equal :high, result.confidence
104+
assert_equal false, result.sub_maximal
105+
assert_equal 10.0, result.adjusted_distance_km
106+
end
107+
108+
def test_detailed_vo2max_confidence_high_for_10k
109+
result = @calc.estimate_detailed_vo2max(10.0, '00:40:00')
110+
assert_equal :high, result.confidence
111+
end
112+
113+
def test_detailed_vo2max_confidence_medium_for_half_marathon
114+
result = @calc.estimate_detailed_vo2max(21.0975, '01:40:00')
115+
assert_equal :medium, result.confidence
116+
end
117+
118+
def test_detailed_vo2max_confidence_low_for_marathon
119+
result = @calc.estimate_detailed_vo2max(42.195, '04:00:00')
120+
assert_equal :low, result.confidence
121+
end
122+
123+
def test_detailed_vo2max_elevation_adjustment_increases_value
124+
flat_result = @calc.estimate_detailed_vo2max(10.0, '00:40:00')
125+
hilly_result = @calc.estimate_detailed_vo2max(10.0, '00:40:00', elevation_gain_m: 100)
126+
127+
assert hilly_result.value > flat_result.value
128+
assert_equal 10.6, hilly_result.adjusted_distance_km # 10km + 100m * 6 = 10.6km
129+
end
130+
131+
def test_detailed_vo2max_sub_maximal_detection
132+
# HR intensity = 140 / 200 = 70% (< 85%)
133+
result = @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 140, hr_max: 200)
134+
assert_equal true, result.sub_maximal
135+
assert_equal :low, result.confidence
136+
end
137+
138+
def test_detailed_vo2max_maximal_effort_detection
139+
# HR intensity = 180 / 200 = 90% (> 85%)
140+
result = @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 180, hr_max: 200)
141+
assert_equal false, result.sub_maximal
142+
assert_equal :high, result.confidence
143+
end
144+
145+
def test_detailed_vo2max_raises_for_invalid_hr_values
146+
assert_raises(Calcpace::NonPositiveInputError) { @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 0, hr_max: 200) }
147+
assert_raises(Calcpace::NonPositiveInputError) { @calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 150, hr_max: 0) }
148+
end
149+
150+
def test_detailed_vo2max_raises_when_hr_avg_exceeds_hr_max
151+
assert_raises(Calcpace::Error) do
152+
@calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 210, hr_max: 200)
153+
end
154+
end
155+
156+
def test_detailed_vo2max_raises_when_only_one_hr_value_is_provided
157+
assert_raises(Calcpace::Error) do
158+
@calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 170)
159+
end
160+
161+
assert_raises(Calcpace::Error) do
162+
@calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_max: 190)
163+
end
164+
end
165+
166+
def test_detailed_vo2max_raises_for_negative_elevation_gain
167+
assert_raises(Calcpace::Error) do
168+
@calc.estimate_detailed_vo2max(10.0, '00:40:00', elevation_gain_m: -100)
169+
end
170+
end
171+
172+
def test_detailed_vo2max_confidence_low_for_short_effort
173+
# < 5 min has high anaerobic contribution
174+
result = @calc.estimate_detailed_vo2max(1.0, '00:04:00')
175+
assert_equal :low, result.confidence
176+
end
93177
end

0 commit comments

Comments
 (0)