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
|
@@ -1,34 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
|
+
require_relative 'humidity'
|
|
4
5
|
|
|
5
6
|
# Module for adjusting race performance based on environmental conditions
|
|
6
7
|
#
|
|
7
8
|
# Scientific basis:
|
|
8
|
-
# - Heat:
|
|
9
|
+
# - Heat: Ely et al. (2007) "Impact of Weather on Marathon-Running Performance"
|
|
10
|
+
# (qualitative: slowing grows with WBGT, more for slower runners) and
|
|
11
|
+
# El Helou et al. (2012) "Impact of Environmental Parameters on Marathon
|
|
12
|
+
# Running Performance" (duration scaling, see HEAT_DURATION_FACTORS)
|
|
9
13
|
# - Altitude: NCAA Altitude Adjustment Factors (TFRRS)
|
|
14
|
+
# - Humidity: Australian Bureau of Meteorology simplified WBGT
|
|
15
|
+
# (WBGT = 0.567·Ta + 0.393·e + 3.94, e = vapour pressure in hPa)
|
|
10
16
|
module EnvironmentalAdjuster
|
|
11
17
|
DATA_PATH = File.expand_path('data/environmental_factors.yml', __dir__).freeze
|
|
12
18
|
FACTORS = YAML.safe_load_file(DATA_PATH, permitted_classes: [], aliases: false).freeze
|
|
13
19
|
|
|
20
|
+
# Heat duration scaling: [minutes, factor] points, joined by straight lines
|
|
21
|
+
# and flat outside the first and last point. The base heat penalty in
|
|
22
|
+
# environmental_factors.yml is for a 60-minute effort (factor 1.0).
|
|
23
|
+
# - 30 min (0.5x) and 60 min (1.0x): kept from the original model; no
|
|
24
|
+
# marathon dataset covers efforts this short.
|
|
25
|
+
# - 3 h (1.76x) and 4 h (2.81x, flat after): weighted least-squares fit to
|
|
26
|
+
# El Helou et al. (2012) Table S3 — the time penalty against 15 °C at
|
|
27
|
+
# 20 °C and 25 °C for eight finisher groups (2:41–4:54) divided by the
|
|
28
|
+
# 60-minute base (itself fitted to the same table). The 2 h value (1.38x)
|
|
29
|
+
# is the straight line 60 → 180 min: no group finishes between 1 h and
|
|
30
|
+
# 2:41. Derivation table in environmental_factors.yml.
|
|
31
|
+
HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 1.76], [240.0, 2.81]].freeze
|
|
32
|
+
|
|
33
|
+
# Relative humidity (%) the temperature-only heat curve stands for
|
|
34
|
+
# (see EnvironmentalAdjuster::Humidity)
|
|
35
|
+
REFERENCE_HUMIDITY = Humidity::REFERENCE_HUMIDITY
|
|
36
|
+
|
|
14
37
|
# Calculates the performance penalty percentage for given environmental conditions
|
|
15
38
|
#
|
|
16
39
|
# @param temperature [Numeric, nil] ambient temperature
|
|
17
40
|
# @param temperature_unit [Symbol, String] :c (Celsius) or :f (Fahrenheit)
|
|
18
41
|
# @param altitude [Numeric, nil] altitude in meters
|
|
19
42
|
# @param time_seconds [Numeric, nil] duration of the effort in seconds
|
|
20
|
-
# @
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
# @param humidity [Numeric, nil] relative humidity in % (0–100). Optional;
|
|
44
|
+
# without it (and without dew_point) the heat curve assumes
|
|
45
|
+
# REFERENCE_HUMIDITY. With it, the temperature is replaced by the effective
|
|
46
|
+
# temperature that has the same simplified WBGT at REFERENCE_HUMIDITY.
|
|
47
|
+
# @param dew_point [Numeric, nil] dew point, in temperature_unit. Alternative
|
|
48
|
+
# to humidity (pass one or the other), must not exceed the temperature
|
|
49
|
+
# @return [Hash] hash with :total_penalty_percent and breakdown in :factors;
|
|
50
|
+
# when humidity or dew_point is given, :factors also carries
|
|
51
|
+
# :effective_temperature_celsius
|
|
52
|
+
# @raise [ArgumentError] if humidity is outside 0–100, dew_point is above the
|
|
53
|
+
# temperature, both are given, or either is given without a temperature
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] #=> 12.16
|
|
57
|
+
# calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] #=> 35.94
|
|
58
|
+
# calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] #=> 11.07
|
|
59
|
+
def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil,
|
|
60
|
+
humidity: nil, dew_point: nil)
|
|
61
|
+
effective = effective_temperature(temperature, temperature_unit, humidity, dew_point)
|
|
62
|
+
heat_penalty = calculate_heat_penalty(effective, time_seconds)
|
|
23
63
|
altitude_penalty = calculate_altitude_penalty(altitude)
|
|
24
64
|
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
altitude: altitude_penalty
|
|
30
|
-
}
|
|
31
|
-
}
|
|
65
|
+
factors = { heat: heat_penalty, altitude: altitude_penalty }
|
|
66
|
+
factors[:effective_temperature_celsius] = effective.round(2) unless humidity.nil? && dew_point.nil?
|
|
67
|
+
|
|
68
|
+
{ total_penalty_percent: (heat_penalty + altitude_penalty).round(2), factors: factors }
|
|
32
69
|
end
|
|
33
70
|
|
|
34
71
|
# Adjusts a given time based on environmental conditions
|
|
@@ -71,10 +108,19 @@ module EnvironmentalAdjuster
|
|
|
71
108
|
|
|
72
109
|
private
|
|
73
110
|
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
# Air temperature in °C, moved to the temperature that has the same
|
|
112
|
+
# simplified WBGT at REFERENCE_HUMIDITY when humidity or dew point is known
|
|
113
|
+
def effective_temperature(temp, unit, humidity, dew_point)
|
|
114
|
+
Humidity.check_inputs!(temp, humidity, dew_point)
|
|
115
|
+
temp_c = temp && normalize_temperature(temp, unit)
|
|
116
|
+
return temp_c if temp_c.nil? || (humidity.nil? && dew_point.nil?)
|
|
117
|
+
|
|
118
|
+
Humidity.effective_temperature(temp_c, humidity: humidity,
|
|
119
|
+
dew_point_c: dew_point && normalize_temperature(dew_point, unit))
|
|
120
|
+
end
|
|
76
121
|
|
|
77
|
-
|
|
122
|
+
def calculate_heat_penalty(temp_c, time_seconds)
|
|
123
|
+
return 0.0 if temp_c.nil?
|
|
78
124
|
|
|
79
125
|
data = FACTORS.fetch('heat')
|
|
80
126
|
ideal_min, ideal_max = data.fetch('ideal_range_celsius')
|
|
@@ -89,23 +135,11 @@ module EnvironmentalAdjuster
|
|
|
89
135
|
def duration_factor(time_seconds)
|
|
90
136
|
return 1.0 if time_seconds.nil?
|
|
91
137
|
|
|
92
|
-
minutes = time_seconds / 60.0
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
0.5
|
|
98
|
-
elsif minutes <= 60
|
|
99
|
-
# Scale from 0.5x (30m) up to 1.0x (60m)
|
|
100
|
-
0.5 + (((minutes - 30.0) / 30.0) * 0.5)
|
|
101
|
-
elsif minutes <= 180
|
|
102
|
-
# Scale from 1.0x (60m) up to 3.0x (180m / 3h)
|
|
103
|
-
1.0 + (((minutes - 60.0) / 120.0) * 2.0)
|
|
104
|
-
else
|
|
105
|
-
# Scale from 3.0x (3h) up to 4.5x (4h)
|
|
106
|
-
capped_minutes = [minutes, 240.0].min
|
|
107
|
-
3.0 + (((capped_minutes - 180.0) / 60.0) * 1.5)
|
|
108
|
-
end
|
|
138
|
+
minutes = (time_seconds / 60.0).clamp(HEAT_DURATION_FACTORS.first.first, HEAT_DURATION_FACTORS.last.first)
|
|
139
|
+
(from_minutes, from_factor), (to_minutes, to_factor) =
|
|
140
|
+
HEAT_DURATION_FACTORS.each_cons(2).find { |_, (upper, _)| minutes <= upper }
|
|
141
|
+
|
|
142
|
+
from_factor + (((minutes - from_minutes) / (to_minutes - from_minutes)) * (to_factor - from_factor))
|
|
109
143
|
end
|
|
110
144
|
|
|
111
145
|
def normalize_temperature(temp, unit)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Module for grade-adjusted pace (GAP): the flat-ground pace that costs the
|
|
4
|
+
# same energy as a pace run on a slope
|
|
5
|
+
#
|
|
6
|
+
# Uses the energy cost of running on gradients measured by Minetti et al.
|
|
7
|
+
# (2002) on ten runners on a treadmill inclined from −45% to +45%:
|
|
8
|
+
#
|
|
9
|
+
# Cr(i) = 155.4·i⁵ − 30.4·i⁴ − 43.3·i³ + 46.3·i² + 19.5·i + 3.6 (R² = 0.999)
|
|
10
|
+
#
|
|
11
|
+
# where Cr is the metabolic cost in J·kg⁻¹·m⁻¹ and i the gradient as a
|
|
12
|
+
# fraction (rise over horizontal run, 0.05 = 5%). The adjustment factor is
|
|
13
|
+
# Cr(i) / Cr(0): running a metre of 10% climb costs about 1.66 flat metres,
|
|
14
|
+
# a metre of 10% descent about 0.60. Cost is cheapest near −20% and rises
|
|
15
|
+
# again on steeper descents, where braking takes over.
|
|
16
|
+
#
|
|
17
|
+
# The flat-equivalent pace is pace / factor: the speed that, on level ground,
|
|
18
|
+
# spends energy at the same rate. Minetti found the cost per metre independent
|
|
19
|
+
# of speed, so the factor does not depend on how fast the runner is going.
|
|
20
|
+
#
|
|
21
|
+
# Limits worth knowing:
|
|
22
|
+
# - The polynomial is a fit to treadmill measurements between −0.45 and +0.45;
|
|
23
|
+
# grades outside that range are clamped to it rather than extrapolated.
|
|
24
|
+
# - It is a metabolic model. It does not account for the muscular cost of long
|
|
25
|
+
# descents, technical terrain or the fact that a runner rarely holds the
|
|
26
|
+
# metabolic equivalent on a steep climb — field GAP models fitted to heart
|
|
27
|
+
# rate (Strava's, for instance) are gentler on climbs.
|
|
28
|
+
# - Its constant term (3.6) is the fit's value on the flat; Minetti measured
|
|
29
|
+
# 3.40 ± 0.24 J·kg⁻¹·m⁻¹ there. The factor divides by Cr(0) so it is exactly
|
|
30
|
+
# 1.0 on the flat.
|
|
31
|
+
#
|
|
32
|
+
# Reference: Minetti, A. E., Moia, C., Roi, G. S., Susta, D., & Ferretti, G.
|
|
33
|
+
# (2002). Energy cost of walking and running at extreme uphill and downhill
|
|
34
|
+
# slopes. Journal of Applied Physiology, 93(3), 1039–1046.
|
|
35
|
+
# https://doi.org/10.1152/japplphysiol.01177.2001
|
|
36
|
+
module GradeAdjustedPace
|
|
37
|
+
# Gradient range of Minetti et al.'s measurements, as fractions
|
|
38
|
+
MINETTI_GRADE_RANGE = (-0.45..0.45)
|
|
39
|
+
|
|
40
|
+
# Polynomial coefficients for Cr(i), highest power first (J·kg⁻¹·m⁻¹)
|
|
41
|
+
MINETTI_RUNNING_COEFFICIENTS = [155.4, -30.4, -43.3, 46.3, 19.5, 3.6].freeze
|
|
42
|
+
|
|
43
|
+
# Returns how many flat metres one metre at a given gradient is worth
|
|
44
|
+
#
|
|
45
|
+
# @param grade [Numeric] gradient as a fraction (0.05 = 5% uphill, −0.05 = 5%
|
|
46
|
+
# downhill); clamped to −0.45..0.45, the range Minetti et al. measured
|
|
47
|
+
# @return [Float] Cr(grade) / Cr(0) — 1.0 on the flat, above 1 uphill,
|
|
48
|
+
# below 1 on moderate descents
|
|
49
|
+
# @raise [ArgumentError] if grade is not a finite number
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# calc.grade_adjustment_factor(0) #=> 1.0
|
|
53
|
+
# calc.grade_adjustment_factor(0.1) #=> 1.6578372222222222
|
|
54
|
+
# calc.grade_adjustment_factor(-0.1) #=> 0.5976961111111111
|
|
55
|
+
def grade_adjustment_factor(grade)
|
|
56
|
+
check_grade(grade)
|
|
57
|
+
|
|
58
|
+
minetti_running_cost(grade.to_f.clamp(MINETTI_GRADE_RANGE)) / minetti_running_cost(0.0)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Converts a pace run on a slope into the flat pace of equal effort
|
|
62
|
+
#
|
|
63
|
+
# @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS or HH:MM:SS)
|
|
64
|
+
# @param grade [Numeric] gradient as a fraction (see #grade_adjustment_factor)
|
|
65
|
+
# @param unit [Symbol, String] unit the pace is expressed in — :km (default) or
|
|
66
|
+
# :mi. The factor is per distance, so the result comes back in the same unit
|
|
67
|
+
# and the unit only has to be a supported one
|
|
68
|
+
# @return [Float] flat-equivalent pace in seconds per unit
|
|
69
|
+
# @raise [ArgumentError] if grade is not a finite number
|
|
70
|
+
# @raise [Calcpace::NonPositiveInputError] if pace is not positive
|
|
71
|
+
# @raise [Calcpace::InvalidTimeFormatError] if a string pace is not a valid clock
|
|
72
|
+
# @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
|
|
73
|
+
#
|
|
74
|
+
# @example
|
|
75
|
+
# calc.grade_adjusted_pace(360, 0.1) #=> 217.1503903847952
|
|
76
|
+
# calc.grade_adjusted_pace('05:00', -0.05) #=> 393.31023895122013
|
|
77
|
+
# calc.grade_adjusted_pace(480, 0.05, unit: :mi) #=> 368.82127811700303
|
|
78
|
+
def grade_adjusted_pace(pace, grade, unit: :km)
|
|
79
|
+
pace_unit_meters(unit)
|
|
80
|
+
factor = grade_adjustment_factor(grade)
|
|
81
|
+
pace_seconds = pace_seconds_from(pace)
|
|
82
|
+
check_positive(pace_seconds, 'Pace')
|
|
83
|
+
|
|
84
|
+
pace_seconds.to_f / factor
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Grade-adjusted pace as a clock string
|
|
88
|
+
#
|
|
89
|
+
# @param pace [Numeric, String] pace in seconds per unit or time string
|
|
90
|
+
# @param grade [Numeric] gradient as a fraction
|
|
91
|
+
# @param unit [Symbol, String] unit the pace is expressed in — :km (default) or :mi
|
|
92
|
+
# @param compact [Boolean] when true, return the compact display format
|
|
93
|
+
# @return [String] flat-equivalent pace in HH:MM:SS format, or 'M:SS' / 'H:MM:SS'
|
|
94
|
+
# with compact: true
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# calc.grade_adjusted_pace_clock('06:00', 0.1) #=> '00:03:37'
|
|
98
|
+
# calc.grade_adjusted_pace_clock('06:00', 0.1, compact: true) #=> '3:37'
|
|
99
|
+
def grade_adjusted_pace_clock(pace, grade, unit: :km, compact: false)
|
|
100
|
+
convert_to_clocktime(grade_adjusted_pace(pace, grade, unit: unit), compact: compact)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def check_grade(grade)
|
|
106
|
+
return if grade.is_a?(Numeric) && grade.to_f.finite?
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Grade must be a finite number (a fraction: 0.05 = 5%), got #{grade.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def minetti_running_cost(grade)
|
|
112
|
+
MINETTI_RUNNING_COEFFICIENTS.reduce(0.0) { |sum, coefficient| (sum * grade) + coefficient }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvironmentalAdjuster
|
|
4
|
+
# Turns air temperature plus humidity into the effective temperature the heat
|
|
5
|
+
# curve is read at.
|
|
6
|
+
#
|
|
7
|
+
# The heat curve is keyed on air temperature, but heat stress depends on
|
|
8
|
+
# humidity too: sweat evaporates less in humid air. Wet-bulb globe temperature
|
|
9
|
+
# (WBGT) captures both. The Australian Bureau of Meteorology's simplified WBGT,
|
|
10
|
+
# for outdoor conditions with moderate sun and light wind, is
|
|
11
|
+
#
|
|
12
|
+
# WBGT = 0.567·Ta + 0.393·e + 3.94
|
|
13
|
+
# e = RH/100 · 6.105·exp(17.27·Ta / (237.7 + Ta)) (hPa)
|
|
14
|
+
#
|
|
15
|
+
# The effective temperature is the air temperature that, at
|
|
16
|
+
# REFERENCE_HUMIDITY, has the same WBGT as the real (temperature, humidity)
|
|
17
|
+
# pair: WBGT(T_eff, REFERENCE_HUMIDITY) = WBGT(T, RH). Solving that equation
|
|
18
|
+
# (rather than adding ΔWBGT / 0.567) keeps the reference air's own vapour
|
|
19
|
+
# pressure rising with temperature, as it does along the temperature-only
|
|
20
|
+
# curve; the shortcut would roughly double the humidity effect at 30 °C.
|
|
21
|
+
module Humidity
|
|
22
|
+
# Relative humidity (%) the temperature-only heat curve stands for. The
|
|
23
|
+
# simplified WBGT equals the air temperature at 51–56% RH between 20 °C and
|
|
24
|
+
# 35 °C, so at 50% the curve reads the same whether its input is taken as
|
|
25
|
+
# air temperature or as WBGT; 50% is also mid-range for the marathons
|
|
26
|
+
# behind the duration factor (El Helou et al. 2012: mean race-day RH
|
|
27
|
+
# 51–78%). humidity: 50 gives the same numbers as no humidity at all.
|
|
28
|
+
REFERENCE_HUMIDITY = 50.0
|
|
29
|
+
|
|
30
|
+
# Simplified WBGT coefficients (Australian Bureau of Meteorology)
|
|
31
|
+
WBGT_TEMPERATURE_COEFFICIENT = 0.567
|
|
32
|
+
WBGT_VAPOUR_PRESSURE_COEFFICIENT = 0.393
|
|
33
|
+
|
|
34
|
+
# Bisection: the bracket is ±40 °C around the air temperature (0–100% RH
|
|
35
|
+
# moves the effective temperature by far less) and 60 halvings take it
|
|
36
|
+
# below 1e-16 °C
|
|
37
|
+
BRACKET_CELSIUS = 40.0
|
|
38
|
+
BISECTION_STEPS = 60
|
|
39
|
+
|
|
40
|
+
# Lowest accepted dew point (°C). The Magnus formula has a pole at
|
|
41
|
+
# −237.7 °C, and no weather on Earth has a dew point anywhere near −100 °C.
|
|
42
|
+
MIN_DEW_POINT_CELSIUS = -100.0
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# @param temp [Numeric, nil] air temperature in any unit (nil = none given)
|
|
47
|
+
# @param humidity [Object] relative humidity input
|
|
48
|
+
# @param dew_point [Object] dew point input
|
|
49
|
+
# @raise [ArgumentError] if the combination or a value is invalid
|
|
50
|
+
def check_inputs!(temp, humidity, dew_point)
|
|
51
|
+
return if humidity.nil? && dew_point.nil?
|
|
52
|
+
raise ArgumentError, 'Pass either humidity or dew_point, not both' if humidity && dew_point
|
|
53
|
+
raise ArgumentError, 'humidity and dew_point need a finite temperature' unless finite_number?(temp)
|
|
54
|
+
|
|
55
|
+
check_values!(humidity, dew_point)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_values!(humidity, dew_point)
|
|
59
|
+
unless valid_dew_point?(dew_point)
|
|
60
|
+
raise ArgumentError, "dew_point must be a finite number (got #{dew_point.inspect})"
|
|
61
|
+
end
|
|
62
|
+
return if valid_humidity?(humidity)
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "humidity must be a relative humidity between 0 and 100 (got #{humidity.inspect})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param temp_c [Float] air temperature in °C
|
|
68
|
+
# @param humidity [Numeric, nil] relative humidity in %
|
|
69
|
+
# @param dew_point_c [Numeric, nil] dew point in °C (used when humidity is nil)
|
|
70
|
+
# @return [Float] effective temperature in °C, unrounded (round only for
|
|
71
|
+
# display, so that humidity: REFERENCE_HUMIDITY reads the curve at
|
|
72
|
+
# exactly the air temperature)
|
|
73
|
+
# @raise [ArgumentError] if the dew point is above the air temperature or
|
|
74
|
+
# below MIN_DEW_POINT_CELSIUS
|
|
75
|
+
def effective_temperature(temp_c, humidity: nil, dew_point_c: nil)
|
|
76
|
+
# The exact solution; bisection would land within an ulp of it, which a
|
|
77
|
+
# later rounding step can still tip over a boundary
|
|
78
|
+
return temp_c if humidity == REFERENCE_HUMIDITY
|
|
79
|
+
|
|
80
|
+
vapour = humidity ? humidity / 100.0 * saturation_vapour_pressure(temp_c) : dew_point_vapour(dew_point_c, temp_c)
|
|
81
|
+
temperature_at_reference_humidity(temp_c, vapour)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Saturation vapour pressure in hPa (the Magnus form the Bureau of
|
|
85
|
+
# Meteorology pairs with its simplified WBGT)
|
|
86
|
+
def saturation_vapour_pressure(temp_c)
|
|
87
|
+
6.105 * Math.exp(17.27 * temp_c / (237.7 + temp_c))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The temperature-and-humidity part of the simplified WBGT (the 3.94
|
|
91
|
+
# constant cancels out when two WBGTs are compared)
|
|
92
|
+
def wbgt_without_constant(temp_c, vapour_hpa)
|
|
93
|
+
(WBGT_TEMPERATURE_COEFFICIENT * temp_c) + (WBGT_VAPOUR_PRESSURE_COEFFICIENT * vapour_hpa)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Solves WBGT(x, REFERENCE_HUMIDITY) = WBGT(temp_c, vapour) for x. The left
|
|
97
|
+
# side rises strictly with x, so bisection converges.
|
|
98
|
+
def temperature_at_reference_humidity(temp_c, vapour_hpa)
|
|
99
|
+
target = wbgt_without_constant(temp_c, vapour_hpa)
|
|
100
|
+
low = temp_c - BRACKET_CELSIUS
|
|
101
|
+
high = temp_c + BRACKET_CELSIUS
|
|
102
|
+
BISECTION_STEPS.times do
|
|
103
|
+
mid = (low + high) / 2.0
|
|
104
|
+
reference_wbgt(mid) < target ? low = mid : high = mid
|
|
105
|
+
end
|
|
106
|
+
(low + high) / 2.0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reference_wbgt(temp_c)
|
|
110
|
+
wbgt_without_constant(temp_c, REFERENCE_HUMIDITY / 100.0 * saturation_vapour_pressure(temp_c))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def dew_point_vapour(dew_point_c, temp_c)
|
|
114
|
+
if dew_point_c < MIN_DEW_POINT_CELSIUS
|
|
115
|
+
raise ArgumentError, "dew_point (#{dew_point_c} °C) is below #{MIN_DEW_POINT_CELSIUS} °C"
|
|
116
|
+
end
|
|
117
|
+
if dew_point_c > temp_c
|
|
118
|
+
raise ArgumentError, "dew_point (#{dew_point_c} °C) cannot be above the temperature (#{temp_c} °C)"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
saturation_vapour_pressure(dew_point_c)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def valid_dew_point?(dew_point)
|
|
125
|
+
dew_point.nil? || finite_number?(dew_point)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def valid_humidity?(humidity)
|
|
129
|
+
humidity.nil? || (finite_number?(humidity) && humidity.to_f.between?(0.0, 100.0))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Real numbers only: Complex is Numeric too, and has no order to compare
|
|
133
|
+
def finite_number?(value)
|
|
134
|
+
value.is_a?(Numeric) && value.real? && value.to_f.finite?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -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
|