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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +395 -11
- data/README.md +330 -46
- data/calcpace.gemspec +9 -5
- data/lib/calcpace/age_grading.rb +29 -24
- 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/errors.rb +3 -0
- 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 +8 -1
- metadata +16 -9
- data/lib/calcpace/data/wma_2023_open_standards.yml +0 -38
- data/lib/calcpace/data/wma_2023_road.yml +0 -16
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Module for race predictions built from the runner's own data rather than from
|
|
4
|
+
# a population-wide constant
|
|
5
|
+
#
|
|
6
|
+
# Two models live here:
|
|
7
|
+
#
|
|
8
|
+
# - Tanda (2011): marathon time from the volume and pace of the last weeks of
|
|
9
|
+
# training — no race result needed.
|
|
10
|
+
# - A personal Riegel exponent fitted to two of the runner's own performances,
|
|
11
|
+
# used instead of the fixed 1.06 of RacePredictor#predict_time.
|
|
12
|
+
module PersonalizedPredictor
|
|
13
|
+
# Tanda (2011) regression coefficients for marathon pace in seconds per km:
|
|
14
|
+
#
|
|
15
|
+
# Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P
|
|
16
|
+
#
|
|
17
|
+
# K = mean weekly distance (km/week), P = mean training pace (s/km), both
|
|
18
|
+
# averaged over the 8 weeks ending one week before the race.
|
|
19
|
+
#
|
|
20
|
+
# @see https://doi.org/10.4100/jhse.2011.63.05 G. Tanda, "Prediction of
|
|
21
|
+
# marathon performance time on the basis of training indices", Journal of
|
|
22
|
+
# Human Sport and Exercise 6(3):511–520, 2011
|
|
23
|
+
TANDA_INTERCEPT = 17.1
|
|
24
|
+
TANDA_VOLUME_AMPLITUDE = 140.0
|
|
25
|
+
TANDA_VOLUME_DECAY = 0.0053
|
|
26
|
+
TANDA_PACE_SLOPE = 0.55
|
|
27
|
+
|
|
28
|
+
# Ranges spanned by the paper's sample (Table 2: 22 runners, 21 of them men,
|
|
29
|
+
# 46 marathons). The equation was fitted inside them; outside, it is an
|
|
30
|
+
# extrapolation. The author names the finish-time range as the validity range.
|
|
31
|
+
TANDA_WEEKLY_DISTANCE_RANGE_KM = (40.4..110.7)
|
|
32
|
+
TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM = (253.3..330.6)
|
|
33
|
+
TANDA_MARATHON_TIME_RANGE_SECONDS = ((167 * 60.0)..(216 * 60.0))
|
|
34
|
+
|
|
35
|
+
TANDA_MARATHON_KM = 42.195
|
|
36
|
+
|
|
37
|
+
# Personal Riegel exponents outside this range almost never describe fitness:
|
|
38
|
+
# below 1.01 the longer race was run at practically the shorter one's pace,
|
|
39
|
+
# above 1.20 the runner slows down more than even an untrained endurance
|
|
40
|
+
# profile would — in both cases one of the two races was most likely not an
|
|
41
|
+
# all-out effort (or not on a comparable course or day).
|
|
42
|
+
PERSONAL_EXPONENT_RANGE = (1.01..1.20)
|
|
43
|
+
|
|
44
|
+
# Predicts marathon time from training volume and pace — Tanda (2011)
|
|
45
|
+
#
|
|
46
|
+
# Uses Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P, where Pm is marathon
|
|
47
|
+
# pace (s/km), K the mean weekly distance (km/week) and P the mean training
|
|
48
|
+
# pace (s/km) over the 8 weeks ending one week before the race. Training pace
|
|
49
|
+
# is the plain average of every run, warm-ups and recoveries included (total
|
|
50
|
+
# time ÷ total distance), not the pace of the quality sessions. The paper
|
|
51
|
+
# reports a standard error of about 4 minutes on the finish time.
|
|
52
|
+
#
|
|
53
|
+
# Inputs or a predicted time outside the paper's sample do not raise — the
|
|
54
|
+
# prediction is still returned, flagged in :out_of_range.
|
|
55
|
+
#
|
|
56
|
+
# @param weekly_distance [Numeric, String] mean weekly training distance, in
|
|
57
|
+
# unit per week — a number or a numeric string ('60')
|
|
58
|
+
# @param training_pace [Numeric, String] mean training pace per unit, in
|
|
59
|
+
# seconds or as a clock string ('05:30')
|
|
60
|
+
# @param unit [Symbol, String] :km (default) or :mi — applies to both inputs
|
|
61
|
+
# and to the returned pace
|
|
62
|
+
# @return [Hash] :time (seconds), :time_clock (HH:MM:SS), :pace (seconds per
|
|
63
|
+
# unit), :pace_clock, :within_validated_range (Boolean) and :out_of_range
|
|
64
|
+
# (Array of :weekly_distance, :training_pace and/or :marathon_time)
|
|
65
|
+
# @raise [Calcpace::NonPositiveInputError] if an input is not a positive,
|
|
66
|
+
# finite number (or numeric string, for weekly_distance)
|
|
67
|
+
# @raise [Calcpace::InvalidTimeFormatError] if training_pace is neither a
|
|
68
|
+
# number nor an HH:MM:SS / MM:SS string
|
|
69
|
+
# @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
|
|
70
|
+
#
|
|
71
|
+
# @example
|
|
72
|
+
# calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:time_clock] # => "03:19:41"
|
|
73
|
+
# calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:pace] # => 283.96
|
|
74
|
+
# calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30')[:out_of_range]
|
|
75
|
+
# # => [:weekly_distance, :marathon_time]
|
|
76
|
+
def predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km)
|
|
77
|
+
weekly = personal_number(weekly_distance)
|
|
78
|
+
check_positive(weekly, 'Weekly distance')
|
|
79
|
+
pace_seconds = personal_time_seconds(training_pace)
|
|
80
|
+
check_positive(pace_seconds, 'Training pace')
|
|
81
|
+
|
|
82
|
+
km_per_unit = normalize_distance_km(1, unit)
|
|
83
|
+
weekly_km = weekly * km_per_unit
|
|
84
|
+
pace_km = pace_seconds / km_per_unit
|
|
85
|
+
|
|
86
|
+
marathon_pace_km = tanda_marathon_pace(weekly_km, pace_km)
|
|
87
|
+
tanda_result(marathon_pace_km, km_per_unit, tanda_out_of_range(weekly_km, pace_km, marathon_pace_km))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Fits a personal Riegel exponent to two performances
|
|
91
|
+
#
|
|
92
|
+
# k = ln(t2 / t1) / ln(d2 / d1). The fixed 1.06 of RacePredictor is a
|
|
93
|
+
# population average; a runner's own k says how much they slow down as the
|
|
94
|
+
# distance grows. A value far from 1.06 — below about 1.01 or above about
|
|
95
|
+
# 1.20 — usually means one of the two races was not an all-out effort, or
|
|
96
|
+
# was run on a course or day that is not comparable.
|
|
97
|
+
#
|
|
98
|
+
# @param race1 [Numeric, String, Symbol] distance in km or race name
|
|
99
|
+
# @param time1 [String, Numeric] time at race1 (HH:MM:SS or seconds)
|
|
100
|
+
# @param race2 [Numeric, String, Symbol] distance in km or race name
|
|
101
|
+
# @param time2 [String, Numeric] time at race2 (HH:MM:SS or seconds)
|
|
102
|
+
# @return [Float] the exponent (order of the two performances does not matter)
|
|
103
|
+
# @raise [ArgumentError] if both races are the same distance or a race name is unknown
|
|
104
|
+
# @raise [Calcpace::NonPositiveInputError] if a distance or time is not positive
|
|
105
|
+
#
|
|
106
|
+
# @example
|
|
107
|
+
# calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') # => 1.0961
|
|
108
|
+
def riegel_exponent(race1, time1, race2, time2)
|
|
109
|
+
distance1, seconds1 = personal_performance(race1, time1)
|
|
110
|
+
distance2, seconds2 = personal_performance(race2, time2)
|
|
111
|
+
ensure_different_distances!(distance1, distance2)
|
|
112
|
+
|
|
113
|
+
Math.log(seconds2 / seconds1) / Math.log(distance2 / distance1)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Predicts a race time with a personal Riegel exponent
|
|
117
|
+
#
|
|
118
|
+
# Fits k to the two performances (see #riegel_exponent), then:
|
|
119
|
+
#
|
|
120
|
+
# - Target between the two races: interpolates along the Riegel curve that
|
|
121
|
+
# passes through both performances, with the raw k and no clamping — the
|
|
122
|
+
# runner's own data already brackets the answer, and it is the same
|
|
123
|
+
# whichever performance it is scaled from or in which order they are given.
|
|
124
|
+
# - Target outside the pair: extrapolates from the performance closer to it
|
|
125
|
+
# in log-distance (the shorter extrapolation), with k clamped to
|
|
126
|
+
# PERSONAL_EXPONENT_RANGE.
|
|
127
|
+
#
|
|
128
|
+
# @param race1 [Numeric, String, Symbol] distance in km or race name
|
|
129
|
+
# @param time1 [String, Numeric] time at race1 (HH:MM:SS or seconds)
|
|
130
|
+
# @param race2 [Numeric, String, Symbol] distance in km or race name
|
|
131
|
+
# @param time2 [String, Numeric] time at race2 (HH:MM:SS or seconds)
|
|
132
|
+
# @param to_race [Numeric, String, Symbol] target distance in km or race name
|
|
133
|
+
# @return [Hash] :time (seconds), :time_clock (HH:MM:SS), :exponent (the one
|
|
134
|
+
# used, after any clamping), :raw_exponent (as fitted), :clamped (Boolean,
|
|
135
|
+
# always false when the target lies between the two races)
|
|
136
|
+
# @raise [ArgumentError] if the two races are the same distance, the target
|
|
137
|
+
# is one of them, or a race name is unknown
|
|
138
|
+
# @raise [Calcpace::NonPositiveInputError] if a distance or time is not positive
|
|
139
|
+
# @raise [Calcpace::InvalidTimeFormatError] if a time is neither a number nor
|
|
140
|
+
# an HH:MM:SS / MM:SS string
|
|
141
|
+
#
|
|
142
|
+
# @example
|
|
143
|
+
# calc.predict_time_personal(5, 1200, 20, 3000, 10)[:time] # => 1897.37
|
|
144
|
+
# calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock]
|
|
145
|
+
# # => "03:38:03"
|
|
146
|
+
# calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:clamped] # => false
|
|
147
|
+
def predict_time_personal(race1, time1, race2, time2, to_race)
|
|
148
|
+
raw = riegel_exponent(race1, time1, race2, time2)
|
|
149
|
+
target = race_distance(to_race)
|
|
150
|
+
performances = [personal_performance(race1, time1), personal_performance(race2, time2)].sort
|
|
151
|
+
performances.map(&:first).each { |distance| ensure_different_distances!(distance, target) }
|
|
152
|
+
(anchor_distance, anchor_seconds), exponent = personal_prediction_basis(performances, target, raw)
|
|
153
|
+
|
|
154
|
+
time = (anchor_seconds * ((target / anchor_distance)**exponent)).round(2)
|
|
155
|
+
{ time: time, time_clock: convert_to_clocktime(time), exponent: exponent.round(4),
|
|
156
|
+
raw_exponent: raw.round(4), clamped: exponent != raw }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def tanda_marathon_pace(weekly_km, pace_km)
|
|
162
|
+
TANDA_INTERCEPT + (TANDA_VOLUME_AMPLITUDE * Math.exp(-TANDA_VOLUME_DECAY * weekly_km)) +
|
|
163
|
+
(TANDA_PACE_SLOPE * pace_km)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def tanda_out_of_range(weekly_km, pace_km, marathon_pace_km)
|
|
167
|
+
checks = {
|
|
168
|
+
weekly_distance: TANDA_WEEKLY_DISTANCE_RANGE_KM.cover?(weekly_km),
|
|
169
|
+
training_pace: TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM.cover?(pace_km),
|
|
170
|
+
marathon_time: TANDA_MARATHON_TIME_RANGE_SECONDS.cover?(marathon_pace_km * TANDA_MARATHON_KM)
|
|
171
|
+
}
|
|
172
|
+
checks.reject { |_name, inside| inside }.keys
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def tanda_result(marathon_pace_km, km_per_unit, out_of_range)
|
|
176
|
+
time = (marathon_pace_km * TANDA_MARATHON_KM).round(2)
|
|
177
|
+
pace = (marathon_pace_km * km_per_unit).round(2)
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
time: time,
|
|
181
|
+
time_clock: convert_to_clocktime(time),
|
|
182
|
+
pace: pace,
|
|
183
|
+
pace_clock: convert_to_clocktime(pace),
|
|
184
|
+
within_validated_range: out_of_range.empty?,
|
|
185
|
+
out_of_range: out_of_range
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A performance as [distance in km, time in seconds], validated
|
|
190
|
+
def personal_performance(race, time)
|
|
191
|
+
seconds = personal_time_seconds(time)
|
|
192
|
+
check_positive(seconds, 'Time')
|
|
193
|
+
[race_distance(race), seconds.to_f]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Seconds from a number, or from a strictly validated clock string — the same
|
|
197
|
+
# rule as Calculator, AgeGrading and Vo2maxEstimator
|
|
198
|
+
def personal_time_seconds(time)
|
|
199
|
+
return time if time.is_a?(Numeric)
|
|
200
|
+
|
|
201
|
+
check_time(time)
|
|
202
|
+
convert_to_seconds(time)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A number, or a numeric string read the way race distances are; nil otherwise
|
|
206
|
+
def personal_number(value)
|
|
207
|
+
value.is_a?(Numeric) ? value : Float(value, exception: false)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# [anchor performance, exponent] for a target, given performances sorted by
|
|
211
|
+
# distance. Between the two, the raw curve through both; outside, the closer
|
|
212
|
+
# performance with the clamped exponent
|
|
213
|
+
def personal_prediction_basis(performances, target, raw)
|
|
214
|
+
shorter, longer = performances
|
|
215
|
+
return [shorter, raw] if target.between?(shorter.first, longer.first)
|
|
216
|
+
|
|
217
|
+
closer = performances.min_by { |distance, _seconds| Math.log(target / distance).abs }
|
|
218
|
+
[closer, raw.clamp(PERSONAL_EXPONENT_RANGE.min, PERSONAL_EXPONENT_RANGE.max)]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -128,15 +128,20 @@ module RacePredictor
|
|
|
128
128
|
# @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
|
|
129
129
|
# @param from_time [String, Numeric] time achieved at known distance
|
|
130
130
|
# @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
|
|
131
|
-
# @param options [Hash] environmental options
|
|
131
|
+
# @param options [Hash] environmental options, forwarded to
|
|
132
|
+
# EnvironmentalAdjuster#calculate_penalty:
|
|
132
133
|
# - :temperature [Numeric]
|
|
133
134
|
# - :temperature_unit [Symbol, String] :c or :f
|
|
134
135
|
# - :altitude [Numeric]
|
|
136
|
+
# - :humidity [Numeric] relative humidity, 0–100 % (optional)
|
|
137
|
+
# - :dew_point [Numeric] dew point in temperature_unit (optional, instead of :humidity)
|
|
135
138
|
# @return [Hash] hash with adjusted prediction and penalty details
|
|
136
139
|
#
|
|
137
140
|
# @example Predict marathon time from 5K adjusted for heat (25C)
|
|
138
141
|
# predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25)
|
|
139
|
-
# #=> { adjusted_time:
|
|
142
|
+
# #=> { adjusted_time: 12483.01, penalty_percent: 8.46, ... }
|
|
143
|
+
# calc.predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25)[:penalty_percent] #=> 8.46
|
|
144
|
+
# calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 25, humidity: 80)[:penalty_percent] #=> 4.87
|
|
140
145
|
def predict_time_adjusted(from_race, from_time, to_race, **)
|
|
141
146
|
predicted_seconds = predict_time(from_race, from_time, to_race)
|
|
142
147
|
adjust_time(predicted_seconds, **)
|
data/lib/calcpace/race_splits.rb
CHANGED
|
@@ -22,7 +22,7 @@ module RaceSplits
|
|
|
22
22
|
#
|
|
23
23
|
# @example Negative splits (second half faster)
|
|
24
24
|
# race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative)
|
|
25
|
-
# #=> ["00:20:
|
|
25
|
+
# #=> ["00:20:12", "00:40:00"] (first 5k 1% slower, second 5k 1% faster)
|
|
26
26
|
def race_splits(race, target_time:, split_distance:, strategy: :even)
|
|
27
27
|
total_distance = race_distance(race)
|
|
28
28
|
target_seconds = target_time.is_a?(String) ? convert_to_seconds(target_time) : target_time
|
|
@@ -133,25 +133,27 @@ module RaceSplits
|
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
# Calculates negative splits (second half faster than first half)
|
|
136
|
-
# First half is
|
|
136
|
+
# First half is run at a pace 1% slower than average, second half 1% faster
|
|
137
|
+
# (e.g. a 3:00:00 marathon goes through halfway in 1:30:54, then 1:29:06)
|
|
137
138
|
#
|
|
138
139
|
# @param total_distance [Float] total race distance in kilometers
|
|
139
140
|
# @param target_seconds [Float] target finish time in seconds
|
|
140
141
|
# @param split_km [Float] split distance in kilometers
|
|
141
142
|
# @return [Array<String>] array of cumulative split times
|
|
142
143
|
def calculate_negative_splits(total_distance, target_seconds, split_km)
|
|
143
|
-
calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 1.
|
|
144
|
+
calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 1.01, second_factor: 0.99)
|
|
144
145
|
end
|
|
145
146
|
|
|
146
147
|
# Calculates positive splits (first half faster than second half)
|
|
147
|
-
# First half is
|
|
148
|
+
# First half is run at a pace 1% faster than average, second half 1% slower
|
|
149
|
+
# (e.g. a 3:00:00 marathon goes through halfway in 1:29:06, then 1:30:54)
|
|
148
150
|
#
|
|
149
151
|
# @param total_distance [Float] total race distance in kilometers
|
|
150
152
|
# @param target_seconds [Float] target finish time in seconds
|
|
151
153
|
# @param split_km [Float] split distance in kilometers
|
|
152
154
|
# @return [Array<String>] array of cumulative split times
|
|
153
155
|
def calculate_positive_splits(total_distance, target_seconds, split_km)
|
|
154
|
-
calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 0.
|
|
156
|
+
calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 0.99, second_factor: 1.01)
|
|
155
157
|
end
|
|
156
158
|
|
|
157
159
|
# Shared logic for variable pace split strategies
|
|
@@ -27,6 +27,19 @@ module TrackCalculator
|
|
|
27
27
|
# Mean radius of the Earth in kilometers (IAU standard)
|
|
28
28
|
EARTH_RADIUS_KM = 6371.0
|
|
29
29
|
|
|
30
|
+
# Shortest horizontal distance a grade is measured over in
|
|
31
|
+
# #track_grade_adjusted_splits. GPS elevation is noisy — a few metres between
|
|
32
|
+
# consecutive fixes even from a barometric altimeter — so a grade read
|
|
33
|
+
# between points 1 m apart can be ±300% on flat ground, and the grade factor's
|
|
34
|
+
# curvature turns that symmetric noise into a fake climb. Over 100 m, ±1–2 m
|
|
35
|
+
# of noise is ±1–2% of grade, while a hill longer than a track straight still
|
|
36
|
+
# shows up.
|
|
37
|
+
GRADE_SEGMENT_MIN_KM = 0.1
|
|
38
|
+
|
|
39
|
+
# Slack on GRADE_SEGMENT_MIN_KM so ten 10 m steps, which sum to a hair under
|
|
40
|
+
# 0.1 km in floating point, still close a 100 m grade segment (1 mm)
|
|
41
|
+
GRADE_SEGMENT_TOLERANCE_KM = 1e-6
|
|
42
|
+
|
|
30
43
|
# Computes the great-circle distance between two GPS coordinates using
|
|
31
44
|
# the Haversine formula.
|
|
32
45
|
#
|
|
@@ -156,6 +169,49 @@ module TrackCalculator
|
|
|
156
169
|
collect_splits(points, split_km, compact: compact)
|
|
157
170
|
end
|
|
158
171
|
|
|
172
|
+
# Pace splits with a grade-adjusted pace (GAP) for each split.
|
|
173
|
+
#
|
|
174
|
+
# Each split is the hash #track_splits returns — same :km, :elapsed and :pace,
|
|
175
|
+
# split boundaries computed the same way — plus :gap, the flat-ground pace of
|
|
176
|
+
# equal effort for that split (Minetti et al., 2002; see GradeAdjustedPace).
|
|
177
|
+
# #track_splits itself is unchanged.
|
|
178
|
+
#
|
|
179
|
+
# How :gap is computed:
|
|
180
|
+
# - The track is cut into grade segments of at least GRADE_SEGMENT_MIN_KM
|
|
181
|
+
# (100 m) of horizontal distance, and each gets one grade: its net
|
|
182
|
+
# elevation change over its length. A leftover shorter than that at the end
|
|
183
|
+
# of a stretch is merged into the segment before it. Grades are clamped to
|
|
184
|
+
# ±45%, the range the model was measured on.
|
|
185
|
+
# - A stretch between points without :ele (or with a NaN/infinite one) is
|
|
186
|
+
# flat (factor 1.0), and a missing :ele ends the grade segment in progress.
|
|
187
|
+
# A stretch with elevation shorter than one grade segment and with no full
|
|
188
|
+
# segment before it to join — or a whole track that short — is flat too.
|
|
189
|
+
# - Distances are the horizontal (Haversine) ones; the factor is applied to
|
|
190
|
+
# them as is, without the √(1 + grade²) slope correction (0.5% at 10%).
|
|
191
|
+
# - Each split's distance is weighted by the grade factor of the segments it
|
|
192
|
+
# covers, and :gap is the split's time over that flat-equivalent distance.
|
|
193
|
+
# A split on flat ground, or with no elevation data, has :gap equal to :pace.
|
|
194
|
+
#
|
|
195
|
+
# @param points [Array<Hash>] points with :lat, :lon and :time keys, and
|
|
196
|
+
# optionally :ele (metres), as for #track_splits
|
|
197
|
+
# @param split_km [Numeric] split interval in kilometers (default: 1.0)
|
|
198
|
+
# @param compact [Boolean] when true, :pace and :gap use the compact display format
|
|
199
|
+
# @return [Array<Hash>] split hashes with :km, :elapsed, :pace and :gap
|
|
200
|
+
# (:gap formatted like :pace)
|
|
201
|
+
# @raise [ArgumentError] if split_km is not positive
|
|
202
|
+
# @raise [ArgumentError] if any point is missing a :time key
|
|
203
|
+
#
|
|
204
|
+
# @example a steady 5% climb at 5:00/km
|
|
205
|
+
# calc.track_grade_adjusted_splits(points, 1.0)
|
|
206
|
+
# #=> [{ km: 1.0, elapsed: 300, pace: "05:00", gap: "03:51" }, ...]
|
|
207
|
+
def track_grade_adjusted_splits(points, split_km = 1.0, compact: false)
|
|
208
|
+
raise ArgumentError, 'split_km must be positive' unless split_km.is_a?(Numeric) && split_km.positive?
|
|
209
|
+
return [] if points.nil? || points.size < 2
|
|
210
|
+
|
|
211
|
+
validate_points_have_time(points)
|
|
212
|
+
collect_splits(points, split_km, compact: compact, grade_factors: segment_grade_factors(points))
|
|
213
|
+
end
|
|
214
|
+
|
|
159
215
|
private
|
|
160
216
|
|
|
161
217
|
def haversine_km(lat1, lon1, lat2, lon2)
|
|
@@ -253,41 +309,56 @@ module TrackCalculator
|
|
|
253
309
|
format('%<min>02d:%<sec>02d', min: pace_seconds / 60, sec: pace_seconds % 60)
|
|
254
310
|
end
|
|
255
311
|
|
|
256
|
-
|
|
312
|
+
# grade_factors, when given, holds one grade factor per segment (see
|
|
313
|
+
# #segment_grade_factors) and turns on the :gap field; without it the splits
|
|
314
|
+
# are exactly the ones #track_splits has always returned.
|
|
315
|
+
def collect_splits(points, split_km, compact:, grade_factors: nil)
|
|
257
316
|
state = { splits: [], start_time: point_time(points.first),
|
|
258
317
|
split_start_time: point_time(points.first),
|
|
259
|
-
accumulated_km: 0.0, split_number: 1, compact: compact
|
|
318
|
+
accumulated_km: 0.0, split_number: 1, compact: compact,
|
|
319
|
+
grade_factors: grade_factors, flat_equivalent_km: 0.0 }
|
|
260
320
|
|
|
261
|
-
|
|
321
|
+
if grade_factors
|
|
322
|
+
points.each_cons(2).with_index { |(a, b), index| process_segment(a, b, split_km, state, index) }
|
|
323
|
+
else
|
|
324
|
+
points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) }
|
|
325
|
+
end
|
|
262
326
|
append_partial_split(points.last, split_km, state)
|
|
263
327
|
state[:splits]
|
|
264
328
|
end
|
|
265
329
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
330
|
+
# index is only given, and the segment only tracked, when computing :gap —
|
|
331
|
+
# track_splits keeps its original per-segment cost
|
|
332
|
+
def process_segment(point_a, point_b, split_km, state, index = nil)
|
|
333
|
+
segment_km = segment_distance_km(point_a, point_b)
|
|
269
334
|
state[:accumulated_km] += segment_km
|
|
335
|
+
state[:segment] = { assigned_km: 0.0, factor: state[:grade_factors].fetch(index) } if index
|
|
270
336
|
|
|
271
337
|
while state[:accumulated_km] >= split_km * state[:split_number]
|
|
272
338
|
record_split(point_a, point_b, segment_km, split_km, state)
|
|
273
339
|
end
|
|
340
|
+
|
|
341
|
+
accumulate_flat_equivalent(state, segment_km)
|
|
274
342
|
end
|
|
275
343
|
|
|
276
344
|
def record_split(point_a, point_b, segment_km, split_km, state)
|
|
277
345
|
offset = (split_km * state[:split_number]) - (state[:accumulated_km] - segment_km)
|
|
346
|
+
accumulate_flat_equivalent(state, offset)
|
|
278
347
|
boundary_time = interpolate_time(point_a, point_b, segment_km, offset)
|
|
279
348
|
state[:splits] << build_split_entry(boundary_time, split_km, state)
|
|
280
349
|
state[:split_start_time] = boundary_time
|
|
281
350
|
state[:split_number] += 1
|
|
351
|
+
state[:flat_equivalent_km] = 0.0
|
|
282
352
|
end
|
|
283
353
|
|
|
284
354
|
def build_split_entry(boundary_time, split_km, state)
|
|
285
355
|
split_elapsed = (boundary_time - state[:split_start_time]).round
|
|
286
|
-
{
|
|
356
|
+
entry = {
|
|
287
357
|
km: (split_km * state[:split_number]).round(2),
|
|
288
358
|
elapsed: (boundary_time - state[:start_time]).round,
|
|
289
359
|
pace: seconds_to_pace(split_elapsed, split_km, compact: state[:compact])
|
|
290
360
|
}
|
|
361
|
+
with_gap(entry, split_elapsed, state)
|
|
291
362
|
end
|
|
292
363
|
|
|
293
364
|
def append_partial_split(last_point, split_km, state)
|
|
@@ -295,11 +366,113 @@ module TrackCalculator
|
|
|
295
366
|
return unless remaining_km > 0.001
|
|
296
367
|
|
|
297
368
|
last_time = point_time(last_point)
|
|
298
|
-
state[:
|
|
369
|
+
split_elapsed = (last_time - state[:split_start_time]).round
|
|
370
|
+
entry = {
|
|
299
371
|
km: state[:accumulated_km].round(2),
|
|
300
372
|
elapsed: (last_time - state[:start_time]).round,
|
|
301
|
-
pace: seconds_to_pace(
|
|
302
|
-
compact: state[:compact])
|
|
373
|
+
pace: seconds_to_pace(split_elapsed, remaining_km, compact: state[:compact])
|
|
303
374
|
}
|
|
375
|
+
state[:splits] << with_gap(entry, split_elapsed, state)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def segment_distance_km(point_a, point_b)
|
|
379
|
+
haversine_distance(dig_key(point_a, :lat), dig_key(point_a, :lon),
|
|
380
|
+
dig_key(point_b, :lat), dig_key(point_b, :lon))
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Adds the flat-equivalent distance of the current segment up to
|
|
384
|
+
# distance_into_segment, for the part not yet credited to an earlier split
|
|
385
|
+
def accumulate_flat_equivalent(state, distance_into_segment)
|
|
386
|
+
return unless state[:grade_factors]
|
|
387
|
+
|
|
388
|
+
segment = state[:segment]
|
|
389
|
+
|
|
390
|
+
state[:flat_equivalent_km] += (distance_into_segment - segment[:assigned_km]) * segment[:factor]
|
|
391
|
+
segment[:assigned_km] = distance_into_segment
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def with_gap(entry, split_elapsed, state)
|
|
395
|
+
return entry unless state[:grade_factors]
|
|
396
|
+
|
|
397
|
+
entry.merge(gap: seconds_to_pace(split_elapsed, state[:flat_equivalent_km], compact: state[:compact]))
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# One grade factor per segment (consecutive point pair). Segments are grouped
|
|
401
|
+
# into grade segments of at least GRADE_SEGMENT_MIN_KM, each with one grade
|
|
402
|
+
# (net elevation change over horizontal distance); see
|
|
403
|
+
# #track_grade_adjusted_splits for the rules.
|
|
404
|
+
def segment_grade_factors(points)
|
|
405
|
+
factors = Array.new(points.size - 1, 1.0)
|
|
406
|
+
window = new_grade_window
|
|
407
|
+
|
|
408
|
+
points.each_cons(2).with_index do |(a, b), index|
|
|
409
|
+
window = extend_grade_window(window, factors, a, b, index)
|
|
410
|
+
end
|
|
411
|
+
close_grade_window(window, factors, final: true)
|
|
412
|
+
factors
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def new_grade_window(previous = nil)
|
|
416
|
+
{ indexes: [], km: 0.0, start_ele: nil, end_ele: nil, previous: previous }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def extend_grade_window(window, factors, point_a, point_b, index)
|
|
420
|
+
ele_a = finite_ele(point_a)
|
|
421
|
+
ele_b = finite_ele(point_b)
|
|
422
|
+
if ele_a.nil? || ele_b.nil?
|
|
423
|
+
close_grade_window(window, factors, final: true)
|
|
424
|
+
return new_grade_window
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
add_to_grade_window(window, index, segment_distance_km(point_a, point_b), ele_a, ele_b)
|
|
428
|
+
return window unless full_grade_window?(window)
|
|
429
|
+
|
|
430
|
+
close_grade_window(window, factors, final: false)
|
|
431
|
+
new_grade_window(window.except(:previous))
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Grade segments treat a NaN or infinite elevation like a missing one
|
|
435
|
+
def finite_ele(point)
|
|
436
|
+
ele = fetch_ele(point)
|
|
437
|
+
ele if ele&.finite?
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def add_to_grade_window(window, index, segment_km, ele_a, ele_b)
|
|
441
|
+
window[:start_ele] ||= ele_a
|
|
442
|
+
window[:end_ele] = ele_b
|
|
443
|
+
window[:indexes] << index
|
|
444
|
+
window[:km] += segment_km
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# A full window gets its own grade. A short leftover — the end of the track
|
|
448
|
+
# or of a stretch with elevation — is merged into the full window before it;
|
|
449
|
+
# with no full window before it (a stretch shorter than a grade segment
|
|
450
|
+
# between missing elevations, or a whole track that short) it stays flat
|
|
451
|
+
# rather than being graded over a few noisy metres.
|
|
452
|
+
def close_grade_window(window, factors, final:)
|
|
453
|
+
return if window[:indexes].empty?
|
|
454
|
+
|
|
455
|
+
unless full_grade_window?(window)
|
|
456
|
+
return unless final && window[:previous]
|
|
457
|
+
|
|
458
|
+
window = merge_grade_windows(window[:previous], window)
|
|
459
|
+
end
|
|
460
|
+
factor = grade_adjustment_factor(window_grade(window))
|
|
461
|
+
window[:indexes].each { |index| factors[index] = factor }
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def full_grade_window?(window)
|
|
465
|
+
window[:km] >= GRADE_SEGMENT_MIN_KM - GRADE_SEGMENT_TOLERANCE_KM
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def merge_grade_windows(previous, window)
|
|
469
|
+
{ indexes: previous[:indexes] + window[:indexes], km: previous[:km] + window[:km],
|
|
470
|
+
start_ele: previous[:start_ele], end_ele: window[:end_ele] }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def window_grade(window)
|
|
474
|
+
return 0.0 unless window[:km].positive?
|
|
475
|
+
|
|
476
|
+
(window[:end_ele] - window[:start_ele]) / (window[:km] * 1000.0)
|
|
304
477
|
end
|
|
305
478
|
end
|
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
#
|
|
10
10
|
# Heart rate zones use the Karvonen method (Heart Rate Reserve):
|
|
11
11
|
# target = hr_rest + pct * (hr_max - hr_rest)
|
|
12
|
+
#
|
|
13
|
+
# Not standalone: it is a part of Calcpace and calls into its siblings —
|
|
14
|
+
# Vo2maxEstimator (estimate_vo2max, vo2_at_velocity), FitnessPredictor
|
|
15
|
+
# (predict_time_from_vo2max, for the marathon band), PaceCalculator
|
|
16
|
+
# (race_distance), Converter and Checker.
|
|
12
17
|
module TrainingZones
|
|
13
|
-
# Training intensities as fraction of VO2max (Daniels' Running Formula)
|
|
18
|
+
# Training intensities as fraction of VO2max (Daniels' Running Formula).
|
|
19
|
+
# The marathon :high (0.84) is the nominal upper bound only: the band's fast
|
|
20
|
+
# end is the predicted race pace (see PREDICTED_RACE_PACE_ZONES).
|
|
14
21
|
TRAINING_INTENSITIES = {
|
|
15
22
|
easy: { low: 0.59, high: 0.74 },
|
|
16
23
|
marathon: { low: 0.75, high: 0.84 },
|
|
@@ -19,6 +26,14 @@ module TrainingZones
|
|
|
19
26
|
repetition: { low: 1.05, high: 1.10 }
|
|
20
27
|
}.freeze
|
|
21
28
|
|
|
29
|
+
# Zones whose fast end is the VDOT-predicted race pace instead of
|
|
30
|
+
# TRAINING_INTENSITIES[zone][:high]. Daniels' M pace is the runner's
|
|
31
|
+
# predicted marathon race pace (FitnessPredictor#predict_time_from_vo2max),
|
|
32
|
+
# which is 0.800–0.849 of VO2max across VO2max 10–100 (0.805 at 30, 0.830
|
|
33
|
+
# at 70). That keeps the marathon band slower than the threshold band
|
|
34
|
+
# (from 0.83) below VO2max ~69.5.
|
|
35
|
+
PREDICTED_RACE_PACE_ZONES = %i[marathon].freeze
|
|
36
|
+
|
|
22
37
|
# A pace band for one training zone (paces per kilometre or mile).
|
|
23
38
|
# slow = lower-intensity end of the band, fast = higher-intensity end.
|
|
24
39
|
PaceBand = Struct.new(:slow_seconds, :fast_seconds, :slow_clock, :fast_clock)
|
|
@@ -46,27 +61,27 @@ module TrainingZones
|
|
|
46
61
|
# @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0)
|
|
47
62
|
# @param unit [Symbol] pace unit — :km (default) or :mi
|
|
48
63
|
# @return [Hash{Symbol => PaceBand}] keys: :easy, :marathon, :threshold,
|
|
49
|
-
# :interval, :repetition — paces per chosen unit
|
|
64
|
+
# :interval, :repetition — paces per chosen unit. The marathon band runs
|
|
65
|
+
# from 75% of VO2max to the VDOT-predicted marathon pace; the prediction
|
|
66
|
+
# covers VO2max 10–100, and outside that range the race-pace intensity of
|
|
67
|
+
# the nearest bound is used
|
|
50
68
|
# @raise [Calcpace::NonPositiveInputError] if vo2max is not positive
|
|
51
69
|
# @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
|
|
52
70
|
#
|
|
53
71
|
# @example
|
|
54
72
|
# calc.training_paces(50.0)[:threshold].fast_clock #=> "00:04:15"
|
|
55
73
|
# calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock #=> "00:06:51"
|
|
74
|
+
# calc.training_paces(50.0)[:marathon].fast_clock #=> "00:04:31"
|
|
56
75
|
def training_paces(vo2max, unit: :km)
|
|
57
76
|
check_positive(vo2max.to_f, 'VO2max')
|
|
58
77
|
meters = pace_unit_meters(unit)
|
|
59
78
|
|
|
60
|
-
TRAINING_INTENSITIES.
|
|
79
|
+
TRAINING_INTENSITIES.to_h do |zone, band|
|
|
61
80
|
slow = pace_seconds_at_pct(vo2max.to_f, band[:low], meters)
|
|
62
|
-
fast = pace_seconds_at_pct(vo2max.to_f, band
|
|
63
|
-
|
|
64
|
-
PaceBand.new(
|
|
65
|
-
|
|
66
|
-
fast_seconds: fast,
|
|
67
|
-
slow_clock: convert_to_clocktime(slow),
|
|
68
|
-
fast_clock: convert_to_clocktime(fast)
|
|
69
|
-
)
|
|
81
|
+
fast = pace_seconds_at_pct(vo2max.to_f, fast_intensity(zone, band, vo2max.to_f), meters)
|
|
82
|
+
|
|
83
|
+
[zone, PaceBand.new(slow_seconds: slow, fast_seconds: fast,
|
|
84
|
+
slow_clock: convert_to_clocktime(slow), fast_clock: convert_to_clocktime(fast))]
|
|
70
85
|
end
|
|
71
86
|
end
|
|
72
87
|
|
|
@@ -336,6 +351,22 @@ module TrainingZones
|
|
|
336
351
|
"Resting heart rate (#{hr_rest}) must be lower than maximum heart rate (#{hr_max})"
|
|
337
352
|
end
|
|
338
353
|
|
|
354
|
+
def fast_intensity(zone, band, vo2max)
|
|
355
|
+
PREDICTED_RACE_PACE_ZONES.include?(zone) ? marathon_race_intensity(vo2max) : band[:high]
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Fraction of VO2max a runner holds at the VDOT-predicted marathon pace.
|
|
359
|
+
# The prediction only covers FitnessPredictor::SUPPORTED_VO2MAX_RANGE, so
|
|
360
|
+
# beyond it the fraction of the nearest bound is used (it barely moves
|
|
361
|
+
# there: 0.800 at VO2max 10, 0.849 at 100).
|
|
362
|
+
def marathon_race_intensity(vo2max)
|
|
363
|
+
range = FitnessPredictor::SUPPORTED_VO2MAX_RANGE
|
|
364
|
+
vo2 = vo2max.clamp(range.min, range.max)
|
|
365
|
+
seconds = predict_time_from_vo2max(vo2, 'marathon')
|
|
366
|
+
|
|
367
|
+
vo2_at_velocity(race_distance('marathon') * Converter::Distance::KM_TO_METERS * 60.0 / seconds) / vo2
|
|
368
|
+
end
|
|
369
|
+
|
|
339
370
|
# Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2
|
|
340
371
|
def velocity_at_vo2(vo2)
|
|
341
372
|
a = 0.000104
|
data/lib/calcpace/version.rb
CHANGED