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.
@@ -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: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance"
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
- # @return [Hash] hash with :total_penalty_percent and breakdown in :factors
21
- def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil)
22
- heat_penalty = calculate_heat_penalty(temperature, temperature_unit, time_seconds)
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
- total_penalty_percent: (heat_penalty + altitude_penalty).round(2),
27
- factors: {
28
- heat: heat_penalty,
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
- def calculate_heat_penalty(temp, unit, time_seconds)
75
- return 0.0 if temp.nil?
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
- temp_c = normalize_temperature(temp, unit)
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
- # Rule based on Matthew Ely (2007) heat degradation curve.
95
- # Scaled for piecewise linear interpolation to avoid jumps.
96
- if minutes <= 30
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: 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