calcpace 1.9.6 → 1.9.7
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 +14 -0
- data/README.md +39 -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.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: 6d9eb6ddf00dde81f5405ab23cf97f609eaf5b11e2f49f69724cc1579e976dc3
|
|
4
|
+
data.tar.gz: 97400c042ef3109c0479655e07bb55e72f136b4dcc5ba044cdd9ae6311ad8dd4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b74a386783c041329e47dbc6308f2844afe7be54fff27049d54f9d91dc00cc696522cdbd1a035cbd401299276a7c75ab9438220248b14b9b815bca39cd52609
|
|
7
|
+
data.tar.gz: 8328528b5680a4eaca14ad9ee9dddc233069386888d1e59cd676614a5d6a7292cc777360cfeabf0cc6d8fa7d8e9f4266feb3dbed7bc0de0b2ebb8d03025bc457
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.9.7] - 2026-05-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Environmental Performance Adjustments module (`EnvironmentalAdjuster`)
|
|
14
|
+
- Adjust race results and predictions based on temperature and altitude
|
|
15
|
+
- Scientific basis: Matthew Ely et al. (2007) for heat and NCAA standards for altitude
|
|
16
|
+
- Data-driven penalty tables stored in `lib/calcpace/data/environmental_factors.yml`
|
|
17
|
+
- Support for interpolation between data points in penalty tables
|
|
18
|
+
- Transparent return values including penalty percentage and factor breakdown
|
|
19
|
+
- New prediction methods with environmental support:
|
|
20
|
+
- `predict_time_adjusted` (Riegel-based)
|
|
21
|
+
- `predict_time_cameron_adjusted` (Cameron-based)
|
|
22
|
+
- `adjust_time` and `calculate_penalty` methods for direct environmental impact analysis
|
|
23
|
+
|
|
10
24
|
## [1.9.6] - 2026-05-15
|
|
11
25
|
|
|
12
26
|
### 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.7'
|
|
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:
|
|
@@ -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
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.7
|
|
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
|