calcpace 1.9.6 → 1.9.8
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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +82 -1
- data/lib/calcpace/cameron_predictor.rb +12 -0
- data/lib/calcpace/data/environmental_factors.yml +31 -0
- data/lib/calcpace/environmental_adjuster.rb +159 -0
- data/lib/calcpace/race_predictor.rb +19 -0
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace/vo2max_estimator.rb +72 -0
- data/lib/calcpace.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c753575818b486be4f9c955d69e062263d61fd604e415c82409203c7d8d03156
|
|
4
|
+
data.tar.gz: 95459f1aa861dec764b2cfae6b9cd69cab5d866e31547d843672e460c309999d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eddb87f77382d6973059b517c9c2ce696d9cc31246f38db46651b1c62d155e52fafb022fff373220f0aced664b4213eddf9a4b57aa71564be22321799cc0f254
|
|
7
|
+
data.tar.gz: 1c5cfc3771471b3d89b9ba8eee8e742bf9508815e13489692d4ceae3d0f2a8a223a29f7b9205c57ceed8f3b7df28df27893c4e512f356489916ffe3644b05010
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
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
|
+
|
|
19
|
+
## [1.9.7] - 2026-05-16
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Environmental Performance Adjustments module (`EnvironmentalAdjuster`)
|
|
23
|
+
- Adjust race results and predictions based on temperature and altitude
|
|
24
|
+
- Scientific basis: Matthew Ely et al. (2007) for heat and NCAA standards for altitude
|
|
25
|
+
- Data-driven penalty tables stored in `lib/calcpace/data/environmental_factors.yml`
|
|
26
|
+
- Support for interpolation between data points in penalty tables
|
|
27
|
+
- Transparent return values including penalty percentage and factor breakdown
|
|
28
|
+
- New prediction methods with environmental support:
|
|
29
|
+
- `predict_time_adjusted` (Riegel-based)
|
|
30
|
+
- `predict_time_cameron_adjusted` (Cameron-based)
|
|
31
|
+
- `adjust_time` and `calculate_penalty` methods for direct environmental impact analysis
|
|
32
|
+
|
|
10
33
|
## [1.9.6] - 2026-05-15
|
|
11
34
|
|
|
12
35
|
### Changed
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A Ruby gem for running and cycling calculations: pace, time, distance, unit conv
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```ruby
|
|
8
|
-
gem 'calcpace', '~> 1.9.
|
|
8
|
+
gem 'calcpace', '~> 1.9.8'
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
@@ -33,6 +33,44 @@ calc.checked_distance('01:21:32', '00:06:27') # => 12.64
|
|
|
33
33
|
|
|
34
34
|
---
|
|
35
35
|
|
|
36
|
+
### Environmental Performance Adjustments
|
|
37
|
+
|
|
38
|
+
Adjust race performance based on heat and altitude. Calculations are based on scientific models
|
|
39
|
+
(Matthew Ely 2007 for heat, NCAA standards for altitude).
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# Calculate penalty for 25°C and 2000m altitude (Defaults to 60-min effort)
|
|
43
|
+
penalty = calc.calculate_penalty(temperature: 25, altitude: 2000)
|
|
44
|
+
# => {
|
|
45
|
+
# total_penalty_percent: 8.62,
|
|
46
|
+
# factors: { heat: 4.3, altitude: 4.32 }
|
|
47
|
+
# }
|
|
48
|
+
|
|
49
|
+
# Fahrenheit support
|
|
50
|
+
calc.calculate_penalty(temperature: 80, temperature_unit: :f)
|
|
51
|
+
# => { total_penalty_percent: 5.03, ... }
|
|
52
|
+
|
|
53
|
+
# Adjust a 3:30 marathon time (12600s) for these conditions (High exposure penalty)
|
|
54
|
+
result = calc.adjust_time(12600, temperature: 25, altitude: 2000)
|
|
55
|
+
# => {
|
|
56
|
+
# original_time: 12600,
|
|
57
|
+
# adjusted_time: 15176.7,
|
|
58
|
+
# adjusted_time_clock: "04:12:56",
|
|
59
|
+
# penalty_percent: 20.45,
|
|
60
|
+
# factors: { heat: 16.13, altitude: 4.32 }
|
|
61
|
+
# }
|
|
62
|
+
|
|
63
|
+
# Predicted adjusted times (Riegel formula)
|
|
64
|
+
calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 28)
|
|
65
|
+
# => { adjusted_time: 2599.74, adjusted_time_clock: "00:43:19", penalty_percent: 3.91, ... }
|
|
66
|
+
|
|
67
|
+
# Predicted adjusted times (Cameron formula)
|
|
68
|
+
calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 80, temperature_unit: :f)
|
|
69
|
+
# => { adjusted_time: 11585.88, adjusted_time_clock: "03:13:05", penalty_percent: 14.18, ... }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
36
74
|
### Unit Conversions
|
|
37
75
|
|
|
38
76
|
30+ units supported. String or symbol format:
|
|
@@ -183,6 +221,8 @@ calc.vo2max_label(51.9) # => "Very Good"
|
|
|
183
221
|
| 30–39 | Fair |
|
|
184
222
|
| < 30 | Beginner |
|
|
185
223
|
|
|
224
|
+
*Thresholds based on Daniels, J. (2014). Daniels' Running Formula (3rd ed.), consistent with ACSM guidelines and McArdle, Katch & Katch (2015) Exercise Physiology.*
|
|
225
|
+
|
|
186
226
|
**Formula:**
|
|
187
227
|
```
|
|
188
228
|
velocity (m/min) = distance_m / time_min
|
|
@@ -193,6 +233,47 @@ VO2max = VO2 / %VO2max
|
|
|
193
233
|
|
|
194
234
|
Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace.
|
|
195
235
|
|
|
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
|
+
|
|
196
277
|
---
|
|
197
278
|
|
|
198
279
|
### Other Utilities
|
|
@@ -96,6 +96,18 @@ module CameronPredictor
|
|
|
96
96
|
convert_to_clocktime(predict_pace_cameron(from_race, from_time, to_race))
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# Predicts race time adjusted for environmental conditions using Cameron formula
|
|
100
|
+
#
|
|
101
|
+
# @param from_race [String, Symbol] known race distance
|
|
102
|
+
# @param from_time [String, Numeric] time achieved at known distance
|
|
103
|
+
# @param to_race [String, Symbol] target race distance to predict
|
|
104
|
+
# @param options [Hash] environmental options (temperature, altitude, etc.)
|
|
105
|
+
# @return [Hash] hash with adjusted prediction and penalty details
|
|
106
|
+
def predict_time_cameron_adjusted(from_race, from_time, to_race, **)
|
|
107
|
+
predicted_seconds = predict_time_cameron(from_race, from_time, to_race)
|
|
108
|
+
adjust_time(predicted_seconds, **)
|
|
109
|
+
end
|
|
110
|
+
|
|
99
111
|
private
|
|
100
112
|
|
|
101
113
|
# Computes the Cameron exponential correction factor for a given distance
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Environmental Performance Adjustment Factors
|
|
2
|
+
#
|
|
3
|
+
# Sources:
|
|
4
|
+
# - Altitude: NCAA Altitude Adjustment Factors (TFRRS)
|
|
5
|
+
# Ref: ~3.76% penalty for 1828.8m (6000ft).
|
|
6
|
+
# - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance"
|
|
7
|
+
# NOTE: These heat factors are the BASELINE for a 60-minute effort.
|
|
8
|
+
# The final penalty is scaled by a DurationFactor (0.5x to 4.5x) based on total exposure time.
|
|
9
|
+
|
|
10
|
+
# Altitude adjustments (NCAA standards)
|
|
11
|
+
altitude:
|
|
12
|
+
threshold_meters: 914.4
|
|
13
|
+
data_points:
|
|
14
|
+
0: 0.0
|
|
15
|
+
914.4: 1.41
|
|
16
|
+
1219.2: 2.15
|
|
17
|
+
1524.0: 2.90
|
|
18
|
+
1828.8: 3.76
|
|
19
|
+
2133.6: 4.75
|
|
20
|
+
2438.4: 5.90
|
|
21
|
+
|
|
22
|
+
# Heat adjustments (60-minute baseline)
|
|
23
|
+
# These values represent the penalty for a 1-hour run.
|
|
24
|
+
# For longer/shorter runs, the DurationFactor will scale these up/down.
|
|
25
|
+
heat:
|
|
26
|
+
ideal_range_celsius: [10.0, 15.0]
|
|
27
|
+
data_points:
|
|
28
|
+
15: 0.0
|
|
29
|
+
20: 2.8 # Base for 60m. For 3h (3.0x) = 8.4% (Ely: 9%)
|
|
30
|
+
25: 4.3 # Base for 60m. For 3h (3.0x) = 12.9% (Ely: 12%)
|
|
31
|
+
30: 6.5 # Base for 60m. For 3h (3.0x) = 19.5%
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
# Module for adjusting race performance based on environmental conditions
|
|
6
|
+
#
|
|
7
|
+
# Scientific basis:
|
|
8
|
+
# - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance"
|
|
9
|
+
# - Altitude: NCAA Altitude Adjustment Factors (TFRRS)
|
|
10
|
+
module EnvironmentalAdjuster
|
|
11
|
+
DATA_PATH = File.expand_path('data/environmental_factors.yml', __dir__).freeze
|
|
12
|
+
FACTORS = YAML.safe_load_file(DATA_PATH, permitted_classes: [], aliases: false).freeze
|
|
13
|
+
|
|
14
|
+
# Calculates the performance penalty percentage for given environmental conditions
|
|
15
|
+
#
|
|
16
|
+
# @param temperature [Numeric, nil] ambient temperature
|
|
17
|
+
# @param temperature_unit [Symbol, String] :c (Celsius) or :f (Fahrenheit)
|
|
18
|
+
# @param altitude [Numeric, nil] altitude in meters
|
|
19
|
+
# @param time_seconds [Numeric, nil] duration of the effort in seconds
|
|
20
|
+
# @return [Hash] hash with :total_penalty_percent and breakdown in :factors
|
|
21
|
+
def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil)
|
|
22
|
+
heat_penalty = calculate_heat_penalty(temperature, temperature_unit, time_seconds)
|
|
23
|
+
altitude_penalty = calculate_altitude_penalty(altitude)
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
total_penalty_percent: (heat_penalty + altitude_penalty).round(2),
|
|
27
|
+
factors: {
|
|
28
|
+
heat: heat_penalty,
|
|
29
|
+
altitude: altitude_penalty
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Adjusts a given time based on environmental conditions
|
|
35
|
+
#
|
|
36
|
+
# @param time_seconds [Numeric] original time in seconds
|
|
37
|
+
# @param options [Hash] environmental options
|
|
38
|
+
# @return [Hash] hash with adjusted time and penalty details
|
|
39
|
+
def adjust_time(time_seconds, **)
|
|
40
|
+
penalty = calculate_penalty(time_seconds: time_seconds, **)
|
|
41
|
+
percent = penalty[:total_penalty_percent]
|
|
42
|
+
adjusted_seconds = (time_seconds * (1 + (percent / 100.0))).round(2)
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
original_time: time_seconds,
|
|
46
|
+
adjusted_time: adjusted_seconds,
|
|
47
|
+
adjusted_time_clock: convert_to_clocktime(adjusted_seconds),
|
|
48
|
+
penalty_percent: percent,
|
|
49
|
+
factors: penalty[:factors]
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Normalizes a time achieved in non-ideal conditions to its ideal equivalent
|
|
54
|
+
#
|
|
55
|
+
# @param time_seconds [Numeric] performance time in seconds
|
|
56
|
+
# @param options [Hash] environmental options
|
|
57
|
+
# @return [Hash] hash with normalized time and penalty details
|
|
58
|
+
def normalize_time(time_seconds, **)
|
|
59
|
+
penalty = calculate_penalty(time_seconds: time_seconds, **)
|
|
60
|
+
percent = penalty[:total_penalty_percent]
|
|
61
|
+
normalized_seconds = (time_seconds / (1 + (percent / 100.0))).round(2)
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
original_time: time_seconds,
|
|
65
|
+
normalized_time: normalized_seconds,
|
|
66
|
+
normalized_time_clock: convert_to_clocktime(normalized_seconds),
|
|
67
|
+
penalty_percent: percent,
|
|
68
|
+
factors: penalty[:factors]
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def calculate_heat_penalty(temp, unit, time_seconds)
|
|
75
|
+
return 0.0 if temp.nil?
|
|
76
|
+
|
|
77
|
+
temp_c = normalize_temperature(temp, unit)
|
|
78
|
+
|
|
79
|
+
data = FACTORS.fetch('heat')
|
|
80
|
+
ideal_min, ideal_max = data.fetch('ideal_range_celsius')
|
|
81
|
+
return 0.0 if temp_c.between?(ideal_min, ideal_max)
|
|
82
|
+
|
|
83
|
+
points = data.fetch('data_points')
|
|
84
|
+
base_penalty = interpolate_environmental_factor(points, temp_c)
|
|
85
|
+
|
|
86
|
+
(base_penalty * duration_factor(time_seconds)).round(2)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def duration_factor(time_seconds)
|
|
90
|
+
return 1.0 if time_seconds.nil?
|
|
91
|
+
|
|
92
|
+
minutes = time_seconds / 60.0
|
|
93
|
+
|
|
94
|
+
# Rule based on Matthew Ely (2007) heat degradation curve.
|
|
95
|
+
# Scaled for piecewise linear interpolation to avoid jumps.
|
|
96
|
+
if minutes <= 30
|
|
97
|
+
0.5
|
|
98
|
+
elsif minutes <= 60
|
|
99
|
+
# Scale from 0.5x (30m) up to 1.0x (60m)
|
|
100
|
+
0.5 + (((minutes - 30.0) / 30.0) * 0.5)
|
|
101
|
+
elsif minutes <= 180
|
|
102
|
+
# Scale from 1.0x (60m) up to 3.0x (180m / 3h)
|
|
103
|
+
1.0 + (((minutes - 60.0) / 120.0) * 2.0)
|
|
104
|
+
else
|
|
105
|
+
# Scale from 3.0x (3h) up to 4.5x (4h)
|
|
106
|
+
capped_minutes = [minutes, 240.0].min
|
|
107
|
+
3.0 + (((capped_minutes - 180.0) / 60.0) * 1.5)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def normalize_temperature(temp, unit)
|
|
112
|
+
return temp.to_f if %i[c celsius].include?(unit.to_s.downcase.to_sym)
|
|
113
|
+
return ((temp.to_f - 32) * 5.0 / 9.0).round(2) if %i[f fahrenheit].include?(unit.to_s.downcase.to_sym)
|
|
114
|
+
|
|
115
|
+
raise ArgumentError, "Unsupported temperature unit '#{unit}'. Supported: :c, :f"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def calculate_altitude_penalty(alt)
|
|
119
|
+
return 0.0 if alt.nil?
|
|
120
|
+
|
|
121
|
+
data = FACTORS.fetch('altitude')
|
|
122
|
+
threshold = data.fetch('threshold_meters')
|
|
123
|
+
return 0.0 if alt <= threshold
|
|
124
|
+
|
|
125
|
+
points = data.fetch('data_points')
|
|
126
|
+
interpolate_environmental_factor(points, alt)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def interpolate_environmental_factor(points, value)
|
|
130
|
+
key_map, sorted_floats = environmental_key_mapping(points)
|
|
131
|
+
|
|
132
|
+
return points.fetch(key_map[sorted_floats.first]).to_f if value <= sorted_floats.first
|
|
133
|
+
return points.fetch(key_map[sorted_floats.last]).to_f if value >= sorted_floats.last
|
|
134
|
+
|
|
135
|
+
lower_val, upper_val = neighboring_environmental_points(sorted_floats, value)
|
|
136
|
+
return points.fetch(key_map[lower_val]).to_f if lower_val == upper_val
|
|
137
|
+
|
|
138
|
+
interpolate_values(points, key_map, lower_val, upper_val, value)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def environmental_key_mapping(points)
|
|
142
|
+
map = points.keys.to_h { |k| [k.to_f, k] }
|
|
143
|
+
[map, map.keys.sort]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def neighboring_environmental_points(sorted_floats, value)
|
|
147
|
+
lower = sorted_floats.select { |k| k <= value }.max
|
|
148
|
+
upper = sorted_floats.select { |k| k >= value }.min
|
|
149
|
+
[lower, upper]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def interpolate_values(points, key_map, lower_val, upper_val, value)
|
|
153
|
+
lower_factor = points.fetch(key_map[lower_val]).to_f
|
|
154
|
+
upper_factor = points.fetch(key_map[upper_val]).to_f
|
|
155
|
+
|
|
156
|
+
ratio = (value - lower_val) / (upper_val - lower_val)
|
|
157
|
+
(lower_factor + ((upper_factor - lower_factor) * ratio)).round(2)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -123,4 +123,23 @@ module RacePredictor
|
|
|
123
123
|
pace_clock: convert_to_clocktime(predicted_pace)
|
|
124
124
|
}
|
|
125
125
|
end
|
|
126
|
+
|
|
127
|
+
# Predicts race time adjusted for environmental conditions
|
|
128
|
+
#
|
|
129
|
+
# @param from_race [String, Symbol] known race distance
|
|
130
|
+
# @param from_time [String, Numeric] time achieved at known distance
|
|
131
|
+
# @param to_race [String, Symbol] target race distance to predict
|
|
132
|
+
# @param options [Hash] environmental options:
|
|
133
|
+
# - :temperature [Numeric]
|
|
134
|
+
# - :temperature_unit [Symbol, String] :c or :f
|
|
135
|
+
# - :altitude [Numeric]
|
|
136
|
+
# @return [Hash] hash with adjusted prediction and penalty details
|
|
137
|
+
#
|
|
138
|
+
# @example Predict marathon time from 5K adjusted for heat (25C)
|
|
139
|
+
# predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25)
|
|
140
|
+
# #=> { adjusted_time: 12213.4, penalty_percent: 6.0, ... }
|
|
141
|
+
def predict_time_adjusted(from_race, from_time, to_race, **)
|
|
142
|
+
predicted_seconds = predict_time(from_race, from_time, to_race)
|
|
143
|
+
adjust_time(predicted_seconds, **)
|
|
144
|
+
end
|
|
126
145
|
end
|
data/lib/calcpace/version.rb
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
# Accuracy: ±3–5 ml/kg/min vs laboratory testing. Best results with efforts
|
|
15
15
|
# between 5 and 60 minutes at race pace (i.e. near-maximal effort).
|
|
16
16
|
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).
|
|
17
21
|
VO2MAX_LABELS = [
|
|
18
22
|
{ min: 70, label: 'Elite' },
|
|
19
23
|
{ min: 60, label: 'Excellent' },
|
|
@@ -23,6 +27,9 @@ module Vo2maxEstimator
|
|
|
23
27
|
{ min: 0, label: 'Beginner' }
|
|
24
28
|
].freeze
|
|
25
29
|
|
|
30
|
+
# Represents a contextualized VO2max estimation result
|
|
31
|
+
Vo2maxResult = Struct.new(:value, :confidence, :sub_maximal, :adjusted_distance_km)
|
|
32
|
+
|
|
26
33
|
# Estimates VO2max from a race performance using Daniels & Gilbert formula
|
|
27
34
|
#
|
|
28
35
|
# @param distance_km [Numeric] race distance in kilometres (must be > 0)
|
|
@@ -48,6 +55,30 @@ module Vo2maxEstimator
|
|
|
48
55
|
(vo2 / pct_vo2max).round(1)
|
|
49
56
|
end
|
|
50
57
|
|
|
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
|
+
|
|
51
82
|
# Returns a descriptive label for a given VO2max value
|
|
52
83
|
#
|
|
53
84
|
# @param value [Numeric] VO2max in ml/kg/min
|
|
@@ -64,6 +95,47 @@ module Vo2maxEstimator
|
|
|
64
95
|
|
|
65
96
|
private
|
|
66
97
|
|
|
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
|
+
|
|
67
139
|
def vo2_at_velocity(velocity)
|
|
68
140
|
-4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2))
|
|
69
141
|
end
|
data/lib/calcpace.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative 'calcpace/calculator'
|
|
4
4
|
require_relative 'calcpace/cameron_predictor'
|
|
5
5
|
require_relative 'calcpace/age_grading'
|
|
6
|
+
require_relative 'calcpace/environmental_adjuster'
|
|
6
7
|
require_relative 'calcpace/checker'
|
|
7
8
|
require_relative 'calcpace/converter'
|
|
8
9
|
require_relative 'calcpace/converter_chain'
|
|
@@ -37,6 +38,7 @@ require_relative 'calcpace/vo2max_estimator'
|
|
|
37
38
|
# @see https://github.com/0jonjo/calcpace
|
|
38
39
|
class Calcpace
|
|
39
40
|
include AgeGrading
|
|
41
|
+
include EnvironmentalAdjuster
|
|
40
42
|
include Calculator
|
|
41
43
|
include CameronPredictor
|
|
42
44
|
include Checker
|
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.
|
|
4
|
+
version: 1.9.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- João Gilberto Saraiva
|
|
@@ -37,8 +37,10 @@ files:
|
|
|
37
37
|
- lib/calcpace/checker.rb
|
|
38
38
|
- lib/calcpace/converter.rb
|
|
39
39
|
- lib/calcpace/converter_chain.rb
|
|
40
|
+
- lib/calcpace/data/environmental_factors.yml
|
|
40
41
|
- lib/calcpace/data/wma_2023_open_standards.yml
|
|
41
42
|
- lib/calcpace/data/wma_2023_road.yml
|
|
43
|
+
- lib/calcpace/environmental_adjuster.rb
|
|
42
44
|
- lib/calcpace/errors.rb
|
|
43
45
|
- lib/calcpace/pace_calculator.rb
|
|
44
46
|
- lib/calcpace/pace_converter.rb
|