Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -231,6 +233,47 @@ 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

`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).
> If you provide heart rate data, both `hr_avg` and `hr_max` must be present.
> `elevation_gain_m` must be zero or positive.

---

### Other Utilities
Expand Down
2 changes: 1 addition & 1 deletion lib/calcpace/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

class Calcpace
VERSION = '1.9.7'
VERSION = '1.9.8'
end
72 changes: 72 additions & 0 deletions lib/calcpace/vo2max_estimator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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)
Expand All @@ -48,6 +55,30 @@ 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)
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))

hr_data = validate_and_analyze_hr(hr_avg, hr_max)
confidence = :low if hr_data[:sub_maximal]

Vo2maxResult.new(
value: vo2max_val,
confidence: confidence,
sub_maximal: hr_data[:sub_maximal],
adjusted_distance_km: adj_dist_km.round(2)
)
end

# Returns a descriptive label for a given VO2max value
#
# @param value [Numeric] VO2max in ml/kg/min
Expand All @@ -64,6 +95,47 @@ 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')
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
elsif time_min > 60 && time_min <= 120
:medium
else
:low
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
Expand Down
84 changes: 84 additions & 0 deletions test/calcpace/test_vo2max_estimator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,88 @@ 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_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
assert_raises(Calcpace::Error) do
@calc.estimate_detailed_vo2max(10.0, '00:40:00', hr_avg: 210, hr_max: 200)
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')
assert_equal :low, result.confidence
end
end
Loading