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.
@@ -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: 13140.19, penalty_percent: 14.17, ... }
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, **)
@@ -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:48", "00:40:00"] (first 5k slower, second 5k faster)
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 ~4% slower, second half is ~4% faster
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.04, second_factor: 0.96)
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 ~4% faster, second half is ~4% slower
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.96, second_factor: 1.04)
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
- def collect_splits(points, split_km, compact:)
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
- points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) }
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
- def process_segment(point_a, point_b, split_km, state)
267
- segment_km = haversine_distance(dig_key(point_a, :lat), dig_key(point_a, :lon),
268
- dig_key(point_b, :lat), dig_key(point_b, :lon))
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[:splits] << {
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((last_time - state[:split_start_time]).round, remaining_km,
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.transform_values do |band|
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[:high], meters)
63
-
64
- PaceBand.new(
65
- slow_seconds: slow,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.18.0'
4
+ VERSION = '2.0.0'
5
5
  end