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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc7e42385a1c6bf01908929f49004b332943dd0971bcb2d93ac69c838bc94ad2
4
- data.tar.gz: 955bff3b89fabb29e6a4223efd5e8654bc0a76c6b59b507a31a1db8673cebce8
3
+ metadata.gz: 6d9eb6ddf00dde81f5405ab23cf97f609eaf5b11e2f49f69724cc1579e976dc3
4
+ data.tar.gz: 97400c042ef3109c0479655e07bb55e72f136b4dcc5ba044cdd9ae6311ad8dd4
5
5
  SHA512:
6
- metadata.gz: 750ec40b02ae3607bb01efd6333ffd0e6ccfa21689914a58e3bb6d8f0ce17a60c6c66f01465766786847979a83fa581130550877edcb57056a549b613e72925f
7
- data.tar.gz: b1bec9ff070a7511a604e5a6d34c2912735b27b7695f16b4afaaf9f6f0fbb9e56135068a881ab8b4de732bcba501bfe6a2dce8d07eff5b13b0f45b3805b3863c
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.6'
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.9.6'
4
+ VERSION = '1.9.7'
5
5
  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.6
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