calcpace 1.18.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +354 -1
- data/README.md +319 -43
- data/calcpace.gemspec +9 -5
- data/lib/calcpace/age_grading.rb +15 -22
- data/lib/calcpace/cameron_predictor.rb +73 -27
- data/lib/calcpace/checker.rb +101 -21
- data/lib/calcpace/converter.rb +19 -14
- data/lib/calcpace/data/environmental_factors.yml +88 -8
- data/lib/calcpace/data/friend_2015_vo2max_percentiles.yml +49 -0
- data/lib/calcpace/data/mldr_2025_road.yml +21 -0
- data/lib/calcpace/data/mldr_2025_road_open_standards.yml +46 -0
- data/lib/calcpace/environmental_adjuster.rb +65 -31
- data/lib/calcpace/grade_adjusted_pace.rb +114 -0
- data/lib/calcpace/humidity.rb +137 -0
- data/lib/calcpace/personalized_predictor.rb +220 -0
- data/lib/calcpace/race_predictor.rb +7 -2
- data/lib/calcpace/race_splits.rb +7 -5
- data/lib/calcpace/track_calculator.rb +183 -10
- data/lib/calcpace/training_zones.rb +42 -11
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace/vo2max_estimator.rb +29 -4
- data/lib/calcpace/vo2max_norms.rb +121 -0
- data/lib/calcpace.rb +7 -0
- metadata +16 -9
- data/lib/calcpace/data/wma_2023_open_standards.yml +0 -40
- data/lib/calcpace/data/wma_2023_road.yml +0 -16
|
@@ -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)
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
|
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
|
-
#
|
|
14
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
# #=> ~
|
|
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,
|
|
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) × [
|
|
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
|
-
# #=> '
|
|
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
|
-
# #=> ~
|
|
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:
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/calcpace/checker.rb
CHANGED
|
@@ -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')
|
|
19
|
-
# check_positive(-5, 'Time')
|
|
20
|
-
# check_positive(0, 'Speed')
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# -
|
|
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')
|
|
41
|
-
# check_time('5:30')
|
|
42
|
-
# check_time('
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
data/lib/calcpace/converter.rb
CHANGED
|
@@ -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')
|
|
85
|
-
# convert_to_seconds('05:30')
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
68
|
+
threshold_meters: 300
|
|
13
69
|
data_points:
|
|
14
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Road-running age factors, one per single year of age, from Alan Jones'
|
|
2
|
+
# 2025 road age-grading tables (approved 2025-01-10 by the USATF Masters Long
|
|
3
|
+
# Distance Running Council). Source, sheet "Age Factors" of:
|
|
4
|
+
# https://github.com/AlanLyttonJones/Age-Grade-Tables/blob/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files/MaleRoadStd2025.xlsx
|
|
5
|
+
# https://github.com/AlanLyttonJones/Age-Grade-Tables/blob/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files/FemaleRoadStd2025.xlsx
|
|
6
|
+
# Columns used: "5 km" -> 5000, "10 km" -> 10000, "H. Mar" (21.0975 km) -> 21097,
|
|
7
|
+
# "Marathon" (42.195 km) -> 42195. The tables start at age 5; ages under 18 are
|
|
8
|
+
# left out because the gem refuses them. Factor = open standard / age standard,
|
|
9
|
+
# so age-graded time = time * factor. See the open standards file for the meta.
|
|
10
|
+
|
|
11
|
+
F:
|
|
12
|
+
"5000": { 18: 0.9953, 19: 1.0000, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 0.9997, 28: 0.9990, 29: 0.9977, 30: 0.9959, 31: 0.9936, 32: 0.9908, 33: 0.9875, 34: 0.9836, 35: 0.9793, 36: 0.9744, 37: 0.9691, 38: 0.9632, 39: 0.9568, 40: 0.9499, 41: 0.9425, 42: 0.9346, 43: 0.9262, 44: 0.9172, 45: 0.9078, 46: 0.8980, 47: 0.8883, 48: 0.8786, 49: 0.8689, 50: 0.8592, 51: 0.8495, 52: 0.8398, 53: 0.8301, 54: 0.8204, 55: 0.8107, 56: 0.8009, 57: 0.7912, 58: 0.7815, 59: 0.7718, 60: 0.7621, 61: 0.7524, 62: 0.7427, 63: 0.7330, 64: 0.7233, 65: 0.7136, 66: 0.7038, 67: 0.6941, 68: 0.6844, 69: 0.6747, 70: 0.6650, 71: 0.6553, 72: 0.6456, 73: 0.6359, 74: 0.6262, 75: 0.6165, 76: 0.6067, 77: 0.5970, 78: 0.5868, 79: 0.5758, 80: 0.5640, 81: 0.5515, 82: 0.5382, 83: 0.5242, 84: 0.5094, 85: 0.4938, 86: 0.4775, 87: 0.4604, 88: 0.4426, 89: 0.4240, 90: 0.4046, 91: 0.3845, 92: 0.3636, 93: 0.3419, 94: 0.3195, 95: 0.2964, 96: 0.2725, 97: 0.2478, 98: 0.2223, 99: 0.1961, 100: 0.1692 }
|
|
13
|
+
"10000": { 18: 0.9820, 19: 0.9920, 20: 0.9980, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9991, 30: 0.9980, 31: 0.9964, 32: 0.9944, 33: 0.9920, 34: 0.9891, 35: 0.9857, 36: 0.9819, 37: 0.9777, 38: 0.9730, 39: 0.9679, 40: 0.9623, 41: 0.9563, 42: 0.9499, 43: 0.9429, 44: 0.9356, 45: 0.9278, 46: 0.9195, 47: 0.9109, 48: 0.9017, 49: 0.8921, 50: 0.8822, 51: 0.8723, 52: 0.8623, 53: 0.8524, 54: 0.8425, 55: 0.8325, 56: 0.8226, 57: 0.8126, 58: 0.8027, 59: 0.7928, 60: 0.7828, 61: 0.7729, 62: 0.7629, 63: 0.7530, 64: 0.7431, 65: 0.7331, 66: 0.7232, 67: 0.7132, 68: 0.7033, 69: 0.6934, 70: 0.6834, 71: 0.6735, 72: 0.6635, 73: 0.6536, 74: 0.6437, 75: 0.6337, 76: 0.6234, 77: 0.6123, 78: 0.6005, 79: 0.5879, 80: 0.5745, 81: 0.5604, 82: 0.5455, 83: 0.5299, 84: 0.5135, 85: 0.4963, 86: 0.4784, 87: 0.4597, 88: 0.4403, 89: 0.4201, 90: 0.3991, 91: 0.3774, 92: 0.3549, 93: 0.3317, 94: 0.3077, 95: 0.2829, 96: 0.2574, 97: 0.2311, 98: 0.2041, 99: 0.1763, 100: 0.1477 }
|
|
14
|
+
"21097": { 18: 0.9680, 19: 0.9820, 20: 0.9920, 21: 0.9980, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9991, 30: 0.9979, 31: 0.9962, 32: 0.9941, 33: 0.9915, 34: 0.9884, 35: 0.9849, 36: 0.9809, 37: 0.9764, 38: 0.9714, 39: 0.9660, 40: 0.9601, 41: 0.9537, 42: 0.9468, 43: 0.9395, 44: 0.9317, 45: 0.9234, 46: 0.9147, 47: 0.9055, 48: 0.8958, 49: 0.8856, 50: 0.8753, 51: 0.8649, 52: 0.8546, 53: 0.8442, 54: 0.8339, 55: 0.8235, 56: 0.8132, 57: 0.8028, 58: 0.7925, 59: 0.7821, 60: 0.7718, 61: 0.7614, 62: 0.7511, 63: 0.7407, 64: 0.7304, 65: 0.7200, 66: 0.7097, 67: 0.6993, 68: 0.6890, 69: 0.6786, 70: 0.6683, 71: 0.6579, 72: 0.6476, 73: 0.6372, 74: 0.6269, 75: 0.6165, 76: 0.6059, 77: 0.5945, 78: 0.5822, 79: 0.5692, 80: 0.5554, 81: 0.5407, 82: 0.5253, 83: 0.5091, 84: 0.4921, 85: 0.4742, 86: 0.4556, 87: 0.4362, 88: 0.4159, 89: 0.3949, 90: 0.3731, 91: 0.3504, 92: 0.3270, 93: 0.3028, 94: 0.2778, 95: 0.2519, 96: 0.2253, 97: 0.1979, 98: 0.1696, 99: 0.1406, 100: 0.1108 }
|
|
15
|
+
"42195": { 18: 0.9217, 19: 0.9391, 20: 0.9553, 21: 0.9689, 22: 0.9801, 23: 0.9888, 24: 0.9950, 25: 0.9988, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9992, 30: 0.9983, 31: 0.9970, 32: 0.9953, 33: 0.9932, 34: 0.9907, 35: 0.9879, 36: 0.9847, 37: 0.9811, 38: 0.9771, 39: 0.9727, 40: 0.9680, 41: 0.9629, 42: 0.9574, 43: 0.9515, 44: 0.9453, 45: 0.9386, 46: 0.9316, 47: 0.9242, 48: 0.9165, 49: 0.9083, 50: 0.8998, 51: 0.8909, 52: 0.8816, 53: 0.8720, 54: 0.8619, 55: 0.8515, 56: 0.8407, 57: 0.8297, 58: 0.8186, 59: 0.8076, 60: 0.7965, 61: 0.7854, 62: 0.7744, 63: 0.7633, 64: 0.7523, 65: 0.7412, 66: 0.7301, 67: 0.7191, 68: 0.7080, 69: 0.6970, 70: 0.6859, 71: 0.6748, 72: 0.6638, 73: 0.6527, 74: 0.6413, 75: 0.6290, 76: 0.6159, 77: 0.6021, 78: 0.5874, 79: 0.5720, 80: 0.5557, 81: 0.5386, 82: 0.5208, 83: 0.5021, 84: 0.4827, 85: 0.4624, 86: 0.4413, 87: 0.4195, 88: 0.3968, 89: 0.3734, 90: 0.3491, 91: 0.3240, 92: 0.2982, 93: 0.2715, 94: 0.2441, 95: 0.2158, 96: 0.1867, 97: 0.1569, 98: 0.1262, 99: 0.0948, 100: 0.0625 }
|
|
16
|
+
|
|
17
|
+
M:
|
|
18
|
+
"5000": { 18: 0.9995, 19: 1.0000, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 0.9999, 31: 0.9988, 32: 0.9965, 33: 0.9930, 34: 0.9883, 35: 0.9824, 36: 0.9755, 37: 0.9685, 38: 0.9615, 39: 0.9545, 40: 0.9475, 41: 0.9405, 42: 0.9335, 43: 0.9265, 44: 0.9195, 45: 0.9125, 46: 0.9055, 47: 0.8985, 48: 0.8915, 49: 0.8845, 50: 0.8775, 51: 0.8705, 52: 0.8635, 53: 0.8565, 54: 0.8495, 55: 0.8425, 56: 0.8355, 57: 0.8285, 58: 0.8215, 59: 0.8145, 60: 0.8075, 61: 0.8005, 62: 0.7935, 63: 0.7865, 64: 0.7795, 65: 0.7725, 66: 0.7655, 67: 0.7585, 68: 0.7514, 69: 0.7436, 70: 0.7353, 71: 0.7264, 72: 0.7169, 73: 0.7068, 74: 0.6960, 75: 0.6847, 76: 0.6728, 77: 0.6603, 78: 0.6472, 79: 0.6334, 80: 0.6191, 81: 0.6042, 82: 0.5887, 83: 0.5726, 84: 0.5558, 85: 0.5385, 86: 0.5206, 87: 0.5021, 88: 0.4830, 89: 0.4632, 90: 0.4429, 91: 0.4220, 92: 0.4005, 93: 0.3784, 94: 0.3556, 95: 0.3323, 96: 0.3084, 97: 0.2839, 98: 0.2588, 99: 0.2330, 100: 0.2067 }
|
|
19
|
+
"10000": { 18: 0.9818, 19: 0.9903, 20: 0.9968, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 0.9996, 32: 0.9985, 33: 0.9967, 34: 0.9942, 35: 0.9909, 36: 0.9869, 37: 0.9822, 38: 0.9767, 39: 0.9705, 40: 0.9636, 41: 0.9561, 42: 0.9486, 43: 0.9411, 44: 0.9336, 45: 0.9261, 46: 0.9186, 47: 0.9111, 48: 0.9036, 49: 0.8961, 50: 0.8886, 51: 0.8811, 52: 0.8736, 53: 0.8661, 54: 0.8586, 55: 0.8511, 56: 0.8436, 57: 0.8361, 58: 0.8286, 59: 0.8211, 60: 0.8136, 61: 0.8061, 62: 0.7986, 63: 0.7911, 64: 0.7836, 65: 0.7761, 66: 0.7686, 67: 0.7611, 68: 0.7536, 69: 0.7461, 70: 0.7386, 71: 0.7308, 72: 0.7223, 73: 0.7131, 74: 0.7033, 75: 0.6928, 76: 0.6816, 77: 0.6697, 78: 0.6572, 79: 0.6440, 80: 0.6301, 81: 0.6156, 82: 0.6004, 83: 0.5845, 84: 0.5680, 85: 0.5508, 86: 0.5329, 87: 0.5143, 88: 0.4951, 89: 0.4752, 90: 0.4546, 91: 0.4334, 92: 0.4115, 93: 0.3889, 94: 0.3657, 95: 0.3418, 96: 0.3172, 97: 0.2919, 98: 0.2660, 99: 0.2394, 100: 0.2121 }
|
|
20
|
+
"21097": { 18: 0.9850, 19: 0.9950, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 1.0000, 32: 0.9996, 33: 0.9982, 34: 0.9960, 35: 0.9928, 36: 0.9888, 37: 0.9839, 38: 0.9781, 39: 0.9714, 40: 0.9638, 41: 0.9560, 42: 0.9483, 43: 0.9405, 44: 0.9327, 45: 0.9249, 46: 0.9171, 47: 0.9094, 48: 0.9016, 49: 0.8938, 50: 0.8860, 51: 0.8782, 52: 0.8705, 53: 0.8627, 54: 0.8549, 55: 0.8471, 56: 0.8393, 57: 0.8316, 58: 0.8238, 59: 0.8160, 60: 0.8082, 61: 0.8004, 62: 0.7927, 63: 0.7849, 64: 0.7771, 65: 0.7693, 66: 0.7615, 67: 0.7538, 68: 0.7460, 69: 0.7382, 70: 0.7304, 71: 0.7223, 72: 0.7135, 73: 0.7040, 74: 0.6938, 75: 0.6830, 76: 0.6715, 77: 0.6593, 78: 0.6464, 79: 0.6328, 80: 0.6185, 81: 0.6036, 82: 0.5880, 83: 0.5717, 84: 0.5547, 85: 0.5370, 86: 0.5186, 87: 0.4996, 88: 0.4799, 89: 0.4595, 90: 0.4384, 91: 0.4167, 92: 0.3942, 93: 0.3711, 94: 0.3473, 95: 0.3228, 96: 0.2976, 97: 0.2718, 98: 0.2452, 99: 0.2180, 100: 0.1901 }
|
|
21
|
+
"42195": { 18: 0.9680, 19: 0.9820, 20: 0.9920, 21: 0.9980, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 0.9999, 37: 0.9979, 38: 0.9934, 39: 0.9865, 40: 0.9783, 41: 0.9701, 42: 0.9619, 43: 0.9537, 44: 0.9455, 45: 0.9373, 46: 0.9291, 47: 0.9209, 48: 0.9127, 49: 0.9045, 50: 0.8963, 51: 0.8881, 52: 0.8799, 53: 0.8717, 54: 0.8635, 55: 0.8553, 56: 0.8471, 57: 0.8389, 58: 0.8307, 59: 0.8225, 60: 0.8143, 61: 0.8061, 62: 0.7979, 63: 0.7897, 64: 0.7815, 65: 0.7733, 66: 0.7651, 67: 0.7569, 68: 0.7487, 69: 0.7405, 70: 0.7323, 71: 0.7241, 72: 0.7155, 73: 0.7063, 74: 0.6963, 75: 0.6857, 76: 0.6743, 77: 0.6623, 78: 0.6495, 79: 0.6361, 80: 0.6219, 81: 0.6071, 82: 0.5915, 83: 0.5753, 84: 0.5583, 85: 0.5407, 86: 0.5223, 87: 0.5033, 88: 0.4835, 89: 0.4631, 90: 0.4419, 91: 0.4201, 92: 0.3975, 93: 0.3743, 94: 0.3503, 95: 0.3257, 96: 0.3003, 97: 0.2743, 98: 0.2475, 99: 0.2201, 100: 0.1919 }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
meta:
|
|
2
|
+
source: "Alan Jones, 2025 road age-grading tables (single-age bests by Tom Bernhard), approved 2025-01-10 by the USATF Masters Long Distance Running Council"
|
|
3
|
+
url: "https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files"
|
|
4
|
+
files:
|
|
5
|
+
- "2025 Files/MaleRoadStd2025.xlsx (sheet \"Age Factors\", row \"OC sec\")"
|
|
6
|
+
- "2025 Files/FemaleRoadStd2025.xlsx (sheet \"Age Facctors\", row \"OC sec\")"
|
|
7
|
+
table_version: "MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"
|
|
8
|
+
|
|
9
|
+
# The Alan Jones road tables (also served by Howard Grubb's calculators) are
|
|
10
|
+
# numeric age factors and open standards only — they define no categories at
|
|
11
|
+
# all. The bands from Local Class (60%) upward follow the USATF Masters /
|
|
12
|
+
# National Masters News convention; the three bands under 60% are Calcpace's
|
|
13
|
+
# own extension, added to give recreational runners a meaningful label instead
|
|
14
|
+
# of a single catch-all.
|
|
15
|
+
age_grade_classifications:
|
|
16
|
+
- min: 100.0
|
|
17
|
+
label: "Approximate World Record Level"
|
|
18
|
+
- min: 90.0
|
|
19
|
+
label: "World Class"
|
|
20
|
+
- min: 80.0
|
|
21
|
+
label: "National Class"
|
|
22
|
+
- min: 70.0
|
|
23
|
+
label: "Regional Class"
|
|
24
|
+
- min: 60.0
|
|
25
|
+
label: "Local Class"
|
|
26
|
+
- min: 50.0
|
|
27
|
+
label: "Intermediate"
|
|
28
|
+
- min: 40.0
|
|
29
|
+
label: "Recreational"
|
|
30
|
+
- min: 0.0
|
|
31
|
+
label: "Active Beginner"
|
|
32
|
+
|
|
33
|
+
# Open-class (OC) road standards, in seconds: the best road time for the
|
|
34
|
+
# distance at any age. 21097 is the half marathon (21.0975 km), 42195 the
|
|
35
|
+
# marathon (42.195 km).
|
|
36
|
+
open_standards_seconds:
|
|
37
|
+
M:
|
|
38
|
+
"5000": 769.0 # 12:49
|
|
39
|
+
"10000": 1584.0 # 26:24
|
|
40
|
+
"21097": 3451.0 # 57:31
|
|
41
|
+
"42195": 7235.0 # 2:00:35
|
|
42
|
+
F:
|
|
43
|
+
"5000": 834.0 # 13:54
|
|
44
|
+
"10000": 1726.0 # 28:46
|
|
45
|
+
"21097": 3772.0 # 1:02:52
|
|
46
|
+
"42195": 7796.0 # 2:09:56
|