calcpace 1.18.0 → 2.0.0

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.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require_relative 'errors'
5
+ require_relative 'checker'
4
6
 
5
7
  # Module for age-grading race performances with a versioned table
6
8
  #
@@ -10,8 +12,14 @@ require 'yaml'
10
12
  # Current scope:
11
13
  # - Common road distances: 5K, 10K, half marathon, marathon
12
14
  # - Sex: male/female
13
- # - Age: 18+
14
- # - Data file is versioned and replaceable (`lib/calcpace/data/wma_2023_road.yml`)
15
+ # - Age: 18+, one factor per year up to 100 (older ages use the age-100 factor)
16
+ # - Data: Alan Jones' 2025 road age-grading tables, approved by the USATF
17
+ # Masters Long Distance Running Council (github.com/AlanLyttonJones/Age-Grade-Tables).
18
+ # The files are versioned and replaceable: factors in
19
+ # `lib/calcpace/data/mldr_2025_road.yml`, open standards and category labels in
20
+ # `lib/calcpace/data/mldr_2025_road_open_standards.yml`
21
+ #
22
+ # The WMA_DATA constant keeps its historical name: it now holds the road table
15
23
  #
16
24
  # Returned values include:
17
25
  # - age grade percentage
@@ -20,17 +28,29 @@ require 'yaml'
20
28
  # - performance category
21
29
  # rubocop:disable Metrics/ModuleLength
22
30
  module AgeGrading
23
- DATA_PATH = File.expand_path('data/wma_2023_road.yml', __dir__).freeze
24
- OPEN_STANDARDS_DATA_PATH = File.expand_path('data/wma_2023_open_standards.yml', __dir__).freeze
31
+ # normalize_age / normalize_sex live in Checker, shared with Vo2maxNorms
32
+ include Checker
33
+
34
+ DATA_PATH = File.expand_path('data/mldr_2025_road.yml', __dir__).freeze
35
+ OPEN_STANDARDS_DATA_PATH = File.expand_path('data/mldr_2025_road_open_standards.yml', __dir__).freeze
25
36
  WMA_DATA = YAML.safe_load_file(DATA_PATH, permitted_classes: [],
26
37
  aliases: false).freeze
27
38
  OPEN_STANDARDS_DATA = YAML.safe_load_file(OPEN_STANDARDS_DATA_PATH, permitted_classes: [],
28
39
  aliases: false).freeze
29
40
  TABLE_VERSION = OPEN_STANDARDS_DATA.fetch('meta').fetch('table_version').freeze
30
41
 
31
- AGE_GRADE_LABELS = OPEN_STANDARDS_DATA.fetch('age_grade_classifications').map do |entry|
42
+ raw_age_grade_labels = OPEN_STANDARDS_DATA.fetch('age_grade_classifications').map do |entry|
32
43
  { min: entry.fetch('min').to_f, label: entry.fetch('label') }
33
- end.freeze
44
+ end
45
+ AGE_GRADE_LABELS = raw_age_grade_labels.sort_by { |entry| -entry[:min] }.freeze
46
+
47
+ age_grade_label_mins = AGE_GRADE_LABELS.map { |entry| entry[:min] }
48
+ unless age_grade_label_mins.all?(&:finite?) &&
49
+ age_grade_label_mins == age_grade_label_mins.uniq &&
50
+ age_grade_label_mins.last == 0.0
51
+ raise Calcpace::InvalidDataError,
52
+ "age_grade_classifications must have unique finite mins ending at 0.0, got #{age_grade_label_mins.inspect}"
53
+ end
34
54
 
35
55
  DISTANCE_TO_METERS = {
36
56
  5.0 => '5000',
@@ -50,7 +70,7 @@ module AgeGrading
50
70
  # How far a distance may sit from a standard and still be graded as it. 2% is
51
71
  # the same window calcpace.app uses to decide a run "is a 5K", so the gem and
52
72
  # the site never disagree about the same run. It stays a matching tolerance,
53
- # not an interpolation: a distance outside it has no WMA factor and is refused
73
+ # not an interpolation: a distance outside it has no table factor and is refused
54
74
  STANDARD_DISTANCE_TOLERANCE_RATIO = 0.02
55
75
 
56
76
  # Floor for the window above, so a future shorter standard still matches
@@ -115,6 +135,7 @@ module AgeGrading
115
135
  #
116
136
  # @param percent [Numeric] age-grade percentage
117
137
  # @return [String] category label
138
+ # @raise [ArgumentError] when percent is not numeric, is negative, or is NaN
118
139
  def age_grade_label(percent)
119
140
  percent_value = begin
120
141
  Float(percent)
@@ -123,6 +144,7 @@ module AgeGrading
123
144
  end
124
145
 
125
146
  raise ArgumentError, 'Age-grade percent must be greater than or equal to 0' if percent_value.negative?
147
+ raise ArgumentError, 'Age-grade percent must be a number' if percent_value.nan?
126
148
 
127
149
  AGE_GRADE_LABELS.find { |entry| percent_value >= entry[:min] }[:label]
128
150
  end
@@ -168,23 +190,6 @@ module AgeGrading
168
190
  convert_to_seconds(time.to_s)
169
191
  end
170
192
 
171
- def normalize_age(age)
172
- age_value = Integer(age)
173
- rescue ArgumentError, TypeError
174
- raise ArgumentError, 'Age must be an integer greater than or equal to 18'
175
- else
176
- raise ArgumentError, 'Age must be at least 18' if age_value < 18
177
-
178
- age_value
179
- end
180
-
181
- def normalize_sex(sex)
182
- normalized = sex.to_s.strip.downcase.to_sym
183
- return normalized if %i[male female].include?(normalized)
184
-
185
- raise ArgumentError, "Sex must be 'male' or 'female'"
186
- end
187
-
188
193
  def interpolated_factor(sex, age, distance_m)
189
194
  table = factor_table(sex, distance_m)
190
195
  ages = table.keys.map(&:to_i).sort
@@ -2,25 +2,40 @@
2
2
 
3
3
  # Module for predicting race times using the Cameron formula
4
4
  #
5
- # An alternative to the Riegel formula (RacePredictor module) that uses an
6
- # exponential correction to better account for physiological differences across
7
- # distances. The correction is larger when predicting from shorter races, where
8
- # anaerobic contribution is greater, and diminishes as the known distance approaches
9
- # the target distance.
5
+ # An alternative to the Riegel formula (RacePredictor module). Dave Cameron fitted
6
+ # a velocity-ratio function to world-best times from 800 m to the marathon; unlike
7
+ # Riegel's single power law, the drop-off it predicts grows with distance, so it is
8
+ # more conservative than Riegel when predicting the marathon from shorter races.
10
9
  #
11
- # Formula: T2 = T1 × (D2/D1) × [(a + b × e^(-D1/c)) / (a + b × e^(-D2/c))]
10
+ # Formula (distances in metres, times in seconds):
11
+ # f(d) = 13.49681 − 0.000030363 × d + 835.7114 / d^0.7905
12
+ # T2 = T1 × (D2/D1) × f(D1) / f(D2)
12
13
  #
13
- # Constants (calibrated for distances in km):
14
- # a = 0.000495
15
- # b = 0.000985
16
- # c = 1.4485
14
+ # Distances are accepted in kilometres (or as race names) like every other method
15
+ # in this gem, and converted to metres before f(d) is evaluated.
17
16
  #
18
- # Reference: Dave Cameron, "A Critical Examination of Racing Predictions" (1997)
17
+ # Valid range: the model was fitted from 800 m to the marathon, and f(d) crosses
18
+ # zero near 445 km, beyond which it returns negative or absurd times. Distances
19
+ # above CAMERON_MAX_DISTANCE_KM (100 km, so the standard '100k' race stays usable)
20
+ # raise ArgumentError on either end of the prediction.
21
+ #
22
+ # References:
23
+ # - Dave Cameron, metric version of his model posted to the t-and-f mailing list,
24
+ # 20 Jun 2001: https://www.mail-archive.com/t-and-f@lists.uoregon.edu/msg11312.html
25
+ # - had2know.org Cameron calculator (same constants, distances in metres; worked
26
+ # example 3.5 mi in 51:30 → 5 mi in ~75:08):
27
+ # https://www.had2know.org/sports/race-performance-prediction-calculator-cameron.html
19
28
  module CameronPredictor
20
- # Cameron formula constants (calibrated for distances in km)
21
- CAMERON_A = 0.000495
22
- CAMERON_B = 0.000985
23
- CAMERON_C = 1.4485
29
+ # Constant term of Cameron's velocity-ratio function f(d)
30
+ CAMERON_CONSTANT = 13.49681
31
+ # Linear coefficient of f(d), per metre
32
+ CAMERON_LINEAR_COEFFICIENT = 0.000030363
33
+ # Numerator of the power term of f(d)
34
+ CAMERON_POWER_COEFFICIENT = 835.7114
35
+ # Exponent of the power term of f(d)
36
+ CAMERON_POWER_EXPONENT = 0.7905
37
+ # Longest distance (km), on either end, a Cameron prediction accepts
38
+ CAMERON_MAX_DISTANCE_KM = 100.0
24
39
 
25
40
  # Predicts race time using the Cameron formula
26
41
  #
@@ -29,26 +44,28 @@ module CameronPredictor
29
44
  # @param from_time [String, Numeric] time achieved at known distance (HH:MM:SS or seconds)
30
45
  # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
31
46
  # @return [Float] predicted time in seconds
32
- # @raise [ArgumentError] if a race name is invalid or the distances are the same
47
+ # @raise [ArgumentError] if a race name is invalid, the distances are the same,
48
+ # or either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km)
33
49
  # @raise [Calcpace::NonPositiveInputError] if a numeric distance is not positive
34
50
  #
35
51
  # @example Predict marathon time from 10K
36
52
  # predict_time_cameron('10k', '00:42:00', 'marathon')
37
- # #=> ~10,654 seconds (approximately 2:57:34)
53
+ # #=> ~11,807 seconds (approximately 3:16:46)
38
54
  #
39
55
  # @example Predict 10K time from 5K
40
56
  # predict_time_cameron('5k', '00:20:00', '10k')
41
- # #=> ~2,546 seconds (approximately 42:26)
57
+ # #=> ~2,500 seconds (approximately 41:39)
42
58
  def predict_time_cameron(from_race, from_time, to_race)
43
59
  from_distance = race_distance(from_race)
44
60
  to_distance = race_distance(to_race)
45
61
 
62
+ ensure_cameron_range!(from_distance, to_distance)
46
63
  ensure_different_distances!(from_distance, to_distance)
47
64
 
48
65
  time_seconds = from_time.is_a?(String) ? convert_to_seconds(from_time) : from_time
49
66
  check_positive(time_seconds, 'Time')
50
67
 
51
- # Cameron formula: T2 = T1 × (D2/D1) × [cameron_factor(D1) / cameron_factor(D2)]
68
+ # Cameron formula: T2 = T1 × (D2/D1) × [f(D1) / f(D2)]
52
69
  time_seconds * (to_distance / from_distance) *
53
70
  (cameron_factor(from_distance) / cameron_factor(to_distance))
54
71
  end
@@ -59,10 +76,11 @@ module CameronPredictor
59
76
  # @param from_time [String, Numeric] time achieved at known distance
60
77
  # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
61
78
  # @return [String] predicted time in HH:MM:SS format
79
+ # @raise [ArgumentError] if either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km)
62
80
  #
63
81
  # @example
64
82
  # predict_time_cameron_clock('10k', '00:42:00', 'marathon')
65
- # #=> '02:57:34'
83
+ # #=> '03:16:46'
66
84
  def predict_time_cameron_clock(from_race, from_time, to_race)
67
85
  convert_to_clocktime(predict_time_cameron(from_race, from_time, to_race))
68
86
  end
@@ -73,10 +91,11 @@ module CameronPredictor
73
91
  # @param from_time [String, Numeric] time achieved at known distance
74
92
  # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
75
93
  # @return [Float] predicted pace in seconds per kilometer
94
+ # @raise [ArgumentError] if either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km)
76
95
  #
77
96
  # @example
78
97
  # predict_pace_cameron('5k', '00:20:00', 'marathon')
79
- # #=> ~255.1 (approximately 4:15/km)
98
+ # #=> ~277.6 (approximately 4:37/km)
80
99
  def predict_pace_cameron(from_race, from_time, to_race)
81
100
  predict_time_cameron(from_race, from_time, to_race) / race_distance(to_race)
82
101
  end
@@ -87,10 +106,11 @@ module CameronPredictor
87
106
  # @param from_time [String, Numeric] time achieved at known distance
88
107
  # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
89
108
  # @return [String] predicted pace in HH:MM:SS format
109
+ # @raise [ArgumentError] if either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km)
90
110
  #
91
111
  # @example
92
112
  # predict_pace_cameron_clock('5k', '00:20:00', 'marathon')
93
- # #=> '00:04:15'
113
+ # #=> '00:04:37'
94
114
  def predict_pace_cameron_clock(from_race, from_time, to_race)
95
115
  convert_to_clocktime(predict_pace_cameron(from_race, from_time, to_race))
96
116
  end
@@ -100,8 +120,19 @@ module CameronPredictor
100
120
  # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
101
121
  # @param from_time [String, Numeric] time achieved at known distance
102
122
  # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
103
- # @param options [Hash] environmental options (temperature, altitude, etc.)
123
+ # @param options [Hash] environmental options, forwarded to
124
+ # EnvironmentalAdjuster#calculate_penalty:
125
+ # - :temperature [Numeric]
126
+ # - :temperature_unit [Symbol, String] :c or :f
127
+ # - :altitude [Numeric]
128
+ # - :humidity [Numeric] relative humidity, 0–100 % (optional)
129
+ # - :dew_point [Numeric] dew point in temperature_unit (optional, instead of :humidity)
104
130
  # @return [Hash] hash with adjusted prediction and penalty details
131
+ # @raise [ArgumentError] if either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km)
132
+ #
133
+ # @example
134
+ # calc.predict_time_cameron_adjusted('5k', '00:20:00', '10k', temperature: 25, humidity: 80)[:adjusted_time_clock]
135
+ # #=> '00:43:41'
105
136
  def predict_time_cameron_adjusted(from_race, from_time, to_race, **)
106
137
  predicted_seconds = predict_time_cameron(from_race, from_time, to_race)
107
138
  adjust_time(predicted_seconds, **)
@@ -109,11 +140,26 @@ module CameronPredictor
109
140
 
110
141
  private
111
142
 
112
- # Computes the Cameron exponential correction factor for a given distance
143
+ # Rejects distances outside the range where Cameron's model is meaningful
144
+ #
145
+ # @param distances [Array<Float>] distances in kilometers
146
+ # @raise [ArgumentError] if any distance exceeds CAMERON_MAX_DISTANCE_KM
147
+ def ensure_cameron_range!(*distances)
148
+ too_long = distances.find { |distance| distance > CAMERON_MAX_DISTANCE_KM }
149
+ return unless too_long
150
+
151
+ raise ArgumentError,
152
+ "Cameron formula is only valid up to #{CAMERON_MAX_DISTANCE_KM} km (got #{too_long} km)"
153
+ end
154
+
155
+ # Evaluates Cameron's velocity-ratio function f(d) for a given distance
113
156
  #
114
- # @param distance_km [Float] distance in kilometers
115
- # @return [Float] correction factor value
157
+ # @param distance_km [Float] distance in kilometers (converted to metres, the
158
+ # unit Cameron's constants are calibrated for)
159
+ # @return [Float] value of f(d)
116
160
  def cameron_factor(distance_km)
117
- CAMERON_A + (CAMERON_B * Math.exp(-distance_km / CAMERON_C))
161
+ meters = distance_km * 1000.0
162
+ CAMERON_CONSTANT - (CAMERON_LINEAR_COEFFICIENT * meters) +
163
+ (CAMERON_POWER_COEFFICIENT / (meters**CAMERON_POWER_EXPONENT))
118
164
  end
119
165
  end
@@ -7,46 +7,126 @@ require_relative 'errors'
7
7
  # This module provides validation methods for numeric inputs and time format strings
8
8
  # used throughout the Calcpace gem.
9
9
  module Checker
10
- # Validates that a number is positive (greater than zero)
10
+ # Validates that a number is positive (greater than zero) and finite
11
+ #
12
+ # NaN and infinity are rejected too: NaN is not positive, and an infinite
13
+ # distance or time would flow through the formulas into nonsense (a finite
14
+ # "prediction") or a FloatDomainError far from the input that caused it. So
15
+ # is an Integer too large for a Float (a clock with hundreds of hour digits
16
+ # parses to one), which would turn into Infinity inside the formulas.
11
17
  #
12
18
  # @param number [Numeric] the number to validate
13
19
  # @param name [String] the name of the parameter for error messages
14
- # @raise [Calcpace::NonPositiveInputError] if number is not positive
20
+ # @raise [Calcpace::NonPositiveInputError] if number is not positive or not finite
15
21
  # @return [void]
16
22
  #
17
23
  # @example
18
- # check_positive(10, 'Distance') #=> nil (valid)
19
- # check_positive(-5, 'Time') #=> raises NonPositiveInputError
20
- # check_positive(0, 'Speed') #=> raises NonPositiveInputError
24
+ # check_positive(10, 'Distance') #=> nil (valid)
25
+ # check_positive(-5, 'Time') #=> raises NonPositiveInputError
26
+ # check_positive(0, 'Speed') #=> raises NonPositiveInputError
27
+ # check_positive(Float::INFINITY, 'Distance') #=> raises NonPositiveInputError
21
28
  def check_positive(number, name = 'Input')
22
- return if number.is_a?(Numeric) && number.positive?
29
+ unless number.is_a?(Numeric) && number.positive?
30
+ raise Calcpace::NonPositiveInputError, "#{name} must be a positive number"
31
+ end
32
+ # An Integer is always finite, but one past Float::MAX overflows to
33
+ # Infinity the moment a formula calls to_f on it
34
+ return if number.finite? && number.to_f.finite?
23
35
 
24
- raise Calcpace::NonPositiveInputError,
25
- "#{name} must be a positive number"
36
+ raise Calcpace::NonPositiveInputError, "#{name} must be a finite positive number"
26
37
  end
27
38
 
28
- # Validates that a time string is in the correct format
39
+ # The clock grammar every time and pace string in the gem is read with. It
40
+ # covers every clock the gem writes (and a few it never writes, such as
41
+ # '-1 03:46:40' or '000000000:00'):
42
+ # - an optional leading '-' (track_splits reports a backwards split as '-0:40')
43
+ # - an optional day prefix, 'D HH:MM:SS', as convert_to_clocktime writes
44
+ # durations above 24 hours ('1 03:46:40'); the hour field after it is two
45
+ # digits below 24
46
+ # - H:MM:SS, where the hours have any number of digits ('400:00:00') and the
47
+ # minutes and seconds are two digits below 60
48
+ # - M:SS, where the minutes have any number of digits and keep counting past
49
+ # the hour ('75:00', '123:45') and the seconds are two digits below 60
50
+ # Nothing else: no '+', no blanks or surrounding whitespace, ASCII digits only,
51
+ # and only in a String whose encoding is valid and ASCII-compatible.
52
+ CLOCK_FORMAT = /
53
+ \A(?<sign>-)?
54
+ (?:(?<days>\d+)[ ](?=(?:[01]\d|2[0-3]):[0-5]\d:))?
55
+ (?:(?<hours>\d+):(?<minutes>[0-5]\d)|(?<total_minutes>\d+))
56
+ :(?<seconds>[0-5]\d)\z
57
+ /x
58
+
59
+ # Validates that a time string is a well-formed clock
29
60
  #
30
- # Accepted formats:
31
- # - HH:MM:SS (hours:minutes:seconds) - e.g., "01:30:45"
32
- # - MM:SS (minutes:seconds) - e.g., "05:30"
33
- # - H:MM:SS or M:SS (single digit hours/minutes) - e.g., "1:30:45"
61
+ # Accepts what CLOCK_FORMAT describes, i.e. every clock the gem formats:
62
+ # H:MM:SS / HH:MM:SS with any number of hour digits, M:SS / MM:SS with any
63
+ # number of minute digits, the 'D HH:MM:SS' day prefix of convert_to_clocktime
64
+ # and an optional leading '-'. Seconds must be below 60, and so must minutes
65
+ # once an hour field is present ("1:60:00" is not a clock); MM:SS keeps
66
+ # counting minutes past the hour, so "75:00" is a valid 75-minute time.
67
+ #
68
+ # A negative clock is well formed (track_splits writes one for a backwards
69
+ # split); methods that need a positive time or pace reject it with
70
+ # Calcpace::NonPositiveInputError after parsing.
34
71
  #
35
72
  # @param time_string [String] the time string to validate
36
73
  # @raise [Calcpace::InvalidTimeFormatError] if format is invalid
37
74
  # @return [void]
38
75
  #
39
76
  # @example
40
- # check_time('01:30:45') #=> nil (valid)
41
- # check_time('5:30') #=> nil (valid)
42
- # check_time('invalid') #=> raises InvalidTimeFormatError
77
+ # check_time('01:30:45') #=> nil (valid)
78
+ # check_time('5:30') #=> nil (valid)
79
+ # check_time('75:00') #=> nil (valid, 75 minutes)
80
+ # check_time('1 03:46:40') #=> nil (valid, 1 day 3:46:40)
81
+ # check_time('-0:40') #=> nil (valid, a backwards split)
82
+ # check_time('05:99') #=> raises InvalidTimeFormatError
83
+ # check_time('1:60:00') #=> raises InvalidTimeFormatError
84
+ # check_time('invalid') #=> raises InvalidTimeFormatError
43
85
  def check_time(time_string)
44
- # Check if string is valid and matches expected patterns
45
- return if time_string.is_a?(String) &&
46
- (time_string =~ /\A\d{1,2}:\d{2}:\d{2}\z/ ||
47
- time_string =~ /\A\d{1,2}:\d{2}\z/)
86
+ clock_match(time_string)
87
+ nil
88
+ end
89
+
90
+ private
91
+
92
+ # @return [MatchData] the CLOCK_FORMAT match for a valid clock
93
+ # @raise [Calcpace::InvalidTimeFormatError] otherwise
94
+ def clock_match(time_string)
95
+ match = CLOCK_FORMAT.match(time_string) if clock_encoding?(time_string)
96
+ return match if match
48
97
 
49
98
  raise Calcpace::InvalidTimeFormatError,
50
- 'It must be a valid time in the XX:XX:XX or XX:XX format'
99
+ 'It must be a valid time in the XX:XX:XX or XX:XX format ' \
100
+ '(seconds below 60, and minutes too when hours are given)'
101
+ end
102
+
103
+ # Only a String in a valid, ASCII-compatible encoding can be a clock: the
104
+ # pattern cannot match UTF-16/UTF-32, and a broken byte sequence raises
105
+ # ArgumentError inside the regexp engine instead of a clock error
106
+ def clock_encoding?(time_string)
107
+ time_string.is_a?(String) && time_string.valid_encoding? && time_string.encoding.ascii_compatible?
108
+ end
109
+
110
+ # Age in whole years, 18 or over — the rule AgeGrading and Vo2maxNorms share
111
+ #
112
+ # @raise [ArgumentError] if age is not an integer or is under 18
113
+ def normalize_age(age)
114
+ age_value = Integer(age)
115
+ rescue ArgumentError, TypeError
116
+ raise ArgumentError, 'Age must be an integer greater than or equal to 18'
117
+ else
118
+ raise ArgumentError, 'Age must be at least 18' if age_value < 18
119
+
120
+ age_value
121
+ end
122
+
123
+ # :male or :female, from any case of a String or Symbol — shared like normalize_age
124
+ #
125
+ # @raise [ArgumentError] for anything else
126
+ def normalize_sex(sex)
127
+ normalized = sex.to_s.strip.downcase.to_sym
128
+ return normalized if %i[male female].include?(normalized)
129
+
130
+ raise ArgumentError, "Sex must be 'male' or 'female'"
51
131
  end
52
132
  end
@@ -77,24 +77,29 @@ module Converter
77
77
 
78
78
  # Converts a time string to total seconds
79
79
  #
80
+ # Every method in the gem that takes a time or pace string goes through
81
+ # here, so they all read the same clocks: the ones Checker#check_time
82
+ # accepts, which cover every clock the gem writes — signed track_splits
83
+ # paces ('-0:40' is -40), minutes past the hour ('75:00'), any number of
84
+ # hours ('400:00:00') and the day prefix of convert_to_clocktime
85
+ # ('1 03:46:40'). Methods that need a positive time reject a negative one
86
+ # afterwards.
87
+ #
80
88
  # @param time [String] time string in HH:MM:SS or MM:SS format
81
- # @return [Integer] total seconds
89
+ # @return [Integer] total seconds (negative for a '-' clock)
90
+ # @raise [Calcpace::InvalidTimeFormatError] if the string is not a valid clock
82
91
  #
83
92
  # @example
84
- # convert_to_seconds('01:30:00') #=> 5400 (1 hour 30 minutes)
85
- # convert_to_seconds('05:30') #=> 330 (5 minutes 30 seconds)
93
+ # convert_to_seconds('01:30:00') #=> 5400 (1 hour 30 minutes)
94
+ # convert_to_seconds('05:30') #=> 330 (5 minutes 30 seconds)
95
+ # convert_to_seconds('1 03:46:40') #=> 100000
96
+ # convert_to_seconds('-0:40') #=> -40
97
+ # convert_to_seconds('05:99') #=> raises InvalidTimeFormatError
86
98
  def convert_to_seconds(time)
87
- parts = time.split(':').map(&:to_i)
88
- case parts.length
89
- when 2
90
- minute, seconds = parts
91
- (minute * 60) + seconds
92
- when 3
93
- hour, minute, seconds = parts
94
- (hour * 3600) + (minute * 60) + seconds
95
- else
96
- 0
97
- end
99
+ clock = clock_match(time)
100
+ minutes = clock[:total_minutes] || clock[:minutes]
101
+ total = (clock[:days].to_i * 86_400) + (clock[:hours].to_i * 3600) + (minutes.to_i * 60) + clock[:seconds].to_i
102
+ clock[:sign] ? -total : total
98
103
  end
99
104
 
100
105
  # Converts seconds to a clocktime string
@@ -3,29 +3,109 @@
3
3
  # Sources:
4
4
  # - Altitude: NCAA Altitude Adjustment Factors (TFRRS)
5
5
  # Ref: ~3.76% penalty for 1828.8m (6000ft).
6
- # - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance"
6
+ # - Heat: Ely et al. (2007) "Impact of Weather on Marathon-Running Performance",
7
+ # MSSE 39(3):487-493 — qualitative source: marathon times slow progressively
8
+ # from 5 to 25 °C WBGT, and more for slower runners (top men 1.7 / 2.5 / 3.3 /
9
+ # 4.5% off the course record in WBGT 5-10 / 10-15 / 15-20 / 20-25 °C).
7
10
  # 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.
11
+ # The final penalty is scaled by a DurationFactor (0.5x at 30 min, 1.0x at
12
+ # 60 min, 1.76x at 3 h, 2.81x at 4 h and beyond; see
13
+ # EnvironmentalAdjuster::HEAT_DURATION_FACTORS) based on total exposure time.
14
+ # Both the shape of the base curve and the 3 h / 4 h factors are fitted to
15
+ # El Helou et al. (2012), PLoS One 7(5):e37407, Table S3 (1.79 M finishers of
16
+ # Berlin, Boston, Chicago, London, New York and Paris, 2001-2010).
17
+ # Derivation (reproduced by the El Helou tests in test_environmental_adjuster.rb):
18
+ # 1. Speed loss (%) at 15, 20 and 25 °C: straight line between the table's
19
+ # points (each group's optimum -10 ... +20 °C, every 5 °C).
20
+ # 2. Time penalty against 15 °C (where the curve is zero):
21
+ # P = ((1 - loss15) / (1 - lossT) - 1) x 100.
22
+ # 3. Base shape: P25 / P20 is 2.70-2.97 in all seven groups that have both
23
+ # (table below). With P ∝ (T - 15)^p, p = log2(P25 / P20); the sex-weighted
24
+ # mean (each sex half the weight) is p = 1.497 -> 1.5. The base is
25
+ # 4.3 · ((T - 15) / 10)^1.5, anchored at the original base(25) = 4.3,
26
+ # which is the only value available for a 60-minute effort.
27
+ # 4. Ratio = P / base(T) (1.52 at 20 °C, 4.3 at 25 °C); finish time =
28
+ # 42195 m / the group's speed at its optimum.
29
+ # 5. Weighted least squares over the 15 ratios (men P1 at 25 °C is beyond
30
+ # the table), each sex half the weight (men 7, women 8), 1.0x at 60 min
31
+ # fixed, 180 and 240 min free, flat after 240: 1.761 and 2.814 -> 1.76 and
32
+ # 2.81. An extra free point at 150 min cut the weighted residual by 1%
33
+ # only; one at 210 min made the curve non-monotonic (2.97 at 210 > 2.73
34
+ # at 240). Neither was kept.
35
+ #
36
+ # | Group | Finish | loss@15 | loss@20 | loss@25 | P20 | P25 | P25/P20 | ratio @20 | @25 |
37
+ # | men P1 | 2:41 | 1.88 | 3.93 | n/a* | 2.14 | n/a* | n/a* | 1.41 | n/a* |
38
+ # | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 2.89 | 1.59 | 1.63 |
39
+ # | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 2.78 | 2.93 | 2.89 |
40
+ # | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 2.86 | 3.40 | 3.43 |
41
+ # | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 2.70 | 1.99 | 1.90 |
42
+ # | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 2.73 | 2.23 | 2.16 |
43
+ # | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 2.97 | 3.57 | 3.74 |
44
+ # | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 2.78 | 2.33 | 2.29 |
45
+ # * men P1's optimum is 3.81 °C, so 25 °C is beyond its last point (+20 °C).
46
+ # (P1 = first percentile, Q1/Q3 = quartiles; losses and P in %.)
47
+ # Caveat: the model has no sex input. With one curve, men's slower groups are
48
+ # under-read at 25 °C (median 11.9% vs 14.76% observed) and women's over-read
49
+ # (median 12.08% vs 9.27%).
50
+ # - Humidity: the points are read at air temperature with ~50% relative
51
+ # humidity (EnvironmentalAdjuster::REFERENCE_HUMIDITY). With humidity: or
52
+ # dew_point:, the temperature is first moved to the effective temperature
53
+ # with the same simplified WBGT (Australian Bureau of Meteorology).
54
+ #
55
+ # Interpolation: at or below threshold_meters the altitude penalty is 0; above it
56
+ # both tables are interpolated linearly between points and clamped to the first and
57
+ # last point.
9
58
 
10
- # Altitude adjustments (NCAA standards)
59
+ # Altitude adjustments
60
+ # - 300 m: start of the curve. Below ~300 m altitude has no measurable effect; the
61
+ # ramp from 300 m to the first NCAA point (914.4 m) replaces the old step, where
62
+ # 914 m gave 0% and 915 m gave 1.41%.
63
+ # - 914.4 m .. 2438.4 m: NCAA table (3000 ft .. 8000 ft), unchanged.
64
+ # - 3000 m, 3500 m, 4000 m: EXTRAPOLATION beyond the NCAA table, from a quadratic
65
+ # fit to the NCAA points: p = 0.3647·x² + 1.9482·x, with x = altitude_km − 0.3.
66
+ # The penalty is capped at the 4000 m value.
11
67
  altitude:
12
- threshold_meters: 914.4
68
+ threshold_meters: 300
13
69
  data_points:
14
- 0: 0.0
70
+ 300: 0.0
15
71
  914.4: 1.41
16
72
  1219.2: 2.15
17
73
  1524.0: 2.90
18
74
  1828.8: 3.76
19
75
  2133.6: 4.75
20
76
  2438.4: 5.90
77
+ 3000: 7.92 # extrapolated (quadratic fit), not NCAA
78
+ 3500: 9.97 # extrapolated (quadratic fit), not NCAA
79
+ 4000: 12.2 # extrapolated (quadratic fit), not NCAA
21
80
 
22
81
  # Heat adjustments (60-minute baseline)
23
82
  # These values represent the penalty for a 1-hour run.
24
83
  # For longer/shorter runs, the DurationFactor will scale these up/down.
84
+ # Points follow base(T) = 4.3 · ((T − 15) / 10)^1.5, every 2.5 °C so that the
85
+ # straight lines between them stay within 0.08 points of the curve:
86
+ # - exponent 1.5: El Helou et al. (2012) Table S3, see the derivation above;
87
+ # the penalty against 15 °C grows 2.70-2.97x from 20 to 25 °C in every
88
+ # group (2^1.497 = 2.82).
89
+ # - anchor base(25) = 4.3: kept from the original model; it is the only value
90
+ # available for short efforts and has no direct published source.
91
+ # - 27.5-35 °C: EXTRAPOLATION of the same law. The marathon data stop at
92
+ # ~25 °C (El Helou's hottest race: 25.2 °C).
93
+ # - Above 35 °C: CAPPED at the 35 °C value (12.16), a deliberate choice, not
94
+ # data. Everything beyond 25.2 °C is extrapolation, and the uncapped law
95
+ # (14.51 at 37.5 °C, 17.0 at 40 °C) gave 47.77% for 4 h at 40 °C. 37.5 °C and
96
+ # 40 °C therefore read like 35 °C, and anything hotter is clamped to the last
97
+ # point as before.
25
98
  heat:
26
99
  ideal_range_celsius: [10.0, 15.0]
27
100
  data_points:
28
101
  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%
102
+ 17.5: 0.54
103
+ 20: 1.52 # Base for 60m. For 3h (1.76x) = 2.68%, for 4h (2.81x) = 4.27%
104
+ 22.5: 2.79
105
+ 25: 4.3 # Base for 60m (anchor). For 3h = 7.57%, for 4h = 12.08%
106
+ 27.5: 6.01 # EXTRAPOLATION from here on
107
+ 30: 7.9 # For 3h = 13.9%, for 4h = 22.2%
108
+ 32.5: 9.95
109
+ 35: 12.16 # For 3h = 21.4%, for 4h = 34.17%; the cap starts here
110
+ 37.5: 12.16 # CAP: same as 35 °C (uncapped law: 14.51)
111
+ 40: 12.16 # CAP: same as 35 °C (uncapped law: 17.0); clamped above
@@ -0,0 +1,49 @@
1
+ # VO2max percentiles by age and sex (ml/kg/min), treadmill CPX with measured VO2max
2
+ #
3
+ # Source: Kaminsky LA, Arena R, Myers J. Reference Standards for
4
+ # Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing:
5
+ # Data From the Fitness Registry and the Importance of Exercise National
6
+ # Database (FRIEND). Mayo Clinic Proceedings 2015;90(11):1515-1523.
7
+ # doi:10.1016/j.mayocp.2015.07.026 (author manuscript: PMC4919021)
8
+ #
9
+ # Table 3, "Sex-Specific Percentiles for CRF From Treadmill Exercise Tests With
10
+ # Measured VO2max Obtained From FRIEND [...]", rows "Men from FRIEND" and
11
+ # "Women from FRIEND". 7,783 treadmill tests (4,611 on men, 3,172 on women)
12
+ # on US adults free of known cardiovascular disease (Table 3 footnote; the
13
+ # paper notes some had other conditions, e.g. diabetes or obesity), VO2max
14
+ # measured by cardiopulmonary exercise testing (not predicted).
15
+ #
16
+ # The same Table 3 also reproduces the Cooper Clinic percentiles as printed in
17
+ # ACSM's Guidelines for Exercise Testing and Prescription, 9th ed. (2014),
18
+ # pp. 88-93 — the paper's reference 13 and, in its words, "the only widely
19
+ # cited reference data in the United States". They are not used here: those
20
+ # VO2max values were predicted from Balke treadmill time rather than measured,
21
+ # and their spread is much narrower (95th percentile for men aged 20-29: 55.5
22
+ # predicted against 66.3 measured).
23
+ #
24
+ # Each row lists the VO2max at the percentiles in `percentiles`, in order.
25
+ # Age bands are keyed by their lower bound and span ten years (20 = 20-29).
26
+
27
+ meta:
28
+ table_version: "FRIEND 2015 (Kaminsky et al., Mayo Clin Proc 90:1515, Table 3)"
29
+ doi: "10.1016/j.mayocp.2015.07.026"
30
+ units: "ml/kg/min"
31
+ modality: "treadmill, measured VO2max"
32
+
33
+ percentiles: [5, 10, 25, 50, 75, 90, 95]
34
+
35
+ M:
36
+ 20: [29.0, 32.1, 40.1, 48.0, 55.2, 61.8, 66.3]
37
+ 30: [27.2, 30.2, 35.9, 42.4, 49.2, 56.5, 59.8]
38
+ 40: [24.2, 26.8, 31.9, 37.8, 45.0, 52.1, 55.6]
39
+ 50: [20.9, 22.8, 27.1, 32.6, 39.7, 45.6, 50.7]
40
+ 60: [17.4, 19.8, 23.7, 28.2, 34.5, 40.3, 43.0]
41
+ 70: [16.3, 17.1, 20.4, 24.4, 30.4, 36.6, 39.7]
42
+
43
+ F:
44
+ 20: [21.7, 23.9, 30.5, 37.6, 44.7, 51.3, 56.0]
45
+ 30: [19.0, 20.9, 25.3, 30.2, 36.1, 41.4, 45.8]
46
+ 40: [17.0, 18.8, 22.1, 26.7, 32.4, 38.4, 41.7]
47
+ 50: [16.0, 17.3, 19.9, 23.4, 27.6, 32.0, 35.9]
48
+ 60: [13.4, 14.6, 17.2, 20.0, 23.8, 27.0, 29.4]
49
+ 70: [13.1, 13.6, 15.6, 18.3, 20.8, 23.1, 24.1]