From 6b6cd8ccdbd6f5eae27cdba0d05784c3ae3f168c Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:20:02 -0300 Subject: [PATCH 01/34] fix: realistic Cameron, altitude, splits and heat models - Cameron: replace the made-up exponential constants with Dave Cameron's velocity-ratio model, f(d) = 13.49681 - 0.000030363*d + 835.7114/d^0.7905 (d in metres). The old constants were far more optimistic than Riegel for the marathon; the real model is more conservative. - Altitude: threshold 914.4 m -> 300 m with a linear ramp to the first NCAA point (no more 0% -> 1.41% jump at 915 m) and quadratic-fit extrapolation to 4000 m instead of a 5.90% cap from 2438 m. - Splits: negative/positive strategies use +-1% pace per half instead of +-4%. - Heat: extrapolate 35 C (8.7%) and 40 C (10.9%) from the 25->30 C slope. --- README.md | 26 +++++-- lib/calcpace/cameron_predictor.rb | 61 ++++++++++------- lib/calcpace/data/environmental_factors.yml | 22 +++++- lib/calcpace/race_splits.rb | 12 ++-- test/calcpace/test_cameron_predictor.rb | 70 +++++++++++++++---- test/calcpace/test_environmental_adjuster.rb | 72 ++++++++++++++++++++ test/calcpace/test_race_splits.rb | 19 ++++++ 7 files changed, 229 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 8afc603..b0761ff 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,13 @@ calc.checked_distance('01:21:32', '00:06:27') # => 12.64 Adjust race performance based on heat and altitude. Calculations are based on scientific models (Matthew Ely 2007 for heat, NCAA standards for altitude). +- **Altitude**: no penalty up to 300 m, then a linear ramp to the first NCAA point + (914.4 m → 1.41%), the NCAA table up to 2438.4 m (5.90%), and an extrapolated + curve beyond it (3000 m → 7.92%, 3500 m → 9.97%, 4000 m → 12.2%, capped there). + São Paulo (760 m) gets ~1.06%. +- **Heat**: 60-minute baseline from 15 °C (0%) to 30 °C (6.5%), extrapolated to + 35 °C (8.7%) and 40 °C (10.9%, capped there), then scaled by effort duration. + ```ruby # Calculate penalty for 25°C and 2000m altitude (Defaults to 60-min effort) penalty = calc.calculate_penalty(temperature: 25, altitude: 2000) @@ -68,7 +75,7 @@ calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 28) # Predicted adjusted times (Cameron formula) calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 80, temperature_unit: :f) -# => { adjusted_time: 11585.88, adjusted_time_clock: "03:13:05", penalty_percent: 14.18, ... } +# => { adjusted_time: 13045.91, adjusted_time_clock: "03:37:25", penalty_percent: 16.02, ... } ``` --- @@ -139,8 +146,10 @@ calc.race_splits('half_marathon', target_time: '01:30:00', split_distance: '5k') # => ["00:21:20", "00:42:40", "01:03:59", "01:25:19", "01:30:00"] # Strategies: :even (default), :negative (second half faster), :positive (first half faster) +# :negative runs the first half 1% slower than average pace and the second half 1% faster; +# :positive is the mirror image. A 3:00:00 marathon splits 1:30:54 + 1:29:06 (:negative). calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative) -# => ["00:20:48", "00:40:00"] +# => ["00:20:12", "00:40:00"] # The race may be a plain distance too; the last split is always the finish calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k') @@ -160,11 +169,16 @@ calc.equivalent_performance('10k', '00:42:00', '5k') # => { time: 1208.67, time_clock: "00:20:08", pace: 241.73, pace_clock: "00:04:01" } ``` -**Cameron formula** (exponential correction — tends to be more conservative from short distances): +**Cameron formula** (Dave Cameron's velocity-ratio model, fitted to world bests from +800 m to the marathon — more conservative than Riegel when predicting the marathon +from shorter races): + +`T2 = T1 × (D2/D1) × f(D1)/f(D2)`, with `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905` +and `d` in metres (distances are still passed in km or as race names). ```ruby -calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "02:57:34" -calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:12" +calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "03:16:46" +calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:39" ``` **Any distance, on either end.** Both formulas are arithmetic on two distances, @@ -173,7 +187,7 @@ so neither end has to be a standard race: ```ruby # From a 7.79 km club race in 26:59 calc.predict_time_clock(7.79, '00:26:59', 'half_marathon') # => "01:17:34" -calc.predict_time_cameron_clock(7.79, '00:26:59', 'half_marathon') # => "01:13:44" +calc.predict_time_cameron_clock(7.79, '00:26:59', 'half_marathon') # => "01:17:26" # To an unnamed distance, and between two of them calc.predict_time_clock('10k', '00:42:00', 15) # => "01:04:33" diff --git a/lib/calcpace/cameron_predictor.rb b/lib/calcpace/cameron_predictor.rb index 78822bf..f60d21e 100644 --- a/lib/calcpace/cameron_predictor.rb +++ b/lib/calcpace/cameron_predictor.rb @@ -2,25 +2,33 @@ # Module for predicting race times using the Cameron formula # -# An alternative to the Riegel formula (RacePredictor module) that uses an -# exponential correction to better account for physiological differences across -# distances. The correction is larger when predicting from shorter races, where -# anaerobic contribution is greater, and diminishes as the known distance approaches -# the target distance. +# An alternative to the Riegel formula (RacePredictor module). Dave Cameron fitted +# a velocity-ratio function to world-best times from 800 m to the marathon; unlike +# Riegel's single power law, the drop-off it predicts grows with distance, so it is +# more conservative than Riegel when predicting the marathon from shorter races. # -# Formula: T2 = T1 × (D2/D1) × [(a + b × e^(-D1/c)) / (a + b × e^(-D2/c))] +# Formula (distances in metres, times in seconds): +# f(d) = 13.49681 − 0.000030363 × d + 835.7114 / d^0.7905 +# T2 = T1 × (D2/D1) × f(D1) / f(D2) # -# Constants (calibrated for distances in km): -# a = 0.000495 -# b = 0.000985 -# c = 1.4485 +# Distances are accepted in kilometres (or as race names) like every other method +# in this gem, and converted to metres before f(d) is evaluated. # -# Reference: Dave Cameron, "A Critical Examination of Racing Predictions" (1997) +# References: +# - Dave Cameron, metric version of his model posted to the t-and-f mailing list, +# 20 Jun 2001: https://www.mail-archive.com/t-and-f@lists.uoregon.edu/msg11312.html +# - had2know.org Cameron calculator (same constants, distances in metres; worked +# example 3.5 mi in 51:30 → 5 mi in ~75:08): +# https://www.had2know.org/sports/race-performance-prediction-calculator-cameron.html module CameronPredictor - # Cameron formula constants (calibrated for distances in km) - CAMERON_A = 0.000495 - CAMERON_B = 0.000985 - CAMERON_C = 1.4485 + # Constant term of Cameron's velocity-ratio function f(d) + CAMERON_CONSTANT = 13.49681 + # Linear coefficient of f(d), per metre + CAMERON_LINEAR_COEFFICIENT = 0.000030363 + # Numerator of the power term of f(d) + CAMERON_POWER_COEFFICIENT = 835.7114 + # Exponent of the power term of f(d) + CAMERON_POWER_EXPONENT = 0.7905 # Predicts race time using the Cameron formula # @@ -34,11 +42,11 @@ module CameronPredictor # # @example Predict marathon time from 10K # predict_time_cameron('10k', '00:42:00', 'marathon') - # #=> ~10,654 seconds (approximately 2:57:34) + # #=> ~11,807 seconds (approximately 3:16:46) # # @example Predict 10K time from 5K # predict_time_cameron('5k', '00:20:00', '10k') - # #=> ~2,546 seconds (approximately 42:26) + # #=> ~2,500 seconds (approximately 41:39) def predict_time_cameron(from_race, from_time, to_race) from_distance = race_distance(from_race) to_distance = race_distance(to_race) @@ -48,7 +56,7 @@ def predict_time_cameron(from_race, from_time, to_race) time_seconds = from_time.is_a?(String) ? convert_to_seconds(from_time) : from_time check_positive(time_seconds, 'Time') - # Cameron formula: T2 = T1 × (D2/D1) × [cameron_factor(D1) / cameron_factor(D2)] + # Cameron formula: T2 = T1 × (D2/D1) × [f(D1) / f(D2)] time_seconds * (to_distance / from_distance) * (cameron_factor(from_distance) / cameron_factor(to_distance)) end @@ -62,7 +70,7 @@ def predict_time_cameron(from_race, from_time, to_race) # # @example # predict_time_cameron_clock('10k', '00:42:00', 'marathon') - # #=> '02:57:34' + # #=> '03:16:46' def predict_time_cameron_clock(from_race, from_time, to_race) convert_to_clocktime(predict_time_cameron(from_race, from_time, to_race)) end @@ -76,7 +84,7 @@ def predict_time_cameron_clock(from_race, from_time, to_race) # # @example # predict_pace_cameron('5k', '00:20:00', 'marathon') - # #=> ~255.1 (approximately 4:15/km) + # #=> ~277.6 (approximately 4:37/km) def predict_pace_cameron(from_race, from_time, to_race) predict_time_cameron(from_race, from_time, to_race) / race_distance(to_race) end @@ -90,7 +98,7 @@ def predict_pace_cameron(from_race, from_time, to_race) # # @example # predict_pace_cameron_clock('5k', '00:20:00', 'marathon') - # #=> '00:04:15' + # #=> '00:04:37' def predict_pace_cameron_clock(from_race, from_time, to_race) convert_to_clocktime(predict_pace_cameron(from_race, from_time, to_race)) end @@ -109,11 +117,14 @@ def predict_time_cameron_adjusted(from_race, from_time, to_race, **) private - # Computes the Cameron exponential correction factor for a given distance + # Evaluates Cameron's velocity-ratio function f(d) for a given distance # - # @param distance_km [Float] distance in kilometers - # @return [Float] correction factor value + # @param distance_km [Float] distance in kilometers (converted to metres, the + # unit Cameron's constants are calibrated for) + # @return [Float] value of f(d) def cameron_factor(distance_km) - CAMERON_A + (CAMERON_B * Math.exp(-distance_km / CAMERON_C)) + meters = distance_km * 1000.0 + CAMERON_CONSTANT - (CAMERON_LINEAR_COEFFICIENT * meters) + + (CAMERON_POWER_COEFFICIENT / (meters**CAMERON_POWER_EXPONENT)) end end diff --git a/lib/calcpace/data/environmental_factors.yml b/lib/calcpace/data/environmental_factors.yml index 116f5e9..2f71233 100644 --- a/lib/calcpace/data/environmental_factors.yml +++ b/lib/calcpace/data/environmental_factors.yml @@ -6,18 +6,32 @@ # - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance" # NOTE: These heat factors are the BASELINE for a 60-minute effort. # The final penalty is scaled by a DurationFactor (0.5x to 4.5x) based on total exposure time. +# +# Interpolation: at or below threshold_meters the altitude penalty is 0; above it +# both tables are interpolated linearly between points and clamped to the first and +# last point. -# Altitude adjustments (NCAA standards) +# Altitude adjustments +# - 300 m: start of the curve. Below ~300 m altitude has no measurable effect; the +# ramp from 300 m to the first NCAA point (914.4 m) replaces the old step, where +# 914 m gave 0% and 915 m gave 1.41%. +# - 914.4 m .. 2438.4 m: NCAA table (3000 ft .. 8000 ft), unchanged. +# - 3000 m, 3500 m, 4000 m: EXTRAPOLATION beyond the NCAA table, from a quadratic +# fit to the NCAA points: p = 0.3647·x² + 1.9482·x, with x = altitude_km − 0.3. +# The penalty is capped at the 4000 m value. altitude: - threshold_meters: 914.4 + threshold_meters: 300 data_points: - 0: 0.0 + 300: 0.0 914.4: 1.41 1219.2: 2.15 1524.0: 2.90 1828.8: 3.76 2133.6: 4.75 2438.4: 5.90 + 3000: 7.92 # extrapolated (quadratic fit), not NCAA + 3500: 9.97 # extrapolated (quadratic fit), not NCAA + 4000: 12.2 # extrapolated (quadratic fit), not NCAA # Heat adjustments (60-minute baseline) # These values represent the penalty for a 1-hour run. @@ -29,3 +43,5 @@ heat: 20: 2.8 # Base for 60m. For 3h (3.0x) = 8.4% (Ely: 9%) 25: 4.3 # Base for 60m. For 3h (3.0x) = 12.9% (Ely: 12%) 30: 6.5 # Base for 60m. For 3h (3.0x) = 19.5% + 35: 8.7 # EXTRAPOLATION: continues the 25→30 slope (0.44 points/°C) + 40: 10.9 # EXTRAPOLATION: continues the 25→30 slope (0.44 points/°C) diff --git a/lib/calcpace/race_splits.rb b/lib/calcpace/race_splits.rb index f53a54a..557d05c 100644 --- a/lib/calcpace/race_splits.rb +++ b/lib/calcpace/race_splits.rb @@ -22,7 +22,7 @@ module RaceSplits # # @example Negative splits (second half faster) # race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative) - # #=> ["00:20:48", "00:40:00"] (first 5k slower, second 5k faster) + # #=> ["00:20:12", "00:40:00"] (first 5k 1% slower, second 5k 1% faster) def race_splits(race, target_time:, split_distance:, strategy: :even) total_distance = race_distance(race) target_seconds = target_time.is_a?(String) ? convert_to_seconds(target_time) : target_time @@ -133,25 +133,27 @@ def calculate_even_splits(total_distance, target_seconds, split_km) end # Calculates negative splits (second half faster than first half) - # First half is ~4% slower, second half is ~4% faster + # First half is run at a pace 1% slower than average, second half 1% faster + # (e.g. a 3:00:00 marathon goes through halfway in 1:30:54, then 1:29:06) # # @param total_distance [Float] total race distance in kilometers # @param target_seconds [Float] target finish time in seconds # @param split_km [Float] split distance in kilometers # @return [Array] array of cumulative split times def calculate_negative_splits(total_distance, target_seconds, split_km) - calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 1.04, second_factor: 0.96) + calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 1.01, second_factor: 0.99) end # Calculates positive splits (first half faster than second half) - # First half is ~4% faster, second half is ~4% slower + # First half is run at a pace 1% faster than average, second half 1% slower + # (e.g. a 3:00:00 marathon goes through halfway in 1:29:06, then 1:30:54) # # @param total_distance [Float] total race distance in kilometers # @param target_seconds [Float] target finish time in seconds # @param split_km [Float] split distance in kilometers # @return [Array] array of cumulative split times def calculate_positive_splits(total_distance, target_seconds, split_km) - calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 0.96, second_factor: 1.04) + calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 0.99, second_factor: 1.01) end # Shared logic for variable pace split strategies diff --git a/test/calcpace/test_cameron_predictor.rb b/test/calcpace/test_cameron_predictor.rb index 305751c..d8a6270 100644 --- a/test/calcpace/test_cameron_predictor.rb +++ b/test/calcpace/test_cameron_predictor.rb @@ -8,10 +8,10 @@ class TestCameronPredictor < CalcpaceTest def test_predict_time_5k_to_10k # 5K in 20:00 → 10K - # Cameron formula: 1200 × (10/5) × [factor(5) / factor(10)] ≈ 2544s ≈ 42:24 + # Cameron formula: 1200 × (10000/5000) × [f(5000) / f(10000)] ≈ 2499.66s ≈ 41:39 result = @calc.predict_time_cameron('5k', '00:20:00', '10k') - assert_in_delta 2544, result, 10 + assert_in_delta 2499.66, result, 0.01 end def test_predict_time_10k_to_half_marathon @@ -23,10 +23,10 @@ def test_predict_time_10k_to_half_marathon end def test_predict_time_10k_to_marathon - # 10K in 42:00 → marathon + # 10K in 42:00 → marathon ≈ 11,806.76s (3:16:46) result = @calc.predict_time_cameron('10k', '00:42:00', 'marathon') - assert_in_delta 10_666, result, 100 + assert_in_delta 11_806.76, result, 0.01 end def test_predict_time_half_to_marathon @@ -71,10 +71,7 @@ def test_predict_time_clock_returns_hhmmss end def test_predict_time_clock_10k_to_marathon - result = @calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') - - parts = result.split(':').map(&:to_i) - assert_equal 2, parts[0], 'Should be 2 hours' + assert_equal '03:16:46', @calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') end # ── predict_pace_cameron ────────────────────────────────────────────────── @@ -107,6 +104,51 @@ def test_cameron_differs_from_riegel refute_in_delta cameron, riegel, 1, 'Cameron and Riegel should produce different predictions' end + def test_cameron_is_more_conservative_than_riegel_for_the_marathon_from_5k + cameron = @calc.predict_time_cameron('5k', '00:20:00', 'marathon') + riegel = @calc.predict_time('5k', '00:20:00', 'marathon') + + assert_operator cameron, :>, riegel, 'Cameron 5K→marathon should be slower than Riegel' + end + + def test_cameron_is_more_conservative_than_riegel_for_the_marathon_from_10k + cameron = @calc.predict_time_cameron('10k', '00:42:00', 'marathon') + riegel = @calc.predict_time('10k', '00:42:00', 'marathon') + + assert_operator cameron, :>, riegel, 'Cameron 10K→marathon should be slower than Riegel' + end + + # ── reference predictions (Cameron's metric model) ─────────────────────── + + def test_reference_5k_to_marathon + # 5K 20:00 → marathon ≈ 3:15:11 + assert_in_delta 11_711.47, @calc.predict_time_cameron('5k', '00:20:00', 'marathon'), 0.01 + end + + def test_reference_half_marathon_to_marathon + # Half 1:30:00 → marathon ≈ 3:11:15 + assert_in_delta 11_475.12, @calc.predict_time_cameron('half_marathon', '01:30:00', 'marathon'), 0.01 + end + + def test_reference_marathon_to_5k + # Marathon 3:30:00 → 5K ≈ 21:31 + assert_in_delta 1291.04, @calc.predict_time_cameron('marathon', '03:30:00', '5k'), 0.01 + end + + def test_reference_matches_had2know_worked_example + # had2know.org Cameron page: 3.5 mi (5632.704 m) in 51:30 → 5 mi (8046.72 m) ≈ 75:08 + result = @calc.predict_time_cameron(5.632704, '00:51:30', 8.04672) + + assert_equal '01:15:08', @calc.convert_to_clocktime(result) + end + + def test_velocity_function_matches_published_constants + # f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905, d in metres + expected = 13.49681 - (0.000030363 * 10_000) + (835.7114 / (10_000**0.7905)) + + assert_in_delta expected, @calc.send(:cameron_factor, 10.0), 1e-12 + end + # ── consistency ────────────────────────────────────────────────────────── def test_round_trip_consistency @@ -154,14 +196,14 @@ def test_zero_time_raises def test_predict_time_cameron_adjusted_with_heat # 5K in 20:00 to 10K - # Normal Cameron: ~2544s - # Duration factor for ~42:24 (2544s) is ~0.707x - # Adjusted for 20°C (Base 2.8% * 0.707 ≈ 1.98% penalty): 2544 * 1.0198 ≈ 2594.4s + # Normal Cameron: ~2499.66s + # Duration factor for ~41:40 (2499.66s) is ~0.694x + # Adjusted for 20°C (Base 2.8% * 0.694 ≈ 1.94% penalty): 2499.66 * 1.0194 ≈ 2548.15s result = @calc.predict_time_cameron_adjusted('5k', '00:20:00', '10k', temperature: 20) assert_kind_of Hash, result - assert_in_delta 2594.4, result[:adjusted_time], 10 - assert_equal 1.98, result[:penalty_percent] + assert_in_delta 2548.15, result[:adjusted_time], 0.01 + assert_equal 1.94, result[:penalty_percent] end # --- free distances (v1.15.0) --- @@ -170,7 +212,7 @@ def test_predict_time_cameron_accepts_a_free_distance # Alagoas Abel: 7.79 km in 26:59 -> half marathon result = @calc.predict_time_cameron(7.79, '00:26:59', 'half_marathon') - assert_in_delta 4424.99, result, 0.01 + assert_in_delta 4646.36, result, 0.01 end def test_predict_time_cameron_numeric_distance_matches_the_named_race diff --git a/test/calcpace/test_environmental_adjuster.rb b/test/calcpace/test_environmental_adjuster.rb index 5442d83..49ce877 100644 --- a/test/calcpace/test_environmental_adjuster.rb +++ b/test/calcpace/test_environmental_adjuster.rb @@ -103,4 +103,76 @@ def test_calculate_penalty_with_long_duration result = @calc.calculate_penalty(temperature: 25, time_seconds: 14_400) assert_equal 19.35, result[:factors][:heat] end + + # --- altitude curve (v1.19.0) --- + + def test_altitude_at_or_below_threshold_has_no_penalty + assert_equal 0.0, @calc.calculate_penalty(altitude: 0)[:factors][:altitude] + assert_equal 0.0, @calc.calculate_penalty(altitude: 300)[:factors][:altitude] + end + + def test_altitude_is_continuous_just_above_the_threshold + assert_in_delta 0.0, @calc.calculate_penalty(altitude: 301)[:factors][:altitude], 0.01 + end + + def test_altitude_is_continuous_around_the_first_ncaa_point + below = @calc.calculate_penalty(altitude: 914)[:factors][:altitude] + at = @calc.calculate_penalty(altitude: 914.4)[:factors][:altitude] + above = @calc.calculate_penalty(altitude: 915)[:factors][:altitude] + + assert_equal 1.41, at + assert_in_delta at, below, 0.01 + assert_in_delta at, above, 0.01 + end + + def test_altitude_sao_paulo + # 760 m: interpolated between 300 m (0.0) and 914.4 m (1.41) + assert_equal 1.06, @calc.calculate_penalty(altitude: 760)[:factors][:altitude] + end + + def test_altitude_keeps_the_ncaa_points + assert_equal 2.15, @calc.calculate_penalty(altitude: 1219.2)[:factors][:altitude] + assert_equal 5.9, @calc.calculate_penalty(altitude: 2438.4)[:factors][:altitude] + end + + def test_altitude_keeps_growing_beyond_the_ncaa_table + assert_equal 7.92, @calc.calculate_penalty(altitude: 3000)[:factors][:altitude] + + result = @calc.calculate_penalty(altitude: 3600)[:factors][:altitude] + assert_operator result, :>, 9.97 + assert_operator result, :<, 12.2 + end + + def test_altitude_is_capped_at_4000_meters + assert_equal 12.2, @calc.calculate_penalty(altitude: 4000)[:factors][:altitude] + assert_equal 12.2, @calc.calculate_penalty(altitude: 5000)[:factors][:altitude] + end + + # --- heat beyond 30 °C (v1.19.0) --- + + def test_heat_at_thirty_five_is_worse_than_at_thirty + at30 = @calc.calculate_penalty(temperature: 30, time_seconds: 3600)[:factors][:heat] + at35 = @calc.calculate_penalty(temperature: 35, time_seconds: 3600)[:factors][:heat] + + assert_equal 6.5, at30 + assert_equal 8.7, at35 + end + + def test_heat_at_forty_is_worse_than_at_thirty_five + assert_equal 10.9, @calc.calculate_penalty(temperature: 40, time_seconds: 3600)[:factors][:heat] + end + + def test_heat_is_capped_at_forty + assert_equal 10.9, @calc.calculate_penalty(temperature: 45, time_seconds: 3600)[:factors][:heat] + end + + def test_environmental_data_keeps_the_structure_the_site_reads + altitude = EnvironmentalAdjuster::FACTORS.fetch('altitude') + heat = EnvironmentalAdjuster::FACTORS.fetch('heat') + + assert_kind_of Numeric, altitude.fetch('threshold_meters') + assert_kind_of Hash, altitude.fetch('data_points') + assert_equal [10.0, 15.0], heat.fetch('ideal_range_celsius') + assert_kind_of Hash, heat.fetch('data_points') + end end diff --git a/test/calcpace/test_race_splits.rb b/test/calcpace/test_race_splits.rb index 212f741..c4d12c3 100644 --- a/test/calcpace/test_race_splits.rb +++ b/test/calcpace/test_race_splits.rb @@ -102,6 +102,25 @@ def test_race_splits_positive_10k assert_equal '00:40:00', result[1] end + # Negative/positive splits are ±1% pace per half (v1.19.0) + def test_race_splits_negative_marathon_is_one_percent_per_half + result = @calc.race_splits('marathon', target_time: '03:00:00', split_distance: 21.0975, strategy: :negative) + + assert_equal %w[01:30:54 03:00:00], result + end + + def test_race_splits_positive_marathon_is_one_percent_per_half + result = @calc.race_splits('marathon', target_time: '03:00:00', split_distance: 21.0975, strategy: :positive) + + assert_equal %w[01:29:06 03:00:00], result + end + + def test_race_splits_negative_10k_by_5k + result = @calc.race_splits('10k', target_time: '00:50:00', split_distance: '5k', strategy: :negative) + + assert_equal %w[00:25:15 00:50:00], result + end + # Test with string time format def test_race_splits_with_string_time result = @calc.race_splits('5k', target_time: '25:00', split_distance: '1k') From 94b6ebae643ee8dfbf2138001a237188073d655e Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:20:02 -0300 Subject: [PATCH 02/34] chore: release 1.19.0 --- CHANGELOG.md | 57 +++++++++++++++++++++++++++++++++++++++++ lib/calcpace/version.rb | 2 +- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e1b5f..5a41777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.19.0] - 2026-10-02 + +### Changed (numbers) +Four models produced unrealistic numbers. The method names, signatures, return +shapes and the structure of `environmental_factors.yml` are unchanged; only the +values they return move. + +- **Cameron prediction now uses Dave Cameron's actual model.** The previous + constants (`a + b·e^(−d/c)` with a = 0.000495, b = 0.000985, c = 1.4485) were + not Cameron's formula and were far more optimistic than Riegel for the + marathon, while the real model is more conservative. `predict_time_cameron` + and friends now use Cameron's velocity-ratio function, with distances in + metres as in his own metric version (t-and-f mailing list, 20 Jun 2001) and + the had2know.org calculator: + `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905`, + `T2 = T1 · (D2/D1) · f(D1)/f(D2)`. Distances are still passed in km or as race + names. +- **Altitude no longer jumps at 914 m and no longer stops at 2438 m.** The + threshold moves from 914.4 m to 300 m with a new `300: 0.0` point, so the + penalty ramps linearly up to the first NCAA point (914.4 m → 1.41%) instead of + jumping from 0% at 914 m to 1.41% at 915 m. The NCAA points are unchanged. + Above 2438.4 m, where everything used to be capped at 5.90%, three points are + extrapolated from a quadratic fit to the NCAA table + (`p = 0.3647·x² + 1.9482·x`, `x = km − 0.3`): 3000 m → 7.92%, + 3500 m → 9.97%, 4000 m → 12.2% (capped there). The redundant `0: 0.0` point is + gone; the YAML keys are the same. +- **Negative/positive race splits are ±1% per half instead of ±4%.** A 3:00:00 + marathon with `strategy: :negative` used to go through halfway in 1:33:36 (a + 7-minute negative split); it now splits 1:30:54 + 1:29:06. +- **Heat above 30 °C keeps increasing.** 35 °C and 40 °C used to get the same + penalty as 30 °C. Two points extrapolate the 25→30 °C slope (0.44 points/°C): + 35 °C → 8.7% and 40 °C → 10.9% at the 60-minute baseline (capped at 40 °C). + The ideal range and the duration scaling are unchanged. + +| Case | 1.18.1 | 1.19.0 | +| --- | --- | --- | +| Cameron 10K 42:00 → marathon | 02:57:34 | 03:16:46 (Riegel 03:13:12) | +| Cameron 5K 20:00 → marathon | 02:59:25 | 03:15:11 (Riegel 03:11:49) | +| Cameron 5K 20:00 → 10K | 00:42:26 | 00:41:39 | +| Cameron 7.79 km 26:59 → half marathon | 01:13:44 | 01:17:26 | +| Altitude 500 m | 0.0% | 0.46% | +| Altitude 760 m (São Paulo) | 0.0% | 1.06% | +| Altitude 914 m / 915 m | 0.0% / 1.41% | 1.41% / 1.41% | +| Altitude 2800 m | 5.9% | 7.2% | +| Altitude 3600 m | 5.9% | 10.42% | +| Heat 35 °C, 60 min | 6.5% | 8.7% | +| Heat 40 °C, 60 min | 6.5% | 10.9% | +| Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 | +| Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 | + +### Removed +- `CameronPredictor::CAMERON_A`, `CAMERON_B` and `CAMERON_C`. They described + the wrong formula, and keeping them would suggest they still drive the + prediction. The new model's constants are `CAMERON_CONSTANT`, + `CAMERON_LINEAR_COEFFICIENT`, `CAMERON_POWER_COEFFICIENT` and + `CAMERON_POWER_EXPONENT`. + ## [1.18.1] - 2026-09-06 ### Fixed diff --git a/lib/calcpace/version.rb b/lib/calcpace/version.rb index 032ee39..cd718cd 100644 --- a/lib/calcpace/version.rb +++ b/lib/calcpace/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Calcpace - VERSION = '1.18.1' + VERSION = '1.19.0' end From ff5bf5443b962c2dfaef7d0c6b454d8b264c6235 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:21:29 -0300 Subject: [PATCH 03/34] feat: age grading with the 2025 road tables Replace the age-grading data with Alan Jones' 2025 road tables (approved 2025-01-10 by the USATF Masters Long Distance Running Council), taken from MaleRoadStd2025.xlsx / FemaleRoadStd2025.xlsx in github.com/AlanLyttonJones/Age-Grade-Tables. The old file, despite its "road" name, held the WMA 2023 track and field factors and track open standards, with no factors under age 30, so road age grades were off by about 1-3%. Factors now cover every age from 18 to 100 for 5K, 10K, half marathon and marathon. Data files renamed to mldr_2025_road*.yml; table_version becomes MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1. Public constants and category labels are unchanged. --- CHANGELOG.md | 35 ++++++++ README.md | 51 ++++++----- calcpace.gemspec | 2 +- lib/calcpace/age_grading.rb | 16 ++-- lib/calcpace/data/mldr_2025_road.yml | 21 +++++ .../data/mldr_2025_road_open_standards.yml | 46 ++++++++++ lib/calcpace/data/wma_2023_open_standards.yml | 40 --------- lib/calcpace/data/wma_2023_road.yml | 16 ---- test/calcpace/test_age_grading.rb | 84 ++++++++++++++++++- 9 files changed, 226 insertions(+), 85 deletions(-) create mode 100644 lib/calcpace/data/mldr_2025_road.yml create mode 100644 lib/calcpace/data/mldr_2025_road_open_standards.yml delete mode 100644 lib/calcpace/data/wma_2023_open_standards.yml delete mode 100644 lib/calcpace/data/wma_2023_road.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e1b5f..0b1d30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Age grading now uses the 2025 road tables.** Age factors and open + standards come from Alan Jones' 2025 road age-grading tables, approved on + 2025-01-10 by the USATF Masters Long Distance Running Council + ([source spreadsheets](https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/master/2025%20Files): + `MaleRoadStd2025.xlsx`, `FemaleRoadStd2025.xlsx`). The previous data, + despite the `wma_2023_road.yml` name, came from the WMA 2023 **track and + field** tables: track open standards (5000 m 12:35 / 14:06, 10 000 m + 26:11 / 29:01), an outdated women's marathon standard (2:14:04), and no + factors under age 30. Road age grades were off by about 1–3%. +- New open standards — men: 5K 12:49, 10K 26:24, half 57:31, marathon + 2:00:35; women: 5K 13:54, 10K 28:46, half 1:02:52, marathon 2:09:56. +- One factor per year of age from 18 to 100. Runners under 30 now get the + table's real factors instead of 1.0 (e.g. a male 18-year-old at 5K: 0.9995; + a female 30-year-old at 5K: 0.9959). Ages over 100 use the age-100 factor, + as ages over the table end did before. +- `table_version` is now `"MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"` (was + `"WMA_2023_ONE_YEAR_FACTORS_V1"`). The data files were renamed to + `lib/calcpace/data/mldr_2025_road.yml` and + `lib/calcpace/data/mldr_2025_road_open_standards.yml`; `DATA_PATH`, + `OPEN_STANDARDS_DATA_PATH`, `WMA_DATA`, `OPEN_STANDARDS_DATA` and + `TABLE_VERSION` keep their names and shapes. The track-only keys + (`"1500"`, `"3000"`) are gone. Category labels are unchanged. + + | Case | Before (WMA 2023 track) | After (2025 road) | Official 2025 | + | --- | --- | --- | --- | + | Male 40, marathon 3:30:00 | 58.8% (factor 0.9804) | 58.7% (0.9783) | 58.7% | + | Female 50, marathon 4:00:00 | 62.7% (0.8915) | 60.2% (0.8998) | 60.2% | + | Female 30, 5K 25:00 | 56.4% (1.0) | 55.8% (0.9959) | 55.8% | + | Male 60, half 1:50:00 | 63.3% (0.8264) | 64.7% (0.8082) | 64.7% | + | Male 55, 10K 45:00 | 69.0% (0.8438) | 68.9% (0.8511) | 68.9% | + + "Official" is age standard / time from the spreadsheets' `AgeStdSec` sheet, + at one decimal. + ## [1.18.1] - 2026-09-06 ### Fixed diff --git a/README.md b/README.md index 8afc603..aa93112 100644 --- a/README.md +++ b/README.md @@ -249,18 +249,18 @@ age factors and open standards. result = calc.age_grade(10.0, '00:45:00', age: 55, sex: :male) # numeric distances also accepted in miles: calc.age_grade(6.21371, '00:45:00', age: 55, sex: :male, distance_unit: :mi) # => { -# age_grade_percent: 69.0, +# age_grade_percent: 68.9, # category: "Local Class", -# age_graded_time_seconds: 2278.26, -# age_graded_time_clock: "00:37:58", -# open_standard_seconds: 1571.0, -# open_standard_clock: "00:26:11", -# factor: 0.8438, -# table_version: "WMA_2023_ONE_YEAR_FACTORS_V1" +# age_graded_time_seconds: 2297.97, +# age_graded_time_clock: "00:38:17", +# open_standard_seconds: 1584.0, +# open_standard_clock: "00:26:24", +# factor: 0.8511, +# table_version: "MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1" # } -calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.2 -calc.age_grade_label(65.2) # => "Local Class" +calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.0 +calc.age_grade_label(65.0) # => "Local Class" ``` `category` (and `age_grade_label`) returns one of: @@ -276,7 +276,7 @@ calc.age_grade_label(65.2) # => "Local Clas | 40–49.9% | Recreational | | below 40% | Active Beginner | -The WMA / Alan Jones (Howard Grubb) tables are numeric age factors and open +The Alan Jones road tables are numeric age factors and open standards only — they define no categories at all. The bands from Local Class (60%) upward follow the USATF Masters / National Masters News convention; the three bands below 60% are calcpace's own extension — most recreational @@ -293,8 +293,8 @@ A numeric distance within **2%** of one of those is graded as that standard — a GPS watch rarely reads a 5K as exactly 5.000 km: ```ruby -calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # => 51.9 -calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 51.9 +calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # => 54.1 +calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 54.1 calc.age_grade(7.79, '00:26:59', age: 36, sex: :male) # => ArgumentError: Unsupported distance 7.79km. Supported: 5.0, 10.0, 21.0975, 42.195 km @@ -302,20 +302,31 @@ calc.age_grade(7.79, '00:26:59', age: 36, sex: :male) That refusal is deliberate, and it is where age grading parts ways with the predictors above. A prediction is a formula and works at any distance; an age -grade is a lookup in the WMA table, which publishes a factor per *specific* +grade is a lookup in the road table, which publishes a factor per *specific* distance. There is no world standard for 7.79 km, so there is no honest percentage to return — interpolating one would produce a number with the look of an official standard and none of the authority. -Age factors are based on WMA 2023 one-year age grading tables: -https://world-masters-athletics.org/documents/competition-rules/ - -Open standards used in `open_standard_seconds` / `open_standard_clock` are loaded -from the bundled WMA 2023 open standards dataset -(`lib/calcpace/data/wma_2023_open_standards.yml`). +Age factors and open standards come from Alan Jones' **2025 road** age-grading +tables, approved on 2025-01-10 by the USATF Masters Long Distance Running +Council — the standard for road races, the same tables behind Howard Grubb's +MLDR road calculator. The source spreadsheets are `MaleRoadStd2025.xlsx` and +`FemaleRoadStd2025.xlsx` in +https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/master/2025%20Files. +The bundled data has one factor per year of age from 18 to 100 (older ages use +the age-100 factor) and lives in `lib/calcpace/data/mldr_2025_road.yml` (factors) +and `lib/calcpace/data/mldr_2025_road_open_standards.yml` (open standards and +category labels). + +| Distance | Open standard (men) | Open standard (women) | +| --- | --- | --- | +| 5K | 12:49 | 13:54 | +| 10K | 26:24 | 28:46 | +| Half marathon | 57:31 | 1:02:52 | +| Marathon | 2:00:35 | 2:09:56 | Field meanings: -- `age_graded_time_clock`: your result after applying the WMA age factor (normalized performance time). +- `age_graded_time_clock`: your result after applying the age factor (normalized performance time). - `open_standard_clock`: the open standard reference time used to compute the percentage for that distance/sex. - `age_grade_percent`: `(open_standard_seconds / age_graded_time_seconds) * 100`. diff --git a/calcpace.gemspec b/calcpace.gemspec index cc3f8b9..a96695c 100644 --- a/calcpace.gemspec +++ b/calcpace.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.description = 'Ruby gem for runners: pace, time, and distance calculations, ' \ 'unit conversions (30+ units), race time predictions (Riegel & Cameron), ' \ 'GPS track analysis (Haversine, elevation gain, per-km splits), ' \ - 'age grading (WMA 2023), VO2max estimation (Daniels & Gilbert), and ' \ + 'age grading (2025 road tables), VO2max estimation (Daniels & Gilbert), and ' \ 'personalized training zones (Daniels paces & Karvonen heart-rate zones).' spec.homepage = 'https://github.com/0jonjo/calcpace' spec.metadata['source_code_uri'] = spec.homepage diff --git a/lib/calcpace/age_grading.rb b/lib/calcpace/age_grading.rb index 1cd47da..2bb1707 100644 --- a/lib/calcpace/age_grading.rb +++ b/lib/calcpace/age_grading.rb @@ -11,8 +11,14 @@ # Current scope: # - Common road distances: 5K, 10K, half marathon, marathon # - Sex: male/female -# - Age: 18+ -# - Data file is versioned and replaceable (`lib/calcpace/data/wma_2023_road.yml`) +# - Age: 18+, one factor per year up to 100 (older ages use the age-100 factor) +# - Data: Alan Jones' 2025 road age-grading tables, approved by the USATF +# Masters Long Distance Running Council (github.com/AlanLyttonJones/Age-Grade-Tables). +# The files are versioned and replaceable: factors in +# `lib/calcpace/data/mldr_2025_road.yml`, open standards and category labels in +# `lib/calcpace/data/mldr_2025_road_open_standards.yml` +# +# The WMA_DATA constant keeps its historical name: it now holds the road table # # Returned values include: # - age grade percentage @@ -21,8 +27,8 @@ # - performance category # rubocop:disable Metrics/ModuleLength module AgeGrading - DATA_PATH = File.expand_path('data/wma_2023_road.yml', __dir__).freeze - OPEN_STANDARDS_DATA_PATH = File.expand_path('data/wma_2023_open_standards.yml', __dir__).freeze + DATA_PATH = File.expand_path('data/mldr_2025_road.yml', __dir__).freeze + OPEN_STANDARDS_DATA_PATH = File.expand_path('data/mldr_2025_road_open_standards.yml', __dir__).freeze WMA_DATA = YAML.safe_load_file(DATA_PATH, permitted_classes: [], aliases: false).freeze OPEN_STANDARDS_DATA = YAML.safe_load_file(OPEN_STANDARDS_DATA_PATH, permitted_classes: [], @@ -60,7 +66,7 @@ module AgeGrading # How far a distance may sit from a standard and still be graded as it. 2% is # the same window calcpace.app uses to decide a run "is a 5K", so the gem and # the site never disagree about the same run. It stays a matching tolerance, - # not an interpolation: a distance outside it has no WMA factor and is refused + # not an interpolation: a distance outside it has no table factor and is refused STANDARD_DISTANCE_TOLERANCE_RATIO = 0.02 # Floor for the window above, so a future shorter standard still matches diff --git a/lib/calcpace/data/mldr_2025_road.yml b/lib/calcpace/data/mldr_2025_road.yml new file mode 100644 index 0000000..ea1a7ad --- /dev/null +++ b/lib/calcpace/data/mldr_2025_road.yml @@ -0,0 +1,21 @@ +# Road-running age factors, one per single year of age, from Alan Jones' +# 2025 road age-grading tables (approved 2025-01-10 by the USATF Masters Long +# Distance Running Council). Source, sheet "Age Factors" of: +# https://github.com/AlanLyttonJones/Age-Grade-Tables/blob/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files/MaleRoadStd2025.xlsx +# https://github.com/AlanLyttonJones/Age-Grade-Tables/blob/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files/FemaleRoadStd2025.xlsx +# Columns used: "5 km" -> 5000, "10 km" -> 10000, "H. Mar" (21.0975 km) -> 21097, +# "Marathon" (42.195 km) -> 42195. The tables start at age 5; ages under 18 are +# left out because the gem refuses them. Factor = open standard / age standard, +# so age-graded time = time * factor. See the open standards file for the meta. + +F: + "5000": { 18: 0.9953, 19: 1.0000, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 0.9997, 28: 0.9990, 29: 0.9977, 30: 0.9959, 31: 0.9936, 32: 0.9908, 33: 0.9875, 34: 0.9836, 35: 0.9793, 36: 0.9744, 37: 0.9691, 38: 0.9632, 39: 0.9568, 40: 0.9499, 41: 0.9425, 42: 0.9346, 43: 0.9262, 44: 0.9172, 45: 0.9078, 46: 0.8980, 47: 0.8883, 48: 0.8786, 49: 0.8689, 50: 0.8592, 51: 0.8495, 52: 0.8398, 53: 0.8301, 54: 0.8204, 55: 0.8107, 56: 0.8009, 57: 0.7912, 58: 0.7815, 59: 0.7718, 60: 0.7621, 61: 0.7524, 62: 0.7427, 63: 0.7330, 64: 0.7233, 65: 0.7136, 66: 0.7038, 67: 0.6941, 68: 0.6844, 69: 0.6747, 70: 0.6650, 71: 0.6553, 72: 0.6456, 73: 0.6359, 74: 0.6262, 75: 0.6165, 76: 0.6067, 77: 0.5970, 78: 0.5868, 79: 0.5758, 80: 0.5640, 81: 0.5515, 82: 0.5382, 83: 0.5242, 84: 0.5094, 85: 0.4938, 86: 0.4775, 87: 0.4604, 88: 0.4426, 89: 0.4240, 90: 0.4046, 91: 0.3845, 92: 0.3636, 93: 0.3419, 94: 0.3195, 95: 0.2964, 96: 0.2725, 97: 0.2478, 98: 0.2223, 99: 0.1961, 100: 0.1692 } + "10000": { 18: 0.9820, 19: 0.9920, 20: 0.9980, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9991, 30: 0.9980, 31: 0.9964, 32: 0.9944, 33: 0.9920, 34: 0.9891, 35: 0.9857, 36: 0.9819, 37: 0.9777, 38: 0.9730, 39: 0.9679, 40: 0.9623, 41: 0.9563, 42: 0.9499, 43: 0.9429, 44: 0.9356, 45: 0.9278, 46: 0.9195, 47: 0.9109, 48: 0.9017, 49: 0.8921, 50: 0.8822, 51: 0.8723, 52: 0.8623, 53: 0.8524, 54: 0.8425, 55: 0.8325, 56: 0.8226, 57: 0.8126, 58: 0.8027, 59: 0.7928, 60: 0.7828, 61: 0.7729, 62: 0.7629, 63: 0.7530, 64: 0.7431, 65: 0.7331, 66: 0.7232, 67: 0.7132, 68: 0.7033, 69: 0.6934, 70: 0.6834, 71: 0.6735, 72: 0.6635, 73: 0.6536, 74: 0.6437, 75: 0.6337, 76: 0.6234, 77: 0.6123, 78: 0.6005, 79: 0.5879, 80: 0.5745, 81: 0.5604, 82: 0.5455, 83: 0.5299, 84: 0.5135, 85: 0.4963, 86: 0.4784, 87: 0.4597, 88: 0.4403, 89: 0.4201, 90: 0.3991, 91: 0.3774, 92: 0.3549, 93: 0.3317, 94: 0.3077, 95: 0.2829, 96: 0.2574, 97: 0.2311, 98: 0.2041, 99: 0.1763, 100: 0.1477 } + "21097": { 18: 0.9680, 19: 0.9820, 20: 0.9920, 21: 0.9980, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9991, 30: 0.9979, 31: 0.9962, 32: 0.9941, 33: 0.9915, 34: 0.9884, 35: 0.9849, 36: 0.9809, 37: 0.9764, 38: 0.9714, 39: 0.9660, 40: 0.9601, 41: 0.9537, 42: 0.9468, 43: 0.9395, 44: 0.9317, 45: 0.9234, 46: 0.9147, 47: 0.9055, 48: 0.8958, 49: 0.8856, 50: 0.8753, 51: 0.8649, 52: 0.8546, 53: 0.8442, 54: 0.8339, 55: 0.8235, 56: 0.8132, 57: 0.8028, 58: 0.7925, 59: 0.7821, 60: 0.7718, 61: 0.7614, 62: 0.7511, 63: 0.7407, 64: 0.7304, 65: 0.7200, 66: 0.7097, 67: 0.6993, 68: 0.6890, 69: 0.6786, 70: 0.6683, 71: 0.6579, 72: 0.6476, 73: 0.6372, 74: 0.6269, 75: 0.6165, 76: 0.6059, 77: 0.5945, 78: 0.5822, 79: 0.5692, 80: 0.5554, 81: 0.5407, 82: 0.5253, 83: 0.5091, 84: 0.4921, 85: 0.4742, 86: 0.4556, 87: 0.4362, 88: 0.4159, 89: 0.3949, 90: 0.3731, 91: 0.3504, 92: 0.3270, 93: 0.3028, 94: 0.2778, 95: 0.2519, 96: 0.2253, 97: 0.1979, 98: 0.1696, 99: 0.1406, 100: 0.1108 } + "42195": { 18: 0.9217, 19: 0.9391, 20: 0.9553, 21: 0.9689, 22: 0.9801, 23: 0.9888, 24: 0.9950, 25: 0.9988, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9992, 30: 0.9983, 31: 0.9970, 32: 0.9953, 33: 0.9932, 34: 0.9907, 35: 0.9879, 36: 0.9847, 37: 0.9811, 38: 0.9771, 39: 0.9727, 40: 0.9680, 41: 0.9629, 42: 0.9574, 43: 0.9515, 44: 0.9453, 45: 0.9386, 46: 0.9316, 47: 0.9242, 48: 0.9165, 49: 0.9083, 50: 0.8998, 51: 0.8909, 52: 0.8816, 53: 0.8720, 54: 0.8619, 55: 0.8515, 56: 0.8407, 57: 0.8297, 58: 0.8186, 59: 0.8076, 60: 0.7965, 61: 0.7854, 62: 0.7744, 63: 0.7633, 64: 0.7523, 65: 0.7412, 66: 0.7301, 67: 0.7191, 68: 0.7080, 69: 0.6970, 70: 0.6859, 71: 0.6748, 72: 0.6638, 73: 0.6527, 74: 0.6413, 75: 0.6290, 76: 0.6159, 77: 0.6021, 78: 0.5874, 79: 0.5720, 80: 0.5557, 81: 0.5386, 82: 0.5208, 83: 0.5021, 84: 0.4827, 85: 0.4624, 86: 0.4413, 87: 0.4195, 88: 0.3968, 89: 0.3734, 90: 0.3491, 91: 0.3240, 92: 0.2982, 93: 0.2715, 94: 0.2441, 95: 0.2158, 96: 0.1867, 97: 0.1569, 98: 0.1262, 99: 0.0948, 100: 0.0625 } + +M: + "5000": { 18: 0.9995, 19: 1.0000, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 0.9999, 31: 0.9988, 32: 0.9965, 33: 0.9930, 34: 0.9883, 35: 0.9824, 36: 0.9755, 37: 0.9685, 38: 0.9615, 39: 0.9545, 40: 0.9475, 41: 0.9405, 42: 0.9335, 43: 0.9265, 44: 0.9195, 45: 0.9125, 46: 0.9055, 47: 0.8985, 48: 0.8915, 49: 0.8845, 50: 0.8775, 51: 0.8705, 52: 0.8635, 53: 0.8565, 54: 0.8495, 55: 0.8425, 56: 0.8355, 57: 0.8285, 58: 0.8215, 59: 0.8145, 60: 0.8075, 61: 0.8005, 62: 0.7935, 63: 0.7865, 64: 0.7795, 65: 0.7725, 66: 0.7655, 67: 0.7585, 68: 0.7514, 69: 0.7436, 70: 0.7353, 71: 0.7264, 72: 0.7169, 73: 0.7068, 74: 0.6960, 75: 0.6847, 76: 0.6728, 77: 0.6603, 78: 0.6472, 79: 0.6334, 80: 0.6191, 81: 0.6042, 82: 0.5887, 83: 0.5726, 84: 0.5558, 85: 0.5385, 86: 0.5206, 87: 0.5021, 88: 0.4830, 89: 0.4632, 90: 0.4429, 91: 0.4220, 92: 0.4005, 93: 0.3784, 94: 0.3556, 95: 0.3323, 96: 0.3084, 97: 0.2839, 98: 0.2588, 99: 0.2330, 100: 0.2067 } + "10000": { 18: 0.9818, 19: 0.9903, 20: 0.9968, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 0.9996, 32: 0.9985, 33: 0.9967, 34: 0.9942, 35: 0.9909, 36: 0.9869, 37: 0.9822, 38: 0.9767, 39: 0.9705, 40: 0.9636, 41: 0.9561, 42: 0.9486, 43: 0.9411, 44: 0.9336, 45: 0.9261, 46: 0.9186, 47: 0.9111, 48: 0.9036, 49: 0.8961, 50: 0.8886, 51: 0.8811, 52: 0.8736, 53: 0.8661, 54: 0.8586, 55: 0.8511, 56: 0.8436, 57: 0.8361, 58: 0.8286, 59: 0.8211, 60: 0.8136, 61: 0.8061, 62: 0.7986, 63: 0.7911, 64: 0.7836, 65: 0.7761, 66: 0.7686, 67: 0.7611, 68: 0.7536, 69: 0.7461, 70: 0.7386, 71: 0.7308, 72: 0.7223, 73: 0.7131, 74: 0.7033, 75: 0.6928, 76: 0.6816, 77: 0.6697, 78: 0.6572, 79: 0.6440, 80: 0.6301, 81: 0.6156, 82: 0.6004, 83: 0.5845, 84: 0.5680, 85: 0.5508, 86: 0.5329, 87: 0.5143, 88: 0.4951, 89: 0.4752, 90: 0.4546, 91: 0.4334, 92: 0.4115, 93: 0.3889, 94: 0.3657, 95: 0.3418, 96: 0.3172, 97: 0.2919, 98: 0.2660, 99: 0.2394, 100: 0.2121 } + "21097": { 18: 0.9850, 19: 0.9950, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 1.0000, 32: 0.9996, 33: 0.9982, 34: 0.9960, 35: 0.9928, 36: 0.9888, 37: 0.9839, 38: 0.9781, 39: 0.9714, 40: 0.9638, 41: 0.9560, 42: 0.9483, 43: 0.9405, 44: 0.9327, 45: 0.9249, 46: 0.9171, 47: 0.9094, 48: 0.9016, 49: 0.8938, 50: 0.8860, 51: 0.8782, 52: 0.8705, 53: 0.8627, 54: 0.8549, 55: 0.8471, 56: 0.8393, 57: 0.8316, 58: 0.8238, 59: 0.8160, 60: 0.8082, 61: 0.8004, 62: 0.7927, 63: 0.7849, 64: 0.7771, 65: 0.7693, 66: 0.7615, 67: 0.7538, 68: 0.7460, 69: 0.7382, 70: 0.7304, 71: 0.7223, 72: 0.7135, 73: 0.7040, 74: 0.6938, 75: 0.6830, 76: 0.6715, 77: 0.6593, 78: 0.6464, 79: 0.6328, 80: 0.6185, 81: 0.6036, 82: 0.5880, 83: 0.5717, 84: 0.5547, 85: 0.5370, 86: 0.5186, 87: 0.4996, 88: 0.4799, 89: 0.4595, 90: 0.4384, 91: 0.4167, 92: 0.3942, 93: 0.3711, 94: 0.3473, 95: 0.3228, 96: 0.2976, 97: 0.2718, 98: 0.2452, 99: 0.2180, 100: 0.1901 } + "42195": { 18: 0.9680, 19: 0.9820, 20: 0.9920, 21: 0.9980, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 0.9999, 37: 0.9979, 38: 0.9934, 39: 0.9865, 40: 0.9783, 41: 0.9701, 42: 0.9619, 43: 0.9537, 44: 0.9455, 45: 0.9373, 46: 0.9291, 47: 0.9209, 48: 0.9127, 49: 0.9045, 50: 0.8963, 51: 0.8881, 52: 0.8799, 53: 0.8717, 54: 0.8635, 55: 0.8553, 56: 0.8471, 57: 0.8389, 58: 0.8307, 59: 0.8225, 60: 0.8143, 61: 0.8061, 62: 0.7979, 63: 0.7897, 64: 0.7815, 65: 0.7733, 66: 0.7651, 67: 0.7569, 68: 0.7487, 69: 0.7405, 70: 0.7323, 71: 0.7241, 72: 0.7155, 73: 0.7063, 74: 0.6963, 75: 0.6857, 76: 0.6743, 77: 0.6623, 78: 0.6495, 79: 0.6361, 80: 0.6219, 81: 0.6071, 82: 0.5915, 83: 0.5753, 84: 0.5583, 85: 0.5407, 86: 0.5223, 87: 0.5033, 88: 0.4835, 89: 0.4631, 90: 0.4419, 91: 0.4201, 92: 0.3975, 93: 0.3743, 94: 0.3503, 95: 0.3257, 96: 0.3003, 97: 0.2743, 98: 0.2475, 99: 0.2201, 100: 0.1919 } diff --git a/lib/calcpace/data/mldr_2025_road_open_standards.yml b/lib/calcpace/data/mldr_2025_road_open_standards.yml new file mode 100644 index 0000000..0265736 --- /dev/null +++ b/lib/calcpace/data/mldr_2025_road_open_standards.yml @@ -0,0 +1,46 @@ +meta: + source: "Alan Jones, 2025 road age-grading tables (single-age bests by Tom Bernhard), approved 2025-01-10 by the USATF Masters Long Distance Running Council" + url: "https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files" + files: + - "2025 Files/MaleRoadStd2025.xlsx (sheet \"Age Factors\", row \"OC sec\")" + - "2025 Files/FemaleRoadStd2025.xlsx (sheet \"Age Facctors\", row \"OC sec\")" + table_version: "MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1" + +# The Alan Jones road tables (also served by Howard Grubb's calculators) are +# numeric age factors and open standards only — they define no categories at +# all. The bands from Local Class (60%) upward follow the USATF Masters / +# National Masters News convention; the three bands under 60% are Calcpace's +# own extension, added to give recreational runners a meaningful label instead +# of a single catch-all. +age_grade_classifications: + - min: 100.0 + label: "Approximate World Record Level" + - min: 90.0 + label: "World Class" + - min: 80.0 + label: "National Class" + - min: 70.0 + label: "Regional Class" + - min: 60.0 + label: "Local Class" + - min: 50.0 + label: "Intermediate" + - min: 40.0 + label: "Recreational" + - min: 0.0 + label: "Active Beginner" + +# Open-class (OC) road standards, in seconds: the best road time for the +# distance at any age. 21097 is the half marathon (21.0975 km), 42195 the +# marathon (42.195 km). +open_standards_seconds: + M: + "5000": 769.0 # 12:49 + "10000": 1584.0 # 26:24 + "21097": 3451.0 # 57:31 + "42195": 7235.0 # 2:00:35 + F: + "5000": 834.0 # 13:54 + "10000": 1726.0 # 28:46 + "21097": 3772.0 # 1:02:52 + "42195": 7796.0 # 2:09:56 diff --git a/lib/calcpace/data/wma_2023_open_standards.yml b/lib/calcpace/data/wma_2023_open_standards.yml deleted file mode 100644 index 0caf513..0000000 --- a/lib/calcpace/data/wma_2023_open_standards.yml +++ /dev/null @@ -1,40 +0,0 @@ -meta: - source: "WMA/Masters Rankings age grading tables (2023)" - url: "https://howardgrubb.co.uk/athletics/wmatnf23.html" - table_version: "WMA_2023_ONE_YEAR_FACTORS_V1" - -# The WMA / Alan Jones (Howard Grubb) tables are numeric age factors and open -# standards only — they define no categories at all. The bands from Local -# Class (60%) upward follow the USATF Masters / National Masters News -# convention; the three bands under 60% are Calcpace's own extension, added -# to give recreational runners a meaningful label instead of a single -# catch-all. -age_grade_classifications: - - min: 100.0 - label: "Approximate World Record Level" - - min: 90.0 - label: "World Class" - - min: 80.0 - label: "National Class" - - min: 70.0 - label: "Regional Class" - - min: 60.0 - label: "Local Class" - - min: 50.0 - label: "Intermediate" - - min: 40.0 - label: "Recreational" - - min: 0.0 - label: "Active Beginner" - -open_standards_seconds: - M: - "5000": 755.0 - "10000": 1571.0 - "21097": 3451.0 - "42195": 7269.0 - F: - "5000": 846.0 - "10000": 1741.0 - "21097": 3772.0 - "42195": 8044.0 diff --git a/lib/calcpace/data/wma_2023_road.yml b/lib/calcpace/data/wma_2023_road.yml deleted file mode 100644 index 0ac8eed..0000000 --- a/lib/calcpace/data/wma_2023_road.yml +++ /dev/null @@ -1,16 +0,0 @@ - -F: - "1500": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 0.9960, 34: 0.9886, 35: 0.9812, 36: 0.9738, 37: 0.9664, 38: 0.9590, 39: 0.9515, 40: 0.9441, 41: 0.9367, 42: 0.9293, 43: 0.9218, 44: 0.9144, 45: 0.9069, 46: 0.8995, 47: 0.8921, 48: 0.8846, 49: 0.8772, 50: 0.8697, 51: 0.8623, 52: 0.8548, 53: 0.8473, 54: 0.8399, 55: 0.8324, 56: 0.8249, 57: 0.8175, 58: 0.8100, 59: 0.8025, 60: 0.7951, 61: 0.7876, 62: 0.7801, 63: 0.7726, 64: 0.7651, 65: 0.7576, 66: 0.7501, 67: 0.7427, 68: 0.7352, 69: 0.7277, 70: 0.7202, 71: 0.7127, 72: 0.7052, 73: 0.6976, 74: 0.6897, 75: 0.6812, 76: 0.6722, 77: 0.6628, 78: 0.6529, 79: 0.6425, 80: 0.6316, 81: 0.6202, 82: 0.6083, 83: 0.5960, 84: 0.5832, 85: 0.5698, 86: 0.5561, 87: 0.5418, 88: 0.5270, 89: 0.5118, 90: 0.4960, 91: 0.4798, 92: 0.4631, 93: 0.4460, 94: 0.4283, 95: 0.4102, 96: 0.3915, 97: 0.3724, 98: 0.3528, 99: 0.3327, 100: 0.3122, 101: 0.2911, 102: 0.2696, 103: 0.2476, 104: 0.2251, 105: 0.2021, 106: 0.1787, 107: 0.1547, 108: 0.1303, 109: 0.1054, 110: 0.0800 } - "3000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 1.0000, 37: 1.0000, 38: 0.9930, 39: 0.9849, 40: 0.9767, 41: 0.9685, 42: 0.9603, 43: 0.9521, 44: 0.9438, 45: 0.9355, 46: 0.9271, 47: 0.9188, 48: 0.9104, 49: 0.9019, 50: 0.8935, 51: 0.8850, 52: 0.8765, 53: 0.8680, 54: 0.8594, 55: 0.8509, 56: 0.8423, 57: 0.8336, 58: 0.8250, 59: 0.8163, 60: 0.8076, 61: 0.7989, 62: 0.7901, 63: 0.7813, 64: 0.7725, 65: 0.7637, 66: 0.7549, 67: 0.7460, 68: 0.7371, 69: 0.7282, 70: 0.7193, 71: 0.7103, 72: 0.7013, 73: 0.6923, 74: 0.6833, 75: 0.6743, 76: 0.6652, 77: 0.6561, 78: 0.6470, 79: 0.6379, 80: 0.6288, 81: 0.6196, 82: 0.6098, 83: 0.5993, 84: 0.5881, 85: 0.5762, 86: 0.5637, 87: 0.5505, 88: 0.5366, 89: 0.5220, 90: 0.5067, 91: 0.4908, 92: 0.4742, 93: 0.4569, 94: 0.4390, 95: 0.4204, 96: 0.4010, 97: 0.3811, 98: 0.3604, 99: 0.3391, 100: 0.3171, 101: 0.2944, 102: 0.2711, 103: 0.2470, 104: 0.2223, 105: 0.1970, 106: 0.1709, 107: 0.1442, 108: 0.1168, 109: 0.0887, 110: 0.0600 } - "5000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 0.9974, 36: 0.9904, 37: 0.9833, 38: 0.9761, 39: 0.9689, 40: 0.9615, 41: 0.9541, 42: 0.9467, 43: 0.9392, 44: 0.9316, 45: 0.9239, 46: 0.9162, 47: 0.9084, 48: 0.9006, 49: 0.8926, 50: 0.8847, 51: 0.8766, 52: 0.8685, 53: 0.8603, 54: 0.8521, 55: 0.8438, 56: 0.8355, 57: 0.8271, 58: 0.8186, 59: 0.8101, 60: 0.8015, 61: 0.7929, 62: 0.7842, 63: 0.7755, 64: 0.7667, 65: 0.7578, 66: 0.7489, 67: 0.7399, 68: 0.7309, 69: 0.7219, 70: 0.7128, 71: 0.7036, 72: 0.6944, 73: 0.6851, 74: 0.6758, 75: 0.6665, 76: 0.6571, 77: 0.6476, 78: 0.6381, 79: 0.6286, 80: 0.6190, 81: 0.6094, 82: 0.5997, 83: 0.5893, 84: 0.5783, 85: 0.5665, 86: 0.5541, 87: 0.5410, 88: 0.5272, 89: 0.5127, 90: 0.4975, 91: 0.4816, 92: 0.4650, 93: 0.4478, 94: 0.4299, 95: 0.4112, 96: 0.3919, 97: 0.3719, 98: 0.3513, 99: 0.3299, 100: 0.3079, 101: 0.2852, 102: 0.2618, 103: 0.2377, 104: 0.2129, 105: 0.1875, 106: 0.1613, 107: 0.1345, 108: 0.1070, 109: 0.0788, 110: 0.0500 } - "10000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 0.9937, 35: 0.9869, 36: 0.9801, 37: 0.9731, 38: 0.9661, 39: 0.9591, 40: 0.9519, 41: 0.9447, 42: 0.9374, 43: 0.9301, 44: 0.9227, 45: 0.9152, 46: 0.9077, 47: 0.9001, 48: 0.8925, 49: 0.8848, 50: 0.8770, 51: 0.8692, 52: 0.8613, 53: 0.8533, 54: 0.8453, 55: 0.8373, 56: 0.8291, 57: 0.8210, 58: 0.8127, 59: 0.8045, 60: 0.7961, 61: 0.7877, 62: 0.7793, 63: 0.7708, 64: 0.7623, 65: 0.7537, 66: 0.7450, 67: 0.7363, 68: 0.7276, 69: 0.7188, 70: 0.7100, 71: 0.7011, 72: 0.6922, 73: 0.6832, 74: 0.6742, 75: 0.6651, 76: 0.6560, 77: 0.6468, 78: 0.6376, 79: 0.6284, 80: 0.6191, 81: 0.6098, 82: 0.6004, 83: 0.5903, 84: 0.5795, 85: 0.5679, 86: 0.5556, 87: 0.5425, 88: 0.5287, 89: 0.5142, 90: 0.4990, 91: 0.4830, 92: 0.4662, 93: 0.4488, 94: 0.4306, 95: 0.4116, 96: 0.3920, 97: 0.3716, 98: 0.3505, 99: 0.3286, 100: 0.3060, 101: 0.2827, 102: 0.2586, 103: 0.2339, 104: 0.2084, 105: 0.1821, 106: 0.1551, 107: 0.1275, 108: 0.0990, 109: 0.0699, 110: 0.0400 } - "21097": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 0.9935, 34: 0.9869, 35: 0.9802, 36: 0.9734, 37: 0.9666, 38: 0.9596, 39: 0.9526, 40: 0.9455, 41: 0.9384, 42: 0.9311, 43: 0.9238, 44: 0.9164, 45: 0.9090, 46: 0.9014, 47: 0.8938, 48: 0.8862, 49: 0.8784, 50: 0.8706, 51: 0.8627, 52: 0.8548, 53: 0.8468, 54: 0.8387, 55: 0.8306, 56: 0.8224, 57: 0.8141, 58: 0.8058, 59: 0.7974, 60: 0.7889, 61: 0.7804, 62: 0.7718, 63: 0.7632, 64: 0.7545, 65: 0.7457, 66: 0.7369, 67: 0.7280, 68: 0.7191, 69: 0.7101, 70: 0.7011, 71: 0.6920, 72: 0.6828, 73: 0.6736, 74: 0.6644, 75: 0.6551, 76: 0.6457, 77: 0.6363, 78: 0.6268, 79: 0.6173, 80: 0.6078, 81: 0.5982, 82: 0.5879, 83: 0.5769, 84: 0.5653, 85: 0.5530, 86: 0.5401, 87: 0.5265, 88: 0.5122, 89: 0.4972, 90: 0.4816, 91: 0.4653, 92: 0.4484, 93: 0.4307, 94: 0.4125, 95: 0.3935, 96: 0.3739, 97: 0.3536, 98: 0.3327, 99: 0.3111, 100: 0.2888, 101: 0.2659, 102: 0.2424, 103: 0.2181, 104: 0.1932, 105: 0.1677, 106: 0.1414, 107: 0.1146, 108: 0.0870, 109: 0.0588, 110: 0.0300 } - "42195": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 0.9982, 36: 0.9918, 37: 0.9854, 38: 0.9789, 39: 0.9722, 40: 0.9654, 41: 0.9585, 42: 0.9515, 43: 0.9444, 44: 0.9371, 45: 0.9298, 46: 0.9223, 47: 0.9148, 48: 0.9071, 49: 0.8993, 50: 0.8915, 51: 0.8835, 52: 0.8754, 53: 0.8672, 54: 0.8590, 55: 0.8506, 56: 0.8421, 57: 0.8336, 58: 0.8249, 59: 0.8162, 60: 0.8073, 61: 0.7984, 62: 0.7894, 63: 0.7803, 64: 0.7711, 65: 0.7618, 66: 0.7524, 67: 0.7430, 68: 0.7335, 69: 0.7239, 70: 0.7142, 71: 0.7044, 72: 0.6946, 73: 0.6846, 74: 0.6746, 75: 0.6646, 76: 0.6544, 77: 0.6442, 78: 0.6339, 79: 0.6235, 80: 0.6131, 81: 0.6025, 82: 0.5914, 83: 0.5795, 84: 0.5670, 85: 0.5538, 86: 0.5400, 87: 0.5254, 88: 0.5103, 89: 0.4944, 90: 0.4779, 91: 0.4608, 92: 0.4429, 93: 0.4245, 94: 0.4053, 95: 0.3855, 96: 0.3651, 97: 0.3439, 98: 0.3222, 99: 0.2997, 100: 0.2767, 101: 0.2529, 102: 0.2285, 103: 0.2035, 104: 0.1778, 105: 0.1515, 106: 0.1245, 107: 0.0968, 108: 0.0685, 109: 0.0396, 110: 0.0100 } - -M: - "1500": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 0.9973, 34: 0.9911, 35: 0.9849, 36: 0.9786, 37: 0.9723, 38: 0.9660, 39: 0.9596, 40: 0.9532, 41: 0.9468, 42: 0.9403, 43: 0.9338, 44: 0.9272, 45: 0.9206, 46: 0.9140, 47: 0.9073, 48: 0.9006, 49: 0.8939, 50: 0.8871, 51: 0.8803, 52: 0.8734, 53: 0.8666, 54: 0.8596, 55: 0.8527, 56: 0.8457, 57: 0.8387, 58: 0.8316, 59: 0.8246, 60: 0.8174, 61: 0.8103, 62: 0.8031, 63: 0.7959, 64: 0.7887, 65: 0.7814, 66: 0.7741, 67: 0.7668, 68: 0.7594, 69: 0.7520, 70: 0.7446, 71: 0.7371, 72: 0.7296, 73: 0.7221, 74: 0.7146, 75: 0.7070, 76: 0.6994, 77: 0.6918, 78: 0.6835, 79: 0.6746, 80: 0.6651, 81: 0.6549, 82: 0.6440, 83: 0.6325, 84: 0.6204, 85: 0.6076, 86: 0.5942, 87: 0.5801, 88: 0.5654, 89: 0.5501, 90: 0.5341, 91: 0.5175, 92: 0.5002, 93: 0.4823, 94: 0.4638, 95: 0.4446, 96: 0.4248, 97: 0.4043, 98: 0.3832, 99: 0.3614, 100: 0.3390, 101: 0.3160, 102: 0.2923, 103: 0.2680, 104: 0.2431, 105: 0.2175, 106: 0.1913, 107: 0.1644, 108: 0.1369, 109: 0.1088, 110: 0.0800 } - "3000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 0.9993, 36: 0.9922, 37: 0.9851, 38: 0.9780, 39: 0.9708, 40: 0.9636, 41: 0.9564, 42: 0.9491, 43: 0.9419, 44: 0.9345, 45: 0.9272, 46: 0.9199, 47: 0.9125, 48: 0.9051, 49: 0.8976, 50: 0.8901, 51: 0.8827, 52: 0.8751, 53: 0.8676, 54: 0.8600, 55: 0.8524, 56: 0.8448, 57: 0.8372, 58: 0.8295, 59: 0.8218, 60: 0.8141, 61: 0.8064, 62: 0.7986, 63: 0.7908, 64: 0.7830, 65: 0.7752, 66: 0.7674, 67: 0.7595, 68: 0.7516, 69: 0.7437, 70: 0.7357, 71: 0.7278, 72: 0.7198, 73: 0.7118, 74: 0.7038, 75: 0.6957, 76: 0.6877, 77: 0.6796, 78: 0.6715, 79: 0.6634, 80: 0.6552, 81: 0.6464, 82: 0.6367, 83: 0.6263, 84: 0.6151, 85: 0.6032, 86: 0.5905, 87: 0.5771, 88: 0.5629, 89: 0.5480, 90: 0.5323, 91: 0.5158, 92: 0.4986, 93: 0.4806, 94: 0.4619, 95: 0.4425, 96: 0.4222, 97: 0.4013, 98: 0.3795, 99: 0.3570, 100: 0.3338, 101: 0.3098, 102: 0.2851, 103: 0.2596, 104: 0.2333, 105: 0.2063, 106: 0.1785, 107: 0.1500, 108: 0.1208, 109: 0.0908, 110: 0.0600 } - "5000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 1.0000, 37: 0.9943, 38: 0.9863, 39: 0.9782, 40: 0.9701, 41: 0.9621, 42: 0.9540, 43: 0.9460, 44: 0.9380, 45: 0.9299, 46: 0.9219, 47: 0.9139, 48: 0.9059, 49: 0.8980, 50: 0.8900, 51: 0.8820, 52: 0.8740, 53: 0.8661, 54: 0.8582, 55: 0.8502, 56: 0.8423, 57: 0.8344, 58: 0.8265, 59: 0.8185, 60: 0.8106, 61: 0.8028, 62: 0.7949, 63: 0.7870, 64: 0.7791, 65: 0.7713, 66: 0.7634, 67: 0.7556, 68: 0.7477, 69: 0.7399, 70: 0.7321, 71: 0.7242, 72: 0.7164, 73: 0.7086, 74: 0.7008, 75: 0.6930, 76: 0.6852, 77: 0.6775, 78: 0.6697, 79: 0.6619, 80: 0.6541, 81: 0.6464, 82: 0.6367, 83: 0.6263, 84: 0.6151, 85: 0.6032, 86: 0.5905, 87: 0.5771, 88: 0.5629, 89: 0.5480, 90: 0.5323, 91: 0.5158, 92: 0.4986, 93: 0.4806, 94: 0.4619, 95: 0.4425, 96: 0.4222, 97: 0.4013, 98: 0.3795, 99: 0.3570, 100: 0.3338, 101: 0.3098, 102: 0.2851, 103: 0.2596, 104: 0.2333, 105: 0.2063, 106: 0.1785, 107: 0.1500, 108: 0.1208, 109: 0.0908, 110: 0.0600 } - "10000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 0.9973, 35: 0.9897, 36: 0.9822, 37: 0.9747, 38: 0.9672, 39: 0.9597, 40: 0.9523, 41: 0.9449, 42: 0.9375, 43: 0.9301, 44: 0.9228, 45: 0.9155, 46: 0.9082, 47: 0.9009, 48: 0.8937, 49: 0.8865, 50: 0.8793, 51: 0.8722, 52: 0.8650, 53: 0.8579, 54: 0.8509, 55: 0.8438, 56: 0.8368, 57: 0.8298, 58: 0.8228, 59: 0.8158, 60: 0.8089, 61: 0.8019, 62: 0.7950, 63: 0.7882, 64: 0.7813, 65: 0.7745, 66: 0.7677, 67: 0.7609, 68: 0.7541, 69: 0.7474, 70: 0.7407, 71: 0.7340, 72: 0.7273, 73: 0.7206, 74: 0.7140, 75: 0.7073, 76: 0.7007, 77: 0.6942, 78: 0.6868, 79: 0.6787, 80: 0.6698, 81: 0.6601, 82: 0.6496, 83: 0.6383, 84: 0.6263, 85: 0.6135, 86: 0.5999, 87: 0.5856, 88: 0.5704, 89: 0.5545, 90: 0.5378, 91: 0.5203, 92: 0.5021, 93: 0.4830, 94: 0.4632, 95: 0.4426, 96: 0.4213, 97: 0.3991, 98: 0.3762, 99: 0.3524, 100: 0.3279, 101: 0.3027, 102: 0.2766, 103: 0.2498, 104: 0.2221, 105: 0.1937, 106: 0.1646, 107: 0.1346, 108: 0.1038, 109: 0.0723, 110: 0.0400 } - "21097": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 0.9953, 36: 0.9885, 37: 0.9816, 38: 0.9748, 39: 0.9680, 40: 0.9612, 41: 0.9544, 42: 0.9476, 43: 0.9408, 44: 0.9340, 45: 0.9272, 46: 0.9204, 47: 0.9137, 48: 0.9069, 49: 0.9002, 50: 0.8934, 51: 0.8867, 52: 0.8799, 53: 0.8732, 54: 0.8665, 55: 0.8598, 56: 0.8531, 57: 0.8464, 58: 0.8397, 59: 0.8331, 60: 0.8264, 61: 0.8197, 62: 0.8131, 63: 0.8064, 64: 0.7998, 65: 0.7931, 66: 0.7865, 67: 0.7799, 68: 0.7733, 69: 0.7666, 70: 0.7600, 71: 0.7534, 72: 0.7468, 73: 0.7396, 74: 0.7318, 75: 0.7233, 76: 0.7142, 77: 0.7044, 78: 0.6941, 79: 0.6831, 80: 0.6715, 81: 0.6592, 82: 0.6463, 83: 0.6328, 84: 0.6187, 85: 0.6039, 86: 0.5885, 87: 0.5725, 88: 0.5558, 89: 0.5385, 90: 0.5206, 91: 0.5021, 92: 0.4829, 93: 0.4631, 94: 0.4426, 95: 0.4216, 96: 0.3999, 97: 0.3776, 98: 0.3546, 99: 0.3310, 100: 0.3068, 101: 0.2820, 102: 0.2565, 103: 0.2304, 104: 0.2036, 105: 0.1763, 106: 0.1483, 107: 0.1197, 108: 0.0904, 109: 0.0605, 110: 0.0300 } - "42195": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 1.0000, 37: 1.0000, 38: 0.9947, 39: 0.9876, 40: 0.9804, 41: 0.9733, 42: 0.9661, 43: 0.9589, 44: 0.9517, 45: 0.9445, 46: 0.9372, 47: 0.9299, 48: 0.9226, 49: 0.9153, 50: 0.9079, 51: 0.9005, 52: 0.8931, 53: 0.8857, 54: 0.8783, 55: 0.8708, 56: 0.8633, 57: 0.8558, 58: 0.8483, 59: 0.8407, 60: 0.8331, 61: 0.8255, 62: 0.8179, 63: 0.8103, 64: 0.8026, 65: 0.7950, 66: 0.7873, 67: 0.7796, 68: 0.7718, 69: 0.7641, 70: 0.7563, 71: 0.7485, 72: 0.7407, 73: 0.7329, 74: 0.7250, 75: 0.7165, 76: 0.7074, 77: 0.6976, 78: 0.6871, 79: 0.6760, 80: 0.6643, 81: 0.6519, 82: 0.6388, 83: 0.6251, 84: 0.6108, 85: 0.5958, 86: 0.5801, 87: 0.5638, 88: 0.5469, 89: 0.5293, 90: 0.5110, 91: 0.4921, 92: 0.4726, 93: 0.4524, 94: 0.4316, 95: 0.4101, 96: 0.3879, 97: 0.3651, 98: 0.3417, 99: 0.3176, 100: 0.2929, 101: 0.2675, 102: 0.2415, 103: 0.2148, 104: 0.1875, 105: 0.1595, 106: 0.1309, 107: 0.1017, 108: 0.0718, 109: 0.0412, 110: 0.0100 } \ No newline at end of file diff --git a/test/calcpace/test_age_grading.rb b/test/calcpace/test_age_grading.rb index d4a1884..231490c 100644 --- a/test/calcpace/test_age_grading.rb +++ b/test/calcpace/test_age_grading.rb @@ -8,7 +8,7 @@ def test_age_grade_returns_expected_shape result = @calc.age_grade(10.0, '00:45:00', age: 55, sex: :male) assert_kind_of Hash, result - assert_equal 'WMA_2023_ONE_YEAR_FACTORS_V1', result[:table_version] + assert_equal 'MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1', result[:table_version] assert_includes result.keys, :age_grade_percent assert_includes result.keys, :category assert_includes result.keys, :age_graded_time_seconds @@ -65,6 +65,84 @@ def test_interpolates_factor_for_in_between_age assert result_fifty_seven[:factor] > result_sixty[:factor] end + # --- 2025 road tables (Alan Jones, USATF MLDR) --- + # + # Expected values come from the official spreadsheets in + # github.com/AlanLyttonJones/Age-Grade-Tables, "2025 Files": the factor from + # sheet "Age Factors" and the percentage as age standard / time, with the age + # standard from sheet "AgeStdSec" (MaleRoadStd2025.xlsx / + # FemaleRoadStd2025.xlsx). Percentages are compared at the gem's one decimal. + OFFICIAL_2025_ROAD_CASES = [ + { race: :marathon, time: '03:30:00', age: 40, sex: :male, + factor: 0.9783, open_standard: 7235.0, age_standard: 7395, percent: 58.7 }, + { race: :marathon, time: '04:00:00', age: 50, sex: :female, + factor: 0.8998, open_standard: 7796.0, age_standard: 8664, percent: 60.2 }, + { race: :'5k', time: '00:25:00', age: 30, sex: :female, + factor: 0.9959, open_standard: 834.0, age_standard: 837, percent: 55.8 }, + { race: :half_marathon, time: '01:50:00', age: 60, sex: :male, + factor: 0.8082, open_standard: 3451.0, age_standard: 4270, percent: 64.7 } + ].freeze + + def test_matches_the_official_2025_road_tables + OFFICIAL_2025_ROAD_CASES.each do |official| + result = @calc.age_grade(official[:race], official[:time], age: official[:age], sex: official[:sex]) + label = official.values_at(:sex, :age, :race, :time).join(' ') + official_percent = official[:age_standard] * 100.0 / @calc.convert_to_seconds(official[:time]) + + assert_equal official[:factor], result[:factor], label + assert_equal official[:open_standard], result[:open_standard_seconds], label + assert_equal official[:percent], official_percent.round(1), label + assert_equal official[:percent], result[:age_grade_percent], label + end + end + + def test_uses_the_2025_road_open_standards + expected = { + male: { '5k' => 769.0, '10k' => 1584.0, 'half_marathon' => 3451.0, 'marathon' => 7235.0 }, + female: { '5k' => 834.0, '10k' => 1726.0, 'half_marathon' => 3772.0, 'marathon' => 7796.0 } + } + + expected.each do |sex, races| + races.each do |race, seconds| + assert_equal seconds, @calc.age_grade(race, '01:00:00', age: 25, sex: sex)[:open_standard_seconds], + "#{sex} #{race}" + end + end + end + + def test_open_standard_clock_for_the_marathon + assert_equal '02:00:35', @calc.age_grade(:marathon, '03:00:00', age: 25, sex: :male)[:open_standard_clock] + assert_equal '02:09:56', @calc.age_grade(:marathon, '03:00:00', age: 25, sex: :female)[:open_standard_clock] + end + + def test_young_adults_get_their_own_road_factors + # The road table has real factors under 30: the peak is in the twenties and + # an 18-year-old is graded slightly up, like a masters runner + assert_equal 0.9995, @calc.age_grade(:'5k', '00:20:00', age: 18, sex: :male)[:factor] + assert_equal 0.9680, @calc.age_grade(:marathon, '03:00:00', age: 18, sex: :male)[:factor] + assert_equal 0.9217, @calc.age_grade(:marathon, '03:00:00', age: 18, sex: :female)[:factor] + assert_equal 1.0, @calc.age_grade(:'10k', '00:45:00', age: 25, sex: :male)[:factor] + assert_equal 0.9959, @calc.age_grade(:'5k', '00:25:00', age: 30, sex: :female)[:factor] + end + + def test_factor_table_covers_every_age_from_eighteen_to_one_hundred + AgeGrading::WMA_DATA.each do |sex, distances| + assert_equal %w[5000 10000 21097 42195], distances.keys, sex + distances.each do |distance, table| + assert_equal (18..100).to_a, table.keys.sort, "#{sex} #{distance}" + assert table.values.all? { |factor| factor.positive? && factor <= 1.0 }, "#{sex} #{distance}" + end + end + end + + def test_ages_past_the_table_end_use_the_last_factor + at_hundred = @calc.age_grade(:'10k', '01:30:00', age: 100, sex: :female) + beyond = @calc.age_grade(:'10k', '01:30:00', age: 104, sex: :female) + + assert_equal 0.1477, at_hundred[:factor] + assert_equal at_hundred, beyond + end + def test_label_classification assert_equal 'Approximate World Record Level', @calc.age_grade_label(100.0) assert_equal 'World Class', @calc.age_grade_label(90.0) @@ -85,7 +163,7 @@ def test_age_grade_label_rounding_moves_the_boundary assert_equal 'Intermediate', @calc.age_grade_label(59.9886) assert_equal 'Local Class', @calc.age_grade_label(59.9886.round(1)) - result = @calc.age_grade(10.0, 2750, age: 40, sex: :male) + result = @calc.age_grade(10.0, 2741, age: 40, sex: :male) assert_equal 60.0, result[:age_grade_percent] assert_equal 'Local Class', result[:category] end @@ -208,7 +286,7 @@ def test_age_grade_tolerance_borders_on_both_sides end def test_age_grade_still_rejects_a_non_standard_distance - # 7.79 km is 22% off a 10K. The WMA publishes a factor per specific + # 7.79 km is 22% off a 10K. The road table publishes a factor per specific # distance, so there is no honest number to return here: interpolating one # would invent a value with the look of an official standard error = assert_raises(ArgumentError) do From f0a71a339aed2e8a75fd37d9d5c3c14a6996f0c9 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:27:55 -0300 Subject: [PATCH 04/34] revert: drop the 1.19.0 release bump All review phases will ship together in a single later release. --- CHANGELOG.md | 57 ----------------------------------------- lib/calcpace/version.rb | 2 +- 2 files changed, 1 insertion(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a41777..b3e1b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,63 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.19.0] - 2026-10-02 - -### Changed (numbers) -Four models produced unrealistic numbers. The method names, signatures, return -shapes and the structure of `environmental_factors.yml` are unchanged; only the -values they return move. - -- **Cameron prediction now uses Dave Cameron's actual model.** The previous - constants (`a + b·e^(−d/c)` with a = 0.000495, b = 0.000985, c = 1.4485) were - not Cameron's formula and were far more optimistic than Riegel for the - marathon, while the real model is more conservative. `predict_time_cameron` - and friends now use Cameron's velocity-ratio function, with distances in - metres as in his own metric version (t-and-f mailing list, 20 Jun 2001) and - the had2know.org calculator: - `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905`, - `T2 = T1 · (D2/D1) · f(D1)/f(D2)`. Distances are still passed in km or as race - names. -- **Altitude no longer jumps at 914 m and no longer stops at 2438 m.** The - threshold moves from 914.4 m to 300 m with a new `300: 0.0` point, so the - penalty ramps linearly up to the first NCAA point (914.4 m → 1.41%) instead of - jumping from 0% at 914 m to 1.41% at 915 m. The NCAA points are unchanged. - Above 2438.4 m, where everything used to be capped at 5.90%, three points are - extrapolated from a quadratic fit to the NCAA table - (`p = 0.3647·x² + 1.9482·x`, `x = km − 0.3`): 3000 m → 7.92%, - 3500 m → 9.97%, 4000 m → 12.2% (capped there). The redundant `0: 0.0` point is - gone; the YAML keys are the same. -- **Negative/positive race splits are ±1% per half instead of ±4%.** A 3:00:00 - marathon with `strategy: :negative` used to go through halfway in 1:33:36 (a - 7-minute negative split); it now splits 1:30:54 + 1:29:06. -- **Heat above 30 °C keeps increasing.** 35 °C and 40 °C used to get the same - penalty as 30 °C. Two points extrapolate the 25→30 °C slope (0.44 points/°C): - 35 °C → 8.7% and 40 °C → 10.9% at the 60-minute baseline (capped at 40 °C). - The ideal range and the duration scaling are unchanged. - -| Case | 1.18.1 | 1.19.0 | -| --- | --- | --- | -| Cameron 10K 42:00 → marathon | 02:57:34 | 03:16:46 (Riegel 03:13:12) | -| Cameron 5K 20:00 → marathon | 02:59:25 | 03:15:11 (Riegel 03:11:49) | -| Cameron 5K 20:00 → 10K | 00:42:26 | 00:41:39 | -| Cameron 7.79 km 26:59 → half marathon | 01:13:44 | 01:17:26 | -| Altitude 500 m | 0.0% | 0.46% | -| Altitude 760 m (São Paulo) | 0.0% | 1.06% | -| Altitude 914 m / 915 m | 0.0% / 1.41% | 1.41% / 1.41% | -| Altitude 2800 m | 5.9% | 7.2% | -| Altitude 3600 m | 5.9% | 10.42% | -| Heat 35 °C, 60 min | 6.5% | 8.7% | -| Heat 40 °C, 60 min | 6.5% | 10.9% | -| Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 | -| Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 | - -### Removed -- `CameronPredictor::CAMERON_A`, `CAMERON_B` and `CAMERON_C`. They described - the wrong formula, and keeping them would suggest they still drive the - prediction. The new model's constants are `CAMERON_CONSTANT`, - `CAMERON_LINEAR_COEFFICIENT`, `CAMERON_POWER_COEFFICIENT` and - `CAMERON_POWER_EXPONENT`. - ## [1.18.1] - 2026-09-06 ### Fixed diff --git a/lib/calcpace/version.rb b/lib/calcpace/version.rb index cd718cd..032ee39 100644 --- a/lib/calcpace/version.rb +++ b/lib/calcpace/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Calcpace - VERSION = '1.19.0' + VERSION = '1.18.1' end From 70c1f7db457760ef22d53c204ce0c42c39bf80b8 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:29:25 -0300 Subject: [PATCH 05/34] fix: limit Cameron predictions to 100 km and tighten tests Cameron's f(d) crosses zero near 445 km, so long distances produced negative or absurd times. Add CAMERON_MAX_DISTANCE_KM (100 km, keeps '100k' usable) and raise ArgumentError when either distance exceeds it. Also strengthen the altitude continuity and YAML structure tests, and move the changelog entry under Unreleased, marking the removed CAMERON_A/B/C constants and the new range limit as breaking. --- CHANGELOG.md | 68 ++++++++++++++++++++ README.md | 3 + lib/calcpace/cameron_predictor.rb | 27 +++++++- test/calcpace/test_cameron_predictor.rb | 50 ++++++++++++++ test/calcpace/test_environmental_adjuster.rb | 24 ++++++- 5 files changed, 170 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e1b5f..cb1d10c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (numbers) +Four models produced unrealistic numbers. The method names, signatures, return +shapes and the structure of `environmental_factors.yml` are unchanged; only the +values they return move, except that Cameron predictions now reject distances +above 100 km (see Breaking). + +- **Cameron prediction now uses Dave Cameron's actual model.** The previous + constants (`a + b·e^(−d/c)` with a = 0.000495, b = 0.000985, c = 1.4485) were + not Cameron's formula and were far more optimistic than Riegel for the + marathon, while the real model is more conservative. `predict_time_cameron` + and friends now use Cameron's velocity-ratio function, with distances in + metres as in his own metric version (t-and-f mailing list, 20 Jun 2001) and + the had2know.org calculator: + `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905`, + `T2 = T1 · (D2/D1) · f(D1)/f(D2)`. Distances are still passed in km or as race + names. The model is fitted from 800 m to the marathon and f(d) crosses zero + near 445 km, so distances above `CAMERON_MAX_DISTANCE_KM` (100 km, which keeps + the standard `'100k'` race usable) now raise `ArgumentError` on either end, + in every Cameron method. Without that limit the formula returns a negative + time for a 500 km target, and sources from 400 km up give nonsense or raise + from the clock conversion. +- **Altitude no longer jumps at 914 m and no longer stops at 2438 m.** The + threshold moves from 914.4 m to 300 m with a new `300: 0.0` point, so the + penalty ramps linearly up to the first NCAA point (914.4 m → 1.41%) instead of + jumping from 0% at 914 m to 1.41% at 915 m. The NCAA points are unchanged. + Above 2438.4 m, where everything used to be capped at 5.90%, three points are + extrapolated from a quadratic fit to the NCAA table + (`p = 0.3647·x² + 1.9482·x`, `x = km − 0.3`): 3000 m → 7.92%, + 3500 m → 9.97%, 4000 m → 12.2% (capped there). The redundant `0: 0.0` point is + gone; the YAML keys are the same. +- **Negative/positive race splits are ±1% per half instead of ±4%.** A 3:00:00 + marathon with `strategy: :negative` used to go through halfway in 1:33:36 (a + 7-minute negative split); it now splits 1:30:54 + 1:29:06. +- **Heat above 30 °C keeps increasing.** 35 °C and 40 °C used to get the same + penalty as 30 °C. Two points extrapolate the 25→30 °C slope (0.44 points/°C): + 35 °C → 8.7% and 40 °C → 10.9% at the 60-minute baseline (capped at 40 °C). + The ideal range and the duration scaling are unchanged. + +| Case | Before (1.18.1) | After | +| --- | --- | --- | +| Cameron 10K 42:00 → marathon | 02:57:34 | 03:16:46 (Riegel 03:13:12) | +| Cameron 5K 20:00 → marathon | 02:59:25 | 03:15:11 (Riegel 03:11:49) | +| Cameron 5K 20:00 → 10K | 00:42:26 | 00:41:39 | +| Cameron 7.79 km 26:59 → half marathon | 01:13:44 | 01:17:26 | +| Altitude 500 m | 0.0% | 0.46% | +| Altitude 760 m (São Paulo) | 0.0% | 1.06% | +| Altitude 914 m / 915 m | 0.0% / 1.41% | 1.41% / 1.41% | +| Altitude 2800 m | 5.9% | 7.2% | +| Altitude 3600 m | 5.9% | 10.42% | +| Heat 35 °C, 60 min | 6.5% | 8.7% | +| Heat 40 °C, 60 min | 6.5% | 10.9% | +| Heat 35 °C, 4 h | 29.25% | 39.15% | +| Heat 40 °C, 4 h | 29.25% | 49.05% | +| Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 | +| Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 | + +### Breaking +- Removed `CameronPredictor::CAMERON_A`, `CAMERON_B` and `CAMERON_C`. They described + the wrong formula, and keeping them would suggest they still drive the + prediction. The new model's constants are `CAMERON_CONSTANT`, + `CAMERON_LINEAR_COEFFICIENT`, `CAMERON_POWER_COEFFICIENT` and + `CAMERON_POWER_EXPONENT`. +- Cameron predictions (`predict_time_cameron`, `_clock`, `predict_pace_cameron`, + `_clock`, `predict_time_cameron_adjusted`) raise `ArgumentError` when either + distance exceeds `CAMERON_MAX_DISTANCE_KM` (100 km). 1.18.1 accepted any + distance, but Cameron's model is only fitted up to the marathon and breaks + down past ~445 km. + ## [1.18.1] - 2026-09-06 ### Fixed diff --git a/README.md b/README.md index b0761ff..009d5fe 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,9 @@ from shorter races): `T2 = T1 × (D2/D1) × f(D1)/f(D2)`, with `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905` and `d` in metres (distances are still passed in km or as race names). +Both distances must be at most `CameronPredictor::CAMERON_MAX_DISTANCE_KM` (100 km): +the model is fitted up to the marathon and breaks down far beyond it, so longer +distances raise `ArgumentError`. ```ruby calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "03:16:46" diff --git a/lib/calcpace/cameron_predictor.rb b/lib/calcpace/cameron_predictor.rb index f60d21e..684fb29 100644 --- a/lib/calcpace/cameron_predictor.rb +++ b/lib/calcpace/cameron_predictor.rb @@ -14,6 +14,11 @@ # Distances are accepted in kilometres (or as race names) like every other method # in this gem, and converted to metres before f(d) is evaluated. # +# Valid range: the model was fitted from 800 m to the marathon, and f(d) crosses +# zero near 445 km, beyond which it returns negative or absurd times. Distances +# above CAMERON_MAX_DISTANCE_KM (100 km, so the standard '100k' race stays usable) +# raise ArgumentError on either end of the prediction. +# # References: # - Dave Cameron, metric version of his model posted to the t-and-f mailing list, # 20 Jun 2001: https://www.mail-archive.com/t-and-f@lists.uoregon.edu/msg11312.html @@ -29,6 +34,8 @@ module CameronPredictor CAMERON_POWER_COEFFICIENT = 835.7114 # Exponent of the power term of f(d) CAMERON_POWER_EXPONENT = 0.7905 + # Longest distance (km), on either end, a Cameron prediction accepts + CAMERON_MAX_DISTANCE_KM = 100.0 # Predicts race time using the Cameron formula # @@ -37,7 +44,8 @@ module CameronPredictor # @param from_time [String, Numeric] time achieved at known distance (HH:MM:SS or seconds) # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name # @return [Float] predicted time in seconds - # @raise [ArgumentError] if a race name is invalid or the distances are the same + # @raise [ArgumentError] if a race name is invalid, the distances are the same, + # or either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km) # @raise [Calcpace::NonPositiveInputError] if a numeric distance is not positive # # @example Predict marathon time from 10K @@ -51,6 +59,7 @@ def predict_time_cameron(from_race, from_time, to_race) from_distance = race_distance(from_race) to_distance = race_distance(to_race) + ensure_cameron_range!(from_distance, to_distance) ensure_different_distances!(from_distance, to_distance) time_seconds = from_time.is_a?(String) ? convert_to_seconds(from_time) : from_time @@ -67,6 +76,7 @@ def predict_time_cameron(from_race, from_time, to_race) # @param from_time [String, Numeric] time achieved at known distance # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name # @return [String] predicted time in HH:MM:SS format + # @raise [ArgumentError] if either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km) # # @example # predict_time_cameron_clock('10k', '00:42:00', 'marathon') @@ -81,6 +91,7 @@ def predict_time_cameron_clock(from_race, from_time, to_race) # @param from_time [String, Numeric] time achieved at known distance # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name # @return [Float] predicted pace in seconds per kilometer + # @raise [ArgumentError] if either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km) # # @example # predict_pace_cameron('5k', '00:20:00', 'marathon') @@ -95,6 +106,7 @@ def predict_pace_cameron(from_race, from_time, to_race) # @param from_time [String, Numeric] time achieved at known distance # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name # @return [String] predicted pace in HH:MM:SS format + # @raise [ArgumentError] if either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km) # # @example # predict_pace_cameron_clock('5k', '00:20:00', 'marathon') @@ -110,6 +122,7 @@ def predict_pace_cameron_clock(from_race, from_time, to_race) # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name # @param options [Hash] environmental options (temperature, altitude, etc.) # @return [Hash] hash with adjusted prediction and penalty details + # @raise [ArgumentError] if either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km) def predict_time_cameron_adjusted(from_race, from_time, to_race, **) predicted_seconds = predict_time_cameron(from_race, from_time, to_race) adjust_time(predicted_seconds, **) @@ -117,6 +130,18 @@ def predict_time_cameron_adjusted(from_race, from_time, to_race, **) private + # Rejects distances outside the range where Cameron's model is meaningful + # + # @param distances [Array] distances in kilometers + # @raise [ArgumentError] if any distance exceeds CAMERON_MAX_DISTANCE_KM + def ensure_cameron_range!(*distances) + too_long = distances.find { |distance| distance > CAMERON_MAX_DISTANCE_KM } + return unless too_long + + raise ArgumentError, + "Cameron formula is only valid up to #{CAMERON_MAX_DISTANCE_KM} km (got #{too_long} km)" + end + # Evaluates Cameron's velocity-ratio function f(d) for a given distance # # @param distance_km [Float] distance in kilometers (converted to metres, the diff --git a/test/calcpace/test_cameron_predictor.rb b/test/calcpace/test_cameron_predictor.rb index d8a6270..7aa883e 100644 --- a/test/calcpace/test_cameron_predictor.rb +++ b/test/calcpace/test_cameron_predictor.rb @@ -192,6 +192,56 @@ def test_zero_time_raises end end + # ── valid distance range ───────────────────────────────────────────────── + # f(d) crosses zero near 445 km, so beyond the fitted range the formula returns + # negative or absurd times. Distances above CAMERON_MAX_DISTANCE_KM raise. + + CAMERON_VARIANTS = %i[predict_time_cameron predict_time_cameron_clock + predict_pace_cameron predict_pace_cameron_clock + predict_time_cameron_adjusted].freeze + + def test_max_distance_constant + assert_in_delta 100.0, CameronPredictor::CAMERON_MAX_DISTANCE_KM, 0.0 + end + + def test_max_distance_is_accepted_as_source_and_target + CAMERON_VARIANTS.each do |method| + @calc.public_send(method, 100, '08:00:00', 'marathon') + @calc.public_send(method, '100k', '08:00:00', 'marathon') + @calc.public_send(method, 'marathon', '03:30:00', 100) + @calc.public_send(method, 'marathon', '03:30:00', '100k') + end + end + + def test_max_distance_prediction_is_sane + # Marathon 3:30:00 → 100 km should be slower than 2.37× the marathon time + result = @calc.predict_time_cameron('marathon', '03:30:00', '100k') + + assert_operator result, :>, 12_600 * (100 / 42.195) + end + + def test_distances_above_the_max_raise_as_source + [100.1, 445.5, 1000].each do |distance| + CAMERON_VARIANTS.each do |method| + error = assert_raises(ArgumentError, "#{method} from #{distance} km") do + @calc.public_send(method, distance, '10:00:00', 'marathon') + end + assert_match(/Cameron.*100\.0 km/, error.message) + end + end + end + + def test_distances_above_the_max_raise_as_target + [100.1, 445.5, 1000].each do |distance| + CAMERON_VARIANTS.each do |method| + error = assert_raises(ArgumentError, "#{method} to #{distance} km") do + @calc.public_send(method, '10k', '00:42:00', distance) + end + assert_match(/Cameron.*100\.0 km/, error.message) + end + end + end + # ── adjusted predictions ─────────────────────────────────────────────────── def test_predict_time_cameron_adjusted_with_heat diff --git a/test/calcpace/test_environmental_adjuster.rb b/test/calcpace/test_environmental_adjuster.rb index 49ce877..fe4366d 100644 --- a/test/calcpace/test_environmental_adjuster.rb +++ b/test/calcpace/test_environmental_adjuster.rb @@ -112,7 +112,18 @@ def test_altitude_at_or_below_threshold_has_no_penalty end def test_altitude_is_continuous_just_above_the_threshold - assert_in_delta 0.0, @calc.calculate_penalty(altitude: 301)[:factors][:altitude], 0.01 + at_threshold = @calc.calculate_penalty(altitude: 300)[:factors][:altitude] + ten_above = @calc.calculate_penalty(altitude: 310)[:factors][:altitude] + + assert_operator ten_above, :>, at_threshold, 'penalty should start growing right above the threshold' + assert_operator ten_above - at_threshold, :<, 0.05 + end + + def test_altitude_is_continuous_across_the_first_ncaa_point + below = @calc.calculate_penalty(altitude: 914.3)[:factors][:altitude] + above = @calc.calculate_penalty(altitude: 914.5)[:factors][:altitude] + + assert_operator (above - below).abs, :<, 0.05 end def test_altitude_is_continuous_around_the_first_ncaa_point @@ -174,5 +185,16 @@ def test_environmental_data_keeps_the_structure_the_site_reads assert_kind_of Hash, altitude.fetch('data_points') assert_equal [10.0, 15.0], heat.fetch('ideal_range_celsius') assert_kind_of Hash, heat.fetch('data_points') + + [altitude, heat].each do |table| + table.fetch('data_points').each do |key, value| + assert_kind_of Numeric, key + assert_kind_of Numeric, value + end + end + + first_key, first_value = altitude.fetch('data_points').min_by { |key, _| key } + assert_in_delta altitude.fetch('threshold_meters'), first_key, 0.0 + assert_in_delta 0.0, first_value, 0.0 end end From ab09d4ec5e4b0af525d9374a28ec99cd3cfb310f Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:30:22 -0300 Subject: [PATCH 06/34] feat: marathon from training volume and personal Riegel exponent Add PersonalizedPredictor with predictions built from the runner's own data: - predict_marathon_from_training: Tanda (2011) regression on mean weekly distance and mean training pace over the 8 weeks before the race. Inputs or a predicted time outside the paper's sample are flagged in :out_of_range instead of raising. - riegel_exponent: personal k fitted to two performances. - predict_time_personal: Riegel from the performance closer to the target (log-distance) with k clamped to [1.01, 1.20]. --- lib/calcpace.rb | 2 + lib/calcpace/personalized_predictor.rb | 186 +++++++++++++++ test/calcpace/test_personalized_predictor.rb | 226 +++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 lib/calcpace/personalized_predictor.rb create mode 100644 test/calcpace/test_personalized_predictor.rb diff --git a/lib/calcpace.rb b/lib/calcpace.rb index 363c153..2bcd658 100644 --- a/lib/calcpace.rb +++ b/lib/calcpace.rb @@ -12,6 +12,7 @@ require_relative 'calcpace/lap_analyzer' require_relative 'calcpace/pace_calculator' require_relative 'calcpace/pace_converter' +require_relative 'calcpace/personalized_predictor' require_relative 'calcpace/race_predictor' require_relative 'calcpace/race_splits' require_relative 'calcpace/stride_calculator' @@ -52,6 +53,7 @@ class Calcpace include LapAnalyzer include PaceCalculator include PaceConverter + include PersonalizedPredictor include RacePredictor include RaceSplits include StrideCalculator diff --git a/lib/calcpace/personalized_predictor.rb b/lib/calcpace/personalized_predictor.rb new file mode 100644 index 0000000..68b6e95 --- /dev/null +++ b/lib/calcpace/personalized_predictor.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +# Module for race predictions built from the runner's own data rather than from +# a population-wide constant +# +# Two models live here: +# +# - Tanda (2011): marathon time from the volume and pace of the last weeks of +# training — no race result needed. +# - A personal Riegel exponent fitted to two of the runner's own performances, +# used instead of the fixed 1.06 of RacePredictor#predict_time. +module PersonalizedPredictor + # Tanda (2011) regression coefficients for marathon pace in seconds per km: + # + # Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P + # + # K = mean weekly distance (km/week), P = mean training pace (s/km), both + # averaged over the 8 weeks ending one week before the race. + # + # @see https://doi.org/10.4100/jhse.2011.63.05 G. Tanda, "Prediction of + # marathon performance time on the basis of training indices", Journal of + # Human Sport and Exercise 6(3):511–520, 2011 + TANDA_INTERCEPT = 17.1 + TANDA_VOLUME_AMPLITUDE = 140.0 + TANDA_VOLUME_DECAY = 0.0053 + TANDA_PACE_SLOPE = 0.55 + + # Ranges spanned by the paper's sample (Table 2: 22 runners, 21 of them men, + # 46 marathons). The equation was fitted inside them; outside, it is an + # extrapolation. The author names the finish-time range as the validity range. + TANDA_WEEKLY_DISTANCE_RANGE_KM = (40.4..110.7) + TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM = (253.3..330.6) + TANDA_MARATHON_TIME_RANGE_SECONDS = ((167 * 60.0)..(216 * 60.0)) + + MARATHON_KM = 42.195 + + # Personal Riegel exponents outside this range almost never describe fitness: + # below 1.01 the longer race was run at practically the shorter one's pace, + # above 1.20 the runner slows down more than even an untrained endurance + # profile would — in both cases one of the two races was most likely not an + # all-out effort (or not on a comparable course or day). + PERSONAL_EXPONENT_RANGE = (1.01..1.20) + + # Predicts marathon time from training volume and pace — Tanda (2011) + # + # Uses Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P, where Pm is marathon + # pace (s/km), K the mean weekly distance (km/week) and P the mean training + # pace (s/km) over the 8 weeks ending one week before the race. Training pace + # is the plain average of every run, warm-ups and recoveries included (total + # time ÷ total distance), not the pace of the quality sessions. The paper + # reports a standard error of about 4 minutes on the finish time. + # + # Inputs or a predicted time outside the paper's sample do not raise — the + # prediction is still returned, flagged in :out_of_range. + # + # @param weekly_distance [Numeric] mean weekly training distance, in unit per week + # @param training_pace [Numeric, String] mean training pace per unit, in + # seconds or as a clock string ('05:30') + # @param unit [Symbol, String] :km (default) or :mi — applies to both inputs + # and to the returned pace + # @return [Hash] :time (seconds), :time_clock (HH:MM:SS), :pace (seconds per + # unit), :pace_clock, :within_validated_range (Boolean) and :out_of_range + # (Array of :weekly_distance, :training_pace and/or :marathon_time) + # @raise [Calcpace::NonPositiveInputError] if an input is not positive + # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi + # + # @example + # calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:time_clock] # => "03:19:41" + # calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:pace] # => 283.96 + # calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30')[:out_of_range] + # # => [:weekly_distance, :marathon_time] + def predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km) + check_positive(weekly_distance, 'Weekly distance') + pace_seconds = training_pace.is_a?(String) ? convert_to_seconds(training_pace) : training_pace + check_positive(pace_seconds, 'Training pace') + + km_per_unit = normalize_distance_km(1, unit) + weekly_km = weekly_distance * km_per_unit + pace_km = pace_seconds / km_per_unit + + marathon_pace_km = tanda_marathon_pace(weekly_km, pace_km) + tanda_result(marathon_pace_km, km_per_unit, tanda_out_of_range(weekly_km, pace_km, marathon_pace_km)) + end + + # Fits a personal Riegel exponent to two performances + # + # k = ln(t2 / t1) / ln(d2 / d1). The fixed 1.06 of RacePredictor is a + # population average; a runner's own k says how much they slow down as the + # distance grows. A value far from 1.06 — below about 1.01 or above about + # 1.20 — usually means one of the two races was not an all-out effort, or + # was run on a course or day that is not comparable. + # + # @param race1 [Numeric, String, Symbol] distance in km or race name + # @param time1 [String, Numeric] time at race1 (HH:MM:SS or seconds) + # @param race2 [Numeric, String, Symbol] distance in km or race name + # @param time2 [String, Numeric] time at race2 (HH:MM:SS or seconds) + # @return [Float] the exponent (order of the two performances does not matter) + # @raise [ArgumentError] if both races are the same distance or a race name is unknown + # @raise [Calcpace::NonPositiveInputError] if a distance or time is not positive + # + # @example + # calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') # => 1.0961 + def riegel_exponent(race1, time1, race2, time2) + distance1, seconds1 = performance(race1, time1) + distance2, seconds2 = performance(race2, time2) + ensure_different_distances!(distance1, distance2) + + Math.log(seconds2 / seconds1) / Math.log(distance2 / distance1) + end + + # Predicts a race time with a personal Riegel exponent + # + # Fits k to the two performances (see #riegel_exponent), clamps it to + # PERSONAL_EXPONENT_RANGE, and applies Riegel from whichever performance is + # closer to the target in log-distance — the shorter extrapolation. On an + # exact tie (target at the geometric mean of the two) the first one is used. + # + # @param race1 [Numeric, String, Symbol] distance in km or race name + # @param time1 [String, Numeric] time at race1 (HH:MM:SS or seconds) + # @param race2 [Numeric, String, Symbol] distance in km or race name + # @param time2 [String, Numeric] time at race2 (HH:MM:SS or seconds) + # @param to_race [Numeric, String, Symbol] target distance in km or race name + # @return [Hash] :time (seconds), :time_clock (HH:MM:SS), :exponent (the one + # used, after clamping), :raw_exponent (as fitted), :clamped (Boolean) + # @raise [ArgumentError] if the two races are the same distance, the target + # is one of them, or a race name is unknown + # @raise [Calcpace::NonPositiveInputError] if a distance or time is not positive + # + # @example + # calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock] + # # => "03:38:03" + # calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:clamped] # => false + def predict_time_personal(race1, time1, race2, time2, to_race) + raw = riegel_exponent(race1, time1, race2, time2) + exponent = raw.clamp(PERSONAL_EXPONENT_RANGE.min, PERSONAL_EXPONENT_RANGE.max) + target = race_distance(to_race) + anchor_distance, anchor_seconds = closest_performance([performance(race1, time1), performance(race2, time2)], + target) + ensure_different_distances!(anchor_distance, target) + + time = (anchor_seconds * ((target / anchor_distance)**exponent)).round(2) + { time: time, time_clock: convert_to_clocktime(time), exponent: exponent.round(4), + raw_exponent: raw.round(4), clamped: exponent != raw } + end + + private + + def tanda_marathon_pace(weekly_km, pace_km) + TANDA_INTERCEPT + (TANDA_VOLUME_AMPLITUDE * Math.exp(-TANDA_VOLUME_DECAY * weekly_km)) + + (TANDA_PACE_SLOPE * pace_km) + end + + def tanda_out_of_range(weekly_km, pace_km, marathon_pace_km) + checks = { + weekly_distance: TANDA_WEEKLY_DISTANCE_RANGE_KM.cover?(weekly_km), + training_pace: TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM.cover?(pace_km), + marathon_time: TANDA_MARATHON_TIME_RANGE_SECONDS.cover?(marathon_pace_km * MARATHON_KM) + } + checks.reject { |_name, inside| inside }.keys + end + + def tanda_result(marathon_pace_km, km_per_unit, out_of_range) + time = (marathon_pace_km * MARATHON_KM).round(2) + pace = (marathon_pace_km * km_per_unit).round(2) + + { + time: time, + time_clock: convert_to_clocktime(time), + pace: pace, + pace_clock: convert_to_clocktime(pace), + within_validated_range: out_of_range.empty?, + out_of_range: out_of_range + } + end + + # A performance as [distance in km, time in seconds], validated + def performance(race, time) + seconds = time.is_a?(String) ? convert_to_seconds(time) : time + check_positive(seconds, 'Time') + [race_distance(race), seconds.to_f] + end + + def closest_performance(performances, target) + performances.min_by { |distance, _seconds| Math.log(target / distance).abs } + end +end diff --git a/test/calcpace/test_personalized_predictor.rb b/test/calcpace/test_personalized_predictor.rb new file mode 100644 index 0000000..660b365 --- /dev/null +++ b/test/calcpace/test_personalized_predictor.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Tests for predictions built from the runner's own data: training volume +# (Tanda 2011) and a personal Riegel exponent fitted to two performances +class TestPersonalizedPredictor < CalcpaceTest + # --- Tanda (2011): marathon from training volume --------------------------- + + # Pm = 17.1 + 140.0 * exp(-0.0053 * 60) + 0.55 * 300 = 283.96 s/km + def test_marathon_from_training_applies_the_tanda_equation + result = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300) + + assert_in_delta 283.96, result[:pace], 0.01 + assert_in_delta 283.9644 * 42.195, result[:time], 0.01 + assert_equal '03:19:41', result[:time_clock] + assert_equal '00:04:43', result[:pace_clock] + end + + def test_the_sample_means_reproduce_the_sample_mean_marathon_pace + # Table 2 of the paper: K = 65.9 km/week and P = 284.6 s/km on average, + # mean race pace 271.8 s/km. A regression evaluated at the means of its own + # predictors has to land next to the mean of its response. + result = @calc.predict_marathon_from_training(weekly_distance: 65.9, training_pace: 284.6) + + assert_in_delta 271.8, result[:pace], 1.0 + end + + def test_training_pace_accepts_a_clock_string + numeric = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300) + clock = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00') + + assert_equal numeric, clock + end + + def test_more_volume_and_faster_training_both_predict_a_faster_marathon + base = @calc.predict_marathon_from_training(weekly_distance: 50, training_pace: 300)[:time] + more_volume = @calc.predict_marathon_from_training(weekly_distance: 80, training_pace: 300)[:time] + faster_training = @calc.predict_marathon_from_training(weekly_distance: 50, training_pace: 280)[:time] + + assert_operator more_volume, :<, base + assert_operator faster_training, :<, base + end + + def test_inputs_inside_the_paper_sample_are_flagged_as_validated + result = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300) + + assert result[:within_validated_range] + assert_empty result[:out_of_range] + end + + def test_low_volume_is_flagged_but_still_predicted + # 40 km/week is just under the sample minimum of 40.4 km/week, and the + # resulting 3:39 marathon is slower than the slowest one in the sample (3:36) + result = @calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30') + + refute result[:within_validated_range] + assert_equal %i[weekly_distance marathon_time], result[:out_of_range] + assert_equal '03:39:18', result[:time_clock] + end + + def test_slow_training_pace_is_flagged + result = @calc.predict_marathon_from_training(weekly_distance: 25, training_pace: '06:30') + + refute result[:within_validated_range] + assert_equal %i[weekly_distance training_pace marathon_time], result[:out_of_range] + end + + def test_fast_training_pace_is_flagged + result = @calc.predict_marathon_from_training(weekly_distance: 100, training_pace: '04:00') + + assert_includes result[:out_of_range], :training_pace + end + + def test_boundaries_of_the_sample_are_inside_the_validated_range + low = @calc.predict_marathon_from_training(weekly_distance: 40.4, training_pace: 330.6) + high = @calc.predict_marathon_from_training(weekly_distance: 110.7, training_pace: 253.3) + + refute_includes low[:out_of_range], :weekly_distance + refute_includes low[:out_of_range], :training_pace + refute_includes high[:out_of_range], :weekly_distance + refute_includes high[:out_of_range], :training_pace + end + + def test_miles_convert_both_inputs_and_report_pace_per_mile + # 25 mi/week at 8:00/mi is 40.23 km/week at 298.26 s/km + result = @calc.predict_marathon_from_training(weekly_distance: 25, training_pace: '08:00', unit: :mi) + km = @calc.predict_marathon_from_training(weekly_distance: 25 * 1.609344, training_pace: 480 / 1.609344) + + assert_in_delta km[:time], result[:time], 0.01 + assert_in_delta km[:pace] * 1.609344, result[:pace], 0.01 + assert_in_delta 473.56, result[:pace], 0.01 + assert_equal '00:07:53', result[:pace_clock] + assert_equal km[:out_of_range], result[:out_of_range] + end + + def test_unit_is_case_insensitive + upper = @calc.predict_marathon_from_training(weekly_distance: 25, training_pace: 480, unit: 'MI') + lower = @calc.predict_marathon_from_training(weekly_distance: 25, training_pace: 480, unit: :mi) + + assert_equal lower, upper + end + + def test_unsupported_unit_raises + assert_raises(Calcpace::UnsupportedUnitError) do + @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300, unit: :furlong) + end + end + + def test_non_positive_inputs_raise + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: 0, training_pace: 300) + end + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: -1) + end + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 'abc') + end + end + + def test_non_numeric_weekly_distance_raises + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: '60', training_pace: 300) + end + end + + # --- Personal Riegel exponent ---------------------------------------------- + + def test_riegel_exponent_from_two_performances + # ln(6120 / 2700) / ln(21.0975 / 10) + exponent = @calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') + + assert_in_delta 1.096094, exponent, 1e-6 + end + + def test_riegel_exponent_recovers_the_standard_exponent + half = @calc.predict_time('10k', 2700, 'half_marathon') + + assert_in_delta 1.06, @calc.riegel_exponent('10k', 2700, 'half_marathon', half), 1e-9 + end + + def test_riegel_exponent_does_not_depend_on_order + forward = @calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') + backward = @calc.riegel_exponent('half_marathon', '01:42:00', '10k', '00:45:00') + + assert_in_delta forward, backward, 1e-12 + end + + def test_riegel_exponent_accepts_numeric_distances_and_seconds + named = @calc.riegel_exponent('5k', '00:20:00', '10k', '00:42:00') + numeric = @calc.riegel_exponent(5, 1200, '10', 2520) + + assert_in_delta named, numeric, 1e-12 + end + + def test_riegel_exponent_rejects_the_same_distance + assert_error_with_message(ArgumentError, 'different distances') do + @calc.riegel_exponent('10k', '00:45:00', 10, '00:46:00') + end + end + + def test_riegel_exponent_rejects_unknown_race_and_non_positive_time + assert_raises(ArgumentError) { @calc.riegel_exponent('10q', 2700, '5k', 1300) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.riegel_exponent('10k', 0, '5k', 1300) } + end + + def test_personal_prediction_uses_the_performance_closest_to_the_target + # The half marathon is closer to the marathon than the 10K, so the + # prediction scales 1:42:00 by (42.195 / 21.0975)^1.096094 + result = @calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon') + + assert_in_delta 6120 * (2**1.096094), result[:time], 0.1 + assert_equal '03:38:03', result[:time_clock] + assert_in_delta 1.0961, result[:exponent], 1e-4 + assert_in_delta 1.0961, result[:raw_exponent], 1e-4 + refute result[:clamped] + end + + def test_personal_prediction_anchor_does_not_depend_on_argument_order + forward = @calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', '5k') + backward = @calc.predict_time_personal('half_marathon', '01:42:00', '10k', '00:45:00', '5k') + + assert_in_delta forward[:time], backward[:time], 1e-6 + # 5K is anchored on the 10K: 2700 * (5 / 10)^1.096094 + assert_in_delta 2700 * (0.5**1.096094), forward[:time], 0.1 + end + + def test_an_implausibly_low_exponent_is_clamped + # A 10K at 3:00/km followed by a half at the same pace: k = 1.0 + result = @calc.predict_time_personal('10k', 1800, 'half_marathon', 3797.55, 'marathon') + + assert result[:clamped] + assert_in_delta 1.0, result[:raw_exponent], 1e-4 + assert_in_delta 1.01, result[:exponent], 1e-12 + assert_in_delta 3797.55 * (2**1.01), result[:time], 0.1 + end + + def test_an_implausibly_high_exponent_is_clamped + result = @calc.predict_time_personal('5k', '00:20:00', '10k', '00:50:00', 'half_marathon') + + assert result[:clamped] + assert_operator result[:raw_exponent], :>, 1.2 + assert_in_delta 1.2, result[:exponent], 1e-12 + end + + def test_a_longer_race_run_faster_is_clamped_not_raised + result = @calc.predict_time_personal('5k', '00:25:00', '10k', '00:24:00', 'marathon') + + assert result[:clamped] + assert_operator result[:raw_exponent], :<, 0 + assert_in_delta 1.01, result[:exponent], 1e-12 + end + + def test_personal_prediction_rejects_a_target_already_known + assert_raises(ArgumentError) do + @calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'half_marathon') + end + end + + def test_personal_prediction_rejects_two_performances_at_the_same_distance + assert_raises(ArgumentError) do + @calc.predict_time_personal('10k', '00:45:00', '10k', '00:44:00', 'marathon') + end + end +end From 57707aea1eef61a5756c5c89e80070e33f9bb28d Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:31:19 -0300 Subject: [PATCH 07/34] docs: pin age-grade source commit and note WMA_DATA key removal Link the 2025 road tables at the commit the data came from, correct the age-clamp note (old table ran to 110, new ends at 100), list the dropped WMA_DATA track keys under Breaking, and replace the vacuous interpolation test with one that drives a sparse stubbed table. --- CHANGELOG.md | 17 ++++++++++++----- README.md | 5 +++-- test/calcpace/test_age_grading.rb | 15 ++++++++++++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1d30a..93e8893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking +- The public `AgeGrading::WMA_DATA` constant no longer has the track keys + (`"1500"`, `"3000"`): each sex now holds only the road distances the gem + grades — `"5000"`, `"10000"`, `"21097"` and `"42195"`. Code that read + `WMA_DATA["M"]["1500"]` (or `"3000"`) directly gets a `KeyError` / `nil`. + Its factor tables also start at age 18 and end at 100 (they were 30–110). + ### Changed - **Age grading now uses the 2025 road tables.** Age factors and open standards come from Alan Jones' 2025 road age-grading tables, approved on 2025-01-10 by the USATF Masters Long Distance Running Council - ([source spreadsheets](https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/master/2025%20Files): + ([source spreadsheets](https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files): `MaleRoadStd2025.xlsx`, `FemaleRoadStd2025.xlsx`). The previous data, despite the `wma_2023_road.yml` name, came from the WMA 2023 **track and field** tables: track open standards (5000 m 12:35 / 14:06, 10 000 m @@ -21,15 +28,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 2:00:35; women: 5K 13:54, 10K 28:46, half 1:02:52, marathon 2:09:56. - One factor per year of age from 18 to 100. Runners under 30 now get the table's real factors instead of 1.0 (e.g. a male 18-year-old at 5K: 0.9995; - a female 30-year-old at 5K: 0.9959). Ages over 100 use the age-100 factor, - as ages over the table end did before. + a female 30-year-old at 5K: 0.9959). The old table ran to 110; the 2025 + road tables end at 100, so ages 101 and over now use the age-100 factor. - `table_version` is now `"MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"` (was `"WMA_2023_ONE_YEAR_FACTORS_V1"`). The data files were renamed to `lib/calcpace/data/mldr_2025_road.yml` and `lib/calcpace/data/mldr_2025_road_open_standards.yml`; `DATA_PATH`, `OPEN_STANDARDS_DATA_PATH`, `WMA_DATA`, `OPEN_STANDARDS_DATA` and - `TABLE_VERSION` keep their names and shapes. The track-only keys - (`"1500"`, `"3000"`) are gone. Category labels are unchanged. + `TABLE_VERSION` keep their names and shapes (see Breaking for the keys + `WMA_DATA` lost). Category labels are unchanged. | Case | Before (WMA 2023 track) | After (2025 road) | Official 2025 | | --- | --- | --- | --- | diff --git a/README.md b/README.md index aa93112..190bc04 100644 --- a/README.md +++ b/README.md @@ -311,8 +311,9 @@ Age factors and open standards come from Alan Jones' **2025 road** age-grading tables, approved on 2025-01-10 by the USATF Masters Long Distance Running Council — the standard for road races, the same tables behind Howard Grubb's MLDR road calculator. The source spreadsheets are `MaleRoadStd2025.xlsx` and -`FemaleRoadStd2025.xlsx` in -https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/master/2025%20Files. +`FemaleRoadStd2025.xlsx`, linked here at the commit the bundled data was +taken from: +https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files The bundled data has one factor per year of age from 18 to 100 (older ages use the age-100 factor) and lives in `lib/calcpace/data/mldr_2025_road.yml` (factors) and `lib/calcpace/data/mldr_2025_road_open_standards.yml` (open standards and diff --git a/test/calcpace/test_age_grading.rb b/test/calcpace/test_age_grading.rb index 231490c..ff56de1 100644 --- a/test/calcpace/test_age_grading.rb +++ b/test/calcpace/test_age_grading.rb @@ -56,7 +56,7 @@ def test_age_factored_time_rounds_up_to_hundredth assert_equal result[:age_graded_time_seconds], (scaled / 100.0) end - def test_interpolates_factor_for_in_between_age + def test_factor_decreases_with_age_through_the_masters_years result_fifty_five = @calc.age_grade(10.0, '00:45:00', age: 55, sex: :male) result_fifty_seven = @calc.age_grade(10.0, '00:45:00', age: 57, sex: :male) result_sixty = @calc.age_grade(10.0, '00:45:00', age: 60, sex: :male) @@ -65,6 +65,19 @@ def test_interpolates_factor_for_in_between_age assert result_fifty_seven[:factor] > result_sixty[:factor] end + def test_interpolates_linearly_between_the_ages_of_a_sparse_table + # The bundled table has every age from 18 to 100, so interpolation only + # matters for a replacement table with gaps (e.g. five-year steps) + sparse = { 30 => 1.0, 40 => 0.9, 50 => 0.8 } + @calc.define_singleton_method(:factor_table) { |_sex, _distance_m| sparse } + + assert_equal 0.97, @calc.age_grade(10.0, '00:45:00', age: 33, sex: :male)[:factor] + assert_equal 0.85, @calc.age_grade(10.0, '00:45:00', age: 45, sex: :male)[:factor] + assert_equal 0.9, @calc.age_grade(10.0, '00:45:00', age: 40, sex: :male)[:factor] + assert_equal 1.0, @calc.age_grade(10.0, '00:45:00', age: 25, sex: :male)[:factor] + assert_equal 0.8, @calc.age_grade(10.0, '00:45:00', age: 70, sex: :male)[:factor] + end + # --- 2025 road tables (Alan Jones, USATF MLDR) --- # # Expected values come from the official spreadsheets in From 3ecc0af3a17c15c17cb42a323f3ba9bec20e295d Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:31:28 -0300 Subject: [PATCH 08/34] docs: personalized predictions in README and CHANGELOG --- CHANGELOG.md | 26 ++++++++++++++++++++++ README.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e1b5f..7980de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km)` + predicts a marathon from the mean weekly distance and mean training pace of + the 8 weeks before the race, with Tanda (2011), *Journal of Human Sport and + Exercise* 6(3):511–520: `Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P`. + Returns `:time`, `:time_clock`, `:pace`, `:pace_clock` (in `unit`, `:km` or + `:mi`), `:within_validated_range` and `:out_of_range`, which lists any of + `:weekly_distance` (sample: 40.4–110.7 km/week), `:training_pace` + (253.3–330.6 s/km) and `:marathon_time` (167–216 min) that fall outside the + paper's sample. Out of range is a flag, not an error. + + ```ruby + calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:time_clock] # => "03:19:41" + ``` +- `riegel_exponent(race1, time1, race2, time2)` fits a personal Riegel + exponent, `ln(t2/t1) / ln(d2/d1)`, to two performances. +- `predict_time_personal(race1, time1, race2, time2, to_race)` predicts with + that exponent, clamped to 1.01–1.20, from whichever performance is closer to + the target in log-distance. Returns `:time`, `:time_clock`, `:exponent`, + `:raw_exponent` and `:clamped`; an exponent that needed clamping usually + means one of the races was not all-out. + + ```ruby + calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock] # => "03:38:03" + ``` + ## [1.18.1] - 2026-09-06 ### Fixed diff --git a/README.md b/README.md index 8afc603..1835908 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,69 @@ the caller's, and it always was, standard race names included. --- +### Personalized Predictions + +**Marathon from training volume** — Tanda (2011), no race result needed. The +inputs are the mean weekly distance and the mean training pace over the 8 weeks +ending one week before the race: + +```ruby +calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00') +# => { time: 11981.88, time_clock: "03:19:41", pace: 283.96, pace_clock: "00:04:43", +# within_validated_range: true, out_of_range: [] } + +calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30') +# => { time: 13158.72, time_clock: "03:39:18", pace: 311.86, pace_clock: "00:05:11", +# within_validated_range: false, out_of_range: [:weekly_distance, :marathon_time] } + +# unit: :mi — weekly miles, pace per mile in and out +calc.predict_marathon_from_training(weekly_distance: 25, training_pace: '08:00', unit: :mi)[:pace_clock] # => "00:07:53" +``` + +The equation is `Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P` (Pm marathon +pace in s/km, K km/week, P s/km). Training pace is the plain average of every +run — total time over total distance, warm-ups and easy days included — not the +pace of the hard sessions. The paper reports a standard error of about 4 minutes. + +It was fitted on 22 experienced runners (21 men) and 46 marathons, so it is only +validated inside that sample: 40.4–110.7 km/week, training pace 4:13–5:31/km, +finish 2:47–3:36. Outside it the prediction is still returned, and +`out_of_range` names what fell outside (`:weekly_distance`, `:training_pace`, +`:marathon_time`) — a warning, not an error. A low-volume runner at an easy pace +will usually see all three. + +> G. Tanda, "Prediction of marathon performance time on the basis of training +> indices", *Journal of Human Sport and Exercise* 6(3):511–520, 2011. +> doi:10.4100/jhse.2011.63.05 + +**Personal Riegel exponent** — fit the fatigue factor to two of your own races +instead of the population 1.06: + +```ruby +calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') # => 1.0961 + +calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon') +# => { time: 13083.04, time_clock: "03:38:03", exponent: 1.0961, raw_exponent: 1.0961, clamped: false } +``` + +The standard Riegel gives 3:32:39 from that half and 3:27:00 from that 10K; this +runner fades more than average, and the personal exponent says so. The +prediction is made from whichever race is closer to the target (in log-distance), +with the exponent clamped to 1.01–1.20: + +```ruby +calc.predict_time_personal('5k', '00:20:00', '10k', '00:50:00', 'half_marathon') +# => { time: 7348.5, time_clock: "02:02:28", exponent: 1.2, raw_exponent: 1.3219, clamped: true } +``` + +An exponent outside that range — or `clamped: true` — usually means one of the +two races was not an all-out effort, or was run on a course or day that does +not compare with the other. Both races may be names or distances in km; two +races at the same distance, or a target equal to one of them, raise +`ArgumentError`. + +--- + ### GPS Track Analysis Accepts an array of hashes with `:lat`, `:lon`, and optionally `:ele` (metres) and `:time` (`Time`): From 4d603bc97f97367067921e5fa7cc032d712f06be Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:35:42 -0300 Subject: [PATCH 09/34] feat: grade-adjusted pace from Minetti et al. (2002) Add grade_adjustment_factor, grade_adjusted_pace(_clock) and track_grade_adjusted_splits, which returns the track_splits fields plus a per-split :gap. Grades are measured over segments of at least 100 m so GPS elevation noise does not turn into fake climbing; points without :ele are flat. track_splits and estimate_detailed_vo2max are unchanged. --- CHANGELOG.md | 12 + README.md | 52 +++++ lib/calcpace.rb | 2 + lib/calcpace/grade_adjusted_pace.rb | 114 ++++++++++ lib/calcpace/track_calculator.rb | 174 ++++++++++++++- lib/calcpace/vo2max_estimator.rb | 7 + test/calcpace/test_grade_adjusted_pace.rb | 253 ++++++++++++++++++++++ 7 files changed, 604 insertions(+), 10 deletions(-) create mode 100644 lib/calcpace/grade_adjusted_pace.rb create mode 100644 test/calcpace/test_grade_adjusted_pace.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e1b5f..78e0487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Grade-adjusted pace from the energy cost of running on gradients of + Minetti et al. (2002), J Appl Physiol 93:1039–1046: + `grade_adjustment_factor(grade)` (Cr(i)/Cr(0), grade as a fraction, clamped + to the measured ±0.45), `grade_adjusted_pace(pace, grade, unit: :km)` and + `grade_adjusted_pace_clock(pace, grade, unit: :km, compact: false)`. +- `track_grade_adjusted_splits(points, split_km = 1.0, compact: false)`: the + `track_splits` splits with a `:gap` pace per split, computed segment by + segment from `:ele`. Grades are measured over segments of at least 100 m of + horizontal distance so GPS elevation noise does not become fake climbing; + stretches without `:ele` are flat. `track_splits` output is unchanged. + ## [1.18.1] - 2026-09-06 ### Fixed diff --git a/README.md b/README.md index 8afc603..30c6af5 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,58 @@ in both formats (`"-00:40"` / `"-0:40"`) rather than raising. **Haversine formula** — great-circle distance on a sphere (R = 6,371 km). Accuracy: ~0.3% of GPS/WGS84. Best for running and cycling distances; not for geodetic surveying. +#### Grade-adjusted pace (GAP) + +The flat-ground pace that costs the same energy as a pace run on a slope, from +the energy cost of running on gradients measured by **Minetti et al. (2002)**. +The grade is a fraction (rise over horizontal distance): `0.05` is 5% uphill, +`-0.05` is 5% downhill. + +```ruby +calc.grade_adjustment_factor(0.1) # => 1.6578372222222222 (a metre at +10% ≈ 1.66 flat metres) +calc.grade_adjustment_factor(-0.1) # => 0.5976961111111111 + +calc.grade_adjusted_pace(360, 0.1) # => 217.1503903847952 (s/km) +calc.grade_adjusted_pace_clock('06:00', 0.1) # => "00:03:37" +calc.grade_adjusted_pace_clock('06:00', 0.1, compact: true) # => "3:37" +calc.grade_adjusted_pace(480, 0.05, unit: :mi) # => 368.82127811700303 (s/mi) + +# Per-split GAP for a GPS track: the track_splits fields plus :gap +calc.track_grade_adjusted_splits(points, 1.0) +# => [{ km: 1.0, elapsed: 415, pace: "06:55", gap: "06:50" }, +# { km: 1.51, elapsed: 600, pace: "06:04", gap: "06:21" }] +``` + +| Grade | −10% | −5% | 0% | +5% | +10% | +|-------|------|-----|----|-----|------| +| Factor | 0.598 | 0.763 | 1.000 | 1.301 | 1.658 | + +**Formula** (J·kg⁻¹·m⁻¹, R² = 0.999): +``` +Cr(i) = 155.4·i⁵ − 30.4·i⁴ − 43.3·i³ + 46.3·i² + 19.5·i + 3.6 +factor = Cr(i) / Cr(0) +GAP = pace / factor +``` + +- Grades are clamped to **±45%**, the range Minetti et al. measured; nothing + is extrapolated beyond it. Running is cheapest near −20% and gets dearer + again on steeper descents. +- It is a metabolic model: it does not see the muscular cost of long descents + or technical terrain, and field models fitted to heart rate (Strava's, for + instance) are gentler on steep climbs. +- `track_grade_adjusted_splits` leaves `track_splits` untouched: it returns the + same `:km`, `:elapsed` and `:pace` with `:gap` added (formatted like `:pace`, + `compact:` applies to both). GPS elevation is noisy, so grades are measured + over **grade segments of at least 100 m** of horizontal distance — read + between fixes a metre apart, ±2 m of jitter would be a ±400% grade. A short + leftover at the end of a stretch joins the segment before it. Stretches + between points without `:ele` count as flat, so a track with no elevation + has `:gap` equal to `:pace`. +- `estimate_detailed_vo2max` keeps its own flat elevation heuristic (100 m of + gain = 600 m of flat), so its numbers do not change. + +*Minetti, A. E., Moia, C., Roi, G. S., Susta, D., & Ferretti, G. (2002). Energy cost of walking and running at extreme uphill and downhill slopes. Journal of Applied Physiology, 93(3), 1039–1046. https://doi.org/10.1152/japplphysiol.01177.2001* + --- ### Age Grading (Road Races) diff --git a/lib/calcpace.rb b/lib/calcpace.rb index 363c153..f4b17ab 100644 --- a/lib/calcpace.rb +++ b/lib/calcpace.rb @@ -9,6 +9,7 @@ require_relative 'calcpace/converter' require_relative 'calcpace/converter_chain' require_relative 'calcpace/fitness_predictor' +require_relative 'calcpace/grade_adjusted_pace' require_relative 'calcpace/lap_analyzer' require_relative 'calcpace/pace_calculator' require_relative 'calcpace/pace_converter' @@ -49,6 +50,7 @@ class Calcpace include Converter include ConverterChain include FitnessPredictor + include GradeAdjustedPace include LapAnalyzer include PaceCalculator include PaceConverter diff --git a/lib/calcpace/grade_adjusted_pace.rb b/lib/calcpace/grade_adjusted_pace.rb new file mode 100644 index 0000000..da8a97f --- /dev/null +++ b/lib/calcpace/grade_adjusted_pace.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# Module for grade-adjusted pace (GAP): the flat-ground pace that costs the +# same energy as a pace run on a slope +# +# Uses the energy cost of running on gradients measured by Minetti et al. +# (2002) on ten runners on a treadmill inclined from −45% to +45%: +# +# Cr(i) = 155.4·i⁵ − 30.4·i⁴ − 43.3·i³ + 46.3·i² + 19.5·i + 3.6 (R² = 0.999) +# +# where Cr is the metabolic cost in J·kg⁻¹·m⁻¹ and i the gradient as a +# fraction (rise over horizontal run, 0.05 = 5%). The adjustment factor is +# Cr(i) / Cr(0): running a metre of 10% climb costs about 1.66 flat metres, +# a metre of 10% descent about 0.60. Cost is cheapest near −20% and rises +# again on steeper descents, where braking takes over. +# +# The flat-equivalent pace is pace / factor: the speed that, on level ground, +# spends energy at the same rate. Minetti found the cost per metre independent +# of speed, so the factor does not depend on how fast the runner is going. +# +# Limits worth knowing: +# - The polynomial is a fit to treadmill measurements between −0.45 and +0.45; +# grades outside that range are clamped to it rather than extrapolated. +# - It is a metabolic model. It does not account for the muscular cost of long +# descents, technical terrain or the fact that a runner rarely holds the +# metabolic equivalent on a steep climb — field GAP models fitted to heart +# rate (Strava's, for instance) are gentler on climbs. +# - Its constant term (3.6) is the fit's value on the flat; Minetti measured +# 3.40 ± 0.24 J·kg⁻¹·m⁻¹ there. The factor divides by Cr(0) so it is exactly +# 1.0 on the flat. +# +# Reference: Minetti, A. E., Moia, C., Roi, G. S., Susta, D., & Ferretti, G. +# (2002). Energy cost of walking and running at extreme uphill and downhill +# slopes. Journal of Applied Physiology, 93(3), 1039–1046. +# https://doi.org/10.1152/japplphysiol.01177.2001 +module GradeAdjustedPace + # Gradient range of Minetti et al.'s measurements, as fractions + MINETTI_GRADE_RANGE = (-0.45..0.45) + + # Polynomial coefficients for Cr(i), highest power first (J·kg⁻¹·m⁻¹) + MINETTI_RUNNING_COEFFICIENTS = [155.4, -30.4, -43.3, 46.3, 19.5, 3.6].freeze + + # Returns how many flat metres one metre at a given gradient is worth + # + # @param grade [Numeric] gradient as a fraction (0.05 = 5% uphill, −0.05 = 5% + # downhill); clamped to −0.45..0.45, the range Minetti et al. measured + # @return [Float] Cr(grade) / Cr(0) — 1.0 on the flat, above 1 uphill, + # below 1 on moderate descents + # @raise [ArgumentError] if grade is not a finite number + # + # @example + # calc.grade_adjustment_factor(0) #=> 1.0 + # calc.grade_adjustment_factor(0.1) #=> 1.6578372222222222 + # calc.grade_adjustment_factor(-0.1) #=> 0.5976961111111111 + def grade_adjustment_factor(grade) + check_grade(grade) + + minetti_running_cost(grade.to_f.clamp(MINETTI_GRADE_RANGE)) / minetti_running_cost(0.0) + end + + # Converts a pace run on a slope into the flat pace of equal effort + # + # @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS or HH:MM:SS) + # @param grade [Numeric] gradient as a fraction (see #grade_adjustment_factor) + # @param unit [Symbol, String] unit the pace is expressed in — :km (default) or + # :mi. The factor is per distance, so the result comes back in the same unit + # and the unit only has to be a supported one + # @return [Float] flat-equivalent pace in seconds per unit + # @raise [ArgumentError] if grade is not a finite number + # @raise [Calcpace::NonPositiveInputError] if pace is not positive + # @raise [Calcpace::InvalidTimeFormatError] if a string pace is not a valid clock + # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi + # + # @example + # calc.grade_adjusted_pace(360, 0.1) #=> 217.1503903847952 + # calc.grade_adjusted_pace('05:00', -0.05) #=> 393.31023895122013 + # calc.grade_adjusted_pace(480, 0.05, unit: :mi) #=> 368.82127811700303 + def grade_adjusted_pace(pace, grade, unit: :km) + pace_unit_meters(unit) + factor = grade_adjustment_factor(grade) + pace_seconds = pace_seconds_from(pace) + check_positive(pace_seconds, 'Pace') + + pace_seconds.to_f / factor + end + + # Grade-adjusted pace as a clock string + # + # @param pace [Numeric, String] pace in seconds per unit or time string + # @param grade [Numeric] gradient as a fraction + # @param unit [Symbol, String] unit the pace is expressed in — :km (default) or :mi + # @param compact [Boolean] when true, return the compact display format + # @return [String] flat-equivalent pace in HH:MM:SS format, or 'M:SS' / 'H:MM:SS' + # with compact: true + # + # @example + # calc.grade_adjusted_pace_clock('06:00', 0.1) #=> '00:03:37' + # calc.grade_adjusted_pace_clock('06:00', 0.1, compact: true) #=> '3:37' + def grade_adjusted_pace_clock(pace, grade, unit: :km, compact: false) + convert_to_clocktime(grade_adjusted_pace(pace, grade, unit: unit), compact: compact) + end + + private + + def check_grade(grade) + return if grade.is_a?(Numeric) && grade.to_f.finite? + + raise ArgumentError, "Grade must be a finite number (a fraction: 0.05 = 5%), got #{grade.inspect}" + end + + def minetti_running_cost(grade) + MINETTI_RUNNING_COEFFICIENTS.reduce(0.0) { |sum, coefficient| (sum * grade) + coefficient } + end +end diff --git a/lib/calcpace/track_calculator.rb b/lib/calcpace/track_calculator.rb index a50d257..6c73812 100644 --- a/lib/calcpace/track_calculator.rb +++ b/lib/calcpace/track_calculator.rb @@ -27,6 +27,19 @@ module TrackCalculator # Mean radius of the Earth in kilometers (IAU standard) EARTH_RADIUS_KM = 6371.0 + # Shortest horizontal distance a grade is measured over in + # #track_grade_adjusted_splits. GPS elevation is noisy — a few metres between + # consecutive fixes even from a barometric altimeter — so a grade read + # between points 1 m apart can be ±300% on flat ground, and the grade factor's + # curvature turns that symmetric noise into a fake climb. Over 100 m, ±1–2 m + # of noise is ±1–2% of grade, while a hill longer than a track straight still + # shows up. + GRADE_SEGMENT_MIN_KM = 0.1 + + # Slack on GRADE_SEGMENT_MIN_KM so ten 10 m steps, which sum to a hair under + # 0.1 km in floating point, still close a 100 m grade segment (1 mm) + GRADE_SEGMENT_TOLERANCE_KM = 1e-6 + # Computes the great-circle distance between two GPS coordinates using # the Haversine formula. # @@ -156,6 +169,45 @@ def track_splits(points, split_km = 1.0, compact: false) collect_splits(points, split_km, compact: compact) end + # Pace splits with a grade-adjusted pace (GAP) for each split. + # + # Each split is the hash #track_splits returns — same :km, :elapsed and :pace, + # split boundaries computed the same way — plus :gap, the flat-ground pace of + # equal effort for that split (Minetti et al., 2002; see GradeAdjustedPace). + # #track_splits itself is unchanged. + # + # How :gap is computed: + # - The track is cut into grade segments of at least GRADE_SEGMENT_MIN_KM + # (100 m) of horizontal distance, and each gets one grade: its net + # elevation change over its length. A leftover shorter than that at the end + # of a stretch is merged into the segment before it. Grades are clamped to + # ±45%, the range the model was measured on. + # - A stretch between points without :ele is flat (factor 1.0), and a missing + # :ele ends the grade segment in progress. + # - Each split's distance is weighted by the grade factor of the segments it + # covers, and :gap is the split's time over that flat-equivalent distance. + # A split on flat ground, or with no elevation data, has :gap equal to :pace. + # + # @param points [Array] points with :lat, :lon and :time keys, and + # optionally :ele (metres), as for #track_splits + # @param split_km [Numeric] split interval in kilometers (default: 1.0) + # @param compact [Boolean] when true, :pace and :gap use the compact display format + # @return [Array] split hashes with :km, :elapsed, :pace and :gap + # (:gap formatted like :pace) + # @raise [ArgumentError] if split_km is not positive + # @raise [ArgumentError] if any point is missing a :time key + # + # @example a steady 5% climb at 5:00/km + # calc.track_grade_adjusted_splits(points, 1.0) + # #=> [{ km: 1.0, elapsed: 300, pace: "05:00", gap: "03:51" }, ...] + def track_grade_adjusted_splits(points, split_km = 1.0, compact: false) + raise ArgumentError, 'split_km must be positive' unless split_km.is_a?(Numeric) && split_km.positive? + return [] if points.nil? || points.size < 2 + + validate_points_have_time(points) + collect_splits(points, split_km, compact: compact, grade_factors: segment_grade_factors(points)) + end + private def haversine_km(lat1, lon1, lat2, lon2) @@ -253,41 +305,50 @@ def format_pace(pace_seconds, compact:) format('%02d:%02d', min: pace_seconds / 60, sec: pace_seconds % 60) end - def collect_splits(points, split_km, compact:) + # grade_factors, when given, holds one grade factor per segment (see + # #segment_grade_factors) and turns on the :gap field; without it the splits + # are exactly the ones #track_splits has always returned. + def collect_splits(points, split_km, compact:, grade_factors: nil) state = { splits: [], start_time: point_time(points.first), split_start_time: point_time(points.first), - accumulated_km: 0.0, split_number: 1, compact: compact } + accumulated_km: 0.0, split_number: 1, compact: compact, + grade_factors: grade_factors, flat_equivalent_km: 0.0 } - points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) } + points.each_cons(2).with_index { |(a, b), index| process_segment(a, b, split_km, state, index) } append_partial_split(points.last, split_km, state) state[:splits] end - def process_segment(point_a, point_b, split_km, state) - segment_km = haversine_distance(dig_key(point_a, :lat), dig_key(point_a, :lon), - dig_key(point_b, :lat), dig_key(point_b, :lon)) + def process_segment(point_a, point_b, split_km, state, index) + segment_km = segment_distance_km(point_a, point_b) state[:accumulated_km] += segment_km + state[:segment] = { km: segment_km, assigned_km: 0.0, factor: state[:grade_factors]&.fetch(index) } while state[:accumulated_km] >= split_km * state[:split_number] record_split(point_a, point_b, segment_km, split_km, state) end + + accumulate_flat_equivalent(state, segment_km) end def record_split(point_a, point_b, segment_km, split_km, state) offset = (split_km * state[:split_number]) - (state[:accumulated_km] - segment_km) + accumulate_flat_equivalent(state, offset) boundary_time = interpolate_time(point_a, point_b, segment_km, offset) state[:splits] << build_split_entry(boundary_time, split_km, state) state[:split_start_time] = boundary_time state[:split_number] += 1 + state[:flat_equivalent_km] = 0.0 end def build_split_entry(boundary_time, split_km, state) split_elapsed = (boundary_time - state[:split_start_time]).round - { + entry = { km: (split_km * state[:split_number]).round(2), elapsed: (boundary_time - state[:start_time]).round, pace: seconds_to_pace(split_elapsed, split_km, compact: state[:compact]) } + with_gap(entry, split_elapsed, state) end def append_partial_split(last_point, split_km, state) @@ -295,11 +356,104 @@ def append_partial_split(last_point, split_km, state) return unless remaining_km > 0.001 last_time = point_time(last_point) - state[:splits] << { + split_elapsed = (last_time - state[:split_start_time]).round + entry = { km: state[:accumulated_km].round(2), elapsed: (last_time - state[:start_time]).round, - pace: seconds_to_pace((last_time - state[:split_start_time]).round, remaining_km, - compact: state[:compact]) + pace: seconds_to_pace(split_elapsed, remaining_km, compact: state[:compact]) } + state[:splits] << with_gap(entry, split_elapsed, state) + end + + def segment_distance_km(point_a, point_b) + haversine_distance(dig_key(point_a, :lat), dig_key(point_a, :lon), + dig_key(point_b, :lat), dig_key(point_b, :lon)) + end + + # Adds the flat-equivalent distance of the current segment up to + # distance_into_segment, for the part not yet credited to an earlier split + def accumulate_flat_equivalent(state, distance_into_segment) + segment = state[:segment] + return unless segment[:factor] + + state[:flat_equivalent_km] += (distance_into_segment - segment[:assigned_km]) * segment[:factor] + segment[:assigned_km] = distance_into_segment + end + + def with_gap(entry, split_elapsed, state) + return entry unless state[:grade_factors] + + entry.merge(gap: seconds_to_pace(split_elapsed, state[:flat_equivalent_km], compact: state[:compact])) + end + + # One grade factor per segment (consecutive point pair). Segments are grouped + # into grade segments of at least GRADE_SEGMENT_MIN_KM, each with one grade + # (net elevation change over horizontal distance); see + # #track_grade_adjusted_splits for the rules. + def segment_grade_factors(points) + factors = Array.new(points.size - 1, 1.0) + window = new_grade_window + + points.each_cons(2).with_index do |(a, b), index| + window = extend_grade_window(window, factors, a, b, index) + end + close_grade_window(window, factors, final: true) + factors + end + + def new_grade_window(previous = nil) + { indexes: [], km: 0.0, start_ele: nil, end_ele: nil, previous: previous } + end + + def extend_grade_window(window, factors, point_a, point_b, index) + ele_a = fetch_ele(point_a) + ele_b = fetch_ele(point_b) + if ele_a.nil? || ele_b.nil? + close_grade_window(window, factors, final: true) + return new_grade_window + end + + add_to_grade_window(window, index, segment_distance_km(point_a, point_b), ele_a, ele_b) + return window unless full_grade_window?(window) + + close_grade_window(window, factors, final: false) + new_grade_window(window.except(:previous)) + end + + def add_to_grade_window(window, index, segment_km, ele_a, ele_b) + window[:start_ele] ||= ele_a + window[:end_ele] = ele_b + window[:indexes] << index + window[:km] += segment_km + end + + # A full window gets its own grade. A short leftover — the end of the track + # or of a stretch with elevation — is merged into the full window before it, + # when there is one, rather than graded on its own over a few noisy metres. + def close_grade_window(window, factors, final:) + return if window[:indexes].empty? + + window = merge_grade_windows(window[:previous], window) if final && short_with_previous?(window) + factor = grade_adjustment_factor(window_grade(window)) + window[:indexes].each { |index| factors[index] = factor } + end + + def short_with_previous?(window) + !full_grade_window?(window) && window[:previous] + end + + def full_grade_window?(window) + window[:km] >= GRADE_SEGMENT_MIN_KM - GRADE_SEGMENT_TOLERANCE_KM + end + + def merge_grade_windows(previous, window) + { indexes: previous[:indexes] + window[:indexes], km: previous[:km] + window[:km], + start_ele: previous[:start_ele], end_ele: window[:end_ele] } + end + + def window_grade(window) + return 0.0 unless window[:km].positive? + + (window[:end_ele] - window[:start_ele]) / (window[:km] * 1000.0) end end diff --git a/lib/calcpace/vo2max_estimator.rb b/lib/calcpace/vo2max_estimator.rb index 87584e5..fc04354 100644 --- a/lib/calcpace/vo2max_estimator.rb +++ b/lib/calcpace/vo2max_estimator.rb @@ -61,6 +61,13 @@ def estimate_vo2max(distance, time, distance_unit: :km) # Estimates a detailed and contextualized VO2max # + # Elevation is folded in with a flat heuristic — every 100 m of gain adds + # 600 m of equivalent flat distance — that ignores where the climbing is and + # gives nothing back for descents. It is kept as is so results stay + # comparable across versions. For a profile-aware view of a GPS track, see + # GradeAdjustedPace#grade_adjusted_pace and + # TrackCalculator#track_grade_adjusted_splits (Minetti et al., 2002). + # # @param distance [Numeric] race distance, in kilometres by default or in # the unit given by distance_unit # @param time [String, Integer] finish time diff --git a/test/calcpace/test_grade_adjusted_pace.rb b/test/calcpace/test_grade_adjusted_pace.rb new file mode 100644 index 0000000..ec732f0 --- /dev/null +++ b/test/calcpace/test_grade_adjusted_pace.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Tests for grade-adjusted pace (Minetti et al., 2002) and its GPS-track splits +class TestGradeAdjustedPace < CalcpaceTest + # Minetti et al. (2002) running polynomial, evaluated independently of the + # library so a typo in a coefficient cannot pass by agreeing with itself + def minetti(grade) + (155.4 * (grade**5)) - (30.4 * (grade**4)) - (43.3 * (grade**3)) + + (46.3 * (grade**2)) + (19.5 * grade) + 3.6 + end + + # --------------------------------------------------------------------------- + # grade_adjustment_factor + # --------------------------------------------------------------------------- + + def test_factor_is_one_on_the_flat + assert_in_delta 1.0, @calc.grade_adjustment_factor(0), 1e-12 + end + + def test_factor_follows_the_minetti_polynomial + [-0.3, -0.1, -0.05, 0.05, 0.1, 0.3].each do |grade| + assert_in_delta minetti(grade) / 3.6, @calc.grade_adjustment_factor(grade), 1e-12 + end + end + + def test_factor_known_values + assert_in_delta 1.658, @calc.grade_adjustment_factor(0.10), 0.001 + assert_in_delta 0.598, @calc.grade_adjustment_factor(-0.10), 0.001 + end + + def test_factor_is_cheapest_around_minus_twenty_percent + assert_operator @calc.grade_adjustment_factor(-0.20), :<, @calc.grade_adjustment_factor(-0.10) + assert_operator @calc.grade_adjustment_factor(-0.20), :<, @calc.grade_adjustment_factor(-0.30) + end + + def test_factor_clamps_to_the_measured_range + assert_equal @calc.grade_adjustment_factor(0.45), @calc.grade_adjustment_factor(0.6) + assert_equal @calc.grade_adjustment_factor(-0.45), @calc.grade_adjustment_factor(-1) + end + + def test_factor_rejects_non_numeric_or_non_finite_grades + [nil, '0.05', Float::NAN, Float::INFINITY, -Float::INFINITY].each do |grade| + assert_raises(ArgumentError) { @calc.grade_adjustment_factor(grade) } + end + end + + # --------------------------------------------------------------------------- + # grade_adjusted_pace / grade_adjusted_pace_clock + # --------------------------------------------------------------------------- + + def test_uphill_pace_is_faster_on_the_flat + assert_in_delta 360 / (minetti(0.10) / 3.6), @calc.grade_adjusted_pace(360, 0.10), 1e-9 + end + + def test_downhill_pace_is_slower_on_the_flat + assert_operator @calc.grade_adjusted_pace(300, -0.05), :>, 300 + end + + def test_flat_pace_is_unchanged + assert_in_delta 300.0, @calc.grade_adjusted_pace(300, 0), 1e-12 + end + + def test_accepts_a_clock_pace + assert_equal @calc.grade_adjusted_pace(360, 0.05), @calc.grade_adjusted_pace('06:00', 0.05) + end + + def test_returns_a_float + assert_kind_of Float, @calc.grade_adjusted_pace(300, 0) + end + + def test_unit_does_not_change_the_math + assert_equal @calc.grade_adjusted_pace(480, 0.05), @calc.grade_adjusted_pace(480, 0.05, unit: :mi) + end + + def test_rejects_unknown_unit + assert_raises(Calcpace::UnsupportedUnitError) { @calc.grade_adjusted_pace(300, 0.05, unit: :furlong) } + end + + def test_rejects_non_positive_pace + assert_raises(Calcpace::NonPositiveInputError) { @calc.grade_adjusted_pace(0, 0.05) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.grade_adjusted_pace(-300, 0.05) } + end + + def test_rejects_malformed_clock_pace + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.grade_adjusted_pace('06:xx', 0.05) } + end + + def test_rejects_invalid_grade + assert_raises(ArgumentError) { @calc.grade_adjusted_pace(300, nil) } + end + + def test_clock_formats + assert_equal '00:03:37', @calc.grade_adjusted_pace_clock('06:00', 0.10) + assert_equal '3:37', @calc.grade_adjusted_pace_clock('06:00', 0.10, compact: true) + end + + # --------------------------------------------------------------------------- + # track_grade_adjusted_splits + # --------------------------------------------------------------------------- + + # Degrees of latitude per kilometre on the gem's spherical Earth + KM_PER_DEGREE = TrackCalculator::EARTH_RADIUS_KM * Math::PI / 180.0 + + # A straight track going north, one point every step_m metres, run at a + # steady pace. ele: is a lambda of the horizontal distance in metres, or nil + # to leave elevation out entirely. + def build_track(distance_m:, step_m: 10, pace_sec_per_km: 300, ele: ->(_m) { 100.0 }) + start = Time.new(2026, 1, 1, 7, 0, 0) + (0..(distance_m / step_m)).map do |i| + metres = i * step_m + point = { lat: metres / 1000.0 / KM_PER_DEGREE, lon: 0.0, time: start + (metres / 1000.0 * pace_sec_per_km) } + point[:ele] = ele.call(metres) if ele + point + end + end + + def pace_seconds(clock) + sign = clock.start_with?('-') ? -1 : 1 + minutes, seconds = clock.delete_prefix('-').split(':').map(&:to_i) + sign * ((minutes * 60) + seconds) + end + + def test_keeps_track_splits_fields_identical + points = build_track(distance_m: 2500, ele: ->(m) { 100 + (0.05 * m) }) + + gap_splits = @calc.track_grade_adjusted_splits(points, 1.0) + + assert_equal(@calc.track_splits(points, 1.0), + gap_splits.map { |split| split.except(:gap) }) + end + + def test_track_splits_output_is_unchanged_by_elevation + flat = build_track(distance_m: 2500) + hilly = build_track(distance_m: 2500, ele: ->(m) { 100 + (0.05 * m) }) + + assert_equal @calc.track_splits(flat, 1.0), @calc.track_splits(hilly, 1.0) + assert_equal %i[km elapsed pace], @calc.track_splits(hilly, 1.0).first.keys + end + + def test_adds_a_gap_field_to_every_split + points = build_track(distance_m: 2500) + result = @calc.track_grade_adjusted_splits(points, 1.0) + + assert_equal 3, result.size + result.each { |split| assert_equal %i[km elapsed pace gap], split.keys } + end + + def test_flat_track_gap_equals_pace + points = build_track(distance_m: 2500) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal split[:pace], split[:gap] + end + end + + def test_track_without_elevation_is_treated_as_flat + points = build_track(distance_m: 2500, ele: nil) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal split[:pace], split[:gap] + end + end + + def test_steady_climb_gap_matches_the_single_grade_formula + points = build_track(distance_m: 3000, ele: ->(m) { 100 + (0.05 * m) }) + expected = @calc.grade_adjusted_pace(300, 0.05) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal '05:00', split[:pace] + assert_in_delta expected, pace_seconds(split[:gap]), 1 + end + end + + def test_steady_descent_gap_is_slower_than_pace + points = build_track(distance_m: 2000, ele: ->(m) { 500 - (0.05 * m) }) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_operator pace_seconds(split[:gap]), :>, pace_seconds(split[:pace]) + end + end + + # One-metre GPS points with up to ±2 m of elevation jitter on flat ground. + # Read point to point that is a grade of up to ±400% on every segment — + # clamped to ±45%, and the factor's curvature turns that symmetric noise into + # a fake climb worth a GAP around 1:30/km. Over 100 m grade segments what is + # left is the noise at the split's two ends (up to 4 m of net "climb" over + # 1 km, about 2%) and a little curvature: a few seconds + def test_elevation_noise_on_short_segments_does_not_create_fake_grades + points = build_track(distance_m: 2000, step_m: 1, ele: ->(m) { 100 + (2.0 * Math.sin(m * 1.7)) }) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_in_delta pace_seconds(split[:pace]), pace_seconds(split[:gap]), 10 + end + end + + def test_climb_then_descent_lands_in_the_right_splits + points = build_track(distance_m: 2000, ele: ->(m) { m <= 1000 ? 100 + (0.08 * m) : 180 - (0.08 * (m - 1000)) }) + first, second = @calc.track_grade_adjusted_splits(points, 1.0) + + assert_in_delta @calc.grade_adjusted_pace(300, 0.08), pace_seconds(first[:gap]), 1 + assert_in_delta @calc.grade_adjusted_pace(300, -0.08), pace_seconds(second[:gap]), 1 + end + + def test_points_missing_elevation_are_flat_segments + # Elevation dropped for the whole second kilometre (a GPS fix without + # altitude): that kilometre counts as flat, the first keeps its climb + points = build_track(distance_m: 2000, ele: ->(m) { 100 + (0.05 * m) }) + points.each { |point| point.delete(:ele) if point[:lat] * KM_PER_DEGREE > 1.0 } + first, second = @calc.track_grade_adjusted_splits(points, 1.0) + + assert_operator pace_seconds(first[:gap]), :<, pace_seconds(first[:pace]) + assert_equal second[:pace], second[:gap] + end + + def test_string_keys_are_accepted + points = build_track(distance_m: 1000, ele: ->(m) { 100 + (0.05 * m) }) + .map { |point| point.transform_keys(&:to_s) } + + split = @calc.track_grade_adjusted_splits(points, 1.0).first + assert_operator pace_seconds(split[:gap]), :<, pace_seconds(split[:pace]) + end + + def test_compact_gap_format + points = build_track(distance_m: 1000, ele: ->(m) { 100 + (0.05 * m) }) + padded = @calc.track_grade_adjusted_splits(points, 1.0).first + compact = @calc.track_grade_adjusted_splits(points, 1.0, compact: true).first + + assert_equal padded[:gap].delete_prefix('0'), compact[:gap] + assert_equal '5:00', compact[:pace] + end + + def test_partial_split_gets_a_gap + points = build_track(distance_m: 1500, ele: ->(m) { 100 + (0.05 * m) }) + partial = @calc.track_grade_adjusted_splits(points, 1.0).last + + assert_equal 1.5, partial[:km] + assert_in_delta @calc.grade_adjusted_pace(300, 0.05), pace_seconds(partial[:gap]), 1 + end + + def test_empty_or_single_point_tracks_return_empty + assert_equal [], @calc.track_grade_adjusted_splits([], 1.0) + assert_equal [], @calc.track_grade_adjusted_splits(nil, 1.0) + assert_equal [], @calc.track_grade_adjusted_splits(build_track(distance_m: 0), 1.0) + end + + def test_validates_like_track_splits + points = build_track(distance_m: 100) + assert_raises(ArgumentError) { @calc.track_grade_adjusted_splits(points, 0) } + assert_raises(ArgumentError) { @calc.track_grade_adjusted_splits([{ lat: 0, lon: 0 }, { lat: 0.001, lon: 0 }], 1.0) } + end +end From 83caffc8fac042a74e43479aef35ee72d4fe00f5 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:38:38 -0300 Subject: [PATCH 10/34] fix: reject non-finite numbers in check_positive Infinity passed the positive check, so an infinite distance or time reached the formulas: an infinite weekly distance produced a finite marathon prediction, an infinite pace a FloatDomainError far from the input. It now raises NonPositiveInputError, like zero, negatives and NaN. --- CHANGELOG.md | 8 ++++++++ lib/calcpace/checker.rb | 23 +++++++++++++++-------- test/calcpace/test_checker.rb | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7980de5..b2a3aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock] # => "03:38:03" ``` +### Fixed +- `check_positive` let `Float::INFINITY` through, so every method guarded by it + accepted an infinite distance or time: an infinite weekly distance became a + finite (and fast) marathon prediction, an infinite pace a `FloatDomainError` + far from the input. Infinity now raises `Calcpace::NonPositiveInputError` + ("must be a finite positive number"), like zero, negatives and NaN already + did. + ## [1.18.1] - 2026-09-06 ### Fixed diff --git a/lib/calcpace/checker.rb b/lib/calcpace/checker.rb index d90eb54..ee43654 100644 --- a/lib/calcpace/checker.rb +++ b/lib/calcpace/checker.rb @@ -7,22 +7,29 @@ # This module provides validation methods for numeric inputs and time format strings # used throughout the Calcpace gem. module Checker - # Validates that a number is positive (greater than zero) + # Validates that a number is positive (greater than zero) and finite + # + # NaN and infinity are rejected too: NaN is not positive, and an infinite + # distance or time would flow through the formulas into nonsense (a finite + # "prediction") or a FloatDomainError far from the input that caused it. # # @param number [Numeric] the number to validate # @param name [String] the name of the parameter for error messages - # @raise [Calcpace::NonPositiveInputError] if number is not positive + # @raise [Calcpace::NonPositiveInputError] if number is not positive or not finite # @return [void] # # @example - # check_positive(10, 'Distance') #=> nil (valid) - # check_positive(-5, 'Time') #=> raises NonPositiveInputError - # check_positive(0, 'Speed') #=> raises NonPositiveInputError + # check_positive(10, 'Distance') #=> nil (valid) + # check_positive(-5, 'Time') #=> raises NonPositiveInputError + # check_positive(0, 'Speed') #=> raises NonPositiveInputError + # check_positive(Float::INFINITY, 'Distance') #=> raises NonPositiveInputError def check_positive(number, name = 'Input') - return if number.is_a?(Numeric) && number.positive? + unless number.is_a?(Numeric) && number.positive? + raise Calcpace::NonPositiveInputError, "#{name} must be a positive number" + end + return if number.finite? - raise Calcpace::NonPositiveInputError, - "#{name} must be a positive number" + raise Calcpace::NonPositiveInputError, "#{name} must be a finite positive number" end # Validates that a time string is in the correct format diff --git a/test/calcpace/test_checker.rb b/test/calcpace/test_checker.rb index 10af000..66312e9 100644 --- a/test/calcpace/test_checker.rb +++ b/test/calcpace/test_checker.rb @@ -9,6 +9,20 @@ def test_check_positive assert_nil @calc.check_positive(1) end + def test_check_positive_rejects_non_finite_numbers + assert_raises(Calcpace::NonPositiveInputError) { @calc.check_positive(Float::INFINITY) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.check_positive(-Float::INFINITY) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.check_positive(Float::NAN) } + assert_nil @calc.check_positive(1e300) + assert_nil @calc.check_positive(Rational(1, 3)) + end + + def test_check_positive_names_the_input_when_it_is_not_finite + assert_error_with_message(Calcpace::NonPositiveInputError, 'Distance must be a finite positive number') do + @calc.check_positive(Float::INFINITY, 'Distance') + end + end + def test_check_time assert_raises(Calcpace::InvalidTimeFormatError) { @calc.check_time('') } assert_raises(Calcpace::InvalidTimeFormatError) { @calc.check_time('1-2-3') } From ee791428f52d8c12436cb227b4d28610ea801323 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:38:53 -0300 Subject: [PATCH 11/34] feat: interpolate personal predictions and validate inputs strictly - predict_time_personal: a target between the two known races is now interpolated along the Riegel curve through both, with the raw exponent and no clamping, so the result agrees with the runner's own data and does not depend on argument order. Extrapolation outside the pair keeps the clamped exponent from the closer race. - Time strings go through check_time, like Calculator, AgeGrading and Vo2maxEstimator: malformed strings and symbols raise InvalidTimeFormatError instead of NonPositiveInputError. - weekly_distance accepts numeric strings, read the way race distances are. - Private helpers prefixed with personal_ to avoid mixin name collisions. - README: training pace range given in seconds (253.3-330.6 s/km). --- CHANGELOG.md | 11 ++- README.md | 30 +++++-- lib/calcpace/personalized_predictor.rb | 80 +++++++++++++------ test/calcpace/test_personalized_predictor.rb | 84 +++++++++++++++++++- 4 files changed, 168 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a3aa6..2c3ad17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `riegel_exponent(race1, time1, race2, time2)` fits a personal Riegel exponent, `ln(t2/t1) / ln(d2/d1)`, to two performances. - `predict_time_personal(race1, time1, race2, time2, to_race)` predicts with - that exponent, clamped to 1.01–1.20, from whichever performance is closer to - the target in log-distance. Returns `:time`, `:time_clock`, `:exponent`, - `:raw_exponent` and `:clamped`; an exponent that needed clamping usually - means one of the races was not all-out. + that exponent. A target between the two races is interpolated along the + curve through both, with the raw exponent (never clamped, independent of + argument order); a target outside the pair is extrapolated from the closer + performance in log-distance, with the exponent clamped to 1.01–1.20. + Returns `:time`, `:time_clock`, `:exponent`, `:raw_exponent` and + `:clamped`; an exponent that needed clamping usually means one of the races + was not all-out. ```ruby calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock] # => "03:38:03" diff --git a/README.md b/README.md index 1835908..173c8e3 100644 --- a/README.md +++ b/README.md @@ -221,9 +221,11 @@ The equation is `Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P` (Pm marat pace in s/km, K km/week, P s/km). Training pace is the plain average of every run — total time over total distance, warm-ups and easy days included — not the pace of the hard sessions. The paper reports a standard error of about 4 minutes. +`weekly_distance` may be a number or a numeric string (`'60'`); `training_pace` +is seconds or an `MM:SS` / `HH:MM:SS` string. It was fitted on 22 experienced runners (21 men) and 46 marathons, so it is only -validated inside that sample: 40.4–110.7 km/week, training pace 4:13–5:31/km, +validated inside that sample: 40.4–110.7 km/week, training pace 253.3–330.6 s/km (4:13–5:30/km), finish 2:47–3:36. Outside it the prediction is still returned, and `out_of_range` names what fell outside (`:weekly_distance`, `:training_pace`, `:marathon_time`) — a warning, not an error. A low-volume runner at an easy pace @@ -244,20 +246,34 @@ calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'mara ``` The standard Riegel gives 3:32:39 from that half and 3:27:00 from that 10K; this -runner fades more than average, and the personal exponent says so. The -prediction is made from whichever race is closer to the target (in log-distance), -with the exponent clamped to 1.01–1.20: +runner fades more than average, and the personal exponent says so. + +When the target lies outside the two races, the prediction extrapolates from +whichever race is closer to it (in log-distance), with the exponent clamped to +1.01–1.20: ```ruby calc.predict_time_personal('5k', '00:20:00', '10k', '00:50:00', 'half_marathon') # => { time: 7348.5, time_clock: "02:02:28", exponent: 1.2, raw_exponent: 1.3219, clamped: true } ``` +When the target lies between them, it interpolates along the curve through both +performances with the raw exponent, never clamped: the runner's own data +already brackets the answer, and the result does not depend on which race comes +first. + +```ruby +calc.predict_time_personal(5, 1200, 20, 3000, 10)[:time] # => 1897.37 +calc.predict_time_personal(20, 3000, 5, 1200, 10)[:time] # => 1897.37 +``` + An exponent outside that range — or `clamped: true` — usually means one of the two races was not an all-out effort, or was run on a course or day that does -not compare with the other. Both races may be names or distances in km; two -races at the same distance, or a target equal to one of them, raise -`ArgumentError`. +not compare with the other — the raw exponent in an interpolation +(0.661 above) is worth the same suspicion. Both races may be names or distances +in km; two races at the same distance, or a target equal to one of them, raise +`ArgumentError`. Times are seconds or `HH:MM:SS` / `MM:SS` strings; anything +else raises `Calcpace::InvalidTimeFormatError`. --- diff --git a/lib/calcpace/personalized_predictor.rb b/lib/calcpace/personalized_predictor.rb index 68b6e95..5376073 100644 --- a/lib/calcpace/personalized_predictor.rb +++ b/lib/calcpace/personalized_predictor.rb @@ -32,7 +32,7 @@ module PersonalizedPredictor TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM = (253.3..330.6) TANDA_MARATHON_TIME_RANGE_SECONDS = ((167 * 60.0)..(216 * 60.0)) - MARATHON_KM = 42.195 + TANDA_MARATHON_KM = 42.195 # Personal Riegel exponents outside this range almost never describe fitness: # below 1.01 the longer race was run at practically the shorter one's pace, @@ -53,7 +53,8 @@ module PersonalizedPredictor # Inputs or a predicted time outside the paper's sample do not raise — the # prediction is still returned, flagged in :out_of_range. # - # @param weekly_distance [Numeric] mean weekly training distance, in unit per week + # @param weekly_distance [Numeric, String] mean weekly training distance, in + # unit per week — a number or a numeric string ('60') # @param training_pace [Numeric, String] mean training pace per unit, in # seconds or as a clock string ('05:30') # @param unit [Symbol, String] :km (default) or :mi — applies to both inputs @@ -61,7 +62,10 @@ module PersonalizedPredictor # @return [Hash] :time (seconds), :time_clock (HH:MM:SS), :pace (seconds per # unit), :pace_clock, :within_validated_range (Boolean) and :out_of_range # (Array of :weekly_distance, :training_pace and/or :marathon_time) - # @raise [Calcpace::NonPositiveInputError] if an input is not positive + # @raise [Calcpace::NonPositiveInputError] if an input is not a positive, + # finite number (or numeric string, for weekly_distance) + # @raise [Calcpace::InvalidTimeFormatError] if training_pace is neither a + # number nor an HH:MM:SS / MM:SS string # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi # # @example @@ -70,12 +74,13 @@ module PersonalizedPredictor # calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30')[:out_of_range] # # => [:weekly_distance, :marathon_time] def predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km) - check_positive(weekly_distance, 'Weekly distance') - pace_seconds = training_pace.is_a?(String) ? convert_to_seconds(training_pace) : training_pace + weekly = personal_number(weekly_distance) + check_positive(weekly, 'Weekly distance') + pace_seconds = personal_time_seconds(training_pace) check_positive(pace_seconds, 'Training pace') km_per_unit = normalize_distance_km(1, unit) - weekly_km = weekly_distance * km_per_unit + weekly_km = weekly * km_per_unit pace_km = pace_seconds / km_per_unit marathon_pace_km = tanda_marathon_pace(weekly_km, pace_km) @@ -101,8 +106,8 @@ def predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km) # @example # calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') # => 1.0961 def riegel_exponent(race1, time1, race2, time2) - distance1, seconds1 = performance(race1, time1) - distance2, seconds2 = performance(race2, time2) + distance1, seconds1 = personal_performance(race1, time1) + distance2, seconds2 = personal_performance(race2, time2) ensure_different_distances!(distance1, distance2) Math.log(seconds2 / seconds1) / Math.log(distance2 / distance1) @@ -110,10 +115,15 @@ def riegel_exponent(race1, time1, race2, time2) # Predicts a race time with a personal Riegel exponent # - # Fits k to the two performances (see #riegel_exponent), clamps it to - # PERSONAL_EXPONENT_RANGE, and applies Riegel from whichever performance is - # closer to the target in log-distance — the shorter extrapolation. On an - # exact tie (target at the geometric mean of the two) the first one is used. + # Fits k to the two performances (see #riegel_exponent), then: + # + # - Target between the two races: interpolates along the Riegel curve that + # passes through both performances, with the raw k and no clamping — the + # runner's own data already brackets the answer, and it is the same + # whichever performance it is scaled from or in which order they are given. + # - Target outside the pair: extrapolates from the performance closer to it + # in log-distance (the shorter extrapolation), with k clamped to + # PERSONAL_EXPONENT_RANGE. # # @param race1 [Numeric, String, Symbol] distance in km or race name # @param time1 [String, Numeric] time at race1 (HH:MM:SS or seconds) @@ -121,22 +131,25 @@ def riegel_exponent(race1, time1, race2, time2) # @param time2 [String, Numeric] time at race2 (HH:MM:SS or seconds) # @param to_race [Numeric, String, Symbol] target distance in km or race name # @return [Hash] :time (seconds), :time_clock (HH:MM:SS), :exponent (the one - # used, after clamping), :raw_exponent (as fitted), :clamped (Boolean) + # used, after any clamping), :raw_exponent (as fitted), :clamped (Boolean, + # always false when the target lies between the two races) # @raise [ArgumentError] if the two races are the same distance, the target # is one of them, or a race name is unknown # @raise [Calcpace::NonPositiveInputError] if a distance or time is not positive + # @raise [Calcpace::InvalidTimeFormatError] if a time is neither a number nor + # an HH:MM:SS / MM:SS string # # @example + # calc.predict_time_personal(5, 1200, 20, 3000, 10)[:time] # => 1897.37 # calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock] # # => "03:38:03" # calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:clamped] # => false def predict_time_personal(race1, time1, race2, time2, to_race) raw = riegel_exponent(race1, time1, race2, time2) - exponent = raw.clamp(PERSONAL_EXPONENT_RANGE.min, PERSONAL_EXPONENT_RANGE.max) target = race_distance(to_race) - anchor_distance, anchor_seconds = closest_performance([performance(race1, time1), performance(race2, time2)], - target) - ensure_different_distances!(anchor_distance, target) + performances = [personal_performance(race1, time1), personal_performance(race2, time2)].sort + performances.map(&:first).each { |distance| ensure_different_distances!(distance, target) } + (anchor_distance, anchor_seconds), exponent = personal_prediction_basis(performances, target, raw) time = (anchor_seconds * ((target / anchor_distance)**exponent)).round(2) { time: time, time_clock: convert_to_clocktime(time), exponent: exponent.round(4), @@ -154,13 +167,13 @@ def tanda_out_of_range(weekly_km, pace_km, marathon_pace_km) checks = { weekly_distance: TANDA_WEEKLY_DISTANCE_RANGE_KM.cover?(weekly_km), training_pace: TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM.cover?(pace_km), - marathon_time: TANDA_MARATHON_TIME_RANGE_SECONDS.cover?(marathon_pace_km * MARATHON_KM) + marathon_time: TANDA_MARATHON_TIME_RANGE_SECONDS.cover?(marathon_pace_km * TANDA_MARATHON_KM) } checks.reject { |_name, inside| inside }.keys end def tanda_result(marathon_pace_km, km_per_unit, out_of_range) - time = (marathon_pace_km * MARATHON_KM).round(2) + time = (marathon_pace_km * TANDA_MARATHON_KM).round(2) pace = (marathon_pace_km * km_per_unit).round(2) { @@ -174,13 +187,34 @@ def tanda_result(marathon_pace_km, km_per_unit, out_of_range) end # A performance as [distance in km, time in seconds], validated - def performance(race, time) - seconds = time.is_a?(String) ? convert_to_seconds(time) : time + def personal_performance(race, time) + seconds = personal_time_seconds(time) check_positive(seconds, 'Time') [race_distance(race), seconds.to_f] end - def closest_performance(performances, target) - performances.min_by { |distance, _seconds| Math.log(target / distance).abs } + # Seconds from a number, or from a strictly validated clock string — the same + # rule as Calculator, AgeGrading and Vo2maxEstimator + def personal_time_seconds(time) + return time if time.is_a?(Numeric) + + check_time(time) + convert_to_seconds(time) + end + + # A number, or a numeric string read the way race distances are; nil otherwise + def personal_number(value) + value.is_a?(Numeric) ? value : Float(value, exception: false) + end + + # [anchor performance, exponent] for a target, given performances sorted by + # distance. Between the two, the raw curve through both; outside, the closer + # performance with the clamped exponent + def personal_prediction_basis(performances, target, raw) + shorter, longer = performances + return [shorter, raw] if target.between?(shorter.first, longer.first) + + closer = performances.min_by { |distance, _seconds| Math.log(target / distance).abs } + [closer, raw.clamp(PERSONAL_EXPONENT_RANGE.min, PERSONAL_EXPONENT_RANGE.max)] end end diff --git a/test/calcpace/test_personalized_predictor.rb b/test/calcpace/test_personalized_predictor.rb index 660b365..44503be 100644 --- a/test/calcpace/test_personalized_predictor.rb +++ b/test/calcpace/test_personalized_predictor.rb @@ -114,14 +114,42 @@ def test_non_positive_inputs_raise assert_raises(Calcpace::NonPositiveInputError) do @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: -1) end - assert_raises(Calcpace::NonPositiveInputError) do - @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 'abc') + end + + def test_malformed_training_pace_raises_invalid_time_format + ['5:3x', 'abc', '', :'05:00', nil].each do |pace| + assert_raises(Calcpace::InvalidTimeFormatError, "expected #{pace.inspect} to be rejected") do + @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: pace) + end end end + def test_numeric_string_weekly_distance_is_accepted + string = @calc.predict_marathon_from_training(weekly_distance: '60', training_pace: 300) + numeric = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300) + + assert_equal numeric, string + end + def test_non_numeric_weekly_distance_raises + ['abc', '', nil, :'60'].each do |distance| + assert_raises(Calcpace::NonPositiveInputError, "expected #{distance.inspect} to be rejected") do + @calc.predict_marathon_from_training(weekly_distance: distance, training_pace: 300) + end + end + end + + def test_non_finite_inputs_raise + [Float::INFINITY, Float::NAN].each do |value| + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: value, training_pace: 300) + end + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: value) + end + end assert_raises(Calcpace::NonPositiveInputError) do - @calc.predict_marathon_from_training(weekly_distance: '60', training_pace: 300) + @calc.predict_marathon_from_training(weekly_distance: 'Infinity', training_pace: 300) end end @@ -163,6 +191,18 @@ def test_riegel_exponent_rejects_the_same_distance def test_riegel_exponent_rejects_unknown_race_and_non_positive_time assert_raises(ArgumentError) { @calc.riegel_exponent('10q', 2700, '5k', 1300) } assert_raises(Calcpace::NonPositiveInputError) { @calc.riegel_exponent('10k', 0, '5k', 1300) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.riegel_exponent('10k', Float::INFINITY, '5k', 1300) } + end + + def test_malformed_race_times_raise_invalid_time_format + ['45:0x', 'abc', :'00:45:00', nil].each do |time| + assert_raises(Calcpace::InvalidTimeFormatError, "expected #{time.inspect} to be rejected") do + @calc.riegel_exponent('10k', time, '5k', 1300) + end + assert_raises(Calcpace::InvalidTimeFormatError) do + @calc.predict_time_personal('10k', 2700, '5k', time, 'marathon') + end + end end def test_personal_prediction_uses_the_performance_closest_to_the_target @@ -212,6 +252,44 @@ def test_a_longer_race_run_faster_is_clamped_not_raised assert_in_delta 1.01, result[:exponent], 1e-12 end + def test_a_target_between_the_two_races_uses_the_raw_exponent + # k = ln(3000 / 1200) / ln(20 / 5) = 0.661, far below the clamp: but the 10K + # lies between the two known races, so the curve through both of them is + # used as is — clamping would contradict the runner's own data + result = @calc.predict_time_personal(5, 1200, 20, 3000, 10) + + refute result[:clamped] + assert_in_delta 0.6610, result[:exponent], 1e-4 + assert_equal result[:raw_exponent], result[:exponent] + assert_in_delta 1200 * (2**(Math.log(2.5) / Math.log(4))), result[:time], 0.01 + end + + def test_interpolation_does_not_depend_on_argument_order + # 10 km is the geometric mean of 5 and 20: both races are equally close + forward = @calc.predict_time_personal(5, 1200, 20, 3000, 10) + backward = @calc.predict_time_personal(20, 3000, 5, 1200, 10) + + assert_equal forward, backward + end + + def test_interpolation_passes_through_both_known_performances + # Between a 10K in 45:00 and a half in 1:42:00, a 15K sits on the same + # curve whichever performance it is scaled from + result = @calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 15) + exponent = Math.log(6120.0 / 2700) / Math.log(21.0975 / 10) + + assert_in_delta 2700 * (1.5**exponent), result[:time], 0.01 + assert_in_delta 6120 * ((15 / 21.0975)**exponent), result[:time], 0.01 + end + + def test_extrapolation_outside_the_pair_is_still_clamped + result = @calc.predict_time_personal(5, 1200, 20, 3000, 'marathon') + + assert result[:clamped] + assert_in_delta 1.01, result[:exponent], 1e-12 + assert_in_delta 3000 * ((42.195 / 20)**1.01), result[:time], 0.01 + end + def test_personal_prediction_rejects_a_target_already_known assert_raises(ArgumentError) do @calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'half_marathon') From 9c03c8332992db397a4cd4fc6e85da85e5be5b8b Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:39:02 -0300 Subject: [PATCH 12/34] feat: VO2max percentiles and labels by age and sex Add vo2max_percentile(value, age:, sex:) and optional age:/sex: keywords on vo2max_label, read against the FRIEND registry percentiles of measured treadmill VO2max (Kaminsky et al., Mayo Clin Proc 2015;90:1515, Table 3), stored as YAML. Labels map to the same six strings on published percentile cuts. Without age and sex, vo2max_label is unchanged. --- CHANGELOG.md | 15 ++ README.md | 43 ++++++ lib/calcpace.rb | 2 + .../data/friend_2015_vo2max_percentiles.yml | 48 +++++++ lib/calcpace/vo2max_estimator.rb | 25 +++- lib/calcpace/vo2max_norms.rb | 115 +++++++++++++++ test/calcpace/test_vo2max_norms.rb | 131 ++++++++++++++++++ 7 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 lib/calcpace/data/friend_2015_vo2max_percentiles.yml create mode 100644 lib/calcpace/vo2max_norms.rb create mode 100644 test/calcpace/test_vo2max_norms.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e0487..2385252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 segment from `:ele`. Grades are measured over segments of at least 100 m of horizontal distance so GPS elevation noise does not become fake climbing; stretches without `:ele` are flat. `track_splits` output is unchanged. +- VO2max norms by age and sex from the FRIEND registry (Kaminsky, Arena & + Myers, Mayo Clin Proc 2015;90(11):1515–1523, Table 3: treadmill, measured + VO2max), stored in `lib/calcpace/data/friend_2015_vo2max_percentiles.yml`: + - `vo2max_label(value, age:, sex:)` — optional keywords; with both, the + label comes from the percentile among the same sex and age decade (≥95th + Elite, ≥90th Excellent, ≥75th Very Good, ≥50th Good, ≥25th Fair, else + Beginner). Without them the fixed thresholds and labels are unchanged. + - `vo2max_percentile(value, age:, sex:)` — linearly interpolated percentile, + bounded to the table's 5–95. Ages 18–19 use the 20–29 row and 80+ the + 70–79 row; under 18 is rejected. + +### Changed +- The `vo2max_label` docstring now documents the error it actually raises for + a non-positive value (`Calcpace::NonPositiveInputError`, not `ArgumentError`). + Behaviour is unchanged. ## [1.18.1] - 2026-09-06 diff --git a/README.md b/README.md index 30c6af5..5baed12 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,49 @@ calc.vo2max_label(51.9) # => "Very Good" *Thresholds based on Daniels, J. (2014). Daniels' Running Formula (3rd ed.), consistent with ACSM guidelines and McArdle, Katch & Katch (2015) Exercise Physiology.* +#### By age and sex + +The fixed thresholds above are the same for everyone. Give `vo2max_label` an +age and a sex and it reads the value against people of the same sex and age +decade instead, using the **FRIEND registry** percentiles of VO2max measured on +a treadmill (Kaminsky, Arena & Myers, 2015): + +```ruby +calc.vo2max_label(45) # => "Good" (fixed thresholds, unchanged) +calc.vo2max_label(45, age: 25, sex: :male) # => "Fair" +calc.vo2max_label(45, age: 60, sex: :male) # => "Elite" +calc.vo2max_label(45, age: 25, sex: :female) # => "Very Good" +calc.vo2max_label(45, age: 60, sex: :female) # => "Elite" + +calc.vo2max_percentile(45, age: 25, sex: :male) # => 40.5 +calc.vo2max_percentile(45, age: 25, sex: :female) # => 75.7 +calc.vo2max_percentile(45, age: 60, sex: :male) # => 95.0 +``` + +| Percentile (same sex and age decade) | Level | +|--------------------------------------|-----------| +| ≥ 95th | Elite | +| 90th–94th | Excellent | +| 75th–89th | Very Good | +| 50th–74th | Good | +| 25th–49th | Fair | +| < 25th | Beginner | + +- The cuts sit on percentiles the table publishes (5th, 10th, 25th, 50th, + 75th, 90th, 95th), so a label never depends on interpolation. They are + calcpace's choice: FRIEND publishes percentiles, not labels. +- `vo2max_percentile` interpolates linearly between the published percentiles, + rounded to one decimal, and is bounded to the table: `5.0` means at or below + the 5th percentile, `95.0` at or above the 95th. +- Age decades (20–29 … 70–79) are used as published, without blending, so a + 29- and a 30-year-old read different rows. Ages 18–19 use the 20–29 row and + 80+ the 70–79 row; under 18 raises `ArgumentError`, as does a sex other than + male/female. Age and sex must be given together. +- The registry measured VO2max in a lab; a VO2max estimated from a race time + carries its own ±3–5 ml/kg/min on top. + +*Kaminsky, L. A., Arena, R., & Myers, J. (2015). Reference Standards for Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: Data From the Fitness Registry and the Importance of Exercise National Database. Mayo Clinic Proceedings, 90(11), 1515–1523, Table 3 (rows "Men/Women from FRIEND"; 7,783 adults free of known cardiovascular disease). https://doi.org/10.1016/j.mayocp.2015.07.026. The same table also lists the Cooper Clinic norms printed in ACSM's Guidelines for Exercise Testing and Prescription (9th ed., 2014); those are predicted from treadmill time rather than measured, and are not used here.* + **Formula:** ``` velocity (m/min) = distance_m / time_min diff --git a/lib/calcpace.rb b/lib/calcpace.rb index f4b17ab..b79279c 100644 --- a/lib/calcpace.rb +++ b/lib/calcpace.rb @@ -19,6 +19,7 @@ require_relative 'calcpace/track_calculator' require_relative 'calcpace/training_zones' require_relative 'calcpace/vo2max_estimator' +require_relative 'calcpace/vo2max_norms' # Calcpace - A Ruby gem for pace, distance, and time calculations # @@ -60,6 +61,7 @@ class Calcpace include TrackCalculator include TrainingZones include Vo2maxEstimator + include Vo2maxNorms # Creates a new Calcpace instance # diff --git a/lib/calcpace/data/friend_2015_vo2max_percentiles.yml b/lib/calcpace/data/friend_2015_vo2max_percentiles.yml new file mode 100644 index 0000000..6a2ea88 --- /dev/null +++ b/lib/calcpace/data/friend_2015_vo2max_percentiles.yml @@ -0,0 +1,48 @@ +# VO2max percentiles by age and sex (ml/kg/min), treadmill CPX with measured VO2max +# +# Source: Kaminsky LA, Arena R, Myers J. Reference Standards for +# Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: +# Data From the Fitness Registry and the Importance of Exercise National +# Database (FRIEND). Mayo Clinic Proceedings 2015;90(11):1515-1523. +# doi:10.1016/j.mayocp.2015.07.026 (author manuscript: PMC4919021) +# +# Table 3, "Sex-Specific Percentiles for CRF From Treadmill Exercise Tests With +# Measured VO2max Obtained From FRIEND [...]", rows "Men from FRIEND" and +# "Women from FRIEND". 7,783 apparently healthy US adults (4,611 men, 3,172 +# women) free of known cardiovascular disease, VO2max measured by +# cardiopulmonary exercise testing (not predicted). +# +# The same Table 3 also reproduces the Cooper Clinic percentiles as printed in +# ACSM's Guidelines for Exercise Testing and Prescription, 9th ed. (2014), +# pp. 88-93 — the paper's reference 13 and, in its words, "the only widely +# cited reference data in the United States". They are not used here: those +# VO2max values were predicted from Balke treadmill time rather than measured, +# and their spread is much narrower (95th percentile for men aged 20-29: 55.5 +# predicted against 66.3 measured). +# +# Each row lists the VO2max at the percentiles in `percentiles`, in order. +# Age bands are keyed by their lower bound and span ten years (20 = 20-29). + +meta: + table_version: "FRIEND 2015 (Kaminsky et al., Mayo Clin Proc 90:1515, Table 3)" + doi: "10.1016/j.mayocp.2015.07.026" + units: "ml/kg/min" + modality: "treadmill, measured VO2max" + +percentiles: [5, 10, 25, 50, 75, 90, 95] + +M: + 20: [29.0, 32.1, 40.1, 48.0, 55.2, 61.8, 66.3] + 30: [27.2, 30.2, 35.9, 42.4, 49.2, 56.5, 59.8] + 40: [24.2, 26.8, 31.9, 37.8, 45.0, 52.1, 55.6] + 50: [20.9, 22.8, 27.1, 32.6, 39.7, 45.6, 50.7] + 60: [17.4, 19.8, 23.7, 28.2, 34.5, 40.3, 43.0] + 70: [16.3, 17.1, 20.4, 24.4, 30.4, 36.6, 39.7] + +F: + 20: [21.7, 23.9, 30.5, 37.6, 44.7, 51.3, 56.0] + 30: [19.0, 20.9, 25.3, 30.2, 36.1, 41.4, 45.8] + 40: [17.0, 18.8, 22.1, 26.7, 32.4, 38.4, 41.7] + 50: [16.0, 17.3, 19.9, 23.4, 27.6, 32.0, 35.9] + 60: [13.4, 14.6, 17.2, 20.0, 23.8, 27.0, 29.4] + 70: [13.1, 13.6, 15.6, 18.3, 20.8, 23.1, 24.1] diff --git a/lib/calcpace/vo2max_estimator.rb b/lib/calcpace/vo2max_estimator.rb index fc04354..c2cc0a2 100644 --- a/lib/calcpace/vo2max_estimator.rb +++ b/lib/calcpace/vo2max_estimator.rb @@ -102,16 +102,33 @@ def estimate_detailed_vo2max(distance, time, elevation_gain_m: 0, hr_avg: nil, h # Returns a descriptive label for a given VO2max value # + # Without age and sex, the label comes from fixed thresholds that are the same + # for everyone (see VO2MAX_LABELS) — exactly as it always has. With both, it + # comes from where the value sits among people of the same sex and age + # decade, measured on a treadmill in the FRIEND registry (see + # Vo2maxNorms#vo2max_percentile and Vo2maxNorms::VO2MAX_PERCENTILE_LABELS): + # the same 45 ml/kg/min is "Fair" for a 25-year-old man and "Elite" for a + # 60-year-old woman. + # # @param value [Numeric] VO2max in ml/kg/min + # @param age [Integer, nil] age in years (18 or over); give it with sex + # @param sex [String, Symbol, nil] male or female; give it with age # @return [String] label: "Beginner", "Fair", "Good", "Very Good", "Excellent", or "Elite" - # @raise [ArgumentError] if value is not positive + # @raise [Calcpace::NonPositiveInputError] if value is not positive + # @raise [ArgumentError] if only one of age and sex is given, or either is invalid # # @example - # calc.vo2max_label(51.9) #=> "Very Good" - def vo2max_label(value) + # calc.vo2max_label(51.9) #=> "Very Good" + # calc.vo2max_label(45) #=> "Good" + # calc.vo2max_label(45, age: 25, sex: :male) #=> "Fair" + # calc.vo2max_label(45, age: 60, sex: :female) #=> "Elite" + def vo2max_label(value, age: nil, sex: nil) check_positive(value.to_f, 'VO2max value') + return VO2MAX_LABELS.find { |entry| value.to_f >= entry[:min] }[:label] if age.nil? && sex.nil? + raise ArgumentError, 'Age and sex must be provided together' if age.nil? || sex.nil? - VO2MAX_LABELS.find { |entry| value.to_f >= entry[:min] }[:label] + percentile = raw_vo2max_percentile(value.to_f, age, sex) + Vo2maxNorms::VO2MAX_PERCENTILE_LABELS.find { |entry| percentile >= entry[:min] }[:label] end private diff --git a/lib/calcpace/vo2max_norms.rb b/lib/calcpace/vo2max_norms.rb new file mode 100644 index 0000000..300f41d --- /dev/null +++ b/lib/calcpace/vo2max_norms.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'yaml' +require_relative 'errors' + +# Module for reading a VO2max against people of the same age and sex +# +# Uses the FRIEND registry (Fitness Registry and the Importance of Exercise +# National Database) percentiles of VO2max measured by cardiopulmonary +# exercise testing on a treadmill in 7,783 apparently healthy US adults: +# +# Kaminsky, L. A., Arena, R., & Myers, J. (2015). Reference Standards for +# Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: +# Data From the Fitness Registry and the Importance of Exercise National +# Database. Mayo Clinic Proceedings, 90(11), 1515–1523, Table 3. +# https://doi.org/10.1016/j.mayocp.2015.07.026 +# +# The table is stored, with its provenance, in +# `lib/calcpace/data/friend_2015_vo2max_percentiles.yml`. +# Vo2maxEstimator#vo2max_label uses it when given age and sex. +module Vo2maxNorms + # Percentile norms by age and sex: FRIEND registry, measured treadmill VO2max + # (Kaminsky, Arena & Myers, Mayo Clin Proc 2015;90(11):1515–1523, Table 3). + # See the data file for the full provenance. + NORMS_DATA_PATH = File.expand_path('data/friend_2015_vo2max_percentiles.yml', __dir__).freeze + norms_data = YAML.safe_load_file(NORMS_DATA_PATH, permitted_classes: [], aliases: false) + VO2MAX_NORMS_VERSION = norms_data.fetch('meta').fetch('table_version').freeze + VO2MAX_NORM_PERCENTILES = norms_data.fetch('percentiles').map(&:to_f).freeze + VO2MAX_NORMS = %w[M F].to_h do |sex| + bands = norms_data.fetch(sex).to_h { |age, row| [Integer(age), row.map(&:to_f).freeze] } + [sex.freeze, bands.freeze] + end.freeze + + VO2MAX_NORMS.each do |sex, bands| + bands.each do |age, row| + next if row.size == VO2MAX_NORM_PERCENTILES.size && row.each_cons(2).all? { |low, high| high > low } + + raise Calcpace::InvalidDataError, + "VO2max norms #{sex} #{age}: expected #{VO2MAX_NORM_PERCENTILES.size} rising values, got #{row.inspect}" + end + end + + # Percentile cut for each label when vo2max_label is given age and sex. The + # cuts sit on percentiles the table publishes, so a label never depends on + # interpolation: at or above the 95th percentile is Elite, the 90th + # Excellent, the 75th Very Good, the median Good, the 25th Fair, and below + # the 25th Beginner. These cuts are calcpace's choice — FRIEND publishes + # percentiles, not labels. + VO2MAX_PERCENTILE_LABELS = [ + { min: 95, label: 'Elite' }, + { min: 90, label: 'Excellent' }, + { min: 75, label: 'Very Good' }, + { min: 50, label: 'Good' }, + { min: 25, label: 'Fair' }, + { min: 0, label: 'Beginner' } + ].freeze + + # Approximate percentile of a VO2max among people of the same sex and age + # + # Reads the FRIEND registry percentiles (Kaminsky, Arena & Myers, 2015, + # Table 3: 5th, 10th, 25th, 50th, 75th, 90th and 95th, by decade from 20–29 to + # 70–79) and interpolates linearly between the two published percentiles + # around the value. The registry measured VO2max in a lab; an estimate from a + # race time carries its own ±3–5 ml/kg/min on top. + # + # - Age decades are used as published, without blending across them, so a + # 29- and a 30-year-old read different rows. Ages 18–19 use the 20–29 row and + # 80 or over the 70–79 row; under 18 is rejected (adult norms do not apply). + # - The result is bounded to the table: 5.0 means at or below the 5th + # percentile, 95.0 at or above the 95th. + # + # @param value [Numeric] VO2max in ml/kg/min + # @param age [Integer] age in years (18 or over) + # @param sex [String, Symbol] male or female + # @return [Float] percentile between 5.0 and 95.0, rounded to one decimal + # @raise [Calcpace::NonPositiveInputError] if value is not positive + # @raise [ArgumentError] if age is under 18 or not an integer, or sex is not male/female + # + # @example + # calc.vo2max_percentile(48.0, age: 25, sex: :male) #=> 50.0 + # calc.vo2max_percentile(45, age: 25, sex: :male) #=> 40.5 + # calc.vo2max_percentile(45, age: 25, sex: :female) #=> 75.7 + # calc.vo2max_percentile(45, age: 60, sex: :male) #=> 95.0 + def vo2max_percentile(value, age:, sex:) + check_positive(value.to_f, 'VO2max value') + + raw_vo2max_percentile(value.to_f, age, sex).round(1) + end + + private + + # Unrounded percentile, so a label is decided against the published values + # themselves rather than a rounded reading of them. normalize_age and + # normalize_sex are AgeGrading's — one rule for age and sex gem-wide. + def raw_vo2max_percentile(value, age, sex) + row = vo2max_norm_row(normalize_age(age), normalize_sex(sex)) + return VO2MAX_NORM_PERCENTILES.first if value <= row.first + return VO2MAX_NORM_PERCENTILES.last if value >= row.last + + upper = row.index { |norm| norm > value } + interpolate_percentile(row, upper, value) + end + + def vo2max_norm_row(age, sex) + bands = VO2MAX_NORMS.fetch(sex == :male ? 'M' : 'F') + band = bands.keys.select { |lower| lower <= age }.max || bands.keys.min + bands.fetch(band) + end + + def interpolate_percentile(row, upper, value) + low_norm, high_norm = row.values_at(upper - 1, upper) + low_pct, high_pct = VO2MAX_NORM_PERCENTILES.values_at(upper - 1, upper) + low_pct + ((high_pct - low_pct) * (value - low_norm) / (high_norm - low_norm)) + end +end diff --git a/test/calcpace/test_vo2max_norms.rb b/test/calcpace/test_vo2max_norms.rb new file mode 100644 index 0000000..3bf7185 --- /dev/null +++ b/test/calcpace/test_vo2max_norms.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Tests for VO2max percentiles and labels by age and sex (FRIEND 2015, Table 3) +class TestVo2maxNorms < CalcpaceTest + # --- vo2max_label without age/sex: unchanged --- + + def test_label_without_age_and_sex_keeps_the_fixed_thresholds + { 72.0 => 'Elite', 70 => 'Elite', 65.0 => 'Excellent', 51.9 => 'Very Good', + 45 => 'Good', 35.0 => 'Fair', 29.9 => 'Beginner' }.each do |value, label| + assert_equal label, @calc.vo2max_label(value) + assert_equal label, @calc.vo2max_label(value, age: nil, sex: nil) + end + end + + def test_label_needs_age_and_sex_together + assert_raises(ArgumentError) { @calc.vo2max_label(45, age: 30) } + assert_raises(ArgumentError) { @calc.vo2max_label(45, sex: :male) } + end + + def test_label_with_norms_still_rejects_non_positive_values + assert_raises(Calcpace::NonPositiveInputError) { @calc.vo2max_label(0, age: 30, sex: :male) } + end + + # --- vo2max_label with age/sex --- + + def test_same_value_reads_differently_by_age_and_sex + assert_equal 'Fair', @calc.vo2max_label(45, age: 25, sex: :male) + assert_equal 'Elite', @calc.vo2max_label(45, age: 60, sex: :male) + assert_equal 'Very Good', @calc.vo2max_label(45, age: 25, sex: :female) + assert_equal 'Elite', @calc.vo2max_label(45, age: 60, sex: :female) + end + + # Men 40–49 from FRIEND: 25th 31.9, 50th 37.8, 75th 45.0, 90th 52.1, 95th 55.6 + def test_label_cuts_sit_on_the_published_percentiles + { + 55.6 => 'Elite', 55.59 => 'Excellent', + 52.1 => 'Excellent', 52.09 => 'Very Good', + 45.0 => 'Very Good', 44.99 => 'Good', + 37.8 => 'Good', 37.79 => 'Fair', + 31.9 => 'Fair', 31.89 => 'Beginner', + 10.0 => 'Beginner' + }.each do |value, label| + assert_equal label, @calc.vo2max_label(value, age: 45, sex: :male), "VO2max #{value}" + end + end + + def test_label_uses_the_same_six_labels + labels = (10..90).map { |value| @calc.vo2max_label(value, age: 35, sex: :female) }.uniq + assert_empty labels - Vo2maxEstimator::VO2MAX_LABELS.map { |entry| entry[:label] } + end + + # --- vo2max_percentile --- + + def test_percentile_on_a_published_point + assert_equal 50.0, @calc.vo2max_percentile(48.0, age: 25, sex: :male) + assert_equal 75.0, @calc.vo2max_percentile(44.7, age: 25, sex: :female) + assert_equal 10.0, @calc.vo2max_percentile(14.6, age: 65, sex: :female) + end + + def test_percentile_interpolates_linearly_between_published_points + # Men 20–29: 25th 40.1, 50th 48.0 + assert_in_delta 40.5, @calc.vo2max_percentile(45, age: 25, sex: :male), 0.05 + assert_equal 37.5, @calc.vo2max_percentile(44.05, age: 25, sex: :male) + end + + def test_percentile_is_bounded_to_the_table + assert_equal 95.0, @calc.vo2max_percentile(80, age: 25, sex: :male) + assert_equal 95.0, @calc.vo2max_percentile(66.3, age: 25, sex: :male) + assert_equal 5.0, @calc.vo2max_percentile(29.0, age: 25, sex: :male) + assert_equal 5.0, @calc.vo2max_percentile(15, age: 25, sex: :male) + end + + def test_percentile_is_rounded_to_one_decimal + value = @calc.vo2max_percentile(45, age: 25, sex: :female) + assert_kind_of Float, value + assert_equal value.round(1), value + end + + def test_age_bands_are_decades_used_as_published + assert_equal 50.0, @calc.vo2max_percentile(42.4, age: 30, sex: :male) + assert_equal 50.0, @calc.vo2max_percentile(42.4, age: 39, sex: :male) + refute_equal 50.0, @calc.vo2max_percentile(42.4, age: 29, sex: :male) + end + + def test_ages_outside_the_table_use_the_nearest_band + assert_equal @calc.vo2max_percentile(45, age: 25, sex: :male), @calc.vo2max_percentile(45, age: 18, sex: :male) + assert_equal @calc.vo2max_percentile(25, age: 75, sex: :female), + @calc.vo2max_percentile(25, age: 92, sex: :female) + end + + def test_rejects_minors_and_non_integer_ages + assert_raises(ArgumentError) { @calc.vo2max_percentile(45, age: 17, sex: :male) } + assert_raises(ArgumentError) { @calc.vo2max_percentile(45, age: 'old', sex: :male) } + assert_raises(ArgumentError) { @calc.vo2max_percentile(45, age: nil, sex: :male) } + end + + def test_sex_accepts_strings_and_rejects_anything_else + assert_equal @calc.vo2max_percentile(40, age: 30, sex: :female), + @calc.vo2max_percentile(40, age: 30, sex: ' Female ') + assert_raises(ArgumentError) { @calc.vo2max_percentile(40, age: 30, sex: :x) } + assert_raises(ArgumentError) { @calc.vo2max_percentile(40, age: 30, sex: nil) } + end + + def test_percentile_rejects_non_positive_values + assert_raises(Calcpace::NonPositiveInputError) { @calc.vo2max_percentile(0, age: 30, sex: :male) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.vo2max_percentile(-1, age: 30, sex: :male) } + end + + # --- data file --- + + def test_norms_cover_both_sexes_and_six_decades + %w[M F].each do |sex| + assert_equal [20, 30, 40, 50, 60, 70], Vo2maxNorms::VO2MAX_NORMS.fetch(sex).keys.sort + end + end + + def test_norms_rows_rise_with_the_percentile + Vo2maxNorms::VO2MAX_NORMS.each_value do |bands| + bands.each_value do |row| + assert_equal Vo2maxNorms::VO2MAX_NORM_PERCENTILES.size, row.size + assert_equal row.sort.uniq, row + end + end + end + + def test_norms_version_is_exposed + assert_includes Vo2maxNorms::VO2MAX_NORMS_VERSION, 'FRIEND 2015' + end +end From 4866bd97ad0c690e68f292ea1fdd365283dddbf5 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:41:13 -0300 Subject: [PATCH 13/34] refactor: express heat duration factor as a point table The piecewise duration_factor becomes HEAT_DURATION_FACTORS, a list of [minutes, factor] points joined by straight lines and flat outside them. Values are bit-identical to the previous branches (checked on 0-30000 s), and duration_factor keeps its name and signature for the site mirror. --- lib/calcpace/environmental_adjuster.rb | 33 +++++++++++++------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/lib/calcpace/environmental_adjuster.rb b/lib/calcpace/environmental_adjuster.rb index 52b72a9..34847eb 100644 --- a/lib/calcpace/environmental_adjuster.rb +++ b/lib/calcpace/environmental_adjuster.rb @@ -11,6 +11,17 @@ module EnvironmentalAdjuster DATA_PATH = File.expand_path('data/environmental_factors.yml', __dir__).freeze FACTORS = YAML.safe_load_file(DATA_PATH, permitted_classes: [], aliases: false).freeze + # Heat duration scaling: [minutes, factor] points, joined by straight lines + # and flat outside the first and last point. The base heat penalty in + # environmental_factors.yml is for a 60-minute effort (factor 1.0). + # Rule based on Matthew Ely (2007) heat degradation curve. + HEAT_DURATION_FACTORS = [ + [30.0, 0.5], + [60.0, 1.0], + [180.0, 3.0], + [240.0, 4.5] + ].freeze + # Calculates the performance penalty percentage for given environmental conditions # # @param temperature [Numeric, nil] ambient temperature @@ -89,23 +100,11 @@ def calculate_heat_penalty(temp, unit, time_seconds) def duration_factor(time_seconds) return 1.0 if time_seconds.nil? - minutes = time_seconds / 60.0 - - # Rule based on Matthew Ely (2007) heat degradation curve. - # Scaled for piecewise linear interpolation to avoid jumps. - if minutes <= 30 - 0.5 - elsif minutes <= 60 - # Scale from 0.5x (30m) up to 1.0x (60m) - 0.5 + (((minutes - 30.0) / 30.0) * 0.5) - elsif minutes <= 180 - # Scale from 1.0x (60m) up to 3.0x (180m / 3h) - 1.0 + (((minutes - 60.0) / 120.0) * 2.0) - else - # Scale from 3.0x (3h) up to 4.5x (4h) - capped_minutes = [minutes, 240.0].min - 3.0 + (((capped_minutes - 180.0) / 60.0) * 1.5) - end + minutes = (time_seconds / 60.0).clamp(HEAT_DURATION_FACTORS.first.first, HEAT_DURATION_FACTORS.last.first) + (from_minutes, from_factor), (to_minutes, to_factor) = + HEAT_DURATION_FACTORS.each_cons(2).find { |_, (upper, _)| minutes <= upper } + + from_factor + (((minutes - from_minutes) / (to_minutes - from_minutes)) * (to_factor - from_factor)) end def normalize_temperature(temp, unit) From 8ee0fa5437eaa7673ecea9d0787a546e575aec9f Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:42:10 -0300 Subject: [PATCH 14/34] feat: optional humidity and dew point for the heat penalty calculate_penalty (and adjust_time, normalize_time and the *_adjusted predictions that forward their options) accepts humidity: (relative humidity, 0-100 %) or dew_point: (in temperature_unit). The temperature is replaced by the effective temperature that has the same simplified WBGT (Australian Bureau of Meteorology: 0.567*Ta + 0.393*e + 3.94) at REFERENCE_HUMIDITY = 50 %, so the existing heat curve and the base(temp) x duration_factor(seconds) shape stay as they are. 50 % is the humidity at which the simplified WBGT equals the air temperature for 20-35 C (51-56 %), which is how the temperature-only points (calibrated on Ely's WBGT figures) already read; humidity: 50 reproduces today's numbers exactly. The equation is solved by bisection instead of adding dWBGT/0.567, which would double the humidity effect at 30 C by ignoring the reference air's own vapour pressure. factors gains :effective_temperature_celsius only when humidity or dew point is given. Invalid input raises ArgumentError: humidity outside 0-100 or non-numeric, dew point above the temperature, both given, or either without a temperature. --- lib/calcpace/environmental_adjuster.rb | 63 +++++++--- lib/calcpace/humidity.rb | 121 ++++++++++++++++++ test/calcpace/test_environmental_adjuster.rb | 122 +++++++++++++++++++ 3 files changed, 287 insertions(+), 19 deletions(-) create mode 100644 lib/calcpace/humidity.rb diff --git a/lib/calcpace/environmental_adjuster.rb b/lib/calcpace/environmental_adjuster.rb index 34847eb..f8a86af 100644 --- a/lib/calcpace/environmental_adjuster.rb +++ b/lib/calcpace/environmental_adjuster.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true require 'yaml' +require_relative 'humidity' # Module for adjusting race performance based on environmental conditions # # Scientific basis: # - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance" # - Altitude: NCAA Altitude Adjustment Factors (TFRRS) +# - Humidity: Australian Bureau of Meteorology simplified WBGT +# (WBGT = 0.567·Ta + 0.393·e + 3.94, e = vapour pressure in hPa) module EnvironmentalAdjuster DATA_PATH = File.expand_path('data/environmental_factors.yml', __dir__).freeze FACTORS = YAML.safe_load_file(DATA_PATH, permitted_classes: [], aliases: false).freeze @@ -15,12 +18,11 @@ module EnvironmentalAdjuster # and flat outside the first and last point. The base heat penalty in # environmental_factors.yml is for a 60-minute effort (factor 1.0). # Rule based on Matthew Ely (2007) heat degradation curve. - HEAT_DURATION_FACTORS = [ - [30.0, 0.5], - [60.0, 1.0], - [180.0, 3.0], - [240.0, 4.5] - ].freeze + HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 3.0], [240.0, 4.5]].freeze + + # Relative humidity (%) the temperature-only heat curve stands for + # (see EnvironmentalAdjuster::Humidity) + REFERENCE_HUMIDITY = Humidity::REFERENCE_HUMIDITY # Calculates the performance penalty percentage for given environmental conditions # @@ -28,18 +30,32 @@ module EnvironmentalAdjuster # @param temperature_unit [Symbol, String] :c (Celsius) or :f (Fahrenheit) # @param altitude [Numeric, nil] altitude in meters # @param time_seconds [Numeric, nil] duration of the effort in seconds - # @return [Hash] hash with :total_penalty_percent and breakdown in :factors - def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil) - heat_penalty = calculate_heat_penalty(temperature, temperature_unit, time_seconds) + # @param humidity [Numeric, nil] relative humidity in % (0–100). Optional; + # without it (and without dew_point) the heat curve assumes + # REFERENCE_HUMIDITY. With it, the temperature is replaced by the effective + # temperature that has the same simplified WBGT at REFERENCE_HUMIDITY. + # @param dew_point [Numeric, nil] dew point, in temperature_unit. Alternative + # to humidity (pass one or the other), must not exceed the temperature + # @return [Hash] hash with :total_penalty_percent and breakdown in :factors; + # when humidity or dew_point is given, :factors also carries + # :effective_temperature_celsius + # @raise [ArgumentError] if humidity is outside 0–100, dew_point is above the + # temperature, both are given, or either is given without a temperature + # + # @example + # calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] #=> 9.11 + # calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] #=> 35.94 + # calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] #=> 8.15 + def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil, + humidity: nil, dew_point: nil) + effective = effective_temperature(temperature, temperature_unit, humidity, dew_point) + heat_penalty = calculate_heat_penalty(effective, time_seconds) altitude_penalty = calculate_altitude_penalty(altitude) - { - total_penalty_percent: (heat_penalty + altitude_penalty).round(2), - factors: { - heat: heat_penalty, - altitude: altitude_penalty - } - } + factors = { heat: heat_penalty, altitude: altitude_penalty } + factors[:effective_temperature_celsius] = effective unless humidity.nil? && dew_point.nil? + + { total_penalty_percent: (heat_penalty + altitude_penalty).round(2), factors: factors } end # Adjusts a given time based on environmental conditions @@ -82,10 +98,19 @@ def normalize_time(time_seconds, **) private - def calculate_heat_penalty(temp, unit, time_seconds) - return 0.0 if temp.nil? + # Air temperature in °C, moved to the temperature that has the same + # simplified WBGT at REFERENCE_HUMIDITY when humidity or dew point is known + def effective_temperature(temp, unit, humidity, dew_point) + Humidity.check_inputs!(temp, humidity, dew_point) + temp_c = temp && normalize_temperature(temp, unit) + return temp_c if temp_c.nil? || (humidity.nil? && dew_point.nil?) + + Humidity.effective_temperature(temp_c, humidity: humidity, + dew_point_c: dew_point && normalize_temperature(dew_point, unit)) + end - temp_c = normalize_temperature(temp, unit) + def calculate_heat_penalty(temp_c, time_seconds) + return 0.0 if temp_c.nil? data = FACTORS.fetch('heat') ideal_min, ideal_max = data.fetch('ideal_range_celsius') diff --git a/lib/calcpace/humidity.rb b/lib/calcpace/humidity.rb new file mode 100644 index 0000000..fa999c4 --- /dev/null +++ b/lib/calcpace/humidity.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module EnvironmentalAdjuster + # Turns air temperature plus humidity into the effective temperature the heat + # curve is read at. + # + # The heat curve is keyed on air temperature, but heat stress depends on + # humidity too: sweat evaporates less in humid air. Wet-bulb globe temperature + # (WBGT) captures both. The Australian Bureau of Meteorology's simplified WBGT, + # for outdoor conditions with moderate sun and light wind, is + # + # WBGT = 0.567·Ta + 0.393·e + 3.94 + # e = RH/100 · 6.105·exp(17.27·Ta / (237.7 + Ta)) (hPa) + # + # The effective temperature is the air temperature that, at + # REFERENCE_HUMIDITY, has the same WBGT as the real (temperature, humidity) + # pair: WBGT(T_eff, REFERENCE_HUMIDITY) = WBGT(T, RH). Solving that equation + # (rather than adding ΔWBGT / 0.567) keeps the reference air's own vapour + # pressure rising with temperature, as it does along the temperature-only + # curve; the shortcut would roughly double the humidity effect at 30 °C. + module Humidity + # Relative humidity (%) the temperature-only heat curve stands for. The heat + # points were calibrated against Ely et al.'s WBGT figures while the input + # is air temperature, and the simplified WBGT equals the air temperature at + # 51–56% RH between 20 °C and 35 °C — so a temperature-only reading is a + # reading at about 50% humidity. humidity: 50 gives the same numbers as no + # humidity at all. + REFERENCE_HUMIDITY = 50.0 + + # Simplified WBGT coefficients (Australian Bureau of Meteorology) + WBGT_TEMPERATURE_COEFFICIENT = 0.567 + WBGT_VAPOUR_PRESSURE_COEFFICIENT = 0.393 + + # Bisection: the bracket is ±40 °C around the air temperature (0–100% RH + # moves the effective temperature by far less) and 60 halvings take it + # below 1e-16 °C + BRACKET_CELSIUS = 40.0 + BISECTION_STEPS = 60 + + module_function + + # @param temp [Numeric, nil] air temperature in any unit (nil = none given) + # @param humidity [Object] relative humidity input + # @param dew_point [Object] dew point input + # @raise [ArgumentError] if the combination or a value is invalid + def check_inputs!(temp, humidity, dew_point) + raise ArgumentError, 'Pass either humidity or dew_point, not both' if humidity && dew_point + raise ArgumentError, 'humidity and dew_point need a temperature' if temp.nil? && (humidity || dew_point) + + check_values!(humidity, dew_point) + end + + def check_values!(humidity, dew_point) + unless valid_dew_point?(dew_point) + raise ArgumentError, "dew_point must be a finite number (got #{dew_point.inspect})" + end + return if valid_humidity?(humidity) + + raise ArgumentError, "humidity must be a relative humidity between 0 and 100 (got #{humidity.inspect})" + end + + # @param temp_c [Float] air temperature in °C + # @param humidity [Numeric, nil] relative humidity in % + # @param dew_point_c [Numeric, nil] dew point in °C (used when humidity is nil) + # @return [Float] effective temperature in °C, rounded to 2 decimals + # @raise [ArgumentError] if the dew point is above the air temperature + def effective_temperature(temp_c, humidity: nil, dew_point_c: nil) + vapour = humidity ? humidity / 100.0 * saturation_vapour_pressure(temp_c) : dew_point_vapour(dew_point_c, temp_c) + temperature_at_reference_humidity(temp_c, vapour) + end + + # Saturation vapour pressure in hPa (the Magnus form the Bureau of + # Meteorology pairs with its simplified WBGT) + def saturation_vapour_pressure(temp_c) + 6.105 * Math.exp(17.27 * temp_c / (237.7 + temp_c)) + end + + # The temperature-and-humidity part of the simplified WBGT (the 3.94 + # constant cancels out when two WBGTs are compared) + def wbgt_without_constant(temp_c, vapour_hpa) + (WBGT_TEMPERATURE_COEFFICIENT * temp_c) + (WBGT_VAPOUR_PRESSURE_COEFFICIENT * vapour_hpa) + end + + # Solves WBGT(x, REFERENCE_HUMIDITY) = WBGT(temp_c, vapour) for x. The left + # side rises strictly with x, so bisection converges. + def temperature_at_reference_humidity(temp_c, vapour_hpa) + target = wbgt_without_constant(temp_c, vapour_hpa) + low = temp_c - BRACKET_CELSIUS + high = temp_c + BRACKET_CELSIUS + BISECTION_STEPS.times do + mid = (low + high) / 2.0 + reference_wbgt(mid) < target ? low = mid : high = mid + end + ((low + high) / 2.0).round(2) + end + + def reference_wbgt(temp_c) + wbgt_without_constant(temp_c, REFERENCE_HUMIDITY / 100.0 * saturation_vapour_pressure(temp_c)) + end + + def dew_point_vapour(dew_point_c, temp_c) + if dew_point_c > temp_c + raise ArgumentError, "dew_point (#{dew_point_c} °C) cannot be above the temperature (#{temp_c} °C)" + end + + saturation_vapour_pressure(dew_point_c) + end + + def valid_dew_point?(dew_point) + dew_point.nil? || finite_number?(dew_point) + end + + def valid_humidity?(humidity) + humidity.nil? || (finite_number?(humidity) && humidity.to_f.between?(0.0, 100.0)) + end + + def finite_number?(value) + value.is_a?(Numeric) && value.to_f.finite? + end + end +end diff --git a/test/calcpace/test_environmental_adjuster.rb b/test/calcpace/test_environmental_adjuster.rb index fe4366d..6629fd2 100644 --- a/test/calcpace/test_environmental_adjuster.rb +++ b/test/calcpace/test_environmental_adjuster.rb @@ -177,6 +177,128 @@ def test_heat_is_capped_at_forty assert_equal 10.9, @calc.calculate_penalty(temperature: 45, time_seconds: 3600)[:factors][:heat] end + # --- humidity / dew point (effective temperature) --- + + def test_humidity_at_the_reference_reproduces_the_temperature_only_numbers + [3600, 14_400].each do |seconds| + plain = @calc.calculate_penalty(temperature: 30, time_seconds: seconds) + humid = @calc.calculate_penalty(temperature: 30, humidity: 50, time_seconds: seconds) + + assert_equal plain[:factors][:heat], humid[:factors][:heat] + end + end + + def test_reference_humidity_constant + assert_in_delta 50.0, EnvironmentalAdjuster::REFERENCE_HUMIDITY, 0.0 + end + + def test_humid_air_raises_the_effective_temperature_and_the_penalty + # WBGT(30 °C, 90%) = WBGT(35.94 °C, 50%) → base 8.7 + 0.94/5 × 2.2 = 9.11 + result = @calc.calculate_penalty(temperature: 30, humidity: 90, time_seconds: 3600) + + assert_in_delta 35.94, result[:factors][:effective_temperature_celsius], 0.01 + assert_equal 9.11, result[:factors][:heat] + assert_equal 9.11, result[:total_penalty_percent] + end + + def test_dry_air_lowers_the_effective_temperature_and_the_penalty + # WBGT(30 °C, 30%) = WBGT(26.69 °C, 50%) → base 4.3 + 1.69/5 × 2.2 = 5.04 + result = @calc.calculate_penalty(temperature: 30, humidity: 30, time_seconds: 3600) + + assert_in_delta 26.69, result[:factors][:effective_temperature_celsius], 0.01 + assert_equal 5.04, result[:factors][:heat] + end + + def test_humidity_scales_with_duration_like_temperature + result = @calc.calculate_penalty(temperature: 30, humidity: 90, time_seconds: 7200) + + assert_equal (9.11 * 2.0).round(2), result[:factors][:heat] + end + + def test_humid_air_can_lift_an_ideal_temperature_out_of_the_ideal_range + # 15 °C at 90% behaves like 18.33 °C at 50% → 3.33/5 × 2.8 = 1.86 + result = @calc.calculate_penalty(temperature: 15, humidity: 90, time_seconds: 3600) + + assert_in_delta 18.33, result[:factors][:effective_temperature_celsius], 0.01 + assert_equal 1.86, result[:factors][:heat] + end + + def test_dry_cool_air_stays_penalty_free + result = @calc.calculate_penalty(temperature: 12, humidity: 20, time_seconds: 3600) + + assert_equal 0.0, result[:factors][:heat] + end + + def test_effective_temperature_is_only_reported_when_humidity_is_given + refute @calc.calculate_penalty(temperature: 30)[:factors].key?(:effective_temperature_celsius) + assert_equal %i[heat altitude], @calc.calculate_penalty(temperature: 30)[:factors].keys + end + + def test_dew_point_equal_to_temperature_is_saturated_air + saturated = @calc.calculate_penalty(temperature: 30, dew_point: 30, time_seconds: 3600) + full = @calc.calculate_penalty(temperature: 30, humidity: 100, time_seconds: 3600) + + assert_equal full, saturated + end + + def test_dew_point_matches_the_equivalent_relative_humidity + # Td 20 °C at 30 °C → e = 23.37 hPa of es = 42.43 hPa → RH 55.08% + from_dew = @calc.calculate_penalty(temperature: 30, dew_point: 20, time_seconds: 3600) + from_rh = @calc.calculate_penalty(temperature: 30, humidity: 55.08, time_seconds: 3600) + + assert_in_delta from_rh[:factors][:effective_temperature_celsius], + from_dew[:factors][:effective_temperature_celsius], 0.01 + end + + def test_dew_point_follows_the_temperature_unit + fahrenheit = @calc.calculate_penalty(temperature: 86, dew_point: 68, temperature_unit: :f, time_seconds: 3600) + celsius = @calc.calculate_penalty(temperature: 30, dew_point: 20, time_seconds: 3600) + + assert_equal celsius, fahrenheit + end + + def test_humidity_outside_zero_to_one_hundred_is_rejected + [-1, 100.5, Float::NAN].each do |rh| + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, humidity: rh) } + end + end + + def test_non_numeric_humidity_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, humidity: '80') } + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, dew_point: '20') } + end + + def test_humidity_and_dew_point_together_are_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, humidity: 60, dew_point: 20) } + end + + def test_dew_point_above_temperature_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 20, dew_point: 21) } + end + + def test_humidity_without_temperature_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(humidity: 60) } + assert_raises(ArgumentError) { @calc.calculate_penalty(dew_point: 10) } + end + + def test_adjust_and_normalize_forward_humidity + adjusted = @calc.adjust_time(3600, temperature: 30, humidity: 90) + normalized = @calc.normalize_time(3600, temperature: 30, humidity: 90) + + assert_equal 9.11, adjusted[:penalty_percent] + assert_equal 9.11, normalized[:penalty_percent] + assert_in_delta 35.94, adjusted[:factors][:effective_temperature_celsius], 0.01 + end + + def test_predictions_forward_humidity + dry = @calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 30, humidity: 30) + humid = @calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 30, humidity: 90) + cameron = @calc.predict_time_cameron_adjusted('5k', '00:20:00', '10k', temperature: 30, humidity: 90) + + assert_operator humid[:adjusted_time], :>, dry[:adjusted_time] + assert cameron[:factors].key?(:effective_temperature_celsius) + end + def test_environmental_data_keeps_the_structure_the_site_reads altitude = EnvironmentalAdjuster::FACTORS.fetch('altitude') heat = EnvironmentalAdjuster::FACTORS.fetch('heat') From 77c1f4f03d6014bf83c6655d736fa715e41d224b Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:43:03 -0300 Subject: [PATCH 15/34] fix: cap the heat duration factor at 3.5x from 4 h The factor rose from 3.0x at 3 h to 4.5x at 4 h, i.e. +50% heat penalty for one more hour. El Helou et al. (2012, PLoS One, 1.8 M finishers of six majors, Table S3) give, against the optimum temperature, for the men's median (~3:58) 8.45% at 20 C and 16.9% at 25 C - 3.0x and 3.9x the 60-minute base - and the men's Q3 (~4:28) no more (3.0x / 4.1x). 4.5x sat above every group at both temperatures; the women's groups were lower still (1.7-2.5x). The 3 h anchor (3.0x, Ely et al. 2007: ~9% at 20 C WBGT for a 3 h runner) is kept; the segment now ends at 3.5x at 4 h and stays flat after. 35 C / 4 h goes from 39.15% to 30.45%, 40 C / 4 h from 49.05% to 38.15%. Efforts of 3 h or less are unchanged. --- lib/calcpace/data/environmental_factors.yml | 10 ++++-- lib/calcpace/environmental_adjuster.rb | 9 +++-- test/calcpace/test_environmental_adjuster.rb | 37 ++++++++++++++++++-- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/lib/calcpace/data/environmental_factors.yml b/lib/calcpace/data/environmental_factors.yml index 2f71233..d1f6d50 100644 --- a/lib/calcpace/data/environmental_factors.yml +++ b/lib/calcpace/data/environmental_factors.yml @@ -5,7 +5,13 @@ # Ref: ~3.76% penalty for 1828.8m (6000ft). # - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance" # NOTE: These heat factors are the BASELINE for a 60-minute effort. -# The final penalty is scaled by a DurationFactor (0.5x to 4.5x) based on total exposure time. +# The final penalty is scaled by a DurationFactor (0.5x at 30 min, 1.0x at +# 60 min, 3.0x at 3 h, 3.5x at 4 h and beyond; see +# EnvironmentalAdjuster::HEAT_DURATION_FACTORS) based on total exposure time. +# - Humidity: the points are read at air temperature with ~50% relative +# humidity (EnvironmentalAdjuster::REFERENCE_HUMIDITY). With humidity: or +# dew_point:, the temperature is first moved to the effective temperature +# with the same simplified WBGT (Australian Bureau of Meteorology). # # Interpolation: at or below threshold_meters the altitude penalty is 0; above it # both tables are interpolated linearly between points and clamped to the first and @@ -42,6 +48,6 @@ heat: 15: 0.0 20: 2.8 # Base for 60m. For 3h (3.0x) = 8.4% (Ely: 9%) 25: 4.3 # Base for 60m. For 3h (3.0x) = 12.9% (Ely: 12%) - 30: 6.5 # Base for 60m. For 3h (3.0x) = 19.5% + 30: 6.5 # Base for 60m. For 3h (3.0x) = 19.5%, for 4h (3.5x) = 22.75% 35: 8.7 # EXTRAPOLATION: continues the 25→30 slope (0.44 points/°C) 40: 10.9 # EXTRAPOLATION: continues the 25→30 slope (0.44 points/°C) diff --git a/lib/calcpace/environmental_adjuster.rb b/lib/calcpace/environmental_adjuster.rb index f8a86af..2a2c574 100644 --- a/lib/calcpace/environmental_adjuster.rb +++ b/lib/calcpace/environmental_adjuster.rb @@ -17,8 +17,13 @@ module EnvironmentalAdjuster # Heat duration scaling: [minutes, factor] points, joined by straight lines # and flat outside the first and last point. The base heat penalty in # environmental_factors.yml is for a 60-minute effort (factor 1.0). - # Rule based on Matthew Ely (2007) heat degradation curve. - HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 3.0], [240.0, 4.5]].freeze + # - up to 3 h (3.0x): Ely et al. (2007) — a ~3 h marathoner loses ~9% at + # 20 °C WBGT and ~12% at 25 °C; 2.8 × 3.0 = 8.4%, 4.3 × 3.0 = 12.9%. + # - 4 h (3.5x, flat after): El Helou et al. (2012, 1.8 M finishers, Table S3). + # Men's median (~3:58) loses 8.45% at 20 °C and 16.9% at 25 °C against the + # optimum, i.e. 3.0x and 3.9x the 60-minute base; men's Q3 (~4:28) is no + # worse (3.0x / 4.1x). The previous 4.5x at 4 h was above every group. + HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 3.0], [240.0, 3.5]].freeze # Relative humidity (%) the temperature-only heat curve stands for # (see EnvironmentalAdjuster::Humidity) diff --git a/test/calcpace/test_environmental_adjuster.rb b/test/calcpace/test_environmental_adjuster.rb index 6629fd2..a20f0b7 100644 --- a/test/calcpace/test_environmental_adjuster.rb +++ b/test/calcpace/test_environmental_adjuster.rb @@ -98,10 +98,41 @@ def test_calculate_penalty_with_duration def test_calculate_penalty_with_long_duration # 25C at 240 min (Amateur Marathon) - # Factor: 4.5x - # Penalty: 4.3 * 4.5 = 19.35% + # Factor: 3.5x (El Helou et al. 2012, men's median ~3:58: 3.0-3.9x) + # Penalty: 4.3 * 3.5 = 15.05% result = @calc.calculate_penalty(temperature: 25, time_seconds: 14_400) - assert_equal 19.35, result[:factors][:heat] + assert_equal 15.05, result[:factors][:heat] + end + + # --- heat duration factor beyond 3 h --- + + def test_duration_factor_keeps_the_three_hour_anchor + assert_in_delta 3.0, @calc.send(:duration_factor, 10_800), 1e-12 + end + + def test_duration_factor_reaches_three_and_a_half_at_four_hours + assert_in_delta 3.5, @calc.send(:duration_factor, 14_400), 1e-12 + assert_in_delta 3.25, @calc.send(:duration_factor, 12_600), 1e-12 + end + + def test_duration_factor_is_flat_beyond_four_hours + assert_in_delta 3.5, @calc.send(:duration_factor, 18_000), 1e-12 + assert_in_delta 3.5, @calc.send(:duration_factor, 36_000), 1e-12 + end + + def test_duration_factor_is_continuous_and_monotonic + factors = (0..21_600).step(30).map { |seconds| @calc.send(:duration_factor, seconds) } + + factors.each_cons(2) do |a, b| + assert_operator b, :>=, a + assert_operator b - a, :<, 0.02 + end + end + + def test_extreme_heat_for_four_hours + # 35 °C / 4 h: 8.7 * 3.5 = 30.45% (was 39.15%); 40 °C / 4 h: 10.9 * 3.5 = 38.15% (was 49.05%) + assert_equal 30.45, @calc.calculate_penalty(temperature: 35, time_seconds: 14_400)[:factors][:heat] + assert_equal 38.15, @calc.calculate_penalty(temperature: 40, time_seconds: 14_400)[:factors][:heat] end # --- altitude curve (v1.19.0) --- From d9ee512ebb778f90794871472a99e5291282c567 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:44:12 -0300 Subject: [PATCH 16/34] fix: end the marathon pace band at the predicted marathon pace training_paces(50)[:marathon] ran from 4:50 to 4:25/km (75-84% VO2max), but the VDOT-predicted marathon for VO2max 50 is 3:10:39, 4:31/km, and Daniels' M pace is exactly that predicted race pace - so the fast end was 6 s/km quicker than the runner's own marathon. The fast end now comes from predict_time_from_vo2max(vo2max, 'marathon') (about 80-83% VO2max across 30-70); the slow end stays at 75%. TRAINING_INTENSITIES[:marathon][:high] becomes :race_pace. The prediction covers VO2max 10-100; beyond it the race-pace fraction of the nearest bound (0.800 / 0.849) is used, so training_paces keeps accepting any positive VO2max and stays continuous at the bounds. --- lib/calcpace/training_zones.rb | 37 +++++++++++++++++--- test/calcpace/test_training_zones.rb | 51 ++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/lib/calcpace/training_zones.rb b/lib/calcpace/training_zones.rb index 2fe7146..f4e288a 100644 --- a/lib/calcpace/training_zones.rb +++ b/lib/calcpace/training_zones.rb @@ -10,10 +10,14 @@ # Heart rate zones use the Karvonen method (Heart Rate Reserve): # target = hr_rest + pct * (hr_max - hr_rest) module TrainingZones - # Training intensities as fraction of VO2max (Daniels' Running Formula) + # Training intensities as fraction of VO2max (Daniels' Running Formula). + # The fast end of the marathon band is :race_pace — Daniels' M pace is the + # runner's predicted marathon race pace, so it comes from the VDOT race + # prediction (FitnessPredictor#predict_time_from_vo2max) instead of a fixed + # fraction: ~80% of VO2max for slow marathoners, ~83% at VO2max 70. TRAINING_INTENSITIES = { easy: { low: 0.59, high: 0.74 }, - marathon: { low: 0.75, high: 0.84 }, + marathon: { low: 0.75, high: :race_pace }, threshold: { low: 0.83, high: 0.88 }, interval: { low: 0.95, high: 1.00 }, repetition: { low: 1.05, high: 1.10 } @@ -46,20 +50,24 @@ module TrainingZones # @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0) # @param unit [Symbol] pace unit — :km (default) or :mi # @return [Hash{Symbol => PaceBand}] keys: :easy, :marathon, :threshold, - # :interval, :repetition — paces per chosen unit + # :interval, :repetition — paces per chosen unit. The marathon band runs + # from 75% of VO2max to the VDOT-predicted marathon pace; the prediction + # covers VO2max 10–100, and outside that range the race-pace intensity of + # the nearest bound is used # @raise [Calcpace::NonPositiveInputError] if vo2max is not positive # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi # # @example # calc.training_paces(50.0)[:threshold].fast_clock #=> "00:04:15" # calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock #=> "00:06:51" + # calc.training_paces(50.0)[:marathon].fast_clock #=> "00:04:31" def training_paces(vo2max, unit: :km) check_positive(vo2max.to_f, 'VO2max') meters = pace_unit_meters(unit) TRAINING_INTENSITIES.transform_values do |band| slow = pace_seconds_at_pct(vo2max.to_f, band[:low], meters) - fast = pace_seconds_at_pct(vo2max.to_f, band[:high], meters) + fast = pace_seconds_at_pct(vo2max.to_f, intensity(band[:high], vo2max.to_f), meters) PaceBand.new( slow_seconds: slow, @@ -336,6 +344,27 @@ def check_heart_rates(hr_max, hr_rest) "Resting heart rate (#{hr_rest}) must be lower than maximum heart rate (#{hr_max})" end + def intensity(pct, vo2max) + pct == :race_pace ? marathon_race_intensity(vo2max) : pct + end + + # Fraction of VO2max a runner holds at the VDOT-predicted marathon pace. + # The prediction only covers FitnessPredictor::SUPPORTED_VO2MAX_RANGE, so + # beyond it the fraction of the nearest bound is used (it barely moves + # there: 0.800 at VO2max 10, 0.849 at 100). + def marathon_race_intensity(vo2max) + range = FitnessPredictor::SUPPORTED_VO2MAX_RANGE + vo2 = vo2max.clamp(range.min, range.max) + seconds = predict_time_from_vo2max(vo2, 'marathon') + + vo2_at_velocity(race_distance('marathon') * Converter::Distance::KM_TO_METERS * 60.0 / seconds) / vo2 + end + + # Daniels & Gilbert oxygen cost (ml/kg/min) of running at v m/min + def vo2_at_velocity(velocity) + -4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2)) + end + # Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2 def velocity_at_vo2(vo2) a = 0.000104 diff --git a/test/calcpace/test_training_zones.rb b/test/calcpace/test_training_zones.rb index fbb1726..a89f131 100644 --- a/test/calcpace/test_training_zones.rb +++ b/test/calcpace/test_training_zones.rb @@ -54,6 +54,57 @@ def test_slow_is_always_slower_than_fast_within_each_band end end + # --- marathon band: fast end = VDOT-predicted marathon pace --- + + def test_marathon_band_for_vo2max_fifty + zones = @calc.training_paces(50.0) + + assert_equal 290, zones[:marathon].slow_seconds # 75% → 04:50/km (unchanged) + assert_equal 271, zones[:marathon].fast_seconds # VDOT 50 marathon pace, 04:31/km + assert_equal '00:04:31', zones[:marathon].fast_clock + end + + def test_marathon_fast_end_is_the_predicted_marathon_pace + [30, 40, 50, 60, 70].each do |vo2| + predicted = @calc.predict_time_from_vo2max(vo2, 'marathon') / 42.195 + + assert_in_delta predicted, @calc.training_paces(vo2)[:marathon].fast_seconds, 1, "VO2 #{vo2}" + end + end + + def test_marathon_fast_end_in_miles_is_the_predicted_marathon_pace_per_mile + predicted = @calc.predict_time_from_vo2max(50, 'marathon') / 42.195 * 1.609344 + + assert_in_delta predicted, @calc.training_paces(50, unit: :mi)[:marathon].fast_seconds, 1 + end + + def test_marathon_fast_end_is_never_faster_than_threshold + [10, 30, 50, 70, 100].each do |vo2| + zones = @calc.training_paces(vo2) + + assert_operator zones[:marathon].fast_seconds, :>=, zones[:threshold].fast_seconds, "VO2 #{vo2}" + end + end + + def test_marathon_band_works_outside_the_vdot_prediction_range + # predict_time_from_vo2max supports 10–100; outside it the race-pace + # intensity of the nearest bound is used + low = @calc.training_paces(5)[:marathon] + high = @calc.training_paces(120)[:marathon] + + assert_operator low.slow_seconds, :>, low.fast_seconds + assert_operator high.slow_seconds, :>, high.fast_seconds + assert_operator high.fast_seconds, :<, @calc.training_paces(100)[:marathon].fast_seconds + assert_operator low.fast_seconds, :>, @calc.training_paces(10)[:marathon].fast_seconds + end + + def test_marathon_band_is_continuous_at_the_range_bounds + [[10, 9.99], [100, 100.01]].each do |inside, outside| + assert_in_delta @calc.training_paces(inside)[:marathon].fast_seconds, + @calc.training_paces(outside)[:marathon].fast_seconds, 1 + end + end + def test_training_paces_rejects_non_positive_vo2max assert_raises(Calcpace::NonPositiveInputError) { @calc.training_paces(0) } assert_raises(Calcpace::NonPositiveInputError) { @calc.training_paces(-10) } From 1a770e2e6d58ae00590d568045aea84a6b8d0cf9 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:45:34 -0300 Subject: [PATCH 17/34] docs: humidity, 4 h heat factor and marathon band in README and CHANGELOG Adds the humidity/dew point keywords, the duration factor points, the heat tables (before/after and 30 C by humidity) and the new marathon band to the README and to the Unreleased changelog. The adjust_time and Cameron-adjusted README examples move with the 4 h factor. --- CHANGELOG.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++--- README.md | 56 ++++++++++++++++++++++++++++++++------- 2 files changed, 118 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1d10c..6c99842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Humidity in the heat penalty.** `calculate_penalty` — and therefore + `adjust_time`, `normalize_time`, `predict_time_adjusted` and + `predict_time_cameron_adjusted`, which forward their options — accepts + `humidity:` (relative humidity, 0–100 %) or `dew_point:` (in + `temperature_unit`). 30 °C dry and 30 °C at 80% (typical of coastal Brazil) + used to get the same penalty. The temperature is replaced by an effective + temperature: the air temperature that, at `REFERENCE_HUMIDITY` (50%), has the + same simplified WBGT (Australian Bureau of Meteorology: + `WBGT = 0.567·Ta + 0.393·e + 3.94`, `e` = vapour pressure in hPa) as the real + temperature and humidity. The existing heat curve and the + `base(temperature) × duration_factor(seconds)` shape are untouched, and + `humidity: 50` gives exactly the temperature-only numbers. 50% is the + humidity at which that WBGT equals the air temperature between 20 °C and + 35 °C (51–56%), which is how the temperature points — calibrated on Ely et + al.'s WBGT figures — already read. `factors` gains + `:effective_temperature_celsius` when humidity or dew point is given. + `ArgumentError` for humidity outside 0–100 or non-numeric, a dew point above + the temperature, both keywords together, or either without a temperature. + + | 30 °C at | Effective temperature | 60 min | 4 h | + | --- | --- | --- | --- | + | 30% RH | 26.69 °C | 5.04% | 17.64% | + | 50% RH (= no humidity) | 30.0 °C | 6.5% | 22.75% | + | 70% RH | 33.07 °C | 7.85% | 27.48% | + | 90% RH | 35.94 °C | 9.11% | 31.89% | + ### Changed (numbers) Four models produced unrealistic numbers. The method names, signatures, return shapes and the structure of `environmental_factors.yml` are unchanged; only the @@ -43,7 +70,46 @@ above 100 km (see Breaking). - **Heat above 30 °C keeps increasing.** 35 °C and 40 °C used to get the same penalty as 30 °C. Two points extrapolate the 25→30 °C slope (0.44 points/°C): 35 °C → 8.7% and 40 °C → 10.9% at the 60-minute baseline (capped at 40 °C). - The ideal range and the duration scaling are unchanged. + The ideal range is unchanged. +- **Heat penalty grows less between 3 h and 4 h.** The duration factor went + from 3.0× at 3 h to 4.5× at 4 h (+50% heat penalty for one more hour); it now + ends at 3.5× at 4 h and stays flat after. El Helou et al. (2012, PLoS One, + 1.8 million finishers, Table S3) give, against the optimum temperature, + 8.45% at 20 °C and 16.9% at 25 °C for the men's median (~3:58) — 3.0× and + 3.9× the 60-minute base — and no more for the men's Q3 (~4:28); 4.5× was + above every group, men or women. The 3 h anchor (Ely et al. 2007: ~9% for a + 3 h runner at 20 °C WBGT) and everything up to 3 h are unchanged. The + duration points now live in `EnvironmentalAdjuster::HEAT_DURATION_FACTORS`; + `duration_factor(time_seconds)` keeps its name and signature. + + | Heat penalty (%) | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | + | --- | --- | --- | --- | --- | --- | --- | + | 20 °C | 1.4 | 2.8 | 5.6 | 8.4 | 12.6 → 9.8 | 12.6 → 9.8 | + | 25 °C | 2.15 | 4.3 | 8.6 | 12.9 | 19.35 → 15.05 | 19.35 → 15.05 | + | 30 °C | 3.25 | 6.5 | 13.0 | 19.5 | 29.25 → 22.75 | 29.25 → 22.75 | + | 35 °C | 4.35 | 8.7 | 17.4 | 26.1 | 39.15 → 30.45 | 39.15 → 30.45 | + | 40 °C | 5.45 | 10.9 | 21.8 | 32.7 | 49.05 → 38.15 | 49.05 → 38.15 | + + (35 °C and 40 °C columns compare against the extrapolated points above; in + 1.18.1 both were capped at the 30 °C values.) +- **The marathon pace band ends at the runner's predicted marathon pace.** + Daniels' M pace is the predicted marathon race pace, but + `training_paces(50)[:marathon]` ran from 4:50 to 4:25/km (75–84% VO2max) + while the VDOT marathon prediction for VO2max 50 is 3:10:39, 4:31/km. The + fast end now comes from `predict_time_from_vo2max(vo2max, 'marathon')` + (about 80–83% VO2max); the slow end stays at 75%. The prediction covers + VO2max 10–100, and beyond it the race-pace fraction of the nearest bound + is used, so `training_paces` still accepts any positive VO2max. + `TRAINING_INTENSITIES[:marathon][:high]` is now `:race_pace` instead of + `0.84`. + + | VO2max | M band before | M band after | Predicted marathon pace | + | --- | --- | --- | --- | + | 30 | 7:15–6:38/km | 7:15–6:52/km | 6:52/km | + | 40 | 5:47–5:17/km | 5:47–5:27/km | 5:27/km | + | 50 | 4:50–4:25/km | 4:50–4:31/km | 4:31/km | + | 60 | 4:11–3:49/km | 4:11–3:52/km | 3:52/km | + | 70 | 3:41–3:22/km | 3:41–3:24/km | 3:24/km | | Case | Before (1.18.1) | After | | --- | --- | --- | @@ -58,8 +124,11 @@ above 100 km (see Breaking). | Altitude 3600 m | 5.9% | 10.42% | | Heat 35 °C, 60 min | 6.5% | 8.7% | | Heat 40 °C, 60 min | 6.5% | 10.9% | -| Heat 35 °C, 4 h | 29.25% | 39.15% | -| Heat 40 °C, 4 h | 29.25% | 49.05% | +| Heat 25 °C, 4 h | 19.35% | 15.05% | +| Heat 30 °C, 4 h | 29.25% | 22.75% | +| Heat 35 °C, 4 h | 29.25% | 30.45% | +| Heat 40 °C, 4 h | 29.25% | 38.15% | +| Marathon band, VO2max 50 | 4:50–4:25/km | 4:50–4:31/km | | Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 | | Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 | diff --git a/README.md b/README.md index 009d5fe..5794880 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,41 @@ calc.checked_distance('01:21:32', '00:06:27') # => 12.64 ### Environmental Performance Adjustments -Adjust race performance based on heat and altitude. Calculations are based on scientific models -(Matthew Ely 2007 for heat, NCAA standards for altitude). +Adjust race performance based on heat, humidity and altitude. Calculations are based on scientific models +(Ely et al. 2007 and El Helou et al. 2012 for heat, the Australian Bureau of Meteorology's +simplified WBGT for humidity, NCAA standards for altitude). - **Altitude**: no penalty up to 300 m, then a linear ramp to the first NCAA point (914.4 m → 1.41%), the NCAA table up to 2438.4 m (5.90%), and an extrapolated curve beyond it (3000 m → 7.92%, 3500 m → 9.97%, 4000 m → 12.2%, capped there). São Paulo (760 m) gets ~1.06%. - **Heat**: 60-minute baseline from 15 °C (0%) to 30 °C (6.5%), extrapolated to - 35 °C (8.7%) and 40 °C (10.9%, capped there), then scaled by effort duration. + 35 °C (8.7%) and 40 °C (10.9%, capped there), then scaled by effort duration: + 0.5× up to 30 min, 1.0× at 60 min, 3.0× at 3 h, 3.5× at 4 h and beyond + (linear in between). +- **Humidity** (optional): pass `humidity:` (relative humidity, %) or + `dew_point:` (in `temperature_unit`). Without either, the heat curve assumes + 50% humidity. With one, the temperature is replaced by the effective + temperature that has the same simplified WBGT (`0.567·Ta + 0.393·e + 3.94`) + at 50% humidity, and `factors` reports it as `:effective_temperature_celsius`. + +| Heat penalty (%) | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | +| --- | --- | --- | --- | --- | --- | --- | +| 20 °C | 1.4 | 2.8 | 5.6 | 8.4 | 9.8 | 9.8 | +| 25 °C | 2.15 | 4.3 | 8.6 | 12.9 | 15.05 | 15.05 | +| 30 °C | 3.25 | 6.5 | 13.0 | 19.5 | 22.75 | 22.75 | +| 35 °C | 4.35 | 8.7 | 17.4 | 26.1 | 30.45 | 30.45 | +| 40 °C | 5.45 | 10.9 | 21.8 | 32.7 | 38.15 | 38.15 | + +| 30 °C at | Effective temperature | 60 min | 4 h | +| --- | --- | --- | --- | +| 30% RH | 26.69 °C | 5.04% | 17.64% | +| 50% RH (= no humidity) | 30.0 °C | 6.5% | 22.75% | +| 70% RH | 33.07 °C | 7.85% | 27.48% | +| 90% RH | 35.94 °C | 9.11% | 31.89% | + +Above ~30 °C (and above ~25 °C for 4 h+) the numbers are extrapolations: the +marathon studies behind the curve have little or no data there. ```ruby # Calculate penalty for 25°C and 2000m altitude (Defaults to 60-min effort) @@ -59,14 +85,19 @@ penalty = calc.calculate_penalty(temperature: 25, altitude: 2000) calc.calculate_penalty(temperature: 80, temperature_unit: :f) # => { total_penalty_percent: 5.03, ... } +# Humidity: 30 °C at 90% hits like 35.94 °C at 50% +calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] # => 9.11 +calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] # => 35.94 +calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] # => 8.15 + # Adjust a 3:30 marathon time (12600s) for these conditions (High exposure penalty) result = calc.adjust_time(12600, temperature: 25, altitude: 2000) # => { # original_time: 12600, -# adjusted_time: 15176.7, -# adjusted_time_clock: "04:12:56", -# penalty_percent: 20.45, -# factors: { heat: 16.13, altitude: 4.32 } +# adjusted_time: 14905.8, +# adjusted_time_clock: "04:08:25", +# penalty_percent: 18.3, +# factors: { heat: 13.98, altitude: 4.32 } # } # Predicted adjusted times (Riegel formula) @@ -75,7 +106,7 @@ calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 28) # Predicted adjusted times (Cameron formula) calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 80, temperature_unit: :f) -# => { adjusted_time: 13045.91, adjusted_time_clock: "03:37:25", penalty_percent: 16.02, ... } +# => { adjusted_time: 12976.19, adjusted_time_clock: "03:36:16", penalty_percent: 15.4, ... } ``` --- @@ -423,6 +454,7 @@ Personalized training paces (Daniels' Running Formula) and Karvonen heart-rate z zones = calc.training_paces(50.0) zones[:threshold].fast_clock # => "00:04:15" per km zones[:easy].slow_clock # => "00:05:52" per km +zones[:marathon].fast_clock # => "00:04:31" per km (the VDOT-predicted marathon pace) calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock # => "00:06:51" per mile @@ -441,13 +473,17 @@ calc.hr_zones_from_max(hr_max: 190) | Zone | %VO2max | Purpose | |------|---------|---------| | Easy | 59–74% | Base building, recovery | -| Marathon | 75–84% | Marathon race pace | +| Marathon | 75% – predicted marathon pace (~80–83%) | Marathon race pace | | Threshold | 83–88% | Lactate threshold, tempo runs | | Interval | 95–100% | VO2max development | | Repetition | 105–110% | Speed and running economy | Pace accuracy vs published VDOT tables: within a few seconds per km -(threshold matches exactly; easy band is a range heuristic). +(threshold matches exactly; easy band is a range heuristic). The fast end of the +marathon band is the marathon pace `predict_time_from_vo2max` gives for the same +VO2max (Daniels' M pace is the predicted marathon race pace); that prediction +covers VO2max 10–100, and outside it the race-pace intensity of the nearest bound +is used. `unit:` sets the unit of the returned pace bands; `distance_unit:` sets the unit of a numeric race distance you pass in. Combining `distance_unit:` with a race name raises From fd54e47b5480238035948222392cc5224b2c9338 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 06:49:04 -0300 Subject: [PATCH 18/34] fix: address review of grade-adjusted splits and VO2max norms - Stretches with elevation shorter than a grade segment and with no full segment before them (between missing fixes, or a whole short track) are flat instead of graded over a few noisy metres. - NaN/infinite :ele counts as missing in grade segments instead of raising a misleading grade error. - track_splits skips the GAP bookkeeping, restoring its original cost. - FRIEND citation: 7,783 tests on adults free of known cardiovascular disease, not apparently healthy adults; document truncated float ages, the horizontal-distance assumption, and fix README order and the CHANGELOG signature. --- CHANGELOG.md | 5 +- README.md | 30 +++++++----- .../data/friend_2015_vo2max_percentiles.yml | 7 +-- lib/calcpace/track_calculator.rb | 49 +++++++++++++------ lib/calcpace/vo2max_estimator.rb | 3 +- lib/calcpace/vo2max_norms.rb | 8 +-- test/calcpace/test_grade_adjusted_pace.rb | 29 +++++++++++ 7 files changed, 94 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2385252..7cc6ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `track_splits` splits with a `:gap` pace per split, computed segment by segment from `:ele`. Grades are measured over segments of at least 100 m of horizontal distance so GPS elevation noise does not become fake climbing; - stretches without `:ele` are flat. `track_splits` output is unchanged. + stretches without `:ele` (or with a non-finite one), and stretches with + elevation too short to grade, are flat. `track_splits` output is unchanged. - VO2max norms by age and sex from the FRIEND registry (Kaminsky, Arena & Myers, Mayo Clin Proc 2015;90(11):1515–1523, Table 3: treadmill, measured VO2max), stored in `lib/calcpace/data/friend_2015_vo2max_percentiles.yml`: - - `vo2max_label(value, age:, sex:)` — optional keywords; with both, the + - `vo2max_label(value, age: nil, sex: nil)` — optional keywords; with both, the label comes from the percentile among the same sex and age decade (≥95th Elite, ≥90th Excellent, ≥75th Very Good, ≥50th Good, ≥25th Fair, else Beginner). Without them the fixed thresholds and labels are unchanged. diff --git a/README.md b/README.md index 5baed12..0d05610 100644 --- a/README.md +++ b/README.md @@ -283,8 +283,12 @@ GAP = pace / factor over **grade segments of at least 100 m** of horizontal distance — read between fixes a metre apart, ±2 m of jitter would be a ±400% grade. A short leftover at the end of a stretch joins the segment before it. Stretches - between points without `:ele` count as flat, so a track with no elevation - has `:gap` equal to `:pace`. + between points without `:ele` (or with a NaN/infinite one) count as flat, + so a track with no elevation has `:gap` equal to `:pace`; so does a stretch + with elevation shorter than 100 m that has no full segment before it to + join (between missing fixes, or a whole track that short). +- Track distances are horizontal (Haversine), and the factor is applied to + them without the √(1 + grade²) slope-length correction — 0.5% at 10%. - `estimate_detailed_vo2max` keeps its own flat elevation heuristic (100 m of gain = 600 m of flat), so its numbers do not change. @@ -397,6 +401,16 @@ calc.vo2max_label(51.9) # => "Very Good" *Thresholds based on Daniels, J. (2014). Daniels' Running Formula (3rd ed.), consistent with ACSM guidelines and McArdle, Katch & Katch (2015) Exercise Physiology.* +**Formula:** +``` +velocity (m/min) = distance_m / time_min +VO2 = −4.60 + 0.182258·v + 0.000104·v² +%VO2max = 0.8 + 0.1894393·e^(−0.012778·t) + 0.2989558·e^(−0.1932605·t) +VO2max = VO2 / %VO2max +``` + +Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace. + #### By age and sex The fixed thresholds above are the same for everyone. Give `vo2max_label` an @@ -438,17 +452,7 @@ calc.vo2max_percentile(45, age: 60, sex: :male) # => 95.0 - The registry measured VO2max in a lab; a VO2max estimated from a race time carries its own ±3–5 ml/kg/min on top. -*Kaminsky, L. A., Arena, R., & Myers, J. (2015). Reference Standards for Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: Data From the Fitness Registry and the Importance of Exercise National Database. Mayo Clinic Proceedings, 90(11), 1515–1523, Table 3 (rows "Men/Women from FRIEND"; 7,783 adults free of known cardiovascular disease). https://doi.org/10.1016/j.mayocp.2015.07.026. The same table also lists the Cooper Clinic norms printed in ACSM's Guidelines for Exercise Testing and Prescription (9th ed., 2014); those are predicted from treadmill time rather than measured, and are not used here.* - -**Formula:** -``` -velocity (m/min) = distance_m / time_min -VO2 = −4.60 + 0.182258·v + 0.000104·v² -%VO2max = 0.8 + 0.1894393·e^(−0.012778·t) + 0.2989558·e^(−0.1932605·t) -VO2max = VO2 / %VO2max -``` - -Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace. +*Kaminsky, L. A., Arena, R., & Myers, J. (2015). Reference Standards for Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: Data From the Fitness Registry and the Importance of Exercise National Database. Mayo Clinic Proceedings, 90(11), 1515–1523, Table 3 (rows "Men/Women from FRIEND"; 7,783 treadmill tests on adults free of known cardiovascular disease). https://doi.org/10.1016/j.mayocp.2015.07.026. The same table also lists the Cooper Clinic norms printed in ACSM's Guidelines for Exercise Testing and Prescription (9th ed., 2014); those are predicted from treadmill time rather than measured, and are not used here.* #### Contextualized estimation diff --git a/lib/calcpace/data/friend_2015_vo2max_percentiles.yml b/lib/calcpace/data/friend_2015_vo2max_percentiles.yml index 6a2ea88..28af5a5 100644 --- a/lib/calcpace/data/friend_2015_vo2max_percentiles.yml +++ b/lib/calcpace/data/friend_2015_vo2max_percentiles.yml @@ -8,9 +8,10 @@ # # Table 3, "Sex-Specific Percentiles for CRF From Treadmill Exercise Tests With # Measured VO2max Obtained From FRIEND [...]", rows "Men from FRIEND" and -# "Women from FRIEND". 7,783 apparently healthy US adults (4,611 men, 3,172 -# women) free of known cardiovascular disease, VO2max measured by -# cardiopulmonary exercise testing (not predicted). +# "Women from FRIEND". 7,783 treadmill tests (4,611 on men, 3,172 on women) +# on US adults free of known cardiovascular disease (Table 3 footnote; the +# paper notes some had other conditions, e.g. diabetes or obesity), VO2max +# measured by cardiopulmonary exercise testing (not predicted). # # The same Table 3 also reproduces the Cooper Clinic percentiles as printed in # ACSM's Guidelines for Exercise Testing and Prescription, 9th ed. (2014), diff --git a/lib/calcpace/track_calculator.rb b/lib/calcpace/track_calculator.rb index 6c73812..8d80e86 100644 --- a/lib/calcpace/track_calculator.rb +++ b/lib/calcpace/track_calculator.rb @@ -182,8 +182,12 @@ def track_splits(points, split_km = 1.0, compact: false) # elevation change over its length. A leftover shorter than that at the end # of a stretch is merged into the segment before it. Grades are clamped to # ±45%, the range the model was measured on. - # - A stretch between points without :ele is flat (factor 1.0), and a missing - # :ele ends the grade segment in progress. + # - A stretch between points without :ele (or with a NaN/infinite one) is + # flat (factor 1.0), and a missing :ele ends the grade segment in progress. + # A stretch with elevation shorter than one grade segment and with no full + # segment before it to join — or a whole track that short — is flat too. + # - Distances are the horizontal (Haversine) ones; the factor is applied to + # them as is, without the √(1 + grade²) slope correction (0.5% at 10%). # - Each split's distance is weighted by the grade factor of the segments it # covers, and :gap is the split's time over that flat-equivalent distance. # A split on flat ground, or with no elevation data, has :gap equal to :pace. @@ -314,15 +318,21 @@ def collect_splits(points, split_km, compact:, grade_factors: nil) accumulated_km: 0.0, split_number: 1, compact: compact, grade_factors: grade_factors, flat_equivalent_km: 0.0 } - points.each_cons(2).with_index { |(a, b), index| process_segment(a, b, split_km, state, index) } + if grade_factors + points.each_cons(2).with_index { |(a, b), index| process_segment(a, b, split_km, state, index) } + else + points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) } + end append_partial_split(points.last, split_km, state) state[:splits] end - def process_segment(point_a, point_b, split_km, state, index) + # index is only given, and the segment only tracked, when computing :gap — + # track_splits keeps its original per-segment cost + def process_segment(point_a, point_b, split_km, state, index = nil) segment_km = segment_distance_km(point_a, point_b) state[:accumulated_km] += segment_km - state[:segment] = { km: segment_km, assigned_km: 0.0, factor: state[:grade_factors]&.fetch(index) } + state[:segment] = { assigned_km: 0.0, factor: state[:grade_factors].fetch(index) } if index while state[:accumulated_km] >= split_km * state[:split_number] record_split(point_a, point_b, segment_km, split_km, state) @@ -373,8 +383,9 @@ def segment_distance_km(point_a, point_b) # Adds the flat-equivalent distance of the current segment up to # distance_into_segment, for the part not yet credited to an earlier split def accumulate_flat_equivalent(state, distance_into_segment) + return unless state[:grade_factors] + segment = state[:segment] - return unless segment[:factor] state[:flat_equivalent_km] += (distance_into_segment - segment[:assigned_km]) * segment[:factor] segment[:assigned_km] = distance_into_segment @@ -406,8 +417,8 @@ def new_grade_window(previous = nil) end def extend_grade_window(window, factors, point_a, point_b, index) - ele_a = fetch_ele(point_a) - ele_b = fetch_ele(point_b) + ele_a = finite_ele(point_a) + ele_b = finite_ele(point_b) if ele_a.nil? || ele_b.nil? close_grade_window(window, factors, final: true) return new_grade_window @@ -420,6 +431,12 @@ def extend_grade_window(window, factors, point_a, point_b, index) new_grade_window(window.except(:previous)) end + # Grade segments treat a NaN or infinite elevation like a missing one + def finite_ele(point) + ele = fetch_ele(point) + ele if ele&.finite? + end + def add_to_grade_window(window, index, segment_km, ele_a, ele_b) window[:start_ele] ||= ele_a window[:end_ele] = ele_b @@ -428,20 +445,22 @@ def add_to_grade_window(window, index, segment_km, ele_a, ele_b) end # A full window gets its own grade. A short leftover — the end of the track - # or of a stretch with elevation — is merged into the full window before it, - # when there is one, rather than graded on its own over a few noisy metres. + # or of a stretch with elevation — is merged into the full window before it; + # with no full window before it (a stretch shorter than a grade segment + # between missing elevations, or a whole track that short) it stays flat + # rather than being graded over a few noisy metres. def close_grade_window(window, factors, final:) return if window[:indexes].empty? - window = merge_grade_windows(window[:previous], window) if final && short_with_previous?(window) + unless full_grade_window?(window) + return unless final && window[:previous] + + window = merge_grade_windows(window[:previous], window) + end factor = grade_adjustment_factor(window_grade(window)) window[:indexes].each { |index| factors[index] = factor } end - def short_with_previous?(window) - !full_grade_window?(window) && window[:previous] - end - def full_grade_window?(window) window[:km] >= GRADE_SEGMENT_MIN_KM - GRADE_SEGMENT_TOLERANCE_KM end diff --git a/lib/calcpace/vo2max_estimator.rb b/lib/calcpace/vo2max_estimator.rb index c2cc0a2..f9a7b92 100644 --- a/lib/calcpace/vo2max_estimator.rb +++ b/lib/calcpace/vo2max_estimator.rb @@ -111,7 +111,8 @@ def estimate_detailed_vo2max(distance, time, elevation_gain_m: 0, hr_avg: nil, h # 60-year-old woman. # # @param value [Numeric] VO2max in ml/kg/min - # @param age [Integer, nil] age in years (18 or over); give it with sex + # @param age [Integer, nil] age in years (18 or over, a fractional age is + # truncated); give it with sex # @param sex [String, Symbol, nil] male or female; give it with age # @return [String] label: "Beginner", "Fair", "Good", "Very Good", "Excellent", or "Elite" # @raise [Calcpace::NonPositiveInputError] if value is not positive diff --git a/lib/calcpace/vo2max_norms.rb b/lib/calcpace/vo2max_norms.rb index 300f41d..83683bf 100644 --- a/lib/calcpace/vo2max_norms.rb +++ b/lib/calcpace/vo2max_norms.rb @@ -7,7 +7,8 @@ # # Uses the FRIEND registry (Fitness Registry and the Importance of Exercise # National Database) percentiles of VO2max measured by cardiopulmonary -# exercise testing on a treadmill in 7,783 apparently healthy US adults: +# exercise testing in 7,783 treadmill tests on US adults free of known +# cardiovascular disease: # # Kaminsky, L. A., Arena, R., & Myers, J. (2015). Reference Standards for # Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: @@ -70,11 +71,12 @@ module Vo2maxNorms # percentile, 95.0 at or above the 95th. # # @param value [Numeric] VO2max in ml/kg/min - # @param age [Integer] age in years (18 or over) + # @param age [Integer] age in years (18 or over; a fractional age is + # truncated, as in AgeGrading) # @param sex [String, Symbol] male or female # @return [Float] percentile between 5.0 and 95.0, rounded to one decimal # @raise [Calcpace::NonPositiveInputError] if value is not positive - # @raise [ArgumentError] if age is under 18 or not an integer, or sex is not male/female + # @raise [ArgumentError] if age is under 18 or not a number, or sex is not male/female # # @example # calc.vo2max_percentile(48.0, age: 25, sex: :male) #=> 50.0 diff --git a/test/calcpace/test_grade_adjusted_pace.rb b/test/calcpace/test_grade_adjusted_pace.rb index ec732f0..01af2dd 100644 --- a/test/calcpace/test_grade_adjusted_pace.rb +++ b/test/calcpace/test_grade_adjusted_pace.rb @@ -214,6 +214,35 @@ def test_points_missing_elevation_are_flat_segments assert_equal second[:pace], second[:gap] end + # Every third fix without altitude leaves 20 m stretches with elevation: + # too short to grade, with no full segment before them to join, so they are + # flat rather than read as ±20% from 2 m of jitter + def test_short_isolated_stretches_with_elevation_are_flat + points = build_track(distance_m: 3000, ele: ->(m) { 100 + (2.0 * Math.sin(m * 1.7)) }) + points.each_with_index { |point, i| point.delete(:ele) if (i % 3) == 2 } + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal split[:pace], split[:gap] + end + end + + def test_track_shorter_than_one_grade_segment_is_flat + points = build_track(distance_m: 50, ele: ->(m) { 100 + (0.1 * m) }) + split = @calc.track_grade_adjusted_splits(points, 1.0).first + + assert_equal split[:pace], split[:gap] + end + + def test_non_finite_elevation_counts_as_missing + points = build_track(distance_m: 2000) + points[50][:ele] = Float::NAN + points[120][:ele] = Float::INFINITY + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal split[:pace], split[:gap] + end + end + def test_string_keys_are_accepted points = build_track(distance_m: 1000, ele: ->(m) { 100 + (0.05 * m) }) .map { |point| point.transform_keys(&:to_s) } From ff58a91299ea3297983a3429b41778f950ee1f5f Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:01:41 -0300 Subject: [PATCH 19/34] fix: exact reference humidity and stricter humidity input checks - The heat curve is now read at the unrounded effective temperature; only factors[:effective_temperature_celsius] is rounded. Rounding to 2 decimals before interpolating made humidity: 50 differ from the temperature-only penalty for ~3% of random float temperatures (30.42271454 C: 6.69 vs 6.68). humidity: REFERENCE_HUMIDITY now returns the air temperature itself instead of a bisection result an ulp away. Randomized test over 5000 inputs. - Dew points below -100 C raise ArgumentError (the Magnus formula has a pole at -237.7 C; -240 C used to give T_eff 70 C and the capped penalty). - Complex humidity / dew point and a non-finite temperature combined with humidity or dew point raise ArgumentError instead of NoMethodError / comparison errors. --- README.md | 2 +- lib/calcpace/environmental_adjuster.rb | 4 +- lib/calcpace/humidity.rb | 26 +++++++++-- test/calcpace/test_environmental_adjuster.rb | 48 ++++++++++++++++++-- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5794880..0290b77 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ calc.calculate_penalty(temperature: 80, temperature_unit: :f) # Humidity: 30 °C at 90% hits like 35.94 °C at 50% calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] # => 9.11 calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] # => 35.94 -calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] # => 8.15 +calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] # => 8.16 # Adjust a 3:30 marathon time (12600s) for these conditions (High exposure penalty) result = calc.adjust_time(12600, temperature: 25, altitude: 2000) diff --git a/lib/calcpace/environmental_adjuster.rb b/lib/calcpace/environmental_adjuster.rb index 2a2c574..e621c53 100644 --- a/lib/calcpace/environmental_adjuster.rb +++ b/lib/calcpace/environmental_adjuster.rb @@ -50,7 +50,7 @@ module EnvironmentalAdjuster # @example # calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] #=> 9.11 # calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] #=> 35.94 - # calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] #=> 8.15 + # calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] #=> 8.16 def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil, humidity: nil, dew_point: nil) effective = effective_temperature(temperature, temperature_unit, humidity, dew_point) @@ -58,7 +58,7 @@ def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, tim altitude_penalty = calculate_altitude_penalty(altitude) factors = { heat: heat_penalty, altitude: altitude_penalty } - factors[:effective_temperature_celsius] = effective unless humidity.nil? && dew_point.nil? + factors[:effective_temperature_celsius] = effective.round(2) unless humidity.nil? && dew_point.nil? { total_penalty_percent: (heat_penalty + altitude_penalty).round(2), factors: factors } end diff --git a/lib/calcpace/humidity.rb b/lib/calcpace/humidity.rb index fa999c4..3e313b2 100644 --- a/lib/calcpace/humidity.rb +++ b/lib/calcpace/humidity.rb @@ -37,6 +37,10 @@ module Humidity BRACKET_CELSIUS = 40.0 BISECTION_STEPS = 60 + # Lowest accepted dew point (°C). The Magnus formula has a pole at + # −237.7 °C, and no weather on Earth has a dew point anywhere near −100 °C. + MIN_DEW_POINT_CELSIUS = -100.0 + module_function # @param temp [Numeric, nil] air temperature in any unit (nil = none given) @@ -44,8 +48,9 @@ module Humidity # @param dew_point [Object] dew point input # @raise [ArgumentError] if the combination or a value is invalid def check_inputs!(temp, humidity, dew_point) + return if humidity.nil? && dew_point.nil? raise ArgumentError, 'Pass either humidity or dew_point, not both' if humidity && dew_point - raise ArgumentError, 'humidity and dew_point need a temperature' if temp.nil? && (humidity || dew_point) + raise ArgumentError, 'humidity and dew_point need a finite temperature' unless finite_number?(temp) check_values!(humidity, dew_point) end @@ -62,9 +67,16 @@ def check_values!(humidity, dew_point) # @param temp_c [Float] air temperature in °C # @param humidity [Numeric, nil] relative humidity in % # @param dew_point_c [Numeric, nil] dew point in °C (used when humidity is nil) - # @return [Float] effective temperature in °C, rounded to 2 decimals - # @raise [ArgumentError] if the dew point is above the air temperature + # @return [Float] effective temperature in °C, unrounded (round only for + # display, so that humidity: REFERENCE_HUMIDITY reads the curve at + # exactly the air temperature) + # @raise [ArgumentError] if the dew point is above the air temperature or + # below MIN_DEW_POINT_CELSIUS def effective_temperature(temp_c, humidity: nil, dew_point_c: nil) + # The exact solution; bisection would land within an ulp of it, which a + # later rounding step can still tip over a boundary + return temp_c if humidity == REFERENCE_HUMIDITY + vapour = humidity ? humidity / 100.0 * saturation_vapour_pressure(temp_c) : dew_point_vapour(dew_point_c, temp_c) temperature_at_reference_humidity(temp_c, vapour) end @@ -91,7 +103,7 @@ def temperature_at_reference_humidity(temp_c, vapour_hpa) mid = (low + high) / 2.0 reference_wbgt(mid) < target ? low = mid : high = mid end - ((low + high) / 2.0).round(2) + (low + high) / 2.0 end def reference_wbgt(temp_c) @@ -99,6 +111,9 @@ def reference_wbgt(temp_c) end def dew_point_vapour(dew_point_c, temp_c) + if dew_point_c < MIN_DEW_POINT_CELSIUS + raise ArgumentError, "dew_point (#{dew_point_c} °C) is below #{MIN_DEW_POINT_CELSIUS} °C" + end if dew_point_c > temp_c raise ArgumentError, "dew_point (#{dew_point_c} °C) cannot be above the temperature (#{temp_c} °C)" end @@ -114,8 +129,9 @@ def valid_humidity?(humidity) humidity.nil? || (finite_number?(humidity) && humidity.to_f.between?(0.0, 100.0)) end + # Real numbers only: Complex is Numeric too, and has no order to compare def finite_number?(value) - value.is_a?(Numeric) && value.to_f.finite? + value.is_a?(Numeric) && value.real? && value.to_f.finite? end end end diff --git a/test/calcpace/test_environmental_adjuster.rb b/test/calcpace/test_environmental_adjuster.rb index a20f0b7..7598a2e 100644 --- a/test/calcpace/test_environmental_adjuster.rb +++ b/test/calcpace/test_environmental_adjuster.rb @@ -219,6 +219,46 @@ def test_humidity_at_the_reference_reproduces_the_temperature_only_numbers end end + def test_humidity_at_the_reference_is_exact_for_random_inputs + rng = Random.new(20_261_002) + + 5000.times do + temperature = (rng.rand * 50) - 5 + seconds = rng.rand * 20_000 + plain = @calc.calculate_penalty(temperature: temperature, time_seconds: seconds) + humid = @calc.calculate_penalty(temperature: temperature, humidity: 50, time_seconds: seconds) + + assert_equal plain[:total_penalty_percent], humid[:total_penalty_percent], "T=#{temperature} s=#{seconds}" + end + end + + def test_penalty_is_interpolated_on_the_unrounded_effective_temperature + # 30.42271454 °C used to be rounded to 30.42 before interpolating + plain = @calc.calculate_penalty(temperature: 30.42271454, time_seconds: 3600) + humid = @calc.calculate_penalty(temperature: 30.42271454, humidity: 50, time_seconds: 3600) + + assert_equal plain[:factors][:heat], humid[:factors][:heat] + assert_in_delta 30.42, humid[:factors][:effective_temperature_celsius], 0.0 + end + + def test_dew_point_below_minus_one_hundred_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, dew_point: -240) } + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, dew_point: -100.01) } + assert_kind_of Hash, @calc.calculate_penalty(temperature: 30, dew_point: -100) + end + + def test_complex_humidity_or_dew_point_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, humidity: Complex(80, 0)) } + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, dew_point: Complex(20, 0)) } + end + + def test_non_finite_temperature_with_humidity_is_rejected + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |temperature| + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: temperature, humidity: 50) } + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: temperature, dew_point: 10) } + end + end + def test_reference_humidity_constant assert_in_delta 50.0, EnvironmentalAdjuster::REFERENCE_HUMIDITY, 0.0 end @@ -233,11 +273,11 @@ def test_humid_air_raises_the_effective_temperature_and_the_penalty end def test_dry_air_lowers_the_effective_temperature_and_the_penalty - # WBGT(30 °C, 30%) = WBGT(26.69 °C, 50%) → base 4.3 + 1.69/5 × 2.2 = 5.04 + # WBGT(30 °C, 30%) = WBGT(26.6946 °C, 50%) → base 4.3 + 1.6946/5 × 2.2 = 5.05 result = @calc.calculate_penalty(temperature: 30, humidity: 30, time_seconds: 3600) assert_in_delta 26.69, result[:factors][:effective_temperature_celsius], 0.01 - assert_equal 5.04, result[:factors][:heat] + assert_equal 5.05, result[:factors][:heat] end def test_humidity_scales_with_duration_like_temperature @@ -247,11 +287,11 @@ def test_humidity_scales_with_duration_like_temperature end def test_humid_air_can_lift_an_ideal_temperature_out_of_the_ideal_range - # 15 °C at 90% behaves like 18.33 °C at 50% → 3.33/5 × 2.8 = 1.86 + # 15 °C at 90% behaves like 18.3304 °C at 50% → 3.3304/5 × 2.8 = 1.87 result = @calc.calculate_penalty(temperature: 15, humidity: 90, time_seconds: 3600) assert_in_delta 18.33, result[:factors][:effective_temperature_celsius], 0.01 - assert_equal 1.86, result[:factors][:heat] + assert_equal 1.87, result[:factors][:heat] end def test_dry_cool_air_stays_penalty_free From f7d75fefaca16125b661f46710035ab588cebea2 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:03:08 -0300 Subject: [PATCH 20/34] fix: fit the heat duration factor to El Helou 2012 Table S3 The previous 3.0x (3 h) / 3.5x (4 h) points mixed two baselines: they used penalties measured from the ~6 C optimum, while the gem's heat curve is zero at 15 C, and they leaned on Ely 2007 percentages for a 3 h runner (~9% at 20 C, ~12% at 25 C) that the paper's abstract does not contain. The 3 h and 4 h points are now a weighted least-squares fit to Table S3: for eight groups (men/women P1, Q1, median, Q3; 2:41-4:54) the time penalty against 15 C at 20 and 25 C, interpolated linearly between the published points, divided by the 60-minute base (2.8 / 4.3). 1.0x at 60 min is kept, 180 and 240 min are free, flat after 240, each sex carries half the weight; men P1 at 25 C is beyond the table and left out (15 observations). Result: 1.239 and 2.180 -> 1.24x and 2.18x. 2 h is the straight line between 60 and 180 min (1.12x); no group finishes under 2:41. The derivation table lives in environmental_factors.yml and a test recomputes the fit from the table. The Ely percentages are gone from the comments; Ely 2007 stays as a qualitative source with its abstract's numbers. 25 C: 3 h 12.9% -> 5.33%, 4 h 19.35% -> 9.37%. --- lib/calcpace/data/environmental_factors.yml | 43 ++++++- lib/calcpace/environmental_adjuster.rb | 21 ++-- test/calcpace/test_environmental_adjuster.rb | 124 ++++++++++++++++--- 3 files changed, 156 insertions(+), 32 deletions(-) diff --git a/lib/calcpace/data/environmental_factors.yml b/lib/calcpace/data/environmental_factors.yml index d1f6d50..a3d8fd3 100644 --- a/lib/calcpace/data/environmental_factors.yml +++ b/lib/calcpace/data/environmental_factors.yml @@ -3,11 +3,42 @@ # Sources: # - Altitude: NCAA Altitude Adjustment Factors (TFRRS) # Ref: ~3.76% penalty for 1828.8m (6000ft). -# - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance" -# NOTE: These heat factors are the BASELINE for a 60-minute effort. +# - Heat: Ely et al. (2007) "Impact of Weather on Marathon-Running Performance", +# MSSE 39(3):487-493 — qualitative source: marathon times slow progressively +# from 5 to 25 °C WBGT, and more for slower runners (top men 1.7 / 2.5 / 3.3 / +# 4.5% off the course record in WBGT 5-10 / 10-15 / 15-20 / 20-25 °C). +# NOTE: These heat factors are the BASELINE for a 60-minute effort. They have +# no direct published source at 60 min. # The final penalty is scaled by a DurationFactor (0.5x at 30 min, 1.0x at -# 60 min, 3.0x at 3 h, 3.5x at 4 h and beyond; see +# 60 min, 1.24x at 3 h, 2.18x at 4 h and beyond; see # EnvironmentalAdjuster::HEAT_DURATION_FACTORS) based on total exposure time. +# The 3 h and 4 h points are fitted to El Helou et al. (2012), PLoS One +# 7(5):e37407, Table S3 (1.79 M finishers of Berlin, Boston, Chicago, London, +# New York and Paris, 2001-2010). Derivation (reproduced by +# test_duration_factor_points_are_the_least_squares_fit_of_el_helou_table_s3): +# 1. Speed loss (%) at 15, 20 and 25 °C: straight line between the table's +# points (optimum -10 ... +20 °C, every 5 °C). +# 2. Time penalty against 15 °C: ((1 - loss15) / (1 - lossT) - 1) x 100. +# 3. Ratio = that penalty / the 60-min base (2.8 at 20 °C, 4.3 at 25 °C). +# 4. Finish time = 42195 m / the group's speed at its optimum. +# 5. Weighted least squares over the 15 ratios with 1.0x at 60 min fixed and +# the 180 and 240 min points free (flat after 240), each sex carrying half +# the weight (men 7 observations, women 8): 1.239 and 2.180. +# +# | Group | Finish | loss@15 | loss@20 | loss@25 | vs 15 °C @20 | @25 | ratio @20 | @25 | +# | men P1 | 2:41 | 1.88 | 3.93 | n/a* | 2.14 | n/a* | 0.76 | n/a* | +# | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 0.86 | 1.63 | +# | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 1.59 | 2.89 | +# | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 1.85 | 3.43 | +# | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 1.08 | 1.90 | +# | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 1.21 | 2.16 | +# | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 1.94 | 3.74 | +# | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 1.26 | 2.29 | +# * men P1's optimum is 3.81 °C, so 25 °C is beyond its last point (+20 °C). +# (P1 = first percentile, Q1/Q3 = quartiles; losses and penalties in %.) +# Caveat: El Helou's penalty grows ~2.5-2.9x from 20 to 25 °C, the base only +# 1.54x (2.8 -> 4.3), so one factor per duration over-reads 20 °C and +# under-reads 25 °C for the slower groups. # - Humidity: the points are read at air temperature with ~50% relative # humidity (EnvironmentalAdjuster::REFERENCE_HUMIDITY). With humidity: or # dew_point:, the temperature is first moved to the effective temperature @@ -46,8 +77,8 @@ heat: ideal_range_celsius: [10.0, 15.0] data_points: 15: 0.0 - 20: 2.8 # Base for 60m. For 3h (3.0x) = 8.4% (Ely: 9%) - 25: 4.3 # Base for 60m. For 3h (3.0x) = 12.9% (Ely: 12%) - 30: 6.5 # Base for 60m. For 3h (3.0x) = 19.5%, for 4h (3.5x) = 22.75% + 20: 2.8 # Base for 60m. For 3h (1.24x) = 3.47%, for 4h (2.18x) = 6.1% + 25: 4.3 # Base for 60m. For 3h (1.24x) = 5.33%, for 4h (2.18x) = 9.37% + 30: 6.5 # Base for 60m. For 3h (1.24x) = 8.06%, for 4h (2.18x) = 14.17% 35: 8.7 # EXTRAPOLATION: continues the 25→30 slope (0.44 points/°C) 40: 10.9 # EXTRAPOLATION: continues the 25→30 slope (0.44 points/°C) diff --git a/lib/calcpace/environmental_adjuster.rb b/lib/calcpace/environmental_adjuster.rb index e621c53..05d2686 100644 --- a/lib/calcpace/environmental_adjuster.rb +++ b/lib/calcpace/environmental_adjuster.rb @@ -6,7 +6,10 @@ # Module for adjusting race performance based on environmental conditions # # Scientific basis: -# - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance" +# - Heat: Ely et al. (2007) "Impact of Weather on Marathon-Running Performance" +# (qualitative: slowing grows with WBGT, more for slower runners) and +# El Helou et al. (2012) "Impact of Environmental Parameters on Marathon +# Running Performance" (duration scaling, see HEAT_DURATION_FACTORS) # - Altitude: NCAA Altitude Adjustment Factors (TFRRS) # - Humidity: Australian Bureau of Meteorology simplified WBGT # (WBGT = 0.567·Ta + 0.393·e + 3.94, e = vapour pressure in hPa) @@ -17,13 +20,15 @@ module EnvironmentalAdjuster # Heat duration scaling: [minutes, factor] points, joined by straight lines # and flat outside the first and last point. The base heat penalty in # environmental_factors.yml is for a 60-minute effort (factor 1.0). - # - up to 3 h (3.0x): Ely et al. (2007) — a ~3 h marathoner loses ~9% at - # 20 °C WBGT and ~12% at 25 °C; 2.8 × 3.0 = 8.4%, 4.3 × 3.0 = 12.9%. - # - 4 h (3.5x, flat after): El Helou et al. (2012, 1.8 M finishers, Table S3). - # Men's median (~3:58) loses 8.45% at 20 °C and 16.9% at 25 °C against the - # optimum, i.e. 3.0x and 3.9x the 60-minute base; men's Q3 (~4:28) is no - # worse (3.0x / 4.1x). The previous 4.5x at 4 h was above every group. - HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 3.0], [240.0, 3.5]].freeze + # - 30 min (0.5x) and 60 min (1.0x): kept from the original model; no + # marathon dataset covers efforts this short. + # - 3 h (1.24x) and 4 h (2.18x, flat after): weighted least-squares fit to + # El Helou et al. (2012) Table S3 — the time penalty against 15 °C at + # 20 °C and 25 °C for eight finisher groups (2:41–4:54) divided by the + # 60-minute base. The 2 h value (1.12x) is the straight line 60 → 180 min: + # no group finishes between 1 h and 2:41. Derivation table in + # environmental_factors.yml. + HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 1.24], [240.0, 2.18]].freeze # Relative humidity (%) the temperature-only heat curve stands for # (see EnvironmentalAdjuster::Humidity) diff --git a/test/calcpace/test_environmental_adjuster.rb b/test/calcpace/test_environmental_adjuster.rb index 7598a2e..0d5765f 100644 --- a/test/calcpace/test_environmental_adjuster.rb +++ b/test/calcpace/test_environmental_adjuster.rb @@ -90,34 +90,66 @@ def test_environmental_round_trip_consistency def test_calculate_penalty_with_duration # 25C at 180 min (Marathon sub-3) - # Factor: 3.0x - # Penalty: 4.3 * 3.0 = 12.9% + # Factor: 1.24x (least-squares fit to El Helou et al. 2012, Table S3) + # Penalty: 4.3 * 1.24 = 5.33% result = @calc.calculate_penalty(temperature: 25, time_seconds: 10_800) - assert_equal 12.9, result[:factors][:heat] + assert_equal 5.33, result[:factors][:heat] end def test_calculate_penalty_with_long_duration # 25C at 240 min (Amateur Marathon) - # Factor: 3.5x (El Helou et al. 2012, men's median ~3:58: 3.0-3.9x) - # Penalty: 4.3 * 3.5 = 15.05% + # Factor: 2.18x + # Penalty: 4.3 * 2.18 = 9.37% result = @calc.calculate_penalty(temperature: 25, time_seconds: 14_400) - assert_equal 15.05, result[:factors][:heat] + assert_equal 9.37, result[:factors][:heat] end - # --- heat duration factor beyond 3 h --- + # --- heat duration factor (fit to El Helou et al. 2012) --- - def test_duration_factor_keeps_the_three_hour_anchor - assert_in_delta 3.0, @calc.send(:duration_factor, 10_800), 1e-12 + def test_duration_factor_keeps_the_short_effort_points + assert_in_delta 0.5, @calc.send(:duration_factor, 1200), 1e-12 + assert_in_delta 0.5, @calc.send(:duration_factor, 1800), 1e-12 + assert_in_delta 1.0, @calc.send(:duration_factor, 3600), 1e-12 end - def test_duration_factor_reaches_three_and_a_half_at_four_hours - assert_in_delta 3.5, @calc.send(:duration_factor, 14_400), 1e-12 - assert_in_delta 3.25, @calc.send(:duration_factor, 12_600), 1e-12 + def test_duration_factor_points_for_two_three_and_four_hours + assert_in_delta 1.12, @calc.send(:duration_factor, 7200), 1e-12 + assert_in_delta 1.24, @calc.send(:duration_factor, 10_800), 1e-12 + assert_in_delta 1.71, @calc.send(:duration_factor, 12_600), 1e-12 + assert_in_delta 2.18, @calc.send(:duration_factor, 14_400), 1e-12 end def test_duration_factor_is_flat_beyond_four_hours - assert_in_delta 3.5, @calc.send(:duration_factor, 18_000), 1e-12 - assert_in_delta 3.5, @calc.send(:duration_factor, 36_000), 1e-12 + assert_in_delta 2.18, @calc.send(:duration_factor, 18_000), 1e-12 + assert_in_delta 2.18, @calc.send(:duration_factor, 36_000), 1e-12 + end + + # El Helou et al. (2012) PLoS One 7(5):e37407, Table S3: optimum °C, speed at + # the optimum (m/s) and speed loss (%) at optimum −10, −5, 0, +5, +10, +15, +20 °C + EL_HELOU_TABLE_S3 = { + 'men P1' => [3.81, 4.36, [1.41, 0.35, 0, 0.36, 1.44, 3.29, 6.00]], + 'men Q1' => [6.02, 3.32, [3.27, 0.82, 0, 0.82, 3.38, 7.93, 15.03]], + 'men median' => [6.24, 2.96, [3.77, 0.94, 0, 0.95, 3.91, 9.26, 17.73]], + 'men Q3' => [7.42, 2.62, [4.41, 1.10, 0, 1.12, 4.61, 11.01, 21.42]], + 'women P1' => [9.91, 3.78, [2.97, 0.74, 0, 0.75, 3.06, 7.16, 13.47]], + 'women Q1' => [6.85, 2.93, [2.51, 0.63, 0, 0.63, 2.58, 6.00, 11.18]], + 'women median' => [6.75, 2.65, [2.76, 0.69, 0, 0.70, 2.84, 6.63, 12.43]], + 'women Q3' => [7.35, 2.39, [3.04, 0.76, 0, 0.77, 3.14, 7.35, 13.85]] + }.freeze + + # Reproduces the derivation documented in environmental_factors.yml: the + # time penalty against 15 °C at 20 and 25 °C, divided by the 60-minute base + # (2.8 / 4.3), fitted by weighted least squares (each sex half the weight) + # with the 3 h and 4 h points free and 1.0 at 60 min fixed + def test_duration_factor_points_are_the_least_squares_fit_of_el_helou_table_s3 + observations = el_helou_ratios + assert_equal 15, observations.size # men P1 at 25 °C lies beyond the table + + f180, f240 = weighted_two_point_fit(observations) + points = EnvironmentalAdjuster::HEAT_DURATION_FACTORS.to_h + + assert_in_delta f180, points.fetch(180.0), 0.005 + assert_in_delta f240, points.fetch(240.0), 0.005 end def test_duration_factor_is_continuous_and_monotonic @@ -130,9 +162,9 @@ def test_duration_factor_is_continuous_and_monotonic end def test_extreme_heat_for_four_hours - # 35 °C / 4 h: 8.7 * 3.5 = 30.45% (was 39.15%); 40 °C / 4 h: 10.9 * 3.5 = 38.15% (was 49.05%) - assert_equal 30.45, @calc.calculate_penalty(temperature: 35, time_seconds: 14_400)[:factors][:heat] - assert_equal 38.15, @calc.calculate_penalty(temperature: 40, time_seconds: 14_400)[:factors][:heat] + # 35 °C / 4 h: 8.7 * 2.18 = 18.97%; 40 °C / 4 h: 10.9 * 2.18 = 23.76% + assert_equal 18.97, @calc.calculate_penalty(temperature: 35, time_seconds: 14_400)[:factors][:heat] + assert_equal 23.76, @calc.calculate_penalty(temperature: 40, time_seconds: 14_400)[:factors][:heat] end # --- altitude curve (v1.19.0) --- @@ -283,7 +315,7 @@ def test_dry_air_lowers_the_effective_temperature_and_the_penalty def test_humidity_scales_with_duration_like_temperature result = @calc.calculate_penalty(temperature: 30, humidity: 90, time_seconds: 7200) - assert_equal (9.11 * 2.0).round(2), result[:factors][:heat] + assert_equal (9.11 * @calc.send(:duration_factor, 7200)).round(2), result[:factors][:heat] end def test_humid_air_can_lift_an_ideal_temperature_out_of_the_ideal_range @@ -390,4 +422,60 @@ def test_environmental_data_keeps_the_structure_the_site_reads assert_in_delta altitude.fetch('threshold_meters'), first_key, 0.0 assert_in_delta 0.0, first_value, 0.0 end + + private + + def el_helou_ratios + base = { 20 => 2.8, 25 => 4.3 } + EL_HELOU_TABLE_S3.flat_map do |group, (optimum, speed, losses)| + minutes = 42_195 / speed / 60 + loss15 = table_loss(optimum, losses, 15) + [20, 25].filter_map do |temperature| + loss = table_loss(optimum, losses, temperature) + next unless loss + + penalty = (((1 - (loss15 / 100)) / (1 - (loss / 100))) - 1) * 100 + [group.split.first, minutes, penalty / base[temperature]] + end + end + end + + # Straight line between the published points; nil beyond them + def table_loss(optimum, losses, temperature) + xs = (-10..20).step(5).map { |delta| optimum + delta } + index = xs.each_cons(2).find_index { |low, high| temperature.between?(low, high) } + return nil unless index + + losses[index] + ((temperature - xs[index]) / 5.0 * (losses[index + 1] - losses[index])) + end + + # factor(m) = c0 + c1·f180 + c2·f240 on the 60 → 180 → 240 min segments + def segment_weights(minutes) + if minutes <= 180 + w = (minutes - 60) / 120.0 + [1 - w, w, 0.0] + elsif minutes <= 240 + w = (minutes - 180) / 60.0 + [0.0, 1 - w, w] + else + [0.0, 0.0, 1.0] + end + end + + def weighted_two_point_fit(observations) + per_sex = observations.map(&:first).tally + a11 = a12 = a22 = b1 = b2 = 0.0 + observations.each do |sex, minutes, ratio| + weight = 1.0 / per_sex[sex] + c0, c1, c2 = segment_weights(minutes) + y = ratio - c0 + a11 += weight * c1 * c1 + a12 += weight * c1 * c2 + a22 += weight * c2 * c2 + b1 += weight * c1 * y + b2 += weight * c2 * y + end + det = (a11 * a22) - (a12**2) + [((b1 * a22) - (a12 * b2)) / det, ((a11 * b2) - (a12 * b1)) / det] + end end From beca82d3218123323d355198a05729854a30ad87 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:04:04 -0300 Subject: [PATCH 21/34] fix: keep TRAINING_INTENSITIES numeric TRAINING_INTENSITIES[:marathon][:high] goes back to 0.84 (now the nominal upper bound), so consumers doing arithmetic on the table keep working. Which zones take their fast end from the VDOT-predicted race pace is now a separate constant, PREDICTED_RACE_PACE_ZONES (%i[marathon]). The race-pace intensity is 0.800-0.849 of VO2max over 10-100, so the marathon band stays slower than the threshold band (from 0.83) below VO2max ~69.5, as in Daniels; tested for 30-69. --- lib/calcpace/training_zones.rb | 34 +++++++++++++++------------- test/calcpace/test_training_zones.rb | 20 ++++++++++++++++ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/lib/calcpace/training_zones.rb b/lib/calcpace/training_zones.rb index f4e288a..beef95e 100644 --- a/lib/calcpace/training_zones.rb +++ b/lib/calcpace/training_zones.rb @@ -11,18 +11,24 @@ # target = hr_rest + pct * (hr_max - hr_rest) module TrainingZones # Training intensities as fraction of VO2max (Daniels' Running Formula). - # The fast end of the marathon band is :race_pace — Daniels' M pace is the - # runner's predicted marathon race pace, so it comes from the VDOT race - # prediction (FitnessPredictor#predict_time_from_vo2max) instead of a fixed - # fraction: ~80% of VO2max for slow marathoners, ~83% at VO2max 70. + # The marathon :high (0.84) is the nominal upper bound only: the band's fast + # end is the predicted race pace (see PREDICTED_RACE_PACE_ZONES). TRAINING_INTENSITIES = { easy: { low: 0.59, high: 0.74 }, - marathon: { low: 0.75, high: :race_pace }, + marathon: { low: 0.75, high: 0.84 }, threshold: { low: 0.83, high: 0.88 }, interval: { low: 0.95, high: 1.00 }, repetition: { low: 1.05, high: 1.10 } }.freeze + # Zones whose fast end is the VDOT-predicted race pace instead of + # TRAINING_INTENSITIES[zone][:high]. Daniels' M pace is the runner's + # predicted marathon race pace (FitnessPredictor#predict_time_from_vo2max), + # which is 0.800–0.849 of VO2max across VO2max 10–100 (0.805 at 30, 0.830 + # at 70). That keeps the marathon band slower than the threshold band + # (from 0.83) below VO2max ~69.5. + PREDICTED_RACE_PACE_ZONES = %i[marathon].freeze + # A pace band for one training zone (paces per kilometre or mile). # slow = lower-intensity end of the band, fast = higher-intensity end. PaceBand = Struct.new(:slow_seconds, :fast_seconds, :slow_clock, :fast_clock) @@ -65,16 +71,12 @@ def training_paces(vo2max, unit: :km) check_positive(vo2max.to_f, 'VO2max') meters = pace_unit_meters(unit) - TRAINING_INTENSITIES.transform_values do |band| + TRAINING_INTENSITIES.to_h do |zone, band| slow = pace_seconds_at_pct(vo2max.to_f, band[:low], meters) - fast = pace_seconds_at_pct(vo2max.to_f, intensity(band[:high], vo2max.to_f), meters) - - PaceBand.new( - slow_seconds: slow, - fast_seconds: fast, - slow_clock: convert_to_clocktime(slow), - fast_clock: convert_to_clocktime(fast) - ) + fast = pace_seconds_at_pct(vo2max.to_f, fast_intensity(zone, band, vo2max.to_f), meters) + + [zone, PaceBand.new(slow_seconds: slow, fast_seconds: fast, + slow_clock: convert_to_clocktime(slow), fast_clock: convert_to_clocktime(fast))] end end @@ -344,8 +346,8 @@ def check_heart_rates(hr_max, hr_rest) "Resting heart rate (#{hr_rest}) must be lower than maximum heart rate (#{hr_max})" end - def intensity(pct, vo2max) - pct == :race_pace ? marathon_race_intensity(vo2max) : pct + def fast_intensity(zone, band, vo2max) + PREDICTED_RACE_PACE_ZONES.include?(zone) ? marathon_race_intensity(vo2max) : band[:high] end # Fraction of VO2max a runner holds at the VDOT-predicted marathon pace. diff --git a/test/calcpace/test_training_zones.rb b/test/calcpace/test_training_zones.rb index a89f131..62f6c51 100644 --- a/test/calcpace/test_training_zones.rb +++ b/test/calcpace/test_training_zones.rb @@ -86,6 +86,26 @@ def test_marathon_fast_end_is_never_faster_than_threshold end end + def test_training_intensities_stay_numeric + TrainingZones::TRAINING_INTENSITIES.each_value do |band| + assert_kind_of Numeric, band[:low] + assert_kind_of Numeric, band[:high] + end + assert_in_delta 0.84, TrainingZones::TRAINING_INTENSITIES[:marathon][:high], 0.0 + end + + def test_marathon_is_the_zone_whose_fast_end_is_the_predicted_race_pace + assert_equal %i[marathon], TrainingZones::PREDICTED_RACE_PACE_ZONES + end + + def test_marathon_and_threshold_bands_do_not_overlap_below_vo2max_sixty_nine + (30..69).each do |vo2| + zones = @calc.training_paces(vo2) + + assert_operator zones[:marathon].fast_seconds, :>=, zones[:threshold].slow_seconds, "VO2 #{vo2}" + end + end + def test_marathon_band_works_outside_the_vdot_prediction_range # predict_time_from_vo2max supports 10–100; outside it the race-pace # intensity of the nearest bound is used From 3dd44c9e16a117813a6cb07e3c11932c82d13604 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:06:18 -0300 Subject: [PATCH 22/34] docs: refit tables, humidity options and numeric intensities - README and CHANGELOG: the El Helou Table S3 derivation (method, weighting, per-group table), the new duration points, the 1.18.1 -> now heat grid and the 30 C humidity table; recomputed adjust_time and Cameron-adjusted examples; Ely 2007 kept only as a qualitative source with its abstract's numbers. - predict_time_adjusted / predict_time_cameron_adjusted list humidity: and dew_point: among the forwarded options; the predict_time_adjusted example moves to 12214.84 / 6.13% and gains checked single-line examples. - Marathon band: real intensity range 0.800-0.849, PREDICTED_RACE_PACE_ZONES, and the marathon/threshold bands no longer overlapping below VO2max ~69.5. - REFERENCE_HUMIDITY no longer claims the heat points were calibrated on Ely's WBGT figures. --- CHANGELOG.md | 116 +++++++++++++++++++----------- README.md | 46 +++++++----- lib/calcpace/cameron_predictor.rb | 12 +++- lib/calcpace/humidity.rb | 12 ++-- lib/calcpace/race_predictor.rb | 9 ++- 5 files changed, 127 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c99842..54019e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,22 +17,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 temperature: the air temperature that, at `REFERENCE_HUMIDITY` (50%), has the same simplified WBGT (Australian Bureau of Meteorology: `WBGT = 0.567·Ta + 0.393·e + 3.94`, `e` = vapour pressure in hPa) as the real - temperature and humidity. The existing heat curve and the - `base(temperature) × duration_factor(seconds)` shape are untouched, and + temperature and humidity, solved exactly (by bisection). The existing heat + curve and the `base(temperature) × duration_factor(seconds)` shape are + untouched; the curve is read at the unrounded effective temperature, so `humidity: 50` gives exactly the temperature-only numbers. 50% is the humidity at which that WBGT equals the air temperature between 20 °C and - 35 °C (51–56%), which is how the temperature points — calibrated on Ely et - al.'s WBGT figures — already read. `factors` gains - `:effective_temperature_celsius` when humidity or dew point is given. - `ArgumentError` for humidity outside 0–100 or non-numeric, a dew point above - the temperature, both keywords together, or either without a temperature. + 35 °C (51–56%), i.e. how the temperature-only points already read. `factors` + gains `:effective_temperature_celsius` (rounded to 2 decimals) when humidity + or dew point is given. `ArgumentError` for humidity outside 0–100, NaN, + Complex or non-numeric; a dew point above the temperature, below −100 °C or + not a finite real number; both keywords together; or either without a + finite temperature. The marathon outcome studies (El Helou 2012, Vihma 2010) + found no humidity effect independent of temperature, so the size of this + adjustment rests on the WBGT index, not on race data. | 30 °C at | Effective temperature | 60 min | 4 h | | --- | --- | --- | --- | - | 30% RH | 26.69 °C | 5.04% | 17.64% | - | 50% RH (= no humidity) | 30.0 °C | 6.5% | 22.75% | - | 70% RH | 33.07 °C | 7.85% | 27.48% | - | 90% RH | 35.94 °C | 9.11% | 31.89% | + | 30% RH | 26.69 °C | 5.05% | 11.01% | + | 50% RH (= no humidity) | 30.0 °C | 6.5% | 14.17% | + | 70% RH | 33.07 °C | 7.85% | 17.11% | + | 90% RH | 35.94 °C | 9.11% | 19.86% | ### Changed (numbers) Four models produced unrealistic numbers. The method names, signatures, return @@ -71,37 +75,67 @@ above 100 km (see Breaking). penalty as 30 °C. Two points extrapolate the 25→30 °C slope (0.44 points/°C): 35 °C → 8.7% and 40 °C → 10.9% at the 60-minute baseline (capped at 40 °C). The ideal range is unchanged. -- **Heat penalty grows less between 3 h and 4 h.** The duration factor went - from 3.0× at 3 h to 4.5× at 4 h (+50% heat penalty for one more hour); it now - ends at 3.5× at 4 h and stays flat after. El Helou et al. (2012, PLoS One, - 1.8 million finishers, Table S3) give, against the optimum temperature, - 8.45% at 20 °C and 16.9% at 25 °C for the men's median (~3:58) — 3.0× and - 3.9× the 60-minute base — and no more for the men's Q3 (~4:28); 4.5× was - above every group, men or women. The 3 h anchor (Ely et al. 2007: ~9% for a - 3 h runner at 20 °C WBGT) and everything up to 3 h are unchanged. The - duration points now live in `EnvironmentalAdjuster::HEAT_DURATION_FACTORS`; - `duration_factor(time_seconds)` keeps its name and signature. - - | Heat penalty (%) | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | +- **Heat duration scaling is fitted to marathon data and is much flatter.** + The factor went 1.0× (60 min) → 3.0× (3 h) → 4.5× (4 h), with the 3 h point + justified by Ely 2007 percentages for a 3 h runner (~9% at 20 °C, ~12% at + 25 °C) that the paper's abstract does not contain. It is now 0.5× (≤30 min), + 1.0× (60 min), 1.24× (3 h) and 2.18× (4 h and beyond), linear in between + (1.12× at 2 h). The 30/60-minute points are kept (no marathon dataset covers + them); 3 h and 4 h are a weighted least-squares fit to El Helou et al. + (2012, PLoS One 7(5):e37407, Table S3; 1.79 M finishers of six majors, + 2001–2010): + 1. speed loss at 15, 20 and 25 °C, straight line between the table's points + (each group's optimum −10 … +20 °C); + 2. time penalty against 15 °C, where the gem's curve is zero: + `((1 − loss15) / (1 − lossT) − 1) × 100`; + 3. ratio = that penalty ÷ the 60-minute base (2.8 at 20 °C, 4.3 at 25 °C); + 4. finish time = 42195 m ÷ the group's speed at its optimum; + 5. least squares with 1.0× at 60 min fixed, 180 and 240 min free, flat after + 240, each sex carrying half the weight (men 7 observations, women 8; + men P1 at 25 °C is beyond the table): 1.239 and 2.180. + + | Group | Finish | loss@15 | loss@20 | loss@25 | vs 15 °C @20 | vs 15 °C @25 | ratio @20 | ratio @25 | + | --- | --- | --- | --- | --- | --- | --- | --- | --- | + | men P1 | 2:41 | 1.88 | 3.93 | n/a | 2.14 | n/a | 0.76 | n/a | + | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 0.86 | 1.63 | + | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 1.59 | 2.89 | + | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 1.85 | 3.43 | + | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 1.08 | 1.90 | + | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 1.21 | 2.16 | + | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 1.94 | 3.74 | + | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 1.26 | 2.29 | + + Losses and penalties in %. Ely et al. (2007) remains a qualitative source + (slowing grows from 5 to 25 °C WBGT, more for slower runners; top men 1.7 / + 2.5 / 3.3 / 4.5% off the course record across the WBGT quartiles). The + duration points live in `EnvironmentalAdjuster::HEAT_DURATION_FACTORS`; + `duration_factor(time_seconds)` keeps its name and signature. Caveat: El + Helou's penalty grows 2.5–2.9× from 20 to 25 °C while the base grows 1.54×, + so for the slower groups one factor per duration over-reads 20 °C and + under-reads 25 °C. + + | Heat penalty (%), 1.18.1 → now | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | | --- | --- | --- | --- | --- | --- | --- | - | 20 °C | 1.4 | 2.8 | 5.6 | 8.4 | 12.6 → 9.8 | 12.6 → 9.8 | - | 25 °C | 2.15 | 4.3 | 8.6 | 12.9 | 19.35 → 15.05 | 19.35 → 15.05 | - | 30 °C | 3.25 | 6.5 | 13.0 | 19.5 | 29.25 → 22.75 | 29.25 → 22.75 | - | 35 °C | 4.35 | 8.7 | 17.4 | 26.1 | 39.15 → 30.45 | 39.15 → 30.45 | - | 40 °C | 5.45 | 10.9 | 21.8 | 32.7 | 49.05 → 38.15 | 49.05 → 38.15 | - - (35 °C and 40 °C columns compare against the extrapolated points above; in - 1.18.1 both were capped at the 30 °C values.) + | 20 °C | 1.4 → 1.4 | 2.8 → 2.8 | 5.6 → 3.14 | 8.4 → 3.47 | 12.6 → 6.1 | 12.6 → 6.1 | + | 25 °C | 2.15 → 2.15 | 4.3 → 4.3 | 8.6 → 4.82 | 12.9 → 5.33 | 19.35 → 9.37 | 19.35 → 9.37 | + | 30 °C | 3.25 → 3.25 | 6.5 → 6.5 | 13.0 → 7.28 | 19.5 → 8.06 | 29.25 → 14.17 | 29.25 → 14.17 | + | 35 °C | 3.25 → 4.35 | 6.5 → 8.7 | 13.0 → 9.74 | 19.5 → 10.79 | 29.25 → 18.97 | 29.25 → 18.97 | + | 40 °C | 3.25 → 5.45 | 6.5 → 10.9 | 13.0 → 12.21 | 19.5 → 13.52 | 29.25 → 23.76 | 29.25 → 23.76 | + - **The marathon pace band ends at the runner's predicted marathon pace.** Daniels' M pace is the predicted marathon race pace, but `training_paces(50)[:marathon]` ran from 4:50 to 4:25/km (75–84% VO2max) while the VDOT marathon prediction for VO2max 50 is 3:10:39, 4:31/km. The fast end now comes from `predict_time_from_vo2max(vo2max, 'marathon')` - (about 80–83% VO2max); the slow end stays at 75%. The prediction covers - VO2max 10–100, and beyond it the race-pace fraction of the nearest bound - is used, so `training_paces` still accepts any positive VO2max. - `TRAINING_INTENSITIES[:marathon][:high]` is now `:race_pace` instead of - `0.84`. + (0.800–0.849 of VO2max across VO2max 10–100; 0.805 at 30, 0.830 at 70); the + slow end stays at 75%. The prediction covers VO2max 10–100, and beyond it + the race-pace fraction of the nearest bound is used, so `training_paces` + still accepts any positive VO2max. `TRAINING_INTENSITIES` stays all-numeric + (`marathon: { low: 0.75, high: 0.84 }`, the nominal upper bound); the new + `PREDICTED_RACE_PACE_ZONES` (`%i[marathon]`) names the zones whose fast end + is the predicted race pace. As a result the marathon and threshold bands no + longer overlap below VO2max ~69.5 (the threshold band starts at 0.83): M + pace is slower than T pace, as in Daniels. At VO2max 70 they touch (3:24). | VO2max | M band before | M band after | Predicted marathon pace | | --- | --- | --- | --- | @@ -124,10 +158,12 @@ above 100 km (see Breaking). | Altitude 3600 m | 5.9% | 10.42% | | Heat 35 °C, 60 min | 6.5% | 8.7% | | Heat 40 °C, 60 min | 6.5% | 10.9% | -| Heat 25 °C, 4 h | 19.35% | 15.05% | -| Heat 30 °C, 4 h | 29.25% | 22.75% | -| Heat 35 °C, 4 h | 29.25% | 30.45% | -| Heat 40 °C, 4 h | 29.25% | 38.15% | +| Heat 25 °C, 2 h | 8.6% | 4.82% | +| Heat 25 °C, 3 h | 12.9% | 5.33% | +| Heat 25 °C, 4 h | 19.35% | 9.37% | +| Heat 30 °C, 4 h | 29.25% | 14.17% | +| Heat 35 °C, 4 h | 29.25% | 18.97% | +| Heat 40 °C, 4 h | 29.25% | 23.76% | | Marathon band, VO2max 50 | 4:50–4:25/km | 4:50–4:31/km | | Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 | | Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 | diff --git a/README.md b/README.md index 0290b77..bbe455d 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ calc.checked_distance('01:21:32', '00:06:27') # => 12.64 ### Environmental Performance Adjustments Adjust race performance based on heat, humidity and altitude. Calculations are based on scientific models -(Ely et al. 2007 and El Helou et al. 2012 for heat, the Australian Bureau of Meteorology's +(El Helou et al. 2012 and Ely et al. 2007 for heat, the Australian Bureau of Meteorology's simplified WBGT for humidity, NCAA standards for altitude). - **Altitude**: no penalty up to 300 m, then a linear ramp to the first NCAA point @@ -47,8 +47,12 @@ simplified WBGT for humidity, NCAA standards for altitude). São Paulo (760 m) gets ~1.06%. - **Heat**: 60-minute baseline from 15 °C (0%) to 30 °C (6.5%), extrapolated to 35 °C (8.7%) and 40 °C (10.9%, capped there), then scaled by effort duration: - 0.5× up to 30 min, 1.0× at 60 min, 3.0× at 3 h, 3.5× at 4 h and beyond - (linear in between). + 0.5× up to 30 min, 1.0× at 60 min, 1.24× at 3 h, 2.18× at 4 h and beyond + (linear in between, so 1.12× at 2 h). The 3 h and 4 h points are a + least-squares fit to El Helou et al. (2012, Table S3: eight finisher groups, + 2:41–4:54, time penalty against 15 °C at 20 and 25 °C); the derivation table + is in `lib/calcpace/data/environmental_factors.yml`. The 60-minute base and + the 30/60-minute factors have no direct published source. - **Humidity** (optional): pass `humidity:` (relative humidity, %) or `dew_point:` (in `temperature_unit`). Without either, the heat curve assumes 50% humidity. With one, the temperature is replaced by the effective @@ -57,18 +61,18 @@ simplified WBGT for humidity, NCAA standards for altitude). | Heat penalty (%) | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | | --- | --- | --- | --- | --- | --- | --- | -| 20 °C | 1.4 | 2.8 | 5.6 | 8.4 | 9.8 | 9.8 | -| 25 °C | 2.15 | 4.3 | 8.6 | 12.9 | 15.05 | 15.05 | -| 30 °C | 3.25 | 6.5 | 13.0 | 19.5 | 22.75 | 22.75 | -| 35 °C | 4.35 | 8.7 | 17.4 | 26.1 | 30.45 | 30.45 | -| 40 °C | 5.45 | 10.9 | 21.8 | 32.7 | 38.15 | 38.15 | +| 20 °C | 1.4 | 2.8 | 3.14 | 3.47 | 6.1 | 6.1 | +| 25 °C | 2.15 | 4.3 | 4.82 | 5.33 | 9.37 | 9.37 | +| 30 °C | 3.25 | 6.5 | 7.28 | 8.06 | 14.17 | 14.17 | +| 35 °C | 4.35 | 8.7 | 9.74 | 10.79 | 18.97 | 18.97 | +| 40 °C | 5.45 | 10.9 | 12.21 | 13.52 | 23.76 | 23.76 | | 30 °C at | Effective temperature | 60 min | 4 h | | --- | --- | --- | --- | -| 30% RH | 26.69 °C | 5.04% | 17.64% | -| 50% RH (= no humidity) | 30.0 °C | 6.5% | 22.75% | -| 70% RH | 33.07 °C | 7.85% | 27.48% | -| 90% RH | 35.94 °C | 9.11% | 31.89% | +| 30% RH | 26.69 °C | 5.05% | 11.01% | +| 50% RH (= no humidity) | 30.0 °C | 6.5% | 14.17% | +| 70% RH | 33.07 °C | 7.85% | 17.11% | +| 90% RH | 35.94 °C | 9.11% | 19.86% | Above ~30 °C (and above ~25 °C for 4 h+) the numbers are extrapolations: the marathon studies behind the curve have little or no data there. @@ -94,10 +98,10 @@ calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:to result = calc.adjust_time(12600, temperature: 25, altitude: 2000) # => { # original_time: 12600, -# adjusted_time: 14905.8, -# adjusted_time_clock: "04:08:25", -# penalty_percent: 18.3, -# factors: { heat: 13.98, altitude: 4.32 } +# adjusted_time: 14070.42, +# adjusted_time_clock: "03:54:30", +# penalty_percent: 11.67, +# factors: { heat: 7.35, altitude: 4.32 } # } # Predicted adjusted times (Riegel formula) @@ -106,7 +110,7 @@ calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 28) # Predicted adjusted times (Cameron formula) calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 80, temperature_unit: :f) -# => { adjusted_time: 12976.19, adjusted_time_clock: "03:36:16", penalty_percent: 15.4, ... } +# => { adjusted_time: 12011.41, adjusted_time_clock: "03:20:11", penalty_percent: 6.82, ... } ``` --- @@ -473,7 +477,7 @@ calc.hr_zones_from_max(hr_max: 190) | Zone | %VO2max | Purpose | |------|---------|---------| | Easy | 59–74% | Base building, recovery | -| Marathon | 75% – predicted marathon pace (~80–83%) | Marathon race pace | +| Marathon | 75% – predicted marathon pace (0.800–0.849) | Marathon race pace | | Threshold | 83–88% | Lactate threshold, tempo runs | | Interval | 95–100% | VO2max development | | Repetition | 105–110% | Speed and running economy | @@ -483,7 +487,11 @@ Pace accuracy vs published VDOT tables: within a few seconds per km marathon band is the marathon pace `predict_time_from_vo2max` gives for the same VO2max (Daniels' M pace is the predicted marathon race pace); that prediction covers VO2max 10–100, and outside it the race-pace intensity of the nearest bound -is used. +is used. That intensity is 0.800–0.849 of VO2max (0.805 at VO2max 30, 0.830 at 70), +so the marathon band stays slower than the threshold band below VO2max ~69.5, as in +Daniels. `TRAINING_INTENSITIES[:marathon][:high]` stays 0.84 as the nominal upper +bound; `PREDICTED_RACE_PACE_ZONES` lists the zones whose fast end is the predicted +race pace. `unit:` sets the unit of the returned pace bands; `distance_unit:` sets the unit of a numeric race distance you pass in. Combining `distance_unit:` with a race name raises diff --git a/lib/calcpace/cameron_predictor.rb b/lib/calcpace/cameron_predictor.rb index 684fb29..556262e 100644 --- a/lib/calcpace/cameron_predictor.rb +++ b/lib/calcpace/cameron_predictor.rb @@ -120,9 +120,19 @@ def predict_pace_cameron_clock(from_race, from_time, to_race) # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name # @param from_time [String, Numeric] time achieved at known distance # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name - # @param options [Hash] environmental options (temperature, altitude, etc.) + # @param options [Hash] environmental options, forwarded to + # EnvironmentalAdjuster#calculate_penalty: + # - :temperature [Numeric] + # - :temperature_unit [Symbol, String] :c or :f + # - :altitude [Numeric] + # - :humidity [Numeric] relative humidity, 0–100 % (optional) + # - :dew_point [Numeric] dew point in temperature_unit (optional, instead of :humidity) # @return [Hash] hash with adjusted prediction and penalty details # @raise [ArgumentError] if either distance exceeds CAMERON_MAX_DISTANCE_KM (100 km) + # + # @example + # calc.predict_time_cameron_adjusted('5k', '00:20:00', '10k', temperature: 25, humidity: 80)[:adjusted_time_clock] + # #=> '00:43:23' def predict_time_cameron_adjusted(from_race, from_time, to_race, **) predicted_seconds = predict_time_cameron(from_race, from_time, to_race) adjust_time(predicted_seconds, **) diff --git a/lib/calcpace/humidity.rb b/lib/calcpace/humidity.rb index 3e313b2..fac46cd 100644 --- a/lib/calcpace/humidity.rb +++ b/lib/calcpace/humidity.rb @@ -19,12 +19,12 @@ module EnvironmentalAdjuster # pressure rising with temperature, as it does along the temperature-only # curve; the shortcut would roughly double the humidity effect at 30 °C. module Humidity - # Relative humidity (%) the temperature-only heat curve stands for. The heat - # points were calibrated against Ely et al.'s WBGT figures while the input - # is air temperature, and the simplified WBGT equals the air temperature at - # 51–56% RH between 20 °C and 35 °C — so a temperature-only reading is a - # reading at about 50% humidity. humidity: 50 gives the same numbers as no - # humidity at all. + # Relative humidity (%) the temperature-only heat curve stands for. The + # simplified WBGT equals the air temperature at 51–56% RH between 20 °C and + # 35 °C, so at 50% the curve reads the same whether its input is taken as + # air temperature or as WBGT; 50% is also mid-range for the marathons + # behind the duration factor (El Helou et al. 2012: mean race-day RH + # 51–78%). humidity: 50 gives the same numbers as no humidity at all. REFERENCE_HUMIDITY = 50.0 # Simplified WBGT coefficients (Australian Bureau of Meteorology) diff --git a/lib/calcpace/race_predictor.rb b/lib/calcpace/race_predictor.rb index 026a199..99a8656 100644 --- a/lib/calcpace/race_predictor.rb +++ b/lib/calcpace/race_predictor.rb @@ -128,15 +128,20 @@ def equivalent_performance(from_race, from_time, to_race) # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name # @param from_time [String, Numeric] time achieved at known distance # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name - # @param options [Hash] environmental options: + # @param options [Hash] environmental options, forwarded to + # EnvironmentalAdjuster#calculate_penalty: # - :temperature [Numeric] # - :temperature_unit [Symbol, String] :c or :f # - :altitude [Numeric] + # - :humidity [Numeric] relative humidity, 0–100 % (optional) + # - :dew_point [Numeric] dew point in temperature_unit (optional, instead of :humidity) # @return [Hash] hash with adjusted prediction and penalty details # # @example Predict marathon time from 5K adjusted for heat (25C) # predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25) - # #=> { adjusted_time: 13140.19, penalty_percent: 14.17, ... } + # #=> { adjusted_time: 12214.84, penalty_percent: 6.13, ... } + # calc.predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25)[:penalty_percent] #=> 6.13 + # calc.predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25, humidity: 80)[:penalty_percent] #=> 8.52 def predict_time_adjusted(from_race, from_time, to_race, **) predicted_seconds = predict_time(from_race, from_time, to_race) adjust_time(predicted_seconds, **) From 2f380fe56cb530df6486cf2e73df6054ef4978b2 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:12:23 -0300 Subject: [PATCH 23/34] fix: fit the heat base curve and duration factor jointly to El Helou Against 15 C, El Helou et al. (2012) Table S3 penalties grow 2.70-2.97x from 20 to 25 C in every finisher group, while the base grew only 1.54x (2.8 -> 4.3), so no single duration factor could fit both temperatures. - Base: P proportional to (T - 15)^p with p = log2(P25/P20), the sex-weighted mean being 1.497 -> 1.5. base(T) = 4.3 * ((T - 15)/10)^1.5, anchored at the original 25 C / 60 min value (the only one available for short efforts), stored every 2.5 C from 15 to 40 C (linear interpolation within 0.08 points). 20 C: 2.8 -> 1.52; 30 C: 6.5 -> 7.9; 35 / 40 C (extrapolated): 12.16 / 17.0. - Duration factor refitted against the new base by the same weighted least squares: 1.76x at 3 h, 2.81x at 4 h (flat after); 0.5x / 1.0x kept. Free 150 / 210 min points were tried and rejected (1% gain; non-monotonic). - Weighted residual in penalty points: 18.3 -> 8.8; per group the ratio is now nearly equal at 20 and 25 C. The rest is a sex effect the model cannot see. - The fit test recomputes both p and the factor points from Table S3; the derivation table is in environmental_factors.yml, README and the changelog. Examples and predictor tests updated to the new numbers. 40 C for 4 h is now 47.77% (extrapolated); no cap point applied. --- CHANGELOG.md | 125 ++++++++------- README.md | 58 +++---- lib/calcpace/cameron_predictor.rb | 2 +- lib/calcpace/data/environmental_factors.yml | 86 +++++++---- lib/calcpace/environmental_adjuster.rb | 14 +- lib/calcpace/race_predictor.rb | 6 +- test/calcpace/test_cameron_predictor.rb | 6 +- test/calcpace/test_environmental_adjuster.rb | 151 ++++++++++++------- test/calcpace/test_race_predictor.rb | 6 +- 9 files changed, 266 insertions(+), 188 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54019e1..641ad6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,10 +33,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | 30 °C at | Effective temperature | 60 min | 4 h | | --- | --- | --- | --- | - | 30% RH | 26.69 °C | 5.05% | 11.01% | - | 50% RH (= no humidity) | 30.0 °C | 6.5% | 14.17% | - | 70% RH | 33.07 °C | 7.85% | 17.11% | - | 90% RH | 35.94 °C | 9.11% | 19.86% | + | 30% RH | 26.69 °C | 5.46% | 15.34% | + | 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% | + | 70% RH | 33.07 °C | 10.46% | 29.39% | + | 90% RH | 35.94 °C | 13.04% | 36.64% | ### Changed (numbers) Four models produced unrealistic numbers. The method names, signatures, return @@ -72,55 +72,68 @@ above 100 km (see Breaking). marathon with `strategy: :negative` used to go through halfway in 1:33:36 (a 7-minute negative split); it now splits 1:30:54 + 1:29:06. - **Heat above 30 °C keeps increasing.** 35 °C and 40 °C used to get the same - penalty as 30 °C. Two points extrapolate the 25→30 °C slope (0.44 points/°C): - 35 °C → 8.7% and 40 °C → 10.9% at the 60-minute baseline (capped at 40 °C). - The ideal range is unchanged. -- **Heat duration scaling is fitted to marathon data and is much flatter.** - The factor went 1.0× (60 min) → 3.0× (3 h) → 4.5× (4 h), with the 3 h point - justified by Ely 2007 percentages for a 3 h runner (~9% at 20 °C, ~12% at - 25 °C) that the paper's abstract does not contain. It is now 0.5× (≤30 min), - 1.0× (60 min), 1.24× (3 h) and 2.18× (4 h and beyond), linear in between - (1.12× at 2 h). The 30/60-minute points are kept (no marathon dataset covers - them); 3 h and 4 h are a weighted least-squares fit to El Helou et al. - (2012, PLoS One 7(5):e37407, Table S3; 1.79 M finishers of six majors, - 2001–2010): + penalty as 30 °C. The base curve now continues to 40 °C (capped there): + 12.16% at 35 °C and 17.0% at 40 °C for 60 minutes, extrapolating the fitted + law below. The ideal range is unchanged. +- **Heat base curve and duration scaling are fitted to marathon data.** The + 60-minute base was 2.8 / 4.3 / 6.5% at 20 / 25 / 30 °C (roughly linear) and + the duration factor 1.0× (60 min) → 3.0× (3 h) → 4.5× (4 h), with the 3 h + point justified by Ely 2007 percentages for a 3 h runner (~9% at 20 °C, + ~12% at 25 °C) that the paper's abstract does not contain. Both are now + fitted to El Helou et al. (2012, PLoS One 7(5):e37407, Table S3; 1.79 M + finishers of six majors, 2001–2010): 1. speed loss at 15, 20 and 25 °C, straight line between the table's points (each group's optimum −10 … +20 °C); 2. time penalty against 15 °C, where the gem's curve is zero: - `((1 − loss15) / (1 − lossT) − 1) × 100`; - 3. ratio = that penalty ÷ the 60-minute base (2.8 at 20 °C, 4.3 at 25 °C); - 4. finish time = 42195 m ÷ the group's speed at its optimum; - 5. least squares with 1.0× at 60 min fixed, 180 and 240 min free, flat after - 240, each sex carrying half the weight (men 7 observations, women 8; - men P1 at 25 °C is beyond the table): 1.239 and 2.180. - - | Group | Finish | loss@15 | loss@20 | loss@25 | vs 15 °C @20 | vs 15 °C @25 | ratio @20 | ratio @25 | - | --- | --- | --- | --- | --- | --- | --- | --- | --- | - | men P1 | 2:41 | 1.88 | 3.93 | n/a | 2.14 | n/a | 0.76 | n/a | - | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 0.86 | 1.63 | - | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 1.59 | 2.89 | - | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 1.85 | 3.43 | - | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 1.08 | 1.90 | - | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 1.21 | 2.16 | - | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 1.94 | 3.74 | - | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 1.26 | 2.29 | - - Losses and penalties in %. Ely et al. (2007) remains a qualitative source - (slowing grows from 5 to 25 °C WBGT, more for slower runners; top men 1.7 / - 2.5 / 3.3 / 4.5% off the course record across the WBGT quartiles). The - duration points live in `EnvironmentalAdjuster::HEAT_DURATION_FACTORS`; - `duration_factor(time_seconds)` keeps its name and signature. Caveat: El - Helou's penalty grows 2.5–2.9× from 20 to 25 °C while the base grows 1.54×, - so for the slower groups one factor per duration over-reads 20 °C and - under-reads 25 °C. + `P = ((1 − loss15) / (1 − lossT) − 1) × 100`; + 3. **base shape**: P25/P20 is 2.70–2.97 in every group, so `P ∝ (T − 15)^p` + with `p = log2(P25/P20)`; the sex-weighted mean (each sex half the + weight) is 1.497 → **1.5**. Base = `4.3 · ((T − 15)/10)^1.5`, keeping the + original 25 °C / 60-minute anchor of 4.3% (the only value available for a + 60-minute effort), stored every 2.5 °C from 15 to 40 °C (linear + interpolation stays within 0.08 points of the curve): 0, 0.54, 1.52, + 2.79, 4.3, 6.01, 7.9, 9.95, 12.16, 14.51, 17.0. Above 25 °C this is an + extrapolation (El Helou's hottest race was 25.2 °C); + 4. ratio = P ÷ base(T); finish time = 42195 m ÷ the group's speed at its + optimum; + 5. **duration factor**: weighted least squares over the 15 ratios (men P1 at + 25 °C is beyond the table), each sex half the weight, 0.5× (≤30 min) and + 1.0× (60 min) kept, 180 and 240 min free, flat after 240: 1.761 / 2.814 → + **1.76× at 3 h, 2.81× at 4 h** (1.38× at 2 h on the straight line). A free + 150-min point cut the weighted residual by 1%; a free 210-min point made + the curve non-monotonic (2.97 > 2.73 at 240). Neither was kept. + + | Group | Finish | loss@15 | loss@20 | loss@25 | P20 | P25 | P25/P20 | ratio @20 | ratio @25 | + | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + | men P1 | 2:41 | 1.88 | 3.93 | n/a | 2.14 | n/a | n/a | 1.41 | n/a | + | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 2.89 | 1.59 | 1.63 | + | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 2.78 | 2.93 | 2.89 | + | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 2.86 | 3.40 | 3.43 | + | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 2.70 | 1.99 | 1.90 | + | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 2.73 | 2.23 | 2.16 | + | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 2.97 | 3.57 | 3.74 | + | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 2.78 | 2.33 | 2.29 | + + Losses and P in %. With the new base, each group's ratio is almost the same + at 20 and 25 °C, so `base(T) × duration_factor(seconds)` fits; the weighted + residual in penalty points drops from 18.3 (linear base, 1.24×/2.18×) to + 8.8. What remains is mostly sex: with no sex input, men's slower groups are + under-read at 25 °C (median 11.9% vs 14.76%) and women's over-read (median + 12.08% vs 9.27%). Ely et al. (2007) remains a qualitative source (top men + 1.7 / 2.5 / 3.3 / 4.5% off the course record across WBGT 5–10 … 20–25 °C, + i.e. +2.8 points); the model gives a 2:10 effort 4.03% at 22.5 °C against + 0% at 7.5 °C, a little above that for elite runners. The duration points + live in `EnvironmentalAdjuster::HEAT_DURATION_FACTORS`; + `duration_factor(time_seconds)` and the `environmental_factors.yml` keys are + unchanged. | Heat penalty (%), 1.18.1 → now | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | | --- | --- | --- | --- | --- | --- | --- | - | 20 °C | 1.4 → 1.4 | 2.8 → 2.8 | 5.6 → 3.14 | 8.4 → 3.47 | 12.6 → 6.1 | 12.6 → 6.1 | - | 25 °C | 2.15 → 2.15 | 4.3 → 4.3 | 8.6 → 4.82 | 12.9 → 5.33 | 19.35 → 9.37 | 19.35 → 9.37 | - | 30 °C | 3.25 → 3.25 | 6.5 → 6.5 | 13.0 → 7.28 | 19.5 → 8.06 | 29.25 → 14.17 | 29.25 → 14.17 | - | 35 °C | 3.25 → 4.35 | 6.5 → 8.7 | 13.0 → 9.74 | 19.5 → 10.79 | 29.25 → 18.97 | 29.25 → 18.97 | - | 40 °C | 3.25 → 5.45 | 6.5 → 10.9 | 13.0 → 12.21 | 19.5 → 13.52 | 29.25 → 23.76 | 29.25 → 23.76 | + | 20 °C | 1.4 → 0.76 | 2.8 → 1.52 | 5.6 → 2.1 | 8.4 → 2.68 | 12.6 → 4.27 | 12.6 → 4.27 | + | 25 °C | 2.15 → 2.15 | 4.3 → 4.3 | 8.6 → 5.93 | 12.9 → 7.57 | 19.35 → 12.08 | 19.35 → 12.08 | + | 30 °C | 3.25 → 3.95 | 6.5 → 7.9 | 13.0 → 10.9 | 19.5 → 13.9 | 29.25 → 22.2 | 29.25 → 22.2 | + | 35 °C | 3.25 → 6.08 | 6.5 → 12.16 | 13.0 → 16.78 | 19.5 → 21.4 | 29.25 → 34.17 | 29.25 → 34.17 | + | 40 °C | 3.25 → 8.5 | 6.5 → 17.0 | 13.0 → 23.46 | 19.5 → 29.92 | 29.25 → 47.77 | 29.25 → 47.77 | - **The marathon pace band ends at the runner's predicted marathon pace.** Daniels' M pace is the predicted marathon race pace, but @@ -156,14 +169,16 @@ above 100 km (see Breaking). | Altitude 914 m / 915 m | 0.0% / 1.41% | 1.41% / 1.41% | | Altitude 2800 m | 5.9% | 7.2% | | Altitude 3600 m | 5.9% | 10.42% | -| Heat 35 °C, 60 min | 6.5% | 8.7% | -| Heat 40 °C, 60 min | 6.5% | 10.9% | -| Heat 25 °C, 2 h | 8.6% | 4.82% | -| Heat 25 °C, 3 h | 12.9% | 5.33% | -| Heat 25 °C, 4 h | 19.35% | 9.37% | -| Heat 30 °C, 4 h | 29.25% | 14.17% | -| Heat 35 °C, 4 h | 29.25% | 18.97% | -| Heat 40 °C, 4 h | 29.25% | 23.76% | +| Heat 20 °C, 60 min | 2.8% | 1.52% | +| Heat 30 °C, 60 min | 6.5% | 7.9% | +| Heat 35 °C, 60 min | 6.5% | 12.16% | +| Heat 40 °C, 60 min | 6.5% | 17.0% | +| Heat 25 °C, 2 h | 8.6% | 5.93% | +| Heat 25 °C, 3 h | 12.9% | 7.57% | +| Heat 25 °C, 4 h | 19.35% | 12.08% | +| Heat 30 °C, 4 h | 29.25% | 22.2% | +| Heat 35 °C, 4 h | 29.25% | 34.17% | +| Heat 40 °C, 4 h | 29.25% | 47.77% | | Marathon band, VO2max 50 | 4:50–4:25/km | 4:50–4:31/km | | Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 | | Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 | diff --git a/README.md b/README.md index bbe455d..333ed90 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,16 @@ simplified WBGT for humidity, NCAA standards for altitude). (914.4 m → 1.41%), the NCAA table up to 2438.4 m (5.90%), and an extrapolated curve beyond it (3000 m → 7.92%, 3500 m → 9.97%, 4000 m → 12.2%, capped there). São Paulo (760 m) gets ~1.06%. -- **Heat**: 60-minute baseline from 15 °C (0%) to 30 °C (6.5%), extrapolated to - 35 °C (8.7%) and 40 °C (10.9%, capped there), then scaled by effort duration: - 0.5× up to 30 min, 1.0× at 60 min, 1.24× at 3 h, 2.18× at 4 h and beyond - (linear in between, so 1.12× at 2 h). The 3 h and 4 h points are a - least-squares fit to El Helou et al. (2012, Table S3: eight finisher groups, - 2:41–4:54, time penalty against 15 °C at 20 and 25 °C); the derivation table - is in `lib/calcpace/data/environmental_factors.yml`. The 60-minute base and - the 30/60-minute factors have no direct published source. +- **Heat**: a 60-minute baseline `4.3 · ((T − 15) / 10)^1.5` (0% at 15 °C, + 1.52% at 20 °C, 4.3% at 25 °C, 7.9% at 30 °C; extrapolated to 12.16% at + 35 °C and 17.0% at 40 °C, capped there), stored as points every 2.5 °C, then + scaled by effort duration: 0.5× up to 30 min, 1.0× at 60 min, 1.76× at 3 h, + 2.81× at 4 h and beyond (linear in between, so 1.38× at 2 h). The exponent + and the 3 h / 4 h points are fitted to El Helou et al. (2012, Table S3: eight + finisher groups, 2:41–4:54, time penalty against 15 °C at 20 and 25 °C, + which grows ~2.8× from 20 to 25 °C in every group); the derivation table is + in `lib/calcpace/data/environmental_factors.yml`. The 25 °C / 60-minute + anchor (4.3%) and the 30/60-minute factors have no direct published source. - **Humidity** (optional): pass `humidity:` (relative humidity, %) or `dew_point:` (in `temperature_unit`). Without either, the heat curve assumes 50% humidity. With one, the temperature is replaced by the effective @@ -61,21 +63,21 @@ simplified WBGT for humidity, NCAA standards for altitude). | Heat penalty (%) | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | | --- | --- | --- | --- | --- | --- | --- | -| 20 °C | 1.4 | 2.8 | 3.14 | 3.47 | 6.1 | 6.1 | -| 25 °C | 2.15 | 4.3 | 4.82 | 5.33 | 9.37 | 9.37 | -| 30 °C | 3.25 | 6.5 | 7.28 | 8.06 | 14.17 | 14.17 | -| 35 °C | 4.35 | 8.7 | 9.74 | 10.79 | 18.97 | 18.97 | -| 40 °C | 5.45 | 10.9 | 12.21 | 13.52 | 23.76 | 23.76 | +| 20 °C | 0.76 | 1.52 | 2.1 | 2.68 | 4.27 | 4.27 | +| 25 °C | 2.15 | 4.3 | 5.93 | 7.57 | 12.08 | 12.08 | +| 30 °C | 3.95 | 7.9 | 10.9 | 13.9 | 22.2 | 22.2 | +| 35 °C | 6.08 | 12.16 | 16.78 | 21.4 | 34.17 | 34.17 | +| 40 °C | 8.5 | 17.0 | 23.46 | 29.92 | 47.77 | 47.77 | | 30 °C at | Effective temperature | 60 min | 4 h | | --- | --- | --- | --- | -| 30% RH | 26.69 °C | 5.05% | 11.01% | -| 50% RH (= no humidity) | 30.0 °C | 6.5% | 14.17% | -| 70% RH | 33.07 °C | 7.85% | 17.11% | -| 90% RH | 35.94 °C | 9.11% | 19.86% | +| 30% RH | 26.69 °C | 5.46% | 15.34% | +| 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% | +| 70% RH | 33.07 °C | 10.46% | 29.39% | +| 90% RH | 35.94 °C | 13.04% | 36.64% | -Above ~30 °C (and above ~25 °C for 4 h+) the numbers are extrapolations: the -marathon studies behind the curve have little or no data there. +Above ~25 °C the numbers are extrapolations of the fitted curve: the marathon +studies behind it have no data there (El Helou's hottest race was 25.2 °C). ```ruby # Calculate penalty for 25°C and 2000m altitude (Defaults to 60-min effort) @@ -87,30 +89,30 @@ penalty = calc.calculate_penalty(temperature: 25, altitude: 2000) # Fahrenheit support calc.calculate_penalty(temperature: 80, temperature_unit: :f) -# => { total_penalty_percent: 5.03, ... } +# => { total_penalty_percent: 5.44, ... } # Humidity: 30 °C at 90% hits like 35.94 °C at 50% -calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] # => 9.11 +calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] # => 13.04 calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] # => 35.94 -calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] # => 8.16 +calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] # => 11.07 # Adjust a 3:30 marathon time (12600s) for these conditions (High exposure penalty) result = calc.adjust_time(12600, temperature: 25, altitude: 2000) # => { # original_time: 12600, -# adjusted_time: 14070.42, -# adjusted_time_clock: "03:54:30", -# penalty_percent: 11.67, -# factors: { heat: 7.35, altitude: 4.32 } +# adjusted_time: 14382.9, +# adjusted_time_clock: "03:59:42", +# penalty_percent: 14.15, +# factors: { heat: 9.83, altitude: 4.32 } # } # Predicted adjusted times (Riegel formula) calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 28) -# => { adjusted_time: 2599.74, adjusted_time_clock: "00:43:19", penalty_percent: 3.91, ... } +# => { adjusted_time: 2613.0, adjusted_time_clock: "00:43:33", penalty_percent: 4.44, ... } # Predicted adjusted times (Cameron formula) calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 80, temperature_unit: :f) -# => { adjusted_time: 12011.41, adjusted_time_clock: "03:20:11", penalty_percent: 6.82, ... } +# => { adjusted_time: 12400.47, adjusted_time_clock: "03:26:40", penalty_percent: 10.28, ... } ``` --- diff --git a/lib/calcpace/cameron_predictor.rb b/lib/calcpace/cameron_predictor.rb index 556262e..c05c8d8 100644 --- a/lib/calcpace/cameron_predictor.rb +++ b/lib/calcpace/cameron_predictor.rb @@ -132,7 +132,7 @@ def predict_pace_cameron_clock(from_race, from_time, to_race) # # @example # calc.predict_time_cameron_adjusted('5k', '00:20:00', '10k', temperature: 25, humidity: 80)[:adjusted_time_clock] - # #=> '00:43:23' + # #=> '00:43:41' def predict_time_cameron_adjusted(from_race, from_time, to_race, **) predicted_seconds = predict_time_cameron(from_race, from_time, to_race) adjust_time(predicted_seconds, **) diff --git a/lib/calcpace/data/environmental_factors.yml b/lib/calcpace/data/environmental_factors.yml index a3d8fd3..a56a308 100644 --- a/lib/calcpace/data/environmental_factors.yml +++ b/lib/calcpace/data/environmental_factors.yml @@ -7,38 +7,46 @@ # MSSE 39(3):487-493 — qualitative source: marathon times slow progressively # from 5 to 25 °C WBGT, and more for slower runners (top men 1.7 / 2.5 / 3.3 / # 4.5% off the course record in WBGT 5-10 / 10-15 / 15-20 / 20-25 °C). -# NOTE: These heat factors are the BASELINE for a 60-minute effort. They have -# no direct published source at 60 min. +# NOTE: These heat factors are the BASELINE for a 60-minute effort. # The final penalty is scaled by a DurationFactor (0.5x at 30 min, 1.0x at -# 60 min, 1.24x at 3 h, 2.18x at 4 h and beyond; see +# 60 min, 1.76x at 3 h, 2.81x at 4 h and beyond; see # EnvironmentalAdjuster::HEAT_DURATION_FACTORS) based on total exposure time. -# The 3 h and 4 h points are fitted to El Helou et al. (2012), PLoS One -# 7(5):e37407, Table S3 (1.79 M finishers of Berlin, Boston, Chicago, London, -# New York and Paris, 2001-2010). Derivation (reproduced by -# test_duration_factor_points_are_the_least_squares_fit_of_el_helou_table_s3): +# Both the shape of the base curve and the 3 h / 4 h factors are fitted to +# El Helou et al. (2012), PLoS One 7(5):e37407, Table S3 (1.79 M finishers of +# Berlin, Boston, Chicago, London, New York and Paris, 2001-2010). +# Derivation (reproduced by the El Helou tests in test_environmental_adjuster.rb): # 1. Speed loss (%) at 15, 20 and 25 °C: straight line between the table's -# points (optimum -10 ... +20 °C, every 5 °C). -# 2. Time penalty against 15 °C: ((1 - loss15) / (1 - lossT) - 1) x 100. -# 3. Ratio = that penalty / the 60-min base (2.8 at 20 °C, 4.3 at 25 °C). -# 4. Finish time = 42195 m / the group's speed at its optimum. -# 5. Weighted least squares over the 15 ratios with 1.0x at 60 min fixed and -# the 180 and 240 min points free (flat after 240), each sex carrying half -# the weight (men 7 observations, women 8): 1.239 and 2.180. +# points (each group's optimum -10 ... +20 °C, every 5 °C). +# 2. Time penalty against 15 °C (where the curve is zero): +# P = ((1 - loss15) / (1 - lossT) - 1) x 100. +# 3. Base shape: P25 / P20 is 2.70-2.97 in all seven groups that have both +# (table below). With P ∝ (T - 15)^p, p = log2(P25 / P20); the sex-weighted +# mean (each sex half the weight) is p = 1.497 -> 1.5. The base is +# 4.3 · ((T - 15) / 10)^1.5, anchored at the original base(25) = 4.3, +# which is the only value available for a 60-minute effort. +# 4. Ratio = P / base(T) (1.52 at 20 °C, 4.3 at 25 °C); finish time = +# 42195 m / the group's speed at its optimum. +# 5. Weighted least squares over the 15 ratios (men P1 at 25 °C is beyond +# the table), each sex half the weight (men 7, women 8), 1.0x at 60 min +# fixed, 180 and 240 min free, flat after 240: 1.761 and 2.814 -> 1.76 and +# 2.81. An extra free point at 150 min cut the weighted residual by 1% +# only; one at 210 min made the curve non-monotonic (2.97 at 210 > 2.73 +# at 240). Neither was kept. # -# | Group | Finish | loss@15 | loss@20 | loss@25 | vs 15 °C @20 | @25 | ratio @20 | @25 | -# | men P1 | 2:41 | 1.88 | 3.93 | n/a* | 2.14 | n/a* | 0.76 | n/a* | -# | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 0.86 | 1.63 | -# | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 1.59 | 2.89 | -# | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 1.85 | 3.43 | -# | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 1.08 | 1.90 | -# | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 1.21 | 2.16 | -# | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 1.94 | 3.74 | -# | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 1.26 | 2.29 | +# | Group | Finish | loss@15 | loss@20 | loss@25 | P20 | P25 | P25/P20 | ratio @20 | @25 | +# | men P1 | 2:41 | 1.88 | 3.93 | n/a* | 2.14 | n/a* | n/a* | 1.41 | n/a* | +# | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 2.89 | 1.59 | 1.63 | +# | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 2.78 | 2.93 | 2.89 | +# | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 2.86 | 3.40 | 3.43 | +# | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 2.70 | 1.99 | 1.90 | +# | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 2.73 | 2.23 | 2.16 | +# | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 2.97 | 3.57 | 3.74 | +# | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 2.78 | 2.33 | 2.29 | # * men P1's optimum is 3.81 °C, so 25 °C is beyond its last point (+20 °C). -# (P1 = first percentile, Q1/Q3 = quartiles; losses and penalties in %.) -# Caveat: El Helou's penalty grows ~2.5-2.9x from 20 to 25 °C, the base only -# 1.54x (2.8 -> 4.3), so one factor per duration over-reads 20 °C and -# under-reads 25 °C for the slower groups. +# (P1 = first percentile, Q1/Q3 = quartiles; losses and P in %.) +# Caveat: the model has no sex input. With one curve, men's slower groups are +# under-read at 25 °C (median 11.9% vs 14.76% observed) and women's over-read +# (median 12.08% vs 9.27%). # - Humidity: the points are read at air temperature with ~50% relative # humidity (EnvironmentalAdjuster::REFERENCE_HUMIDITY). With humidity: or # dew_point:, the temperature is first moved to the effective temperature @@ -73,12 +81,26 @@ altitude: # Heat adjustments (60-minute baseline) # These values represent the penalty for a 1-hour run. # For longer/shorter runs, the DurationFactor will scale these up/down. +# Points follow base(T) = 4.3 · ((T − 15) / 10)^1.5, every 2.5 °C so that the +# straight lines between them stay within 0.08 points of the curve: +# - exponent 1.5: El Helou et al. (2012) Table S3, see the derivation above; +# the penalty against 15 °C grows 2.70-2.97x from 20 to 25 °C in every +# group (2^1.497 = 2.82). +# - anchor base(25) = 4.3: kept from the original model; it is the only value +# available for short efforts and has no direct published source. +# - 30 °C and above: EXTRAPOLATION of the same law. The marathon data stop at +# ~25 °C (El Helou's hottest race: 25.2 °C). heat: ideal_range_celsius: [10.0, 15.0] data_points: 15: 0.0 - 20: 2.8 # Base for 60m. For 3h (1.24x) = 3.47%, for 4h (2.18x) = 6.1% - 25: 4.3 # Base for 60m. For 3h (1.24x) = 5.33%, for 4h (2.18x) = 9.37% - 30: 6.5 # Base for 60m. For 3h (1.24x) = 8.06%, for 4h (2.18x) = 14.17% - 35: 8.7 # EXTRAPOLATION: continues the 25→30 slope (0.44 points/°C) - 40: 10.9 # EXTRAPOLATION: continues the 25→30 slope (0.44 points/°C) + 17.5: 0.54 + 20: 1.52 # Base for 60m. For 3h (1.76x) = 2.68%, for 4h (2.81x) = 4.27% + 22.5: 2.79 + 25: 4.3 # Base for 60m (anchor). For 3h = 7.57%, for 4h = 12.08% + 27.5: 6.01 # EXTRAPOLATION from here on + 30: 7.9 # For 3h = 13.9%, for 4h = 22.2% + 32.5: 9.95 + 35: 12.16 + 37.5: 14.51 + 40: 17.0 # capped here; for 4h = 47.77% diff --git a/lib/calcpace/environmental_adjuster.rb b/lib/calcpace/environmental_adjuster.rb index 05d2686..010da85 100644 --- a/lib/calcpace/environmental_adjuster.rb +++ b/lib/calcpace/environmental_adjuster.rb @@ -22,13 +22,13 @@ module EnvironmentalAdjuster # environmental_factors.yml is for a 60-minute effort (factor 1.0). # - 30 min (0.5x) and 60 min (1.0x): kept from the original model; no # marathon dataset covers efforts this short. - # - 3 h (1.24x) and 4 h (2.18x, flat after): weighted least-squares fit to + # - 3 h (1.76x) and 4 h (2.81x, flat after): weighted least-squares fit to # El Helou et al. (2012) Table S3 — the time penalty against 15 °C at # 20 °C and 25 °C for eight finisher groups (2:41–4:54) divided by the - # 60-minute base. The 2 h value (1.12x) is the straight line 60 → 180 min: - # no group finishes between 1 h and 2:41. Derivation table in - # environmental_factors.yml. - HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 1.24], [240.0, 2.18]].freeze + # 60-minute base (itself fitted to the same table). The 2 h value (1.38x) + # is the straight line 60 → 180 min: no group finishes between 1 h and + # 2:41. Derivation table in environmental_factors.yml. + HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 1.76], [240.0, 2.81]].freeze # Relative humidity (%) the temperature-only heat curve stands for # (see EnvironmentalAdjuster::Humidity) @@ -53,9 +53,9 @@ module EnvironmentalAdjuster # temperature, both are given, or either is given without a temperature # # @example - # calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] #=> 9.11 + # calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] #=> 13.04 # calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] #=> 35.94 - # calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] #=> 8.16 + # calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] #=> 11.07 def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil, humidity: nil, dew_point: nil) effective = effective_temperature(temperature, temperature_unit, humidity, dew_point) diff --git a/lib/calcpace/race_predictor.rb b/lib/calcpace/race_predictor.rb index 99a8656..c748fe0 100644 --- a/lib/calcpace/race_predictor.rb +++ b/lib/calcpace/race_predictor.rb @@ -139,9 +139,9 @@ def equivalent_performance(from_race, from_time, to_race) # # @example Predict marathon time from 5K adjusted for heat (25C) # predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25) - # #=> { adjusted_time: 12214.84, penalty_percent: 6.13, ... } - # calc.predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25)[:penalty_percent] #=> 6.13 - # calc.predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25, humidity: 80)[:penalty_percent] #=> 8.52 + # #=> { adjusted_time: 12483.01, penalty_percent: 8.46, ... } + # calc.predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25)[:penalty_percent] #=> 8.46 + # calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 25, humidity: 80)[:penalty_percent] #=> 4.87 def predict_time_adjusted(from_race, from_time, to_race, **) predicted_seconds = predict_time(from_race, from_time, to_race) adjust_time(predicted_seconds, **) diff --git a/test/calcpace/test_cameron_predictor.rb b/test/calcpace/test_cameron_predictor.rb index 7aa883e..dd7b2bf 100644 --- a/test/calcpace/test_cameron_predictor.rb +++ b/test/calcpace/test_cameron_predictor.rb @@ -248,12 +248,12 @@ def test_predict_time_cameron_adjusted_with_heat # 5K in 20:00 to 10K # Normal Cameron: ~2499.66s # Duration factor for ~41:40 (2499.66s) is ~0.694x - # Adjusted for 20°C (Base 2.8% * 0.694 ≈ 1.94% penalty): 2499.66 * 1.0194 ≈ 2548.15s + # Adjusted for 20°C (Base 1.52% * 0.694 ≈ 1.06% penalty): 2499.66 * 1.0106 ≈ 2526.16s result = @calc.predict_time_cameron_adjusted('5k', '00:20:00', '10k', temperature: 20) assert_kind_of Hash, result - assert_in_delta 2548.15, result[:adjusted_time], 0.01 - assert_equal 1.94, result[:penalty_percent] + assert_in_delta 2526.16, result[:adjusted_time], 0.01 + assert_equal 1.06, result[:penalty_percent] end # --- free distances (v1.15.0) --- diff --git a/test/calcpace/test_environmental_adjuster.rb b/test/calcpace/test_environmental_adjuster.rb index 0d5765f..c9e1c1b 100644 --- a/test/calcpace/test_environmental_adjuster.rb +++ b/test/calcpace/test_environmental_adjuster.rb @@ -12,10 +12,10 @@ def test_calculate_penalty_returns_zero_for_ideal_conditions end def test_calculate_penalty_with_heat - # 20°C for 60 min should have 2.8% penalty (Updated scientific baseline) + # 20°C for 60 min: base 4.3 · (5/10)^1.5 = 1.52% result = @calc.calculate_penalty(temperature: 20, time_seconds: 3600) - assert_equal 2.8, result[:factors][:heat] - assert_equal 2.8, result[:total_penalty_percent] + assert_equal 1.52, result[:factors][:heat] + assert_equal 1.52, result[:total_penalty_percent] end def test_calculate_penalty_with_altitude @@ -26,11 +26,11 @@ def test_calculate_penalty_with_altitude end def test_calculate_penalty_combined - # 20°C at 60m (2.8%) and 1828.8m (3.76%) + # 20°C at 60m (1.52%) and 1828.8m (3.76%) result = @calc.calculate_penalty(temperature: 20, altitude: 1828.8, time_seconds: 3600) - assert_equal 2.8, result[:factors][:heat] + assert_equal 1.52, result[:factors][:heat] assert_equal 3.76, result[:factors][:altitude] - assert_equal 6.56, result[:total_penalty_percent] + assert_equal 5.28, result[:total_penalty_percent] end def test_adjust_time_with_no_penalties @@ -42,20 +42,20 @@ def test_adjust_time_with_no_penalties end def test_adjust_time_with_heat_and_altitude - # 3600s at 20°C (Base 2.8% * 1.0x = 2.8%) and 1828.8m (3.76%) = 6.56% penalty - # 3600 * 1.0656 = 3836.16 + # 3600s at 20°C (Base 1.52% * 1.0x) and 1828.8m (3.76%) = 5.28% penalty + # 3600 * 1.0528 = 3790.08 result = @calc.adjust_time(3600, temperature: 20, altitude: 1828.8) assert_equal 3600, result[:original_time] - assert_in_delta 3836.16, result[:adjusted_time], 0.01 - assert_equal 6.56, result[:penalty_percent] - assert_equal '01:03:56', result[:adjusted_time_clock] + assert_in_delta 3790.08, result[:adjusted_time], 0.01 + assert_equal 5.28, result[:penalty_percent] + assert_equal '01:03:10', result[:adjusted_time_clock] end def test_calculate_penalty_with_fahrenheit - # 68°F is 20°C. 20°C for 60m should have 2.8% penalty. + # 68°F is 20°C. 20°C for 60m should have 1.52% penalty. result = @calc.calculate_penalty(temperature: 68, temperature_unit: :f, time_seconds: 3600) - assert_equal 2.8, result[:factors][:heat] - assert_equal 2.8, result[:total_penalty_percent] + assert_equal 1.52, result[:factors][:heat] + assert_equal 1.52, result[:total_penalty_percent] end def test_normalize_time_returns_original_for_ideal_conditions @@ -65,13 +65,12 @@ def test_normalize_time_returns_original_for_ideal_conditions end def test_normalize_time_with_heat - # If I ran 3600s at 20C (Base 2.8% penalty) + # If I ran 3600s at 20C (Base 1.52% penalty) # Duration factor for 60 min is 1.0x - # Effective penalty: 2.8 * 1.0 = 2.8% - # Ideal time: 3600 / 1.028 = 3501.945... + # Ideal time: 3600 / 1.0152 = 3546.10... result = @calc.normalize_time(3600, temperature: 20) - assert_in_delta 3501.95, result[:normalized_time], 0.01 - assert_equal 2.8, result[:penalty_percent] + assert_in_delta 3546.1, result[:normalized_time], 0.01 + assert_equal 1.52, result[:penalty_percent] end def test_environmental_round_trip_consistency @@ -90,18 +89,18 @@ def test_environmental_round_trip_consistency def test_calculate_penalty_with_duration # 25C at 180 min (Marathon sub-3) - # Factor: 1.24x (least-squares fit to El Helou et al. 2012, Table S3) - # Penalty: 4.3 * 1.24 = 5.33% + # Factor: 1.76x (least-squares fit to El Helou et al. 2012, Table S3) + # Penalty: 4.3 * 1.76 = 7.57% result = @calc.calculate_penalty(temperature: 25, time_seconds: 10_800) - assert_equal 5.33, result[:factors][:heat] + assert_equal 7.57, result[:factors][:heat] end def test_calculate_penalty_with_long_duration # 25C at 240 min (Amateur Marathon) - # Factor: 2.18x - # Penalty: 4.3 * 2.18 = 9.37% + # Factor: 2.81x + # Penalty: 4.3 * 2.81 = 12.08% result = @calc.calculate_penalty(temperature: 25, time_seconds: 14_400) - assert_equal 9.37, result[:factors][:heat] + assert_equal 12.08, result[:factors][:heat] end # --- heat duration factor (fit to El Helou et al. 2012) --- @@ -113,15 +112,15 @@ def test_duration_factor_keeps_the_short_effort_points end def test_duration_factor_points_for_two_three_and_four_hours - assert_in_delta 1.12, @calc.send(:duration_factor, 7200), 1e-12 - assert_in_delta 1.24, @calc.send(:duration_factor, 10_800), 1e-12 - assert_in_delta 1.71, @calc.send(:duration_factor, 12_600), 1e-12 - assert_in_delta 2.18, @calc.send(:duration_factor, 14_400), 1e-12 + assert_in_delta 1.38, @calc.send(:duration_factor, 7200), 1e-12 + assert_in_delta 1.76, @calc.send(:duration_factor, 10_800), 1e-12 + assert_in_delta 2.285, @calc.send(:duration_factor, 12_600), 1e-12 + assert_in_delta 2.81, @calc.send(:duration_factor, 14_400), 1e-12 end def test_duration_factor_is_flat_beyond_four_hours - assert_in_delta 2.18, @calc.send(:duration_factor, 18_000), 1e-12 - assert_in_delta 2.18, @calc.send(:duration_factor, 36_000), 1e-12 + assert_in_delta 2.81, @calc.send(:duration_factor, 18_000), 1e-12 + assert_in_delta 2.81, @calc.send(:duration_factor, 36_000), 1e-12 end # El Helou et al. (2012) PLoS One 7(5):e37407, Table S3: optimum °C, speed at @@ -137,10 +136,36 @@ def test_duration_factor_is_flat_beyond_four_hours 'women Q3' => [7.35, 2.39, [3.04, 0.76, 0, 0.77, 3.14, 7.35, 13.85]] }.freeze - # Reproduces the derivation documented in environmental_factors.yml: the - # time penalty against 15 °C at 20 and 25 °C, divided by the 60-minute base - # (2.8 / 4.3), fitted by weighted least squares (each sex half the weight) - # with the 3 h and 4 h points free and 1.0 at 60 min fixed + # Reproduces the derivation documented in environmental_factors.yml, step 1: + # the time penalty against 15 °C grows 2^p times from 20 to 25 °C in every + # group; p is the sex-weighted mean of log2(P25 / P20), and the 60-minute + # base follows 4.3 · ((T − 15) / 10)^p, anchored at base(25) = 4.3 + def test_heat_base_points_follow_the_el_helou_exponent + exponent = el_helou_exponent + assert_in_delta 1.497, exponent, 0.001 + + points = EnvironmentalAdjuster::FACTORS.fetch('heat').fetch('data_points') + points.each do |temperature, base| + expected = (4.3 * (((temperature - 15) / 10.0)**exponent.round(2))).round(2) + assert_in_delta expected, base, 1e-9, "#{temperature} °C" + end + end + + def test_heat_base_points_track_the_power_law_within_a_tenth + exponent = el_helou_exponent.round(2) + + (1500..4000).each do |hundredths| + temperature = hundredths / 100.0 + law = 4.3 * (((temperature - 15) / 10.0)**exponent) + base = @calc.calculate_penalty(temperature: temperature, time_seconds: 3600)[:factors][:heat] + + assert_in_delta law, base, 0.1, "#{temperature} °C" + end + end + + # Step 2: those penalties divided by the base, fitted by weighted least + # squares (each sex half the weight) with the 3 h and 4 h points free and + # 1.0 at 60 min fixed def test_duration_factor_points_are_the_least_squares_fit_of_el_helou_table_s3 observations = el_helou_ratios assert_equal 15, observations.size # men P1 at 25 °C lies beyond the table @@ -162,9 +187,9 @@ def test_duration_factor_is_continuous_and_monotonic end def test_extreme_heat_for_four_hours - # 35 °C / 4 h: 8.7 * 2.18 = 18.97%; 40 °C / 4 h: 10.9 * 2.18 = 23.76% - assert_equal 18.97, @calc.calculate_penalty(temperature: 35, time_seconds: 14_400)[:factors][:heat] - assert_equal 23.76, @calc.calculate_penalty(temperature: 40, time_seconds: 14_400)[:factors][:heat] + # 35 °C / 4 h: 12.16 * 2.81 = 34.17%; 40 °C / 4 h: 17.0 * 2.81 = 47.77% (extrapolated) + assert_equal 34.17, @calc.calculate_penalty(temperature: 35, time_seconds: 14_400)[:factors][:heat] + assert_equal 47.77, @calc.calculate_penalty(temperature: 40, time_seconds: 14_400)[:factors][:heat] end # --- altitude curve (v1.19.0) --- @@ -228,16 +253,16 @@ def test_heat_at_thirty_five_is_worse_than_at_thirty at30 = @calc.calculate_penalty(temperature: 30, time_seconds: 3600)[:factors][:heat] at35 = @calc.calculate_penalty(temperature: 35, time_seconds: 3600)[:factors][:heat] - assert_equal 6.5, at30 - assert_equal 8.7, at35 + assert_equal 7.9, at30 + assert_equal 12.16, at35 end def test_heat_at_forty_is_worse_than_at_thirty_five - assert_equal 10.9, @calc.calculate_penalty(temperature: 40, time_seconds: 3600)[:factors][:heat] + assert_equal 17.0, @calc.calculate_penalty(temperature: 40, time_seconds: 3600)[:factors][:heat] end def test_heat_is_capped_at_forty - assert_equal 10.9, @calc.calculate_penalty(temperature: 45, time_seconds: 3600)[:factors][:heat] + assert_equal 17.0, @calc.calculate_penalty(temperature: 45, time_seconds: 3600)[:factors][:heat] end # --- humidity / dew point (effective temperature) --- @@ -296,34 +321,34 @@ def test_reference_humidity_constant end def test_humid_air_raises_the_effective_temperature_and_the_penalty - # WBGT(30 °C, 90%) = WBGT(35.94 °C, 50%) → base 8.7 + 0.94/5 × 2.2 = 9.11 + # WBGT(30 °C, 90%) = WBGT(35.94 °C, 50%) → base 12.16 + 0.94/2.5 × 2.35 = 13.04 result = @calc.calculate_penalty(temperature: 30, humidity: 90, time_seconds: 3600) assert_in_delta 35.94, result[:factors][:effective_temperature_celsius], 0.01 - assert_equal 9.11, result[:factors][:heat] - assert_equal 9.11, result[:total_penalty_percent] + assert_equal 13.04, result[:factors][:heat] + assert_equal 13.04, result[:total_penalty_percent] end def test_dry_air_lowers_the_effective_temperature_and_the_penalty - # WBGT(30 °C, 30%) = WBGT(26.6946 °C, 50%) → base 4.3 + 1.6946/5 × 2.2 = 5.05 + # WBGT(30 °C, 30%) = WBGT(26.6946 °C, 50%) → base 4.3 + 1.6946/2.5 × 1.71 = 5.46 result = @calc.calculate_penalty(temperature: 30, humidity: 30, time_seconds: 3600) assert_in_delta 26.69, result[:factors][:effective_temperature_celsius], 0.01 - assert_equal 5.05, result[:factors][:heat] + assert_equal 5.46, result[:factors][:heat] end def test_humidity_scales_with_duration_like_temperature result = @calc.calculate_penalty(temperature: 30, humidity: 90, time_seconds: 7200) - assert_equal (9.11 * @calc.send(:duration_factor, 7200)).round(2), result[:factors][:heat] + assert_equal (13.04 * @calc.send(:duration_factor, 7200)).round(2), result[:factors][:heat] end def test_humid_air_can_lift_an_ideal_temperature_out_of_the_ideal_range - # 15 °C at 90% behaves like 18.3304 °C at 50% → 3.3304/5 × 2.8 = 1.87 + # 15 °C at 90% behaves like 18.3304 °C at 50% → 0.54 + 0.8304/2.5 × 0.98 = 0.87 result = @calc.calculate_penalty(temperature: 15, humidity: 90, time_seconds: 3600) assert_in_delta 18.33, result[:factors][:effective_temperature_celsius], 0.01 - assert_equal 1.87, result[:factors][:heat] + assert_equal 0.87, result[:factors][:heat] end def test_dry_cool_air_stays_penalty_free @@ -388,8 +413,8 @@ def test_adjust_and_normalize_forward_humidity adjusted = @calc.adjust_time(3600, temperature: 30, humidity: 90) normalized = @calc.normalize_time(3600, temperature: 30, humidity: 90) - assert_equal 9.11, adjusted[:penalty_percent] - assert_equal 9.11, normalized[:penalty_percent] + assert_equal 13.04, adjusted[:penalty_percent] + assert_equal 13.04, normalized[:penalty_percent] assert_in_delta 35.94, adjusted[:factors][:effective_temperature_celsius], 0.01 end @@ -425,8 +450,8 @@ def test_environmental_data_keeps_the_structure_the_site_reads private - def el_helou_ratios - base = { 20 => 2.8, 25 => 4.3 } + # [sex, finish minutes, temperature, time penalty (%) against 15 °C] + def el_helou_penalties EL_HELOU_TABLE_S3.flat_map do |group, (optimum, speed, losses)| minutes = 42_195 / speed / 60 loss15 = table_loss(optimum, losses, 15) @@ -434,12 +459,26 @@ def el_helou_ratios loss = table_loss(optimum, losses, temperature) next unless loss - penalty = (((1 - (loss15 / 100)) / (1 - (loss / 100))) - 1) * 100 - [group.split.first, minutes, penalty / base[temperature]] + [group.split.first, minutes, temperature, (((1 - (loss15 / 100)) / (1 - (loss / 100))) - 1) * 100] end end end + def el_helou_exponent + pairs = el_helou_penalties.group_by { |sex, minutes, *| [sex, minutes] }.values.select { |rows| rows.size == 2 } + per_sex = pairs.map { |rows| rows.first.first }.tally + pairs.sum do |(at20, at25)| + Math.log2(at25.last / at20.last) / per_sex[at20.first] / per_sex.size + end + end + + def el_helou_ratios + base = EnvironmentalAdjuster::FACTORS.fetch('heat').fetch('data_points') + el_helou_penalties.map do |sex, minutes, temperature, penalty| + [sex, minutes, penalty / base.fetch(temperature)] + end + end + # Straight line between the published points; nil beyond them def table_loss(optimum, losses, temperature) xs = (-10..20).step(5).map { |delta| optimum + delta } diff --git a/test/calcpace/test_race_predictor.rb b/test/calcpace/test_race_predictor.rb index 100c4bd..906ec08 100644 --- a/test/calcpace/test_race_predictor.rb +++ b/test/calcpace/test_race_predictor.rb @@ -228,12 +228,12 @@ def test_predict_time_adjusted_with_heat # 5K in 20:00 to 10K # Normal: ~2501s # Duration factor for ~41:41 (2501s) is: 0.5 + ((41.68 - 30) / 30) * 0.5 ≈ 0.695x - # Adjusted for 20°C (Base 2.8% * 0.695 ≈ 1.95% penalty): 2501.9 * 1.0195 ≈ 2550.7s + # Adjusted for 20°C (Base 1.52% * 0.695 ≈ 1.06% penalty): 2501.9 * 1.0106 ≈ 2528.4s result = @calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 20) assert_kind_of Hash, result - assert_in_delta 2550.7, result[:adjusted_time], 10 - assert_equal 1.95, result[:penalty_percent] + assert_in_delta 2528.44, result[:adjusted_time], 0.01 + assert_equal 1.06, result[:penalty_percent] end def test_predict_time_adjusted_with_altitude From 184080ffc1740e0f2b90777b86c5f4aa6a76cac4 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:24:10 -0300 Subject: [PATCH 24/34] fix: cap the heat base at its 35 C value The 37.5 C and 40 C points become 12.16, the 35 C value, so 35-40 C (and anything hotter, clamped to the last point) reads like 35 C. This is a deliberate choice, not data: everything above 25.2 C, El Helou's hottest race, is extrapolation, and the uncapped law gave 47.77% for 4 h at 40 C. 40 C now gives 12.16% for 60 min and 34.17% for 4 h; 30 C at 90% RH (effective 35.94 C) reaches the cap. The fit test still checks the power law up to 35 C and the cap above it. README/CHANGELOG grids and examples updated, and the changelog gains a known limitation: the heat model has no sex term (men read low and women high at 25 C, per the Table S3 residuals). --- CHANGELOG.md | 29 ++++++++----- README.md | 15 ++++--- lib/calcpace/data/environmental_factors.yml | 13 ++++-- lib/calcpace/environmental_adjuster.rb | 2 +- test/calcpace/test_environmental_adjuster.rb | 44 +++++++++++++------- 5 files changed, 68 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 641ad6e..28ce9c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | 30% RH | 26.69 °C | 5.46% | 15.34% | | 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% | | 70% RH | 33.07 °C | 10.46% | 29.39% | - | 90% RH | 35.94 °C | 13.04% | 36.64% | + | 90% RH | 35.94 °C (capped at 35 °C) | 12.16% | 34.17% | ### Changed (numbers) Four models produced unrealistic numbers. The method names, signatures, return @@ -72,9 +72,9 @@ above 100 km (see Breaking). marathon with `strategy: :negative` used to go through halfway in 1:33:36 (a 7-minute negative split); it now splits 1:30:54 + 1:29:06. - **Heat above 30 °C keeps increasing.** 35 °C and 40 °C used to get the same - penalty as 30 °C. The base curve now continues to 40 °C (capped there): - 12.16% at 35 °C and 17.0% at 40 °C for 60 minutes, extrapolating the fitted - law below. The ideal range is unchanged. + penalty as 30 °C. The base curve now continues to 35 °C (12.16% for 60 + minutes, extrapolating the fitted law below) and is capped there: 35–40 °C + and hotter all read like 35 °C. The ideal range is unchanged. - **Heat base curve and duration scaling are fitted to marathon data.** The 60-minute base was 2.8 / 4.3 / 6.5% at 20 / 25 / 30 °C (roughly linear) and the duration factor 1.0× (60 min) → 3.0× (3 h) → 4.5× (4 h), with the 3 h @@ -92,8 +92,10 @@ above 100 km (see Breaking). original 25 °C / 60-minute anchor of 4.3% (the only value available for a 60-minute effort), stored every 2.5 °C from 15 to 40 °C (linear interpolation stays within 0.08 points of the curve): 0, 0.54, 1.52, - 2.79, 4.3, 6.01, 7.9, 9.95, 12.16, 14.51, 17.0. Above 25 °C this is an - extrapolation (El Helou's hottest race was 25.2 °C); + 2.79, 4.3, 6.01, 7.9, 9.95, 12.16, then 12.16 and 12.16 at 37.5 and + 40 °C. Above 25 °C this is an extrapolation (El Helou's hottest race was + 25.2 °C), so the curve is deliberately capped at 35 °C: the uncapped law + (14.51 at 37.5 °C, 17.0 at 40 °C) gave 47.77% for 4 h at 40 °C; 4. ratio = P ÷ base(T); finish time = 42195 m ÷ the group's speed at its optimum; 5. **duration factor**: weighted least squares over the 15 ratios (men P1 at @@ -119,7 +121,7 @@ above 100 km (see Breaking). residual in penalty points drops from 18.3 (linear base, 1.24×/2.18×) to 8.8. What remains is mostly sex: with no sex input, men's slower groups are under-read at 25 °C (median 11.9% vs 14.76%) and women's over-read (median - 12.08% vs 9.27%). Ely et al. (2007) remains a qualitative source (top men + 12.08% vs 9.27%) — see Known limitations below. Ely et al. (2007) remains a qualitative source (top men 1.7 / 2.5 / 3.3 / 4.5% off the course record across WBGT 5–10 … 20–25 °C, i.e. +2.8 points); the model gives a 2:10 effort 4.03% at 22.5 °C against 0% at 7.5 °C, a little above that for elite runners. The duration points @@ -133,7 +135,7 @@ above 100 km (see Breaking). | 25 °C | 2.15 → 2.15 | 4.3 → 4.3 | 8.6 → 5.93 | 12.9 → 7.57 | 19.35 → 12.08 | 19.35 → 12.08 | | 30 °C | 3.25 → 3.95 | 6.5 → 7.9 | 13.0 → 10.9 | 19.5 → 13.9 | 29.25 → 22.2 | 29.25 → 22.2 | | 35 °C | 3.25 → 6.08 | 6.5 → 12.16 | 13.0 → 16.78 | 19.5 → 21.4 | 29.25 → 34.17 | 29.25 → 34.17 | - | 40 °C | 3.25 → 8.5 | 6.5 → 17.0 | 13.0 → 23.46 | 19.5 → 29.92 | 29.25 → 47.77 | 29.25 → 47.77 | + | 40 °C | 3.25 → 6.08 | 6.5 → 12.16 | 13.0 → 16.78 | 19.5 → 21.4 | 29.25 → 34.17 | 29.25 → 34.17 | - **The marathon pace band ends at the runner's predicted marathon pace.** Daniels' M pace is the predicted marathon race pace, but @@ -172,17 +174,24 @@ above 100 km (see Breaking). | Heat 20 °C, 60 min | 2.8% | 1.52% | | Heat 30 °C, 60 min | 6.5% | 7.9% | | Heat 35 °C, 60 min | 6.5% | 12.16% | -| Heat 40 °C, 60 min | 6.5% | 17.0% | +| Heat 40 °C, 60 min | 6.5% | 12.16% | | Heat 25 °C, 2 h | 8.6% | 5.93% | | Heat 25 °C, 3 h | 12.9% | 7.57% | | Heat 25 °C, 4 h | 19.35% | 12.08% | | Heat 30 °C, 4 h | 29.25% | 22.2% | | Heat 35 °C, 4 h | 29.25% | 34.17% | -| Heat 40 °C, 4 h | 29.25% | 47.77% | +| Heat 40 °C, 4 h | 29.25% | 34.17% | | Marathon band, VO2max 50 | 4:50–4:25/km | 4:50–4:31/km | | Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 | | Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 | +### Known limitations +- **The heat model has no sex term.** One curve serves everyone, and El Helou + et al. (2012) Table S3 shows men slowing more than women in the heat: at + 25 °C the model reads men's slower groups low (men's median 11.9% vs 14.76% + observed, Q3 12.08% vs 16.10%) and women's high (women's median 12.08% vs + 9.27%, Q1 12.08% vs 8.16%). + ### Breaking - Removed `CameronPredictor::CAMERON_A`, `CAMERON_B` and `CAMERON_C`. They described the wrong formula, and keeping them would suggest they still drive the diff --git a/README.md b/README.md index 333ed90..89217c9 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,8 @@ simplified WBGT for humidity, NCAA standards for altitude). São Paulo (760 m) gets ~1.06%. - **Heat**: a 60-minute baseline `4.3 · ((T − 15) / 10)^1.5` (0% at 15 °C, 1.52% at 20 °C, 4.3% at 25 °C, 7.9% at 30 °C; extrapolated to 12.16% at - 35 °C and 17.0% at 40 °C, capped there), stored as points every 2.5 °C, then + 35 °C and capped there, so 35–40 °C and hotter all read like 35 °C), stored + as points every 2.5 °C, then scaled by effort duration: 0.5× up to 30 min, 1.0× at 60 min, 1.76× at 3 h, 2.81× at 4 h and beyond (linear in between, so 1.38× at 2 h). The exponent and the 3 h / 4 h points are fitted to El Helou et al. (2012, Table S3: eight @@ -67,17 +68,19 @@ simplified WBGT for humidity, NCAA standards for altitude). | 25 °C | 2.15 | 4.3 | 5.93 | 7.57 | 12.08 | 12.08 | | 30 °C | 3.95 | 7.9 | 10.9 | 13.9 | 22.2 | 22.2 | | 35 °C | 6.08 | 12.16 | 16.78 | 21.4 | 34.17 | 34.17 | -| 40 °C | 8.5 | 17.0 | 23.46 | 29.92 | 47.77 | 47.77 | +| 40 °C | 6.08 | 12.16 | 16.78 | 21.4 | 34.17 | 34.17 | | 30 °C at | Effective temperature | 60 min | 4 h | | --- | --- | --- | --- | | 30% RH | 26.69 °C | 5.46% | 15.34% | | 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% | | 70% RH | 33.07 °C | 10.46% | 29.39% | -| 90% RH | 35.94 °C | 13.04% | 36.64% | +| 90% RH | 35.94 °C (capped at 35 °C) | 12.16% | 34.17% | Above ~25 °C the numbers are extrapolations of the fitted curve: the marathon -studies behind it have no data there (El Helou's hottest race was 25.2 °C). +studies behind it have no data there (El Helou's hottest race was 25.2 °C). The +cap at 35 °C is a deliberate choice for the same reason: the uncapped curve gave +47.77% for 4 h at 40 °C. ```ruby # Calculate penalty for 25°C and 2000m altitude (Defaults to 60-min effort) @@ -91,8 +94,8 @@ penalty = calc.calculate_penalty(temperature: 25, altitude: 2000) calc.calculate_penalty(temperature: 80, temperature_unit: :f) # => { total_penalty_percent: 5.44, ... } -# Humidity: 30 °C at 90% hits like 35.94 °C at 50% -calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] # => 13.04 +# Humidity: 30 °C at 90% hits like 35.94 °C at 50% (which reads like the 35 °C cap) +calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] # => 12.16 calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] # => 35.94 calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] # => 11.07 diff --git a/lib/calcpace/data/environmental_factors.yml b/lib/calcpace/data/environmental_factors.yml index a56a308..389f1e2 100644 --- a/lib/calcpace/data/environmental_factors.yml +++ b/lib/calcpace/data/environmental_factors.yml @@ -88,8 +88,13 @@ altitude: # group (2^1.497 = 2.82). # - anchor base(25) = 4.3: kept from the original model; it is the only value # available for short efforts and has no direct published source. -# - 30 °C and above: EXTRAPOLATION of the same law. The marathon data stop at +# - 27.5-35 °C: EXTRAPOLATION of the same law. The marathon data stop at # ~25 °C (El Helou's hottest race: 25.2 °C). +# - Above 35 °C: CAPPED at the 35 °C value (12.16), a deliberate choice, not +# data. Everything beyond 25.2 °C is extrapolation, and the uncapped law +# (14.51 at 37.5 °C, 17.0 at 40 °C) gave 47.77% for 4 h at 40 °C. 37.5 °C and +# 40 °C therefore read like 35 °C, and anything hotter is clamped to the last +# point as before. heat: ideal_range_celsius: [10.0, 15.0] data_points: @@ -101,6 +106,6 @@ heat: 27.5: 6.01 # EXTRAPOLATION from here on 30: 7.9 # For 3h = 13.9%, for 4h = 22.2% 32.5: 9.95 - 35: 12.16 - 37.5: 14.51 - 40: 17.0 # capped here; for 4h = 47.77% + 35: 12.16 # For 3h = 21.4%, for 4h = 34.17%; the cap starts here + 37.5: 12.16 # CAP: same as 35 °C (uncapped law: 14.51) + 40: 12.16 # CAP: same as 35 °C (uncapped law: 17.0); clamped above diff --git a/lib/calcpace/environmental_adjuster.rb b/lib/calcpace/environmental_adjuster.rb index 010da85..e7081ae 100644 --- a/lib/calcpace/environmental_adjuster.rb +++ b/lib/calcpace/environmental_adjuster.rb @@ -53,7 +53,7 @@ module EnvironmentalAdjuster # temperature, both are given, or either is given without a temperature # # @example - # calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] #=> 13.04 + # calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] #=> 12.16 # calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] #=> 35.94 # calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] #=> 11.07 def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil, diff --git a/test/calcpace/test_environmental_adjuster.rb b/test/calcpace/test_environmental_adjuster.rb index c9e1c1b..a1381aa 100644 --- a/test/calcpace/test_environmental_adjuster.rb +++ b/test/calcpace/test_environmental_adjuster.rb @@ -146,15 +146,23 @@ def test_heat_base_points_follow_the_el_helou_exponent points = EnvironmentalAdjuster::FACTORS.fetch('heat').fetch('data_points') points.each do |temperature, base| - expected = (4.3 * (((temperature - 15) / 10.0)**exponent.round(2))).round(2) + # Cap A: flat from 35 °C (beyond El Helou's hottest race, 25.2 °C) + capped = [temperature, 35].min + expected = (4.3 * (((capped - 15) / 10.0)**exponent.round(2))).round(2) assert_in_delta expected, base, 1e-9, "#{temperature} °C" end end + def test_heat_base_is_flat_from_thirty_five + [35, 36.3, 37.5, 40, 45].each do |temperature| + assert_equal 12.16, @calc.calculate_penalty(temperature: temperature, time_seconds: 3600)[:factors][:heat] + end + end + def test_heat_base_points_track_the_power_law_within_a_tenth exponent = el_helou_exponent.round(2) - (1500..4000).each do |hundredths| + (1500..3500).each do |hundredths| temperature = hundredths / 100.0 law = 4.3 * (((temperature - 15) / 10.0)**exponent) base = @calc.calculate_penalty(temperature: temperature, time_seconds: 3600)[:factors][:heat] @@ -187,9 +195,9 @@ def test_duration_factor_is_continuous_and_monotonic end def test_extreme_heat_for_four_hours - # 35 °C / 4 h: 12.16 * 2.81 = 34.17%; 40 °C / 4 h: 17.0 * 2.81 = 47.77% (extrapolated) + # 35 °C / 4 h: 12.16 * 2.81 = 34.17%; 40 °C is capped at the 35 °C value assert_equal 34.17, @calc.calculate_penalty(temperature: 35, time_seconds: 14_400)[:factors][:heat] - assert_equal 47.77, @calc.calculate_penalty(temperature: 40, time_seconds: 14_400)[:factors][:heat] + assert_equal 34.17, @calc.calculate_penalty(temperature: 40, time_seconds: 14_400)[:factors][:heat] end # --- altitude curve (v1.19.0) --- @@ -257,12 +265,12 @@ def test_heat_at_thirty_five_is_worse_than_at_thirty assert_equal 12.16, at35 end - def test_heat_at_forty_is_worse_than_at_thirty_five - assert_equal 17.0, @calc.calculate_penalty(temperature: 40, time_seconds: 3600)[:factors][:heat] + def test_heat_at_forty_reads_like_thirty_five + assert_equal 12.16, @calc.calculate_penalty(temperature: 40, time_seconds: 3600)[:factors][:heat] end - def test_heat_is_capped_at_forty - assert_equal 17.0, @calc.calculate_penalty(temperature: 45, time_seconds: 3600)[:factors][:heat] + def test_heat_above_forty_stays_capped + assert_equal 12.16, @calc.calculate_penalty(temperature: 45, time_seconds: 3600)[:factors][:heat] end # --- humidity / dew point (effective temperature) --- @@ -321,12 +329,20 @@ def test_reference_humidity_constant end def test_humid_air_raises_the_effective_temperature_and_the_penalty - # WBGT(30 °C, 90%) = WBGT(35.94 °C, 50%) → base 12.16 + 0.94/2.5 × 2.35 = 13.04 + # WBGT(30 °C, 70%) = WBGT(33.0746 °C, 50%) → base 9.95 + 0.5746/2.5 × 2.21 = 10.46 + result = @calc.calculate_penalty(temperature: 30, humidity: 70, time_seconds: 3600) + + assert_in_delta 33.07, result[:factors][:effective_temperature_celsius], 0.01 + assert_equal 10.46, result[:factors][:heat] + assert_equal 10.46, result[:total_penalty_percent] + end + + def test_very_humid_air_reaches_the_heat_cap + # WBGT(30 °C, 90%) = WBGT(35.94 °C, 50%), above 35 °C → capped base 12.16 result = @calc.calculate_penalty(temperature: 30, humidity: 90, time_seconds: 3600) assert_in_delta 35.94, result[:factors][:effective_temperature_celsius], 0.01 - assert_equal 13.04, result[:factors][:heat] - assert_equal 13.04, result[:total_penalty_percent] + assert_equal 12.16, result[:factors][:heat] end def test_dry_air_lowers_the_effective_temperature_and_the_penalty @@ -340,7 +356,7 @@ def test_dry_air_lowers_the_effective_temperature_and_the_penalty def test_humidity_scales_with_duration_like_temperature result = @calc.calculate_penalty(temperature: 30, humidity: 90, time_seconds: 7200) - assert_equal (13.04 * @calc.send(:duration_factor, 7200)).round(2), result[:factors][:heat] + assert_equal (12.16 * @calc.send(:duration_factor, 7200)).round(2), result[:factors][:heat] end def test_humid_air_can_lift_an_ideal_temperature_out_of_the_ideal_range @@ -413,8 +429,8 @@ def test_adjust_and_normalize_forward_humidity adjusted = @calc.adjust_time(3600, temperature: 30, humidity: 90) normalized = @calc.normalize_time(3600, temperature: 30, humidity: 90) - assert_equal 13.04, adjusted[:penalty_percent] - assert_equal 13.04, normalized[:penalty_percent] + assert_equal 12.16, adjusted[:penalty_percent] + assert_equal 12.16, normalized[:penalty_percent] assert_in_delta 35.94, adjusted[:factors][:effective_temperature_celsius], 0.01 end From 0b0a3250ec3a09c036fc440b5e7734211b2c5b51 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:28:10 -0300 Subject: [PATCH 25/34] fix!: reject clocks with seconds or minutes of 60 and above check_time accepted any two digits per field, so '05:99' or '1:60:00' passed validation and were silently converted to the wrong number of seconds. Seconds must now be below 60, and so must minutes when an hour field is present. MM:SS still counts minutes past the hour ('75:00' is 75 minutes), matching the padded paces track_splits emits ('66:33'). BREAKING CHANGE: invalid clocks now raise InvalidTimeFormatError. --- lib/calcpace/checker.rb | 23 +++++++++++++++-------- test/calcpace/test_checker.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/lib/calcpace/checker.rb b/lib/calcpace/checker.rb index ee43654..d1f4fc8 100644 --- a/lib/calcpace/checker.rb +++ b/lib/calcpace/checker.rb @@ -32,12 +32,16 @@ def check_positive(number, name = 'Input') raise Calcpace::NonPositiveInputError, "#{name} must be a finite positive number" end - # Validates that a time string is in the correct format + # Validates that a time string is a well-formed clock # # Accepted formats: - # - HH:MM:SS (hours:minutes:seconds) - e.g., "01:30:45" - # - MM:SS (minutes:seconds) - e.g., "05:30" - # - H:MM:SS or M:SS (single digit hours/minutes) - e.g., "1:30:45" + # - H:MM:SS / HH:MM:SS (hours:minutes:seconds) - e.g., "1:30:45", "01:30:45" + # - M:SS / MM:SS (minutes:seconds) - e.g., "5:30", "05:30" + # + # Seconds must be below 60 in both formats, and so must minutes once an + # hour field is present ("1:60:00" is not a clock). MM:SS keeps counting + # minutes past the hour, as the padded paces from track_splits do: "75:00" + # is a valid 75-minute time. # # @param time_string [String] the time string to validate # @raise [Calcpace::InvalidTimeFormatError] if format is invalid @@ -46,14 +50,17 @@ def check_positive(number, name = 'Input') # @example # check_time('01:30:45') #=> nil (valid) # check_time('5:30') #=> nil (valid) + # check_time('75:00') #=> nil (valid, 75 minutes) + # check_time('05:99') #=> raises InvalidTimeFormatError + # check_time('1:60:00') #=> raises InvalidTimeFormatError # check_time('invalid') #=> raises InvalidTimeFormatError def check_time(time_string) - # Check if string is valid and matches expected patterns return if time_string.is_a?(String) && - (time_string =~ /\A\d{1,2}:\d{2}:\d{2}\z/ || - time_string =~ /\A\d{1,2}:\d{2}\z/) + (time_string.match?(/\A\d{1,2}:[0-5]\d:[0-5]\d\z/) || + time_string.match?(/\A\d{1,2}:[0-5]\d\z/)) raise Calcpace::InvalidTimeFormatError, - 'It must be a valid time in the XX:XX:XX or XX:XX format' + 'It must be a valid time in the XX:XX:XX or XX:XX format ' \ + '(seconds below 60, and minutes too when hours are given)' end end diff --git a/test/calcpace/test_checker.rb b/test/calcpace/test_checker.rb index 66312e9..0594317 100644 --- a/test/calcpace/test_checker.rb +++ b/test/calcpace/test_checker.rb @@ -28,4 +28,35 @@ def test_check_time assert_raises(Calcpace::InvalidTimeFormatError) { @calc.check_time('1-2-3') } assert_nil @calc.check_time('00:00:00') end + + def test_check_time_accepts_valid_clocks + %w[05:00 5:00 05:59 1:05:00 01:05:00 23:59:59 99:59:59 0:00].each do |time| + assert_nil @calc.check_time(time), "expected #{time} to be valid" + end + end + + # MM:SS keeps counting minutes past the hour: '75:00' is a 75-minute run and + # track_splits itself emits paces like '66:33' in the padded format + def test_check_time_accepts_minutes_past_the_hour_in_mm_ss + %w[60:00 66:33 75:00 99:59].each do |time| + assert_nil @calc.check_time(time), "expected #{time} to be valid" + end + end + + def test_check_time_rejects_seconds_of_sixty_or_more + %w[05:60 05:99 19:99 1:00:60 01:30:99].each do |time| + assert_raises(Calcpace::InvalidTimeFormatError, "expected #{time} to be invalid") { @calc.check_time(time) } + end + end + + def test_check_time_rejects_minutes_of_sixty_or_more_when_hours_are_given + %w[1:60:00 01:75:00 0:99:59].each do |time| + assert_raises(Calcpace::InvalidTimeFormatError, "expected #{time} to be invalid") { @calc.check_time(time) } + end + end + + def test_invalid_clock_is_rejected_by_public_methods + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.checked_pace('00:19:99', 5) } + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.checked_velocity('1:60:00', 10) } + end end From ad3caa3ec66a11610e91c88c356d09dd574e7b74 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:32:04 -0300 Subject: [PATCH 26/34] docs: bring README in line with the 2.0.0 behaviour Document the stricter clock validation and non-finite rejection in the errors section, show the real equivalent_performance output, list the new capabilities in the intro, and state the Ruby versions CI actually tests. --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e3c6d5e..eacf947 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Calcpace [![Gem Version](https://badge.fury.io/rb/calcpace.svg)](https://badge.fury.io/rb/calcpace) -A Ruby gem for runners: pace, time, and distance calculations, unit conversions, race predictions, GPS track analysis, age grading, VO2max estimation, and training zones. +A Ruby gem for runners: pace, time, and distance calculations, unit conversions, race predictions (including personalized ones), GPS track analysis with grade-adjusted pace, heat, humidity and altitude adjustments, age grading, VO2max estimation and norms, and training zones. > **See it in action:** [calcpace.app](https://calcpace.app) — free running calculators, race predictors and a training log, all powered by this gem. ## Installation ```ruby -gem 'calcpace', '~> 1.18.1' +gem 'calcpace', '~> 2.0' ``` ## Usage @@ -27,7 +27,7 @@ calc.pace(3665, 12) # => 305.4 (time / distance) calc.time(210, 12) # => 2520 (pace × distance) calc.distance(9660, 120) # => 80.5 (velocity × time) -# Clocktime input/output (HH:MM:SS or MM:SS) +# Clocktime input/output (HH:MM:SS or MM:SS; seconds below 60, and minutes too when hours are given) calc.clock_pace('01:00:00', 10) # => "00:06:00" calc.clock_time('00:05:31', 12.6) # => "01:09:30" calc.checked_distance('01:21:32', '00:06:27') # => 12.64 @@ -206,7 +206,7 @@ calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k') calc.predict_time_clock('5k', '00:20:00', 'marathon') # => "03:11:49" calc.predict_pace_clock('5k', '00:20:00', 'marathon') # => "00:04:32" calc.equivalent_performance('10k', '00:42:00', '5k') -# => { time: 1208.67, time_clock: "00:20:08", pace: 241.73, pace_clock: "00:04:01" } +# => { time: 1208.6727903498331, time_clock: "00:20:08", pace: 241.73455806996662, pace_clock: "00:04:01" } ``` **Cameron formula** (Dave Cameron's velocity-ratio model, fitted to world bests from @@ -943,8 +943,11 @@ call without it returns exactly what it returned before. All errors inherit from `Calcpace::Error`: -- `Calcpace::NonPositiveInputError` — numeric input is zero or negative -- `Calcpace::InvalidTimeFormatError` — time string not in `HH:MM:SS` or `MM:SS` format +- `Calcpace::NonPositiveInputError` — numeric input is zero, negative, NaN or infinite +- `Calcpace::InvalidTimeFormatError` — time string not in `HH:MM:SS` or `MM:SS` format, + or not a valid clock: seconds must be below 60, and so must minutes when hours are + given (`'05:99'` and `'1:60:00'` raise). `MM:SS` keeps counting minutes past the + hour, so `'75:00'` is 75 minutes - `Calcpace::UnsupportedUnitError` — unknown conversion (`convert`) or unknown `unit:` / `distance_unit:` keyword - `Calcpace::InvalidDataError` — the bundled data table failed its load-time @@ -961,7 +964,7 @@ unknown race names, unsupported age-grading distances, and invalid `age` / `sex` bundle exec rake ``` -Requires Ruby >= 3.2.0. Tested with Ruby 3.2, 3.3, 3.4, and 4.0. +Requires Ruby >= 3.3.0. Tested with Ruby 3.3, 3.4, and 4.0. ## Contributing From d059832b37b0ded6125aa52bf622415a5503cde1 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:32:04 -0300 Subject: [PATCH 27/34] chore: release 2.0.0 Bump the version, fold the merged phases' changelog entries into one deduplicated 2.0.0 section, and list the new capabilities in the gemspec summary and description. --- CHANGELOG.md | 397 +++++++++++++++++++++------------------- calcpace.gemspec | 14 +- lib/calcpace/version.rb | 2 +- 3 files changed, 214 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d287ea..af5e772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,81 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added -- **Humidity in the heat penalty.** `calculate_penalty` — and therefore - `adjust_time`, `normalize_time`, `predict_time_adjusted` and - `predict_time_cameron_adjusted`, which forward their options — accepts - `humidity:` (relative humidity, 0–100 %) or `dew_point:` (in - `temperature_unit`). 30 °C dry and 30 °C at 80% (typical of coastal Brazil) - used to get the same penalty. The temperature is replaced by an effective - temperature: the air temperature that, at `REFERENCE_HUMIDITY` (50%), has the - same simplified WBGT (Australian Bureau of Meteorology: - `WBGT = 0.567·Ta + 0.393·e + 3.94`, `e` = vapour pressure in hPa) as the real - temperature and humidity, solved exactly (by bisection). The existing heat - curve and the `base(temperature) × duration_factor(seconds)` shape are - untouched; the curve is read at the unrounded effective temperature, so - `humidity: 50` gives exactly the temperature-only numbers. 50% is the - humidity at which that WBGT equals the air temperature between 20 °C and - 35 °C (51–56%), i.e. how the temperature-only points already read. `factors` - gains `:effective_temperature_celsius` (rounded to 2 decimals) when humidity - or dew point is given. `ArgumentError` for humidity outside 0–100, NaN, - Complex or non-numeric; a dew point above the temperature, below −100 °C or - not a finite real number; both keywords together; or either without a - finite temperature. The marathon outcome studies (El Helou 2012, Vihma 2010) - found no humidity effect independent of temperature, so the size of this - adjustment rests on the WBGT index, not on race data. +## [2.0.0] - 2026-10-02 - | 30 °C at | Effective temperature | 60 min | 4 h | - | --- | --- | --- | --- | - | 30% RH | 26.69 °C | 5.46% | 15.34% | - | 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% | - | 70% RH | 33.07 °C | 10.46% | 29.39% | - | 90% RH | 35.94 °C (capped at 35 °C) | 12.16% | 34.17% | +A major release. Four physiological models gave unrealistic numbers and now +give different ones (Cameron prediction, altitude, race splits, heat), the +marathon pace band and age grading move to their proper sources, input +validation is stricter, and there are new personalized predictions, +humidity, grade-adjusted pace and VO2max norms. Method names, signatures and +return shapes are unchanged except where listed under Breaking; the +`environmental_factors.yml` keys are unchanged. + +### Breaking +- **Invalid clocks raise.** `check_time` — and so every method that validates + a time string with it (`checked_*`, `estimate_vo2max`, `age_grade`, + `stride_length`, the personalized predictors…) — accepted any two digits per + field, so `'05:99'` or `'1:60:00'` passed and became the wrong number of + seconds. Seconds must now be below 60, and so must minutes when an hour + field is present; anything else raises `Calcpace::InvalidTimeFormatError`. + `MM:SS` still counts minutes past the hour (`'75:00'` is 75 minutes), as the + padded paces `track_splits` emits (`'66:33'`) always have. +- **Cameron predictions are limited to 100 km.** `predict_time_cameron`, + `predict_time_cameron_clock`, `predict_pace_cameron`, + `predict_pace_cameron_clock` and `predict_time_cameron_adjusted` raise + `ArgumentError` when either distance exceeds `CAMERON_MAX_DISTANCE_KM` + (100 km, which keeps the standard `'100k'` race usable). Cameron's model is + fitted from 800 m to the marathon and its `f(d)` crosses zero near 445 km: + without the limit a 500 km target got a negative time. +- **Removed `CameronPredictor::CAMERON_A`, `CAMERON_B` and `CAMERON_C`.** They + described the wrong formula (see Changed). The model's constants are now + `CAMERON_CONSTANT`, `CAMERON_LINEAR_COEFFICIENT`, `CAMERON_POWER_COEFFICIENT` + and `CAMERON_POWER_EXPONENT`. +- **`AgeGrading::WMA_DATA` lost its track keys and changed its age range.** + Each sex now holds only the road distances the gem grades — `"5000"`, + `"10000"`, `"21097"` and `"42195"`; code that read `WMA_DATA["M"]["1500"]` + (or `"3000"`) gets `nil` / `KeyError`. Its factor tables run from age 18 to + 100 (they were 30–110). `table_version` is now + `"MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"` (was + `"WMA_2023_ONE_YEAR_FACTORS_V1"`), and the data files were renamed to + `lib/calcpace/data/mldr_2025_road.yml` and + `lib/calcpace/data/mldr_2025_road_open_standards.yml`. `DATA_PATH`, + `OPEN_STANDARDS_DATA_PATH`, `WMA_DATA`, `OPEN_STANDARDS_DATA` and + `TABLE_VERSION` keep their names. ### Changed (numbers) -Four models produced unrealistic numbers. The method names, signatures, return -shapes and the structure of `environmental_factors.yml` are unchanged; only the -values they return move, except that Cameron predictions now reject distances -above 100 km (see Breaking). - -- **Cameron prediction now uses Dave Cameron's actual model.** The previous - constants (`a + b·e^(−d/c)` with a = 0.000495, b = 0.000985, c = 1.4485) were - not Cameron's formula and were far more optimistic than Riegel for the - marathon, while the real model is more conservative. `predict_time_cameron` - and friends now use Cameron's velocity-ratio function, with distances in - metres as in his own metric version (t-and-f mailing list, 20 Jun 2001) and - the had2know.org calculator: - `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905`, - `T2 = T1 · (D2/D1) · f(D1)/f(D2)`. Distances are still passed in km or as race - names. The model is fitted from 800 m to the marathon and f(d) crosses zero - near 445 km, so distances above `CAMERON_MAX_DISTANCE_KM` (100 km, which keeps - the standard `'100k'` race usable) now raise `ArgumentError` on either end, - in every Cameron method. Without that limit the formula returns a negative - time for a 500 km target, and sources from 400 km up give nonsense or raise - from the clock conversion. +- **Cameron prediction uses Dave Cameron's actual model.** The previous + constants (`a + b·e^(−d/c)` with a = 0.000495, b = 0.000985, c = 1.4485) + were not Cameron's formula and were far more optimistic than Riegel for the + marathon, while the real model is more conservative. The Cameron methods now + use his velocity-ratio function with distances in metres, as in his own + metric version (t-and-f mailing list, 20 Jun 2001) and the had2know.org + calculator: `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905`, + `T2 = T1 · (D2/D1) · f(D1)/f(D2)`. Distances are still passed in km or as + race names. - **Altitude no longer jumps at 914 m and no longer stops at 2438 m.** The - threshold moves from 914.4 m to 300 m with a new `300: 0.0` point, so the - penalty ramps linearly up to the first NCAA point (914.4 m → 1.41%) instead of - jumping from 0% at 914 m to 1.41% at 915 m. The NCAA points are unchanged. - Above 2438.4 m, where everything used to be capped at 5.90%, three points are - extrapolated from a quadratic fit to the NCAA table - (`p = 0.3647·x² + 1.9482·x`, `x = km − 0.3`): 3000 m → 7.92%, - 3500 m → 9.97%, 4000 m → 12.2% (capped there). The redundant `0: 0.0` point is - gone; the YAML keys are the same. -- **Negative/positive race splits are ±1% per half instead of ±4%.** A 3:00:00 - marathon with `strategy: :negative` used to go through halfway in 1:33:36 (a - 7-minute negative split); it now splits 1:30:54 + 1:29:06. -- **Heat above 30 °C keeps increasing.** 35 °C and 40 °C used to get the same - penalty as 30 °C. The base curve now continues to 35 °C (12.16% for 60 - minutes, extrapolating the fitted law below) and is capped there: 35–40 °C - and hotter all read like 35 °C. The ideal range is unchanged. -- **Heat base curve and duration scaling are fitted to marathon data.** The - 60-minute base was 2.8 / 4.3 / 6.5% at 20 / 25 / 30 °C (roughly linear) and - the duration factor 1.0× (60 min) → 3.0× (3 h) → 4.5× (4 h), with the 3 h - point justified by Ely 2007 percentages for a 3 h runner (~9% at 20 °C, - ~12% at 25 °C) that the paper's abstract does not contain. Both are now - fitted to El Helou et al. (2012, PLoS One 7(5):e37407, Table S3; 1.79 M - finishers of six majors, 2001–2010): + penalty starts at 300 m (new `300: 0.0` point) and ramps linearly to the + first NCAA point (914.4 m → 1.41%) instead of jumping from 0% at 914 m to + 1.41% at 915 m. The NCAA points up to 2438.4 m (5.90%) are unchanged. Above + it, where everything used to be capped at 5.90%, three points come from a + quadratic fit to the NCAA table (`p = 0.3647·x² + 1.9482·x`, + `x = km − 0.3`): 3000 m → 7.92%, 3500 m → 9.97%, 4000 m → 12.2% (capped + there). The redundant `0: 0.0` point is gone. +- **Negative and positive race splits are ±1% per half instead of ±4%.** A + 3:00:00 marathon with `strategy: :negative` used to go through halfway in + 1:33:36 (a 7-minute negative split); it now splits 1:30:54 + 1:29:06. +- **Heat: base curve and duration factor fitted jointly to marathon data, and + capped at 35 °C.** The 60-minute base was 2.8 / 4.3 / 6.5% at + 20 / 25 / 30 °C, flat from 30 °C up (35 °C and 40 °C read like 30 °C), and + the duration factor was 1.0× (60 min) → 3.0× (3 h) → 4.5× (4 h), its 3 h + point justified by Ely 2007 percentages that the paper's abstract does not + contain. Both are now fitted to El Helou et al. (2012, PLoS One + 7(5):e37407, Table S3; 1.79 M finishers of six majors, 2001–2010): 1. speed loss at 15, 20 and 25 °C, straight line between the table's points (each group's optimum −10 … +20 °C); 2. time penalty against 15 °C, where the gem's curve is zero: @@ -89,21 +84,21 @@ above 100 km (see Breaking). 3. **base shape**: P25/P20 is 2.70–2.97 in every group, so `P ∝ (T − 15)^p` with `p = log2(P25/P20)`; the sex-weighted mean (each sex half the weight) is 1.497 → **1.5**. Base = `4.3 · ((T − 15)/10)^1.5`, keeping the - original 25 °C / 60-minute anchor of 4.3% (the only value available for a - 60-minute effort), stored every 2.5 °C from 15 to 40 °C (linear - interpolation stays within 0.08 points of the curve): 0, 0.54, 1.52, - 2.79, 4.3, 6.01, 7.9, 9.95, 12.16, then 12.16 and 12.16 at 37.5 and - 40 °C. Above 25 °C this is an extrapolation (El Helou's hottest race was - 25.2 °C), so the curve is deliberately capped at 35 °C: the uncapped law - (14.51 at 37.5 °C, 17.0 at 40 °C) gave 47.77% for 4 h at 40 °C; + 25 °C / 60-minute anchor of 4.3%, stored every 2.5 °C from 15 to 40 °C + (linear interpolation stays within 0.08 points of the curve): 0, 0.54, + 1.52, 2.79, 4.3, 6.01, 7.9, 9.95, 12.16, then 12.16 and 12.16 at 37.5 + and 40 °C. **Capped at 35 °C**: 35–40 °C and hotter all read like 35 °C, + because above 25 °C the curve is an extrapolation (El Helou's hottest + race was 25.2 °C) and the uncapped law (14.51 at 37.5 °C, 17.0 at 40 °C) + gave 47.77% for 4 h at 40 °C; 4. ratio = P ÷ base(T); finish time = 42195 m ÷ the group's speed at its optimum; 5. **duration factor**: weighted least squares over the 15 ratios (men P1 at 25 °C is beyond the table), each sex half the weight, 0.5× (≤30 min) and 1.0× (60 min) kept, 180 and 240 min free, flat after 240: 1.761 / 2.814 → - **1.76× at 3 h, 2.81× at 4 h** (1.38× at 2 h on the straight line). A free - 150-min point cut the weighted residual by 1%; a free 210-min point made - the curve non-monotonic (2.97 > 2.73 at 240). Neither was kept. + **1.76× at 3 h, 2.81× at 4 h** (1.38× at 2 h on the straight line). A + free 150-min point cut the weighted residual by 1%; a free 210-min point + made the curve non-monotonic (2.97 > 2.73 at 240). Neither was kept. | Group | Finish | loss@15 | loss@20 | loss@25 | P20 | P25 | P25/P20 | ratio @20 | ratio @25 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | @@ -116,20 +111,18 @@ above 100 km (see Breaking). | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 2.97 | 3.57 | 3.74 | | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 2.78 | 2.33 | 2.29 | - Losses and P in %. With the new base, each group's ratio is almost the same + Losses and P in %. With the new base each group's ratio is almost the same at 20 and 25 °C, so `base(T) × duration_factor(seconds)` fits; the weighted residual in penalty points drops from 18.3 (linear base, 1.24×/2.18×) to - 8.8. What remains is mostly sex: with no sex input, men's slower groups are - under-read at 25 °C (median 11.9% vs 14.76%) and women's over-read (median - 12.08% vs 9.27%) — see Known limitations below. Ely et al. (2007) remains a qualitative source (top men - 1.7 / 2.5 / 3.3 / 4.5% off the course record across WBGT 5–10 … 20–25 °C, - i.e. +2.8 points); the model gives a 2:10 effort 4.03% at 22.5 °C against - 0% at 7.5 °C, a little above that for elite runners. The duration points - live in `EnvironmentalAdjuster::HEAT_DURATION_FACTORS`; - `duration_factor(time_seconds)` and the `environmental_factors.yml` keys are - unchanged. - - | Heat penalty (%), 1.18.1 → now | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | + 8.8, and what remains is mostly sex (see Known limitations). Ely et al. + (2007) stays a qualitative source (top men 1.7 / 2.5 / 3.3 / 4.5% off the + course record across WBGT 5–10 … 20–25 °C, i.e. +2.8 points); the model + gives a 2:10 effort 4.03% at 22.5 °C against 0% at 7.5 °C, a little above + that for elite runners. The duration points live in the new + `EnvironmentalAdjuster::HEAT_DURATION_FACTORS`; `duration_factor(time_seconds)` + is unchanged. + + | Heat penalty (%), 1.18.1 → 2.0.0 | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | | --- | --- | --- | --- | --- | --- | --- | | 20 °C | 1.4 → 0.76 | 2.8 → 1.52 | 5.6 → 2.1 | 8.4 → 2.68 | 12.6 → 4.27 | 12.6 → 4.27 | | 25 °C | 2.15 → 2.15 | 4.3 → 4.3 | 8.6 → 5.93 | 12.9 → 7.57 | 19.35 → 12.08 | 19.35 → 12.08 | @@ -143,14 +136,14 @@ above 100 km (see Breaking). while the VDOT marathon prediction for VO2max 50 is 3:10:39, 4:31/km. The fast end now comes from `predict_time_from_vo2max(vo2max, 'marathon')` (0.800–0.849 of VO2max across VO2max 10–100; 0.805 at 30, 0.830 at 70); the - slow end stays at 75%. The prediction covers VO2max 10–100, and beyond it - the race-pace fraction of the nearest bound is used, so `training_paces` - still accepts any positive VO2max. `TRAINING_INTENSITIES` stays all-numeric + slow end stays at 75%. Beyond VO2max 10–100 the race-pace fraction of the + nearest bound is used, so `training_paces` still accepts any positive + VO2max. `TRAINING_INTENSITIES` stays all-numeric (`marathon: { low: 0.75, high: 0.84 }`, the nominal upper bound); the new `PREDICTED_RACE_PACE_ZONES` (`%i[marathon]`) names the zones whose fast end - is the predicted race pace. As a result the marathon and threshold bands no - longer overlap below VO2max ~69.5 (the threshold band starts at 0.83): M - pace is slower than T pace, as in Daniels. At VO2max 70 they touch (3:24). + is the predicted race pace. The marathon and threshold bands no longer + overlap below VO2max ~69.5 (the threshold band starts at 0.83): M pace is + slower than T pace, as in Daniels. At VO2max 70 they touch (3:24). | VO2max | M band before | M band after | Predicted marathon pace | | --- | --- | --- | --- | @@ -160,7 +153,36 @@ above 100 km (see Breaking). | 60 | 4:11–3:49/km | 4:11–3:52/km | 3:52/km | | 70 | 3:41–3:22/km | 3:41–3:24/km | 3:24/km | -| Case | Before (1.18.1) | After | +- **Age grading uses the 2025 road tables.** Age factors and open standards + come from Alan Jones' 2025 road age-grading tables, approved on 2025-01-10 + by the USATF Masters Long Distance Running Council + ([source spreadsheets](https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files): + `MaleRoadStd2025.xlsx`, `FemaleRoadStd2025.xlsx`). The previous data, + despite the `wma_2023_road.yml` name, came from the WMA 2023 **track and + field** tables: track open standards (5000 m 12:35 / 14:06, 10 000 m + 26:11 / 29:01), an outdated women's marathon standard (2:14:04) and no + factors under age 30, so road age grades were off by about 1–3%. New open + standards — men: 5K 12:49, 10K 26:24, half 57:31, marathon 2:00:35; women: + 5K 13:54, 10K 28:46, half 1:02:52, marathon 2:09:56. There is one factor + per year of age from 18 to 100: runners under 30 get the table's real + factors instead of 1.0 (a male 18-year-old at 5K: 0.9995; a female + 30-year-old at 5K: 0.9959), and ages 101 and over use the age-100 factor. + Category labels are unchanged. + + | Case | 1.18.1 (WMA 2023 track) | 2.0.0 (2025 road) | Official 2025 | + | --- | --- | --- | --- | + | Male 40, marathon 3:30:00 | 58.8% (factor 0.9804) | 58.7% (0.9783) | 58.7% | + | Female 50, marathon 4:00:00 | 62.7% (0.8915) | 60.2% (0.8998) | 60.2% | + | Female 30, 5K 25:00 | 56.4% (1.0) | 55.8% (0.9959) | 55.8% | + | Male 60, half 1:50:00 | 63.3% (0.8264) | 64.7% (0.8082) | 64.7% | + | Male 55, 10K 45:00 | 69.0% (0.8438) | 68.9% (0.8511) | 68.9% | + + "Official" is age standard / time from the spreadsheets' `AgeStdSec` sheet, + at one decimal. + +Summary of the other models: + +| Case | 1.18.1 | 2.0.0 | | --- | --- | --- | | Cameron 10K 42:00 → marathon | 02:57:34 | 03:16:46 (Riegel 03:13:12) | | Cameron 5K 20:00 → marathon | 02:59:25 | 03:15:11 (Riegel 03:11:49) | @@ -173,78 +195,44 @@ above 100 km (see Breaking). | Altitude 3600 m | 5.9% | 10.42% | | Heat 20 °C, 60 min | 2.8% | 1.52% | | Heat 30 °C, 60 min | 6.5% | 7.9% | -| Heat 35 °C, 60 min | 6.5% | 12.16% | -| Heat 40 °C, 60 min | 6.5% | 12.16% | -| Heat 25 °C, 2 h | 8.6% | 5.93% | -| Heat 25 °C, 3 h | 12.9% | 7.57% | -| Heat 25 °C, 4 h | 19.35% | 12.08% | +| Heat 35 °C / 40 °C, 60 min | 6.5% | 12.16% | +| Heat 25 °C, 2 h / 3 h / 4 h | 8.6% / 12.9% / 19.35% | 5.93% / 7.57% / 12.08% | | Heat 30 °C, 4 h | 29.25% | 22.2% | -| Heat 35 °C, 4 h | 29.25% | 34.17% | -| Heat 40 °C, 4 h | 29.25% | 34.17% | +| Heat 35 °C / 40 °C, 4 h | 29.25% | 34.17% | | Marathon band, VO2max 50 | 4:50–4:25/km | 4:50–4:31/km | | Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 | | Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 | -### Known limitations -- **The heat model has no sex term.** One curve serves everyone, and El Helou - et al. (2012) Table S3 shows men slowing more than women in the heat: at - 25 °C the model reads men's slower groups low (men's median 11.9% vs 14.76% - observed, Q3 12.08% vs 16.10%) and women's high (women's median 12.08% vs - 9.27%, Q1 12.08% vs 8.16%). - -### Breaking -- Removed `CameronPredictor::CAMERON_A`, `CAMERON_B` and `CAMERON_C`. They described - the wrong formula, and keeping them would suggest they still drive the - prediction. The new model's constants are `CAMERON_CONSTANT`, - `CAMERON_LINEAR_COEFFICIENT`, `CAMERON_POWER_COEFFICIENT` and - `CAMERON_POWER_EXPONENT`. -- Cameron predictions (`predict_time_cameron`, `_clock`, `predict_pace_cameron`, - `_clock`, `predict_time_cameron_adjusted`) raise `ArgumentError` when either - distance exceeds `CAMERON_MAX_DISTANCE_KM` (100 km). 1.18.1 accepted any - distance, but Cameron's model is only fitted up to the marathon and breaks - down past ~445 km. -### Breaking -- The public `AgeGrading::WMA_DATA` constant no longer has the track keys - (`"1500"`, `"3000"`): each sex now holds only the road distances the gem - grades — `"5000"`, `"10000"`, `"21097"` and `"42195"`. Code that read - `WMA_DATA["M"]["1500"]` (or `"3000"`) directly gets a `KeyError` / `nil`. - Its factor tables also start at age 18 and end at 100 (they were 30–110). - -### Changed -- **Age grading now uses the 2025 road tables.** Age factors and open - standards come from Alan Jones' 2025 road age-grading tables, approved on - 2025-01-10 by the USATF Masters Long Distance Running Council - ([source spreadsheets](https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files): - `MaleRoadStd2025.xlsx`, `FemaleRoadStd2025.xlsx`). The previous data, - despite the `wma_2023_road.yml` name, came from the WMA 2023 **track and - field** tables: track open standards (5000 m 12:35 / 14:06, 10 000 m - 26:11 / 29:01), an outdated women's marathon standard (2:14:04), and no - factors under age 30. Road age grades were off by about 1–3%. -- New open standards — men: 5K 12:49, 10K 26:24, half 57:31, marathon - 2:00:35; women: 5K 13:54, 10K 28:46, half 1:02:52, marathon 2:09:56. -- One factor per year of age from 18 to 100. Runners under 30 now get the - table's real factors instead of 1.0 (e.g. a male 18-year-old at 5K: 0.9995; - a female 30-year-old at 5K: 0.9959). The old table ran to 110; the 2025 - road tables end at 100, so ages 101 and over now use the age-100 factor. -- `table_version` is now `"MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"` (was - `"WMA_2023_ONE_YEAR_FACTORS_V1"`). The data files were renamed to - `lib/calcpace/data/mldr_2025_road.yml` and - `lib/calcpace/data/mldr_2025_road_open_standards.yml`; `DATA_PATH`, - `OPEN_STANDARDS_DATA_PATH`, `WMA_DATA`, `OPEN_STANDARDS_DATA` and - `TABLE_VERSION` keep their names and shapes (see Breaking for the keys - `WMA_DATA` lost). Category labels are unchanged. +### Added +- **Humidity in the heat penalty.** `calculate_penalty` — and therefore + `adjust_time`, `normalize_time`, `predict_time_adjusted` and + `predict_time_cameron_adjusted`, which forward their options — accepts + `humidity:` (relative humidity, 0–100 %) or `dew_point:` (in + `temperature_unit`). 30 °C dry and 30 °C at 80% (typical of coastal Brazil) + used to get the same penalty. The temperature is replaced by an effective + temperature: the air temperature that, at `REFERENCE_HUMIDITY` (50%), has + the same simplified WBGT (Australian Bureau of Meteorology: + `WBGT = 0.567·Ta + 0.393·e + 3.94`, `e` = vapour pressure in hPa) as the + real temperature and humidity, solved exactly by bisection. The heat curve + is read at the unrounded effective temperature, so `humidity: 50` gives + exactly the temperature-only numbers: 50% is the humidity at which that + WBGT equals the air temperature between 20 °C and 35 °C (51–56%), i.e. how + the temperature-only points already read. `factors` gains + `:effective_temperature_celsius` (rounded to 2 decimals) when humidity or + dew point is given. `ArgumentError` for humidity outside 0–100, NaN, + Complex or non-numeric; a dew point above the temperature, below −100 °C or + not a finite real number; both keywords together; or either without a + finite temperature. - | Case | Before (WMA 2023 track) | After (2025 road) | Official 2025 | + | 30 °C at | Effective temperature | 60 min | 4 h | | --- | --- | --- | --- | - | Male 40, marathon 3:30:00 | 58.8% (factor 0.9804) | 58.7% (0.9783) | 58.7% | - | Female 50, marathon 4:00:00 | 62.7% (0.8915) | 60.2% (0.8998) | 60.2% | - | Female 30, 5K 25:00 | 56.4% (1.0) | 55.8% (0.9959) | 55.8% | - | Male 60, half 1:50:00 | 63.3% (0.8264) | 64.7% (0.8082) | 64.7% | - | Male 55, 10K 45:00 | 69.0% (0.8438) | 68.9% (0.8511) | 68.9% | + | 30% RH | 26.69 °C | 5.46% | 15.34% | + | 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% | + | 70% RH | 33.07 °C | 10.46% | 29.39% | + | 90% RH | 35.94 °C (capped at 35 °C) | 12.16% | 34.17% | - "Official" is age standard / time from the spreadsheets' `AgeStdSec` sheet, - at one decimal. -- `predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km)` +- **Marathon from training volume.** + `predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km)` predicts a marathon from the mean weekly distance and mean training pace of the 8 weeks before the race, with Tanda (2011), *Journal of Human Sport and Exercise* 6(3):511–520: `Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P`. @@ -257,54 +245,77 @@ above 100 km (see Breaking). ```ruby calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:time_clock] # => "03:19:41" ``` -- `riegel_exponent(race1, time1, race2, time2)` fits a personal Riegel - exponent, `ln(t2/t1) / ln(d2/d1)`, to two performances. -- `predict_time_personal(race1, time1, race2, time2, to_race)` predicts with - that exponent. A target between the two races is interpolated along the - curve through both, with the raw exponent (never clamped, independent of - argument order); a target outside the pair is extrapolated from the closer - performance in log-distance, with the exponent clamped to 1.01–1.20. - Returns `:time`, `:time_clock`, `:exponent`, `:raw_exponent` and - `:clamped`; an exponent that needed clamping usually means one of the races - was not all-out. +- **Personal Riegel exponent.** `riegel_exponent(race1, time1, race2, time2)` + fits `ln(t2/t1) / ln(d2/d1)` to two performances, and + `predict_time_personal(race1, time1, race2, time2, to_race)` predicts with + it. A target between the two races is interpolated along the curve through + both, with the raw exponent (never clamped, independent of argument order); + a target outside the pair is extrapolated from the closer performance in + log-distance, with the exponent clamped to 1.01–1.20. Returns `:time`, + `:time_clock`, `:exponent`, `:raw_exponent` and `:clamped`; an exponent + that needed clamping usually means one of the races was not all-out. ```ruby calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock] # => "03:38:03" ``` - -### Fixed -- `check_positive` let `Float::INFINITY` through, so every method guarded by it - accepted an infinite distance or time: an infinite weekly distance became a - finite (and fast) marathon prediction, an infinite pace a `FloatDomainError` - far from the input. Infinity now raises `Calcpace::NonPositiveInputError` - ("must be a finite positive number"), like zero, negatives and NaN already - did. -- Grade-adjusted pace from the energy cost of running on gradients of +- **Grade-adjusted pace** from the energy cost of running on gradients of Minetti et al. (2002), J Appl Physiol 93:1039–1046: `grade_adjustment_factor(grade)` (Cr(i)/Cr(0), grade as a fraction, clamped to the measured ±0.45), `grade_adjusted_pace(pace, grade, unit: :km)` and `grade_adjusted_pace_clock(pace, grade, unit: :km, compact: false)`. -- `track_grade_adjusted_splits(points, split_km = 1.0, compact: false)`: the - `track_splits` splits with a `:gap` pace per split, computed segment by +- **`track_grade_adjusted_splits(points, split_km = 1.0, compact: false)`**: + the `track_splits` splits with a `:gap` pace per split, computed segment by segment from `:ele`. Grades are measured over segments of at least 100 m of horizontal distance so GPS elevation noise does not become fake climbing; stretches without `:ele` (or with a non-finite one), and stretches with elevation too short to grade, are flat. `track_splits` output is unchanged. -- VO2max norms by age and sex from the FRIEND registry (Kaminsky, Arena & +- **VO2max norms by age and sex** from the FRIEND registry (Kaminsky, Arena & Myers, Mayo Clin Proc 2015;90(11):1515–1523, Table 3: treadmill, measured VO2max), stored in `lib/calcpace/data/friend_2015_vo2max_percentiles.yml`: - - `vo2max_label(value, age: nil, sex: nil)` — optional keywords; with both, the - label comes from the percentile among the same sex and age decade (≥95th - Elite, ≥90th Excellent, ≥75th Very Good, ≥50th Good, ≥25th Fair, else - Beginner). Without them the fixed thresholds and labels are unchanged. + - `vo2max_label(value, age: nil, sex: nil)` — optional keywords; with both, + the label comes from the percentile among the same sex and age decade + (≥95th Elite, ≥90th Excellent, ≥75th Very Good, ≥50th Good, ≥25th Fair, + else Beginner). Without them the fixed thresholds and labels are + unchanged. - `vo2max_percentile(value, age:, sex:)` — linearly interpolated percentile, bounded to the table's 5–95. Ages 18–19 use the 20–29 row and 80+ the 70–79 row; under 18 is rejected. +- New public constants: `CameronPredictor::CAMERON_MAX_DISTANCE_KM`, + `EnvironmentalAdjuster::REFERENCE_HUMIDITY`, + `EnvironmentalAdjuster::HEAT_DURATION_FACTORS` and + `TrainingZones::PREDICTED_RACE_PACE_ZONES`. -### Changed +### Fixed +- `check_positive` let `Float::INFINITY` through, so every method guarded by it + accepted an infinite distance or time: an infinite weekly distance became a + finite (and fast) marathon prediction, an infinite pace a `FloatDomainError` + far from the input. Infinity now raises `Calcpace::NonPositiveInputError` + ("must be a finite positive number"), like zero, negatives and NaN already + did. - The `vo2max_label` docstring now documents the error it actually raises for - a non-positive value (`Calcpace::NonPositiveInputError`, not `ArgumentError`). - Behaviour is unchanged. + a non-positive value (`Calcpace::NonPositiveInputError`, not + `ArgumentError`). Behaviour is unchanged. + +### Known limitations +- **The heat model has no sex term.** One curve serves everyone, and El Helou + et al. (2012) Table S3 shows men slowing more than women in the heat: at + 25 °C the model reads men's slower groups low (men's median 11.9% vs 14.76% + observed, Q3 12.08% vs 16.10%) and women's high (women's median 12.08% vs + 9.27%, Q1 12.08% vs 8.16%). +- **Heat above ~25 °C is extrapolated.** The marathon data behind the curve + stops at 25.2 °C; the 35 °C cap is a deliberate choice, not a measurement. + The 25 °C / 60-minute anchor (4.3%) and the 30- and 60-minute duration + factors have no direct published source. +- **The size of the humidity adjustment rests on the WBGT index, not on race + data.** The marathon outcome studies (El Helou 2012, Vihma 2010) found no + humidity effect independent of temperature. +- **Tanda's equation is validated only inside its sample** (22 experienced + runners, 21 of them men): outside it the prediction is still returned and + `out_of_range` says what fell outside. +- **Grade-adjusted pace is a metabolic model.** It does not see the muscular + cost of long descents or technical terrain, and the factor is applied to + horizontal (Haversine) distance without the √(1 + grade²) slope-length + correction (0.5% at 10%). ## [1.18.1] - 2026-09-06 diff --git a/calcpace.gemspec b/calcpace.gemspec index a96695c..9e40f96 100644 --- a/calcpace.gemspec +++ b/calcpace.gemspec @@ -8,12 +8,16 @@ Gem::Specification.new do |spec| spec.authors = ['João Gilberto Saraiva'] spec.email = ['joaogilberto@tuta.io'] - spec.summary = 'Running calculations: pace, race predictions, GPS track analysis, VO2max, and training zones.' + spec.summary = 'Running calculations: pace, race predictions, GPS track analysis, ' \ + 'heat and altitude adjustments, age grading, VO2max, and training zones.' spec.description = 'Ruby gem for runners: pace, time, and distance calculations, ' \ - 'unit conversions (30+ units), race time predictions (Riegel & Cameron), ' \ - 'GPS track analysis (Haversine, elevation gain, per-km splits), ' \ - 'age grading (2025 road tables), VO2max estimation (Daniels & Gilbert), and ' \ - 'personalized training zones (Daniels paces & Karvonen heart-rate zones).' + 'unit conversions (30+ units), race time predictions (Riegel, Cameron, ' \ + 'personal Riegel exponent, and Tanda marathon from training volume), ' \ + 'GPS track analysis (Haversine, elevation gain, per-km splits, ' \ + 'Minetti grade-adjusted pace), heat, humidity, and altitude adjustments, ' \ + 'age grading (2025 road tables), VO2max estimation (Daniels & Gilbert) ' \ + 'with FRIEND age/sex norms, and personalized training zones ' \ + '(Daniels paces & Karvonen heart-rate zones).' spec.homepage = 'https://github.com/0jonjo/calcpace' spec.metadata['source_code_uri'] = spec.homepage spec.license = 'MIT' diff --git a/lib/calcpace/version.rb b/lib/calcpace/version.rb index 032ee39..a593e72 100644 --- a/lib/calcpace/version.rb +++ b/lib/calcpace/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Calcpace - VERSION = '1.18.1' + VERSION = '2.0.0' end From c90238b859620aca7279a295f3fa480373364373 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:36:31 -0300 Subject: [PATCH 28/34] fix!: validate clocks in every method that reads a time string Only a few entry points called check_time; race_splits, the pace converters, the Riegel and Cameron predictors and race_time/race_pace passed strings straight to convert_to_seconds, so '05:99' became 359 s and 'abc' became 0. convert_to_seconds now validates, which puts the 2.0.0 clock rule on every path. '75:00' stays a valid 75 minutes. BREAKING CHANGE: invalid or malformed time strings raise InvalidTimeFormatError in every method, including convert_to_seconds. --- CHANGELOG.md | 24 +++++-- README.md | 4 ++ lib/calcpace/converter.rb | 21 +++--- test/calcpace/test_clock_validation.rb | 89 ++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 test/calcpace/test_clock_validation.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index af5e772..79b5b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,24 @@ return shapes are unchanged except where listed under Breaking; the `environmental_factors.yml` keys are unchanged. ### Breaking -- **Invalid clocks raise.** `check_time` — and so every method that validates - a time string with it (`checked_*`, `estimate_vo2max`, `age_grade`, - `stride_length`, the personalized predictors…) — accepted any two digits per - field, so `'05:99'` or `'1:60:00'` passed and became the wrong number of - seconds. Seconds must now be below 60, and so must minutes when an hour +- **Invalid clocks raise, everywhere.** Every method that takes a time or pace + string now validates it: `convert_to_seconds` itself, and so `checked_*`, + `race_time`/`race_pace` (and `_clock`), `race_splits` (`target_time:`), + `convert_pace`, `pace_km_to_mi`, `pace_mi_to_km`, + `predict_time`/`predict_pace` (and `_clock`), `equivalent_performance`, + `predict_time_adjusted`, every Cameron method, `riegel_exponent`, + `predict_time_personal`, `predict_marathon_from_training`, + `estimate_vo2max`, `estimate_detailed_vo2max`, `training_paces_from_race`, + `age_grade`, `stride_length`, `cadence_for_stride` and `grade_adjusted_pace` + (and `_clock`). Seconds must be below 60, and so must minutes when an hour field is present; anything else raises `Calcpace::InvalidTimeFormatError`. - `MM:SS` still counts minutes past the hour (`'75:00'` is 75 minutes), as the - padded paces `track_splits` emits (`'66:33'`) always have. + Before, `check_time` accepted any two digits per field (`'05:99'`, + `'1:60:00'`), and the methods that did not call it at all turned such + strings — or garbage like `'abc'` — into the wrong number of seconds, + `convert_to_seconds` returning `0` for anything it could not split. `MM:SS` + still counts minutes past the hour (`'75:00'` is 75 minutes), as the padded + paces `track_splits` emits (`'66:33'`) always have. Numeric (seconds) inputs + are unaffected. - **Cameron predictions are limited to 100 km.** `predict_time_cameron`, `predict_time_cameron_clock`, `predict_pace_cameron`, `predict_pace_cameron_clock` and `predict_time_cameron_adjusted` raise diff --git a/README.md b/README.md index eacf947..76ec49b 100644 --- a/README.md +++ b/README.md @@ -918,6 +918,10 @@ calc.convert_to_clocktime(3600) # => "01:00:00" calc.check_time('01:00:00') # => nil (valid) ``` +Every time or pace string the gem reads goes through `convert_to_seconds`, so +the same clock rule holds in every method: `'05:99'` or `'1:60:00'` raise +`Calcpace::InvalidTimeFormatError`, while `'75:00'` is a valid 75 minutes. + `convert_to_clocktime` takes a `compact:` keyword for the format a runner reads on a screen — no zero hour, no leading zero on the most significant component: diff --git a/lib/calcpace/converter.rb b/lib/calcpace/converter.rb index 3785065..7de8ae5 100644 --- a/lib/calcpace/converter.rb +++ b/lib/calcpace/converter.rb @@ -77,24 +77,23 @@ def convert(value, unit) # Converts a time string to total seconds # + # Every method in the gem that takes a time or pace string goes through + # here, so the clock rule of check_time applies to all of them: seconds below + # 60, and minutes too when hours are given. MM:SS keeps counting minutes past + # the hour ('75:00' is 4500 seconds). + # # @param time [String] time string in HH:MM:SS or MM:SS format # @return [Integer] total seconds + # @raise [Calcpace::InvalidTimeFormatError] if the string is not a valid clock # # @example # convert_to_seconds('01:30:00') #=> 5400 (1 hour 30 minutes) # convert_to_seconds('05:30') #=> 330 (5 minutes 30 seconds) + # convert_to_seconds('05:99') #=> raises InvalidTimeFormatError def convert_to_seconds(time) - parts = time.split(':').map(&:to_i) - case parts.length - when 2 - minute, seconds = parts - (minute * 60) + seconds - when 3 - hour, minute, seconds = parts - (hour * 3600) + (minute * 60) + seconds - else - 0 - end + check_time(time) + hour, minute, seconds = time.split(':').map(&:to_i).unshift(0, 0).last(3) + (hour * 3600) + (minute * 60) + seconds end # Converts seconds to a clocktime string diff --git a/test/calcpace/test_clock_validation.rb b/test/calcpace/test_clock_validation.rb new file mode 100644 index 0000000..e979cd7 --- /dev/null +++ b/test/calcpace/test_clock_validation.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Every public method that reads a time or pace string applies the same clock +# rule as check_time: seconds below 60, and minutes too when hours are given. +# MM:SS keeps counting minutes past the hour ('75:00' is 75 minutes). +class TestClockValidation < CalcpaceTest + # Each entry builds a call from one clock string, so the same path can be + # exercised with an invalid clock and with a valid one + PATHS = { + convert_to_seconds: ->(c, t) { c.convert_to_seconds(t) }, + checked_pace: ->(c, t) { c.checked_pace(t, 10) }, + checked_velocity: ->(c, t) { c.checked_velocity(t, 10) }, + checked_distance: ->(c, t) { c.checked_distance(t, '00:05:00') }, + race_splits: ->(c, t) { c.race_splits('half_marathon', target_time: t, split_distance: '5k') }, + pace_km_to_mi: ->(c, t) { c.pace_km_to_mi(t) }, + pace_mi_to_km: ->(c, t) { c.pace_mi_to_km(t) }, + convert_pace: ->(c, t) { c.convert_pace(t, :km_to_mi) }, + race_time: ->(c, t) { c.race_time(t, 'marathon') }, + race_time_clock: ->(c, t) { c.race_time_clock(t, 'marathon') }, + race_pace: ->(c, t) { c.race_pace(t, 'marathon') }, + race_pace_clock: ->(c, t) { c.race_pace_clock(t, 'marathon') }, + predict_time: ->(c, t) { c.predict_time('half_marathon', t, 'marathon') }, + predict_time_clock: ->(c, t) { c.predict_time_clock('half_marathon', t, 'marathon') }, + predict_pace: ->(c, t) { c.predict_pace('half_marathon', t, 'marathon') }, + predict_pace_clock: ->(c, t) { c.predict_pace_clock('half_marathon', t, 'marathon') }, + equivalent_performance: ->(c, t) { c.equivalent_performance('half_marathon', t, 'marathon') }, + predict_time_adjusted: ->(c, t) { c.predict_time_adjusted('half_marathon', t, 'marathon', temperature: 25) }, + predict_time_cameron: ->(c, t) { c.predict_time_cameron('half_marathon', t, 'marathon') }, + predict_time_cameron_clock: ->(c, t) { c.predict_time_cameron_clock('half_marathon', t, 'marathon') }, + predict_pace_cameron: ->(c, t) { c.predict_pace_cameron('half_marathon', t, 'marathon') }, + predict_pace_cameron_clock: ->(c, t) { c.predict_pace_cameron_clock('half_marathon', t, 'marathon') }, + predict_time_cameron_adjusted: lambda { |c, t| + c.predict_time_cameron_adjusted('half_marathon', t, 'marathon', temperature: 25) + }, + predict_time_personal: ->(c, t) { c.predict_time_personal('10k', '00:45:00', 'half_marathon', t, 'marathon') }, + riegel_exponent: ->(c, t) { c.riegel_exponent('10k', '00:45:00', 'half_marathon', t) }, + predict_marathon_from_training: ->(c, t) { c.predict_marathon_from_training(weekly_distance: 60, training_pace: t) }, + estimate_vo2max: ->(c, t) { c.estimate_vo2max(21.0975, t) }, + estimate_detailed_vo2max: ->(c, t) { c.estimate_detailed_vo2max(21.0975, t) }, + training_paces_from_race: ->(c, t) { c.training_paces_from_race('half_marathon', t) }, + age_grade: ->(c, t) { c.age_grade('half_marathon', t, age: 40, sex: :male) }, + stride_length: ->(c, t) { c.stride_length(t, 170) }, + cadence_for_stride: ->(c, t) { c.cadence_for_stride(t, 1.18) }, + grade_adjusted_pace: ->(c, t) { c.grade_adjusted_pace(t, 0.05) }, + grade_adjusted_pace_clock: ->(c, t) { c.grade_adjusted_pace_clock(t, 0.05) } + }.freeze + + # A valid clock for each path: paces get a pace, everything else a race time + PACE_PATHS = %i[pace_km_to_mi pace_mi_to_km convert_pace race_time race_time_clock + predict_marathon_from_training stride_length cadence_for_stride + grade_adjusted_pace grade_adjusted_pace_clock].freeze + + PATHS.each do |name, call| + define_method(:"test_#{name}_rejects_seconds_of_sixty_or_more") do + bad = PACE_PATHS.include?(name) ? '05:99' : '1:40:99' + assert_raises(Calcpace::InvalidTimeFormatError, "#{name} accepted #{bad}") { call.call(@calc, bad) } + end + + define_method(:"test_#{name}_rejects_minutes_of_sixty_or_more_with_hours") do + assert_raises(Calcpace::InvalidTimeFormatError, "#{name} accepted 1:60:00") { call.call(@calc, '1:60:00') } + end + + define_method(:"test_#{name}_accepts_a_valid_clock") do + good = PACE_PATHS.include?(name) ? '05:00' : '1:40:00' + call.call(@calc, good) + end + end + + def test_minutes_past_the_hour_stay_valid_in_mm_ss + assert_equal 4500, @calc.convert_to_seconds('75:00') + assert_equal '01:15:00', @calc.race_splits('10k', target_time: '75:00', split_distance: '10k').last + assert_in_delta 450.0, @calc.race_pace('75:00', '10k') + assert_equal '00:12:04', @calc.pace_km_to_mi('07:30') + assert_operator @calc.predict_time('10k', '75:00', 'half_marathon'), :>, 4500 + end + + def test_numeric_inputs_are_not_affected + assert_in_delta 450.0, @calc.race_pace(4500, '10k') + assert_equal '00:08:02', @calc.convert_pace(300, :km_to_mi) + end + + def test_garbage_strings_raise_instead_of_becoming_zero_seconds + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.convert_to_seconds('abc') } + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.race_splits('10k', target_time: '40', split_distance: '5k') } + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.predict_time('5k', '20:00:00:00', '10k') } + end +end From 8174ea40ae54abfcff50cf32be420186f74357f2 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:36:31 -0300 Subject: [PATCH 29/34] fix: define Calcpace::VERSION when the gem is required lib/calcpace.rb never loaded version.rb, so the constant only existed when the gemspec had been evaluated. --- lib/calcpace.rb | 1 + test/calcpace/test_version.rb | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 test/calcpace/test_version.rb diff --git a/lib/calcpace.rb b/lib/calcpace.rb index 898b2b6..95dba0d 100644 --- a/lib/calcpace.rb +++ b/lib/calcpace.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative 'calcpace/version' require_relative 'calcpace/errors' require_relative 'calcpace/calculator' require_relative 'calcpace/cameron_predictor' diff --git a/test/calcpace/test_version.rb b/test/calcpace/test_version.rb new file mode 100644 index 0000000..1ebdd16 --- /dev/null +++ b/test/calcpace/test_version.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative '../test_helper' +require 'open3' +require 'rbconfig' + +class TestVersion < CalcpaceTest + LIB = File.expand_path('../../lib', __dir__) + + # In a fresh process, so nothing else the suite loaded can define it + def test_require_calcpace_defines_the_version + out, status = Open3.capture2e(RbConfig.ruby, '-I', LIB, '-e', "require 'calcpace'; print Calcpace::VERSION") + + assert_predicate status, :success?, out + assert_match(/\A\d+\.\d+\.\d+\z/, out) + end + + def test_version_matches_the_gem_version_file + assert_equal File.read(File.join(LIB, 'calcpace/version.rb'))[/VERSION = '([^']+)'/, 1], Calcpace::VERSION + end +end From 7f4be006e56da0e81aa62735791c3ff5d23671c0 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:36:31 -0300 Subject: [PATCH 30/34] docs: list the VERSION fix in the 2.0.0 changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b5b1c..83f1280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -302,6 +302,8 @@ Summary of the other models: far from the input. Infinity now raises `Calcpace::NonPositiveInputError` ("must be a finite positive number"), like zero, negatives and NaN already did. +- `Calcpace::VERSION` is defined after `require 'calcpace'`; before, it only + existed once the gemspec had been loaded. - The `vo2max_label` docstring now documents the error it actually raises for a non-positive value (`Calcpace::NonPositiveInputError`, not `ArgumentError`). Behaviour is unchanged. From b7faf67459fe87b44ffd2ff413ec652198eb1ffb Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:53:04 -0300 Subject: [PATCH 31/34] fix: read every clock the gem writes The stricter parser rejected the gem's own output: signed track_splits paces ('-0:40'), padded paces past 100 minutes ('123:45'), compact durations past 100 hours ('400:00:00') and convert_to_clocktime's day prefix ('1 03:46:40'). Clocks are now read with one grammar, Checker::CLOCK_FORMAT, that matches exactly what the gem formats: an optional '-', an optional 'D HH:MM:SS' day prefix, any number of digits in the leading field, and two-digit minutes and seconds below 60. convert_to_seconds keeps the sign; methods that need a positive time still reject negatives through check_positive. Round-trip tests cover convert_to_clocktime, track_splits paces in both formats and race_splits. --- CHANGELOG.md | 27 +++++--- README.md | 24 +++++-- lib/calcpace/checker.rb | 62 +++++++++++++----- lib/calcpace/converter.rb | 26 +++++--- test/calcpace/test_clock_validation.rb | 90 ++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f1280..57f7c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,15 +27,26 @@ return shapes are unchanged except where listed under Breaking; the `predict_time_personal`, `predict_marathon_from_training`, `estimate_vo2max`, `estimate_detailed_vo2max`, `training_paces_from_race`, `age_grade`, `stride_length`, `cadence_for_stride` and `grade_adjusted_pace` - (and `_clock`). Seconds must be below 60, and so must minutes when an hour - field is present; anything else raises `Calcpace::InvalidTimeFormatError`. - Before, `check_time` accepted any two digits per field (`'05:99'`, - `'1:60:00'`), and the methods that did not call it at all turned such + (and `_clock`). Before, `check_time` accepted any two digits per field + (`'05:99'`, `'1:60:00'`), and the methods that did not call it turned such strings — or garbage like `'abc'` — into the wrong number of seconds, - `convert_to_seconds` returning `0` for anything it could not split. `MM:SS` - still counts minutes past the hour (`'75:00'` is 75 minutes), as the padded - paces `track_splits` emits (`'66:33'`) always have. Numeric (seconds) inputs - are unaffected. + `convert_to_seconds` returning `0` for anything it could not split and + dropping the sign of `'-0:40'`. + - Accepted — exactly the clocks the gem writes (`Checker::CLOCK_FORMAT`): + `H:MM:SS` with any number of hour digits (`'400:00:00'`); `M:SS` with any + number of minute digits, counting past the hour (`'75:00'`, `'123:45'`); the + `'D HH:MM:SS'` day prefix of `convert_to_clocktime` (`'1 03:46:40'`); and a + leading `-` (`track_splits` writes `'-0:40'` for a backwards split; + `convert_to_seconds` returns `-40`). + - Rejected with `Calcpace::InvalidTimeFormatError`: seconds that are not two + digits below 60; minutes that are not two digits below 60 when hours are + given (`'1:60:00'`, `'1:5:00'`); after a day prefix, hours that are not two + digits below 24 (`'1 3:46:40'`, `'1 24:00:00'`); a `+` sign, blanks, + surrounding whitespace, non-ASCII digits, more than three fields, and + anything that is not a String. + - A negative clock parses, but every method that needs a positive time or + pace rejects it with `Calcpace::NonPositiveInputError`. Numeric (seconds) + inputs are unaffected. - **Cameron predictions are limited to 100 km.** `predict_time_cameron`, `predict_time_cameron_clock`, `predict_pace_cameron`, `predict_pace_cameron_clock` and `predict_time_cameron_adjusted` raise diff --git a/README.md b/README.md index 76ec49b..48f662c 100644 --- a/README.md +++ b/README.md @@ -919,8 +919,21 @@ calc.check_time('01:00:00') # => nil (valid) ``` Every time or pace string the gem reads goes through `convert_to_seconds`, so -the same clock rule holds in every method: `'05:99'` or `'1:60:00'` raise -`Calcpace::InvalidTimeFormatError`, while `'75:00'` is a valid 75 minutes. +every method reads the same clocks — exactly the ones the gem writes: + +```ruby +calc.convert_to_seconds('75:00') # => 4500 (MM:SS keeps counting minutes) +calc.convert_to_seconds('400:00:00') # => 1440000 (any number of hours) +calc.convert_to_seconds('1 03:46:40') # => 100000 (convert_to_clocktime's day prefix) +calc.convert_to_seconds('-0:40') # => -40 (a backwards track_splits split) +``` + +Seconds must be two digits below 60, and so must minutes when hours are given; +after a day prefix the hours are two digits below 24. A leading `-` is the only +sign, and blanks or surrounding whitespace are not trimmed. Anything else — +`'05:99'`, `'1:60:00'`, `'1 3:46:40'`, `' 05:00'`, `'abc'` — raises +`Calcpace::InvalidTimeFormatError`. A negative clock parses, but every method +that needs a positive time or pace rejects it with `Calcpace::NonPositiveInputError`. `convert_to_clocktime` takes a `compact:` keyword for the format a runner reads on a screen — no zero hour, no leading zero on the most significant component: @@ -948,10 +961,9 @@ call without it returns exactly what it returned before. All errors inherit from `Calcpace::Error`: - `Calcpace::NonPositiveInputError` — numeric input is zero, negative, NaN or infinite -- `Calcpace::InvalidTimeFormatError` — time string not in `HH:MM:SS` or `MM:SS` format, - or not a valid clock: seconds must be below 60, and so must minutes when hours are - given (`'05:99'` and `'1:60:00'` raise). `MM:SS` keeps counting minutes past the - hour, so `'75:00'` is 75 minutes +- `Calcpace::InvalidTimeFormatError` — time string that is not a clock the gem writes + (`[-][D ]H:MM:SS` or `[-]M:SS`, see Other Utilities): seconds must be below 60, and + so must minutes when hours are given (`'05:99'` and `'1:60:00'` raise) - `Calcpace::UnsupportedUnitError` — unknown conversion (`convert`) or unknown `unit:` / `distance_unit:` keyword - `Calcpace::InvalidDataError` — the bundled data table failed its load-time diff --git a/lib/calcpace/checker.rb b/lib/calcpace/checker.rb index d1f4fc8..ee4ccca 100644 --- a/lib/calcpace/checker.rb +++ b/lib/calcpace/checker.rb @@ -32,32 +32,62 @@ def check_positive(number, name = 'Input') raise Calcpace::NonPositiveInputError, "#{name} must be a finite positive number" end + # The clock grammar every time and pace string in the gem is read with — + # exactly the clocks the gem itself writes: + # - an optional leading '-' (track_splits reports a backwards split as '-0:40') + # - an optional day prefix, 'D HH:MM:SS', as convert_to_clocktime writes + # durations above 24 hours ('1 03:46:40'); the hour field after it is two + # digits below 24 + # - H:MM:SS, where the hours have any number of digits ('400:00:00') and the + # minutes and seconds are two digits below 60 + # - M:SS, where the minutes have any number of digits and keep counting past + # the hour ('75:00', '123:45') and the seconds are two digits below 60 + # Nothing else: no '+', no blanks or surrounding whitespace, ASCII digits only. + CLOCK_FORMAT = / + \A(?-)? + (?:(?\d+)[ ](?=(?:[01]\d|2[0-3]):[0-5]\d:))? + (?:(?\d+):(?[0-5]\d)|(?\d+)) + :(?[0-5]\d)\z + /x + # Validates that a time string is a well-formed clock # - # Accepted formats: - # - H:MM:SS / HH:MM:SS (hours:minutes:seconds) - e.g., "1:30:45", "01:30:45" - # - M:SS / MM:SS (minutes:seconds) - e.g., "5:30", "05:30" + # Accepts what CLOCK_FORMAT describes, i.e. every clock the gem formats: + # H:MM:SS / HH:MM:SS with any number of hour digits, M:SS / MM:SS with any + # number of minute digits, the 'D HH:MM:SS' day prefix of convert_to_clocktime + # and an optional leading '-'. Seconds must be below 60, and so must minutes + # once an hour field is present ("1:60:00" is not a clock); MM:SS keeps + # counting minutes past the hour, so "75:00" is a valid 75-minute time. # - # Seconds must be below 60 in both formats, and so must minutes once an - # hour field is present ("1:60:00" is not a clock). MM:SS keeps counting - # minutes past the hour, as the padded paces from track_splits do: "75:00" - # is a valid 75-minute time. + # A negative clock is well formed (track_splits writes one for a backwards + # split); methods that need a positive time or pace reject it with + # Calcpace::NonPositiveInputError after parsing. # # @param time_string [String] the time string to validate # @raise [Calcpace::InvalidTimeFormatError] if format is invalid # @return [void] # # @example - # check_time('01:30:45') #=> nil (valid) - # check_time('5:30') #=> nil (valid) - # check_time('75:00') #=> nil (valid, 75 minutes) - # check_time('05:99') #=> raises InvalidTimeFormatError - # check_time('1:60:00') #=> raises InvalidTimeFormatError - # check_time('invalid') #=> raises InvalidTimeFormatError + # check_time('01:30:45') #=> nil (valid) + # check_time('5:30') #=> nil (valid) + # check_time('75:00') #=> nil (valid, 75 minutes) + # check_time('1 03:46:40') #=> nil (valid, 1 day 3:46:40) + # check_time('-0:40') #=> nil (valid, a backwards split) + # check_time('05:99') #=> raises InvalidTimeFormatError + # check_time('1:60:00') #=> raises InvalidTimeFormatError + # check_time('invalid') #=> raises InvalidTimeFormatError def check_time(time_string) - return if time_string.is_a?(String) && - (time_string.match?(/\A\d{1,2}:[0-5]\d:[0-5]\d\z/) || - time_string.match?(/\A\d{1,2}:[0-5]\d\z/)) + clock_match(time_string) + nil + end + + private + + # @return [MatchData] the CLOCK_FORMAT match for a valid clock + # @raise [Calcpace::InvalidTimeFormatError] otherwise + def clock_match(time_string) + match = CLOCK_FORMAT.match(time_string) if time_string.is_a?(String) + return match if match raise Calcpace::InvalidTimeFormatError, 'It must be a valid time in the XX:XX:XX or XX:XX format ' \ diff --git a/lib/calcpace/converter.rb b/lib/calcpace/converter.rb index 7de8ae5..a2723a9 100644 --- a/lib/calcpace/converter.rb +++ b/lib/calcpace/converter.rb @@ -78,22 +78,28 @@ def convert(value, unit) # Converts a time string to total seconds # # Every method in the gem that takes a time or pace string goes through - # here, so the clock rule of check_time applies to all of them: seconds below - # 60, and minutes too when hours are given. MM:SS keeps counting minutes past - # the hour ('75:00' is 4500 seconds). + # here, so they all read the same clocks: the ones Checker#check_time + # accepts, which are exactly the ones the gem writes — signed track_splits + # paces ('-0:40' is -40), minutes past the hour ('75:00'), any number of + # hours ('400:00:00') and the day prefix of convert_to_clocktime + # ('1 03:46:40'). Methods that need a positive time reject a negative one + # afterwards. # # @param time [String] time string in HH:MM:SS or MM:SS format - # @return [Integer] total seconds + # @return [Integer] total seconds (negative for a '-' clock) # @raise [Calcpace::InvalidTimeFormatError] if the string is not a valid clock # # @example - # convert_to_seconds('01:30:00') #=> 5400 (1 hour 30 minutes) - # convert_to_seconds('05:30') #=> 330 (5 minutes 30 seconds) - # convert_to_seconds('05:99') #=> raises InvalidTimeFormatError + # convert_to_seconds('01:30:00') #=> 5400 (1 hour 30 minutes) + # convert_to_seconds('05:30') #=> 330 (5 minutes 30 seconds) + # convert_to_seconds('1 03:46:40') #=> 100000 + # convert_to_seconds('-0:40') #=> -40 + # convert_to_seconds('05:99') #=> raises InvalidTimeFormatError def convert_to_seconds(time) - check_time(time) - hour, minute, seconds = time.split(':').map(&:to_i).unshift(0, 0).last(3) - (hour * 3600) + (minute * 60) + seconds + clock = clock_match(time) + minutes = clock[:total_minutes] || clock[:minutes] + total = (clock[:days].to_i * 86_400) + (clock[:hours].to_i * 3600) + (minutes.to_i * 60) + clock[:seconds].to_i + clock[:sign] ? -total : total end # Converts seconds to a clocktime string diff --git a/test/calcpace/test_clock_validation.rb b/test/calcpace/test_clock_validation.rb index e979cd7..e77fcf0 100644 --- a/test/calcpace/test_clock_validation.rb +++ b/test/calcpace/test_clock_validation.rb @@ -86,4 +86,94 @@ def test_garbage_strings_raise_instead_of_becoming_zero_seconds assert_raises(Calcpace::InvalidTimeFormatError) { @calc.race_splits('10k', target_time: '40', split_distance: '5k') } assert_raises(Calcpace::InvalidTimeFormatError) { @calc.predict_time('5k', '20:00:00:00', '10k') } end + + # The gem must read every clock it writes: signed track_splits paces, padded + # paces past 100 minutes, compact durations past 100 hours and the day + # prefix of convert_to_clocktime above 24 hours + def test_convert_to_seconds_reads_the_gems_own_formats + { + '-0:40' => -40, '-00:40' => -40, '-5:12' => -312, '-1:06:33' => -3993, + '123:45' => 7425, '400:00:00' => 1_440_000, '1 03:46:40' => 100_000, + '12 00:00:00' => 1_036_800, '-1 03:46:40' => -100_000, '0:00' => 0 + }.each do |clock, seconds| + assert_equal seconds, @calc.convert_to_seconds(clock), clock + assert_nil @calc.check_time(clock), clock + end + end + + def test_malformed_clocks_still_raise + ['', '-', '--05:00', '+05:00', ' 05:00', '05:00 ', '5:0', '1:5:00', '05:60', '1:60:00', + '1 3:46:40', '1 24:00:00', '1 03:46', '1 03:60:00', '1 03:46:40', '1:02:03:04', '05:00:', + "05:00\n", 'abc', '05:00'].each do |clock| + assert_raises(Calcpace::InvalidTimeFormatError, clock.inspect) { @calc.convert_to_seconds(clock) } + assert_raises(Calcpace::InvalidTimeFormatError, clock.inspect) { @calc.check_time(clock) } + end + end + + def test_non_strings_are_not_clocks + [nil, 300, :'05:00'].each do |value| + assert_raises(Calcpace::InvalidTimeFormatError, value.inspect) { @calc.check_time(value) } + end + end + + # A negative clock parses, but every method that needs a positive time or + # pace still refuses it + PATHS.each do |name, call| + define_method(:"test_#{name}_rejects_a_negative_clock") do + next assert_equal(-300, call.call(@calc, '-05:00')) if name == :convert_to_seconds + + negative = PACE_PATHS.include?(name) ? '-05:00' : '-1:40:00' + assert_raises(Calcpace::NonPositiveInputError, "#{name} accepted #{negative}") { call.call(@calc, negative) } + end + end + + ROUND_TRIP_SECONDS = [0, 1, 40, 59, 60, 61, 312, 3599, 3600, 3993, 5999, 6000, 7425, 35_999, 86_399, + 86_400, 100_000, 359_999, 360_000, 1_440_000, 1_000_000_007].freeze + + def test_convert_to_clocktime_round_trips_in_both_formats + (ROUND_TRIP_SECONDS + ROUND_TRIP_SECONDS.map { |s| s + 0.75 }).each do |seconds| + [false, true].each do |compact| + clock = @calc.convert_to_clocktime(seconds, compact: compact) + assert_equal seconds.to_i, @calc.convert_to_seconds(clock), "#{seconds} -> #{clock}" + end + end + end + + def test_track_split_paces_round_trip_in_both_formats + ROUND_TRIP_SECONDS.flat_map { |s| [s, -s] }.each do |pace| + [false, true].each do |compact| + clock = @calc.send(:seconds_to_pace, pace, 1.0, compact: compact) + assert_equal pace, @calc.convert_to_seconds(clock), "#{pace} -> #{clock}" + end + end + end + + def test_track_splits_output_round_trips_including_backwards_and_slow_splits + start = Time.utc(2026, 1, 1, 7) + points = [ + { lat: 0.0, lon: 0.0, time: start }, + { lat: 0.0, lon: 0.009, time: start + 7425 }, # ~1 km in 2:03:45 + { lat: 0.0, lon: 0.018, time: start + 7385 }, # ~1 km, 40 s backwards + { lat: 0.0, lon: 0.0185, time: start + 7700 } + ] + [false, true].each do |compact| + splits = @calc.track_splits(points, 1.0, compact: compact) + paces = splits.map { |split| @calc.convert_to_seconds(split[:pace]) } + + assert_predicate paces.min, :negative?, splits.inspect + assert_operator paces.max, :>, 6000, splits.inspect + splits.zip(paces).each do |split, seconds| + assert_equal split[:pace], @calc.send(:seconds_to_pace, seconds, 1.0, compact: compact) + end + end + end + + def test_race_splits_round_trip + [['marathon', '400:00:00'], ['10k', '00:40:00'], ['marathon', '1 03:46:40'], [100, '99:59:59']].each do |race, time| + splits = @calc.race_splits(race, target_time: time, split_distance: '5k') + seconds = splits.map { |clock| @calc.convert_to_seconds(clock) } + assert_equal seconds.sort, seconds, splits.inspect + assert_equal @calc.convert_to_seconds(time), seconds.last + end + end end From 4ade27110a133119629ca6c559dda8ae02273402 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:53:35 -0300 Subject: [PATCH 32/34] docs: complete the 2.0.0 constants list and compare links List every public constant added since 1.18.1 (checked by diffing the constants of origin/main and this branch) and add the [2.0.0] compare link, pointing [Unreleased] at v2.0.0...HEAD. --- CHANGELOG.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f7c59..e94c585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -301,10 +301,24 @@ Summary of the other models: - `vo2max_percentile(value, age:, sex:)` — linearly interpolated percentile, bounded to the table's 5–95. Ages 18–19 use the 20–29 row and 80+ the 70–79 row; under 18 is rejected. -- New public constants: `CameronPredictor::CAMERON_MAX_DISTANCE_KM`, - `EnvironmentalAdjuster::REFERENCE_HUMIDITY`, - `EnvironmentalAdjuster::HEAT_DURATION_FACTORS` and - `TrainingZones::PREDICTED_RACE_PACE_ZONES`. +- New public constants (removed ones are under Breaking): + - `CameronPredictor`: `CAMERON_CONSTANT`, `CAMERON_LINEAR_COEFFICIENT`, + `CAMERON_POWER_COEFFICIENT`, `CAMERON_POWER_EXPONENT`, + `CAMERON_MAX_DISTANCE_KM`. + - `Checker::CLOCK_FORMAT` — the clock grammar every time string is read with. + - `EnvironmentalAdjuster`: `HEAT_DURATION_FACTORS`, `REFERENCE_HUMIDITY`, and + the `EnvironmentalAdjuster::Humidity` module with `REFERENCE_HUMIDITY`, + `WBGT_TEMPERATURE_COEFFICIENT`, `WBGT_VAPOUR_PRESSURE_COEFFICIENT`, + `MIN_DEW_POINT_CELSIUS`, `BRACKET_CELSIUS` and `BISECTION_STEPS`. + - `GradeAdjustedPace`: `MINETTI_RUNNING_COEFFICIENTS`, `MINETTI_GRADE_RANGE`. + - `PersonalizedPredictor`: `TANDA_INTERCEPT`, `TANDA_VOLUME_AMPLITUDE`, + `TANDA_VOLUME_DECAY`, `TANDA_PACE_SLOPE`, `TANDA_MARATHON_KM`, + `TANDA_WEEKLY_DISTANCE_RANGE_KM`, `TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM`, + `TANDA_MARATHON_TIME_RANGE_SECONDS`, `PERSONAL_EXPONENT_RANGE`. + - `TrackCalculator`: `GRADE_SEGMENT_MIN_KM`, `GRADE_SEGMENT_TOLERANCE_KM`. + - `TrainingZones::PREDICTED_RACE_PACE_ZONES`. + - `Vo2maxNorms`: `NORMS_DATA_PATH`, `VO2MAX_NORMS`, `VO2MAX_NORMS_VERSION`, + `VO2MAX_NORM_PERCENTILES`, `VO2MAX_PERCENTILE_LABELS`. ### Fixed - `check_positive` let `Float::INFINITY` through, so every method guarded by it @@ -982,7 +996,8 @@ predictors are untouched. See git history for changes in earlier versions. -[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.18.1...HEAD +[Unreleased]: https://github.com/0jonjo/calcpace/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/0jonjo/calcpace/compare/v1.18.1...v2.0.0 [1.18.1]: https://github.com/0jonjo/calcpace/compare/v1.18.0...v1.18.1 [1.18.0]: https://github.com/0jonjo/calcpace/compare/v1.17.0...v1.18.0 [1.17.0]: https://github.com/0jonjo/calcpace/compare/v1.16.0...v1.17.0 From 18758dc73c118cad92a8cf41a3668d4f384c5cc3 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 07:55:02 -0300 Subject: [PATCH 33/34] refactor: share age/sex validators in Checker and drop a duplicate helper Vo2maxNorms called AgeGrading's private normalize_age/normalize_sex and only worked inside Calcpace. Both validators now live in Checker, which AgeGrading and Vo2maxNorms include, so Vo2maxNorms works on its own. TrainingZones' copy of vo2_at_velocity duplicated Vo2maxEstimator's; it is gone and the module documents the siblings it relies on. A composition test fails if two included modules ever define the same method again. --- lib/calcpace/age_grading.rb | 21 +++----------- lib/calcpace/checker.rb | 23 ++++++++++++++++ lib/calcpace/training_zones.rb | 10 +++---- lib/calcpace/vo2max_norms.rb | 6 +++- test/calcpace/test_module_composition.rb | 35 ++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 23 deletions(-) create mode 100644 test/calcpace/test_module_composition.rb diff --git a/lib/calcpace/age_grading.rb b/lib/calcpace/age_grading.rb index 2bb1707..02695ea 100644 --- a/lib/calcpace/age_grading.rb +++ b/lib/calcpace/age_grading.rb @@ -2,6 +2,7 @@ require 'yaml' require_relative 'errors' +require_relative 'checker' # Module for age-grading race performances with a versioned table # @@ -27,6 +28,9 @@ # - performance category # rubocop:disable Metrics/ModuleLength module AgeGrading + # normalize_age / normalize_sex live in Checker, shared with Vo2maxNorms + include Checker + DATA_PATH = File.expand_path('data/mldr_2025_road.yml', __dir__).freeze OPEN_STANDARDS_DATA_PATH = File.expand_path('data/mldr_2025_road_open_standards.yml', __dir__).freeze WMA_DATA = YAML.safe_load_file(DATA_PATH, permitted_classes: [], @@ -186,23 +190,6 @@ def parse_time_seconds(time) convert_to_seconds(time.to_s) end - def normalize_age(age) - age_value = Integer(age) - rescue ArgumentError, TypeError - raise ArgumentError, 'Age must be an integer greater than or equal to 18' - else - raise ArgumentError, 'Age must be at least 18' if age_value < 18 - - age_value - end - - def normalize_sex(sex) - normalized = sex.to_s.strip.downcase.to_sym - return normalized if %i[male female].include?(normalized) - - raise ArgumentError, "Sex must be 'male' or 'female'" - end - def interpolated_factor(sex, age, distance_m) table = factor_table(sex, distance_m) ages = table.keys.map(&:to_i).sort diff --git a/lib/calcpace/checker.rb b/lib/calcpace/checker.rb index ee4ccca..0b7463f 100644 --- a/lib/calcpace/checker.rb +++ b/lib/calcpace/checker.rb @@ -93,4 +93,27 @@ def clock_match(time_string) 'It must be a valid time in the XX:XX:XX or XX:XX format ' \ '(seconds below 60, and minutes too when hours are given)' end + + # Age in whole years, 18 or over — the rule AgeGrading and Vo2maxNorms share + # + # @raise [ArgumentError] if age is not an integer or is under 18 + def normalize_age(age) + age_value = Integer(age) + rescue ArgumentError, TypeError + raise ArgumentError, 'Age must be an integer greater than or equal to 18' + else + raise ArgumentError, 'Age must be at least 18' if age_value < 18 + + age_value + end + + # :male or :female, from any case of a String or Symbol — shared like normalize_age + # + # @raise [ArgumentError] for anything else + def normalize_sex(sex) + normalized = sex.to_s.strip.downcase.to_sym + return normalized if %i[male female].include?(normalized) + + raise ArgumentError, "Sex must be 'male' or 'female'" + end end diff --git a/lib/calcpace/training_zones.rb b/lib/calcpace/training_zones.rb index beef95e..784a999 100644 --- a/lib/calcpace/training_zones.rb +++ b/lib/calcpace/training_zones.rb @@ -9,6 +9,11 @@ # # Heart rate zones use the Karvonen method (Heart Rate Reserve): # target = hr_rest + pct * (hr_max - hr_rest) +# +# Not standalone: it is a part of Calcpace and calls into its siblings — +# Vo2maxEstimator (estimate_vo2max, vo2_at_velocity), FitnessPredictor +# (predict_time_from_vo2max, for the marathon band), PaceCalculator +# (race_distance), Converter and Checker. module TrainingZones # Training intensities as fraction of VO2max (Daniels' Running Formula). # The marathon :high (0.84) is the nominal upper bound only: the band's fast @@ -362,11 +367,6 @@ def marathon_race_intensity(vo2max) vo2_at_velocity(race_distance('marathon') * Converter::Distance::KM_TO_METERS * 60.0 / seconds) / vo2 end - # Daniels & Gilbert oxygen cost (ml/kg/min) of running at v m/min - def vo2_at_velocity(velocity) - -4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2)) - end - # Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2 def velocity_at_vo2(vo2) a = 0.000104 diff --git a/lib/calcpace/vo2max_norms.rb b/lib/calcpace/vo2max_norms.rb index 83683bf..715a612 100644 --- a/lib/calcpace/vo2max_norms.rb +++ b/lib/calcpace/vo2max_norms.rb @@ -2,6 +2,7 @@ require 'yaml' require_relative 'errors' +require_relative 'checker' # Module for reading a VO2max against people of the same age and sex # @@ -20,6 +21,9 @@ # `lib/calcpace/data/friend_2015_vo2max_percentiles.yml`. # Vo2maxEstimator#vo2max_label uses it when given age and sex. module Vo2maxNorms + # check_positive, normalize_age and normalize_sex: the same rules as AgeGrading + include Checker + # Percentile norms by age and sex: FRIEND registry, measured treadmill VO2max # (Kaminsky, Arena & Myers, Mayo Clin Proc 2015;90(11):1515–1523, Table 3). # See the data file for the full provenance. @@ -93,7 +97,7 @@ def vo2max_percentile(value, age:, sex:) # Unrounded percentile, so a label is decided against the published values # themselves rather than a rounded reading of them. normalize_age and - # normalize_sex are AgeGrading's — one rule for age and sex gem-wide. + # normalize_sex come from Checker — one rule for age and sex gem-wide. def raw_vo2max_percentile(value, age, sex) row = vo2max_norm_row(normalize_age(age), normalize_sex(sex)) return VO2MAX_NORM_PERCENTILES.first if value <= row.first diff --git a/test/calcpace/test_module_composition.rb b/test/calcpace/test_module_composition.rb new file mode 100644 index 0000000..f0b7b69 --- /dev/null +++ b/test/calcpace/test_module_composition.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Calcpace is one class made of many modules. A method defined in two of them +# is silently overwritten by whichever comes first in the ancestors, so the +# composition itself is tested here. +class TestModuleComposition < CalcpaceTest + CALCPACE_MODULES = (Calcpace.ancestors.take_while { |mod| mod != Object } - [Calcpace]).freeze + + def test_no_two_modules_define_the_same_method + owners = Hash.new { |hash, name| hash[name] = [] } + CALCPACE_MODULES.each do |mod| + (mod.instance_methods(false) + mod.private_instance_methods(false)).each { |name| owners[name] << mod } + end + shared = owners.select { |_name, mods| mods.size > 1 } + + assert_empty shared, "Methods defined in more than one module: #{shared.inspect}" + end + + # Vo2maxNorms declares what it needs (Checker), so it works on its own + def test_vo2max_norms_works_included_alone + norms = Class.new { include Vo2maxNorms }.new + + assert_in_delta 40.5, norms.vo2max_percentile(45, age: 25, sex: :male) + assert_raises(ArgumentError) { norms.vo2max_percentile(45, age: 17, sex: :male) } + assert_raises(ArgumentError) { norms.vo2max_percentile(45, age: 25, sex: :other) } + assert_raises(Calcpace::NonPositiveInputError) { norms.vo2max_percentile(0, age: 25, sex: :male) } + end + + def test_age_and_sex_rules_are_shared_by_age_grading_and_vo2max_norms + assert_equal Checker, Calcpace.instance_method(:normalize_age).owner + assert_equal Checker, Calcpace.instance_method(:normalize_sex).owner + end +end From 81904e30f1528241841b04f69c472956bed50287 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Fri, 2 Oct 2026 08:01:17 -0300 Subject: [PATCH 34/34] fix: reject clocks too large for a Float and non-ASCII encodings A clock with hundreds of hour digits parsed to a finite Integer that check_positive accepted and the formulas turned into Infinity or a FloatDomainError; check_positive now also requires to_f to be finite. clock_match only matches valid, ASCII-compatible strings, so UTF-16 or broken UTF-8 input raises InvalidTimeFormatError instead of an encoding error from the regexp engine. The docs now say the grammar covers every clock the gem writes, not exactly those. --- CHANGELOG.md | 19 ++++++++++++------- README.md | 5 +++-- lib/calcpace/checker.rb | 25 +++++++++++++++++++------ lib/calcpace/converter.rb | 2 +- test/calcpace/test_checker.rb | 9 +++++++++ test/calcpace/test_clock_validation.rb | 24 ++++++++++++++++++++++++ 6 files changed, 68 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e94c585..f96f2a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,21 +32,24 @@ return shapes are unchanged except where listed under Breaking; the strings — or garbage like `'abc'` — into the wrong number of seconds, `convert_to_seconds` returning `0` for anything it could not split and dropping the sign of `'-0:40'`. - - Accepted — exactly the clocks the gem writes (`Checker::CLOCK_FORMAT`): + - Accepted — every clock the gem writes (`Checker::CLOCK_FORMAT`): `H:MM:SS` with any number of hour digits (`'400:00:00'`); `M:SS` with any number of minute digits, counting past the hour (`'75:00'`, `'123:45'`); the `'D HH:MM:SS'` day prefix of `convert_to_clocktime` (`'1 03:46:40'`); and a leading `-` (`track_splits` writes `'-0:40'` for a backwards split; - `convert_to_seconds` returns `-40`). + `convert_to_seconds` returns `-40`). A few clocks the gem never writes + parse too, such as `'-1 03:46:40'`, `'0:00:00'` or `'000000000:00'`. - Rejected with `Calcpace::InvalidTimeFormatError`: seconds that are not two digits below 60; minutes that are not two digits below 60 when hours are given (`'1:60:00'`, `'1:5:00'`); after a day prefix, hours that are not two digits below 24 (`'1 3:46:40'`, `'1 24:00:00'`); a `+` sign, blanks, - surrounding whitespace, non-ASCII digits, more than three fields, and - anything that is not a String. + surrounding whitespace, non-ASCII digits, more than three fields, strings + in an invalid or non-ASCII-compatible encoding (broken UTF-8, UTF-16, + UTF-32), and anything that is not a String. - A negative clock parses, but every method that needs a positive time or - pace rejects it with `Calcpace::NonPositiveInputError`. Numeric (seconds) - inputs are unaffected. + pace rejects it with `Calcpace::NonPositiveInputError`, as it does a + clock too large for a Float (see Fixed). Numeric (seconds) inputs are + unaffected. - **Cameron predictions are limited to 100 km.** `predict_time_cameron`, `predict_time_cameron_clock`, `predict_pace_cameron`, `predict_pace_cameron_clock` and `predict_time_cameron_adjusted` raise @@ -326,7 +329,9 @@ Summary of the other models: finite (and fast) marathon prediction, an infinite pace a `FloatDomainError` far from the input. Infinity now raises `Calcpace::NonPositiveInputError` ("must be a finite positive number"), like zero, negatives and NaN already - did. + did, and so does an Integer too large for a Float (e.g. a clock with + hundreds of hour digits), which used to become `Infinity` or a + `FloatDomainError` inside the formulas. - `Calcpace::VERSION` is defined after `require 'calcpace'`; before, it only existed once the gemspec had been loaded. - The `vo2max_label` docstring now documents the error it actually raises for diff --git a/README.md b/README.md index 48f662c..85a6a62 100644 --- a/README.md +++ b/README.md @@ -919,7 +919,7 @@ calc.check_time('01:00:00') # => nil (valid) ``` Every time or pace string the gem reads goes through `convert_to_seconds`, so -every method reads the same clocks — exactly the ones the gem writes: +every method reads the same clocks, and every clock the gem writes is among them: ```ruby calc.convert_to_seconds('75:00') # => 4500 (MM:SS keeps counting minutes) @@ -930,7 +930,8 @@ calc.convert_to_seconds('-0:40') # => -40 (a backwards track_splits s Seconds must be two digits below 60, and so must minutes when hours are given; after a day prefix the hours are two digits below 24. A leading `-` is the only -sign, and blanks or surrounding whitespace are not trimmed. Anything else — +sign, blanks or surrounding whitespace are not trimmed, and the string must be +in a valid, ASCII-compatible encoding (UTF-8, not UTF-16). Anything else — `'05:99'`, `'1:60:00'`, `'1 3:46:40'`, `' 05:00'`, `'abc'` — raises `Calcpace::InvalidTimeFormatError`. A negative clock parses, but every method that needs a positive time or pace rejects it with `Calcpace::NonPositiveInputError`. diff --git a/lib/calcpace/checker.rb b/lib/calcpace/checker.rb index 0b7463f..d85d31c 100644 --- a/lib/calcpace/checker.rb +++ b/lib/calcpace/checker.rb @@ -11,7 +11,9 @@ module Checker # # NaN and infinity are rejected too: NaN is not positive, and an infinite # distance or time would flow through the formulas into nonsense (a finite - # "prediction") or a FloatDomainError far from the input that caused it. + # "prediction") or a FloatDomainError far from the input that caused it. So + # is an Integer too large for a Float (a clock with hundreds of hour digits + # parses to one), which would turn into Infinity inside the formulas. # # @param number [Numeric] the number to validate # @param name [String] the name of the parameter for error messages @@ -27,13 +29,16 @@ def check_positive(number, name = 'Input') unless number.is_a?(Numeric) && number.positive? raise Calcpace::NonPositiveInputError, "#{name} must be a positive number" end - return if number.finite? + # An Integer is always finite, but one past Float::MAX overflows to + # Infinity the moment a formula calls to_f on it + return if number.finite? && number.to_f.finite? raise Calcpace::NonPositiveInputError, "#{name} must be a finite positive number" end - # The clock grammar every time and pace string in the gem is read with — - # exactly the clocks the gem itself writes: + # The clock grammar every time and pace string in the gem is read with. It + # covers every clock the gem writes (and a few it never writes, such as + # '-1 03:46:40' or '000000000:00'): # - an optional leading '-' (track_splits reports a backwards split as '-0:40') # - an optional day prefix, 'D HH:MM:SS', as convert_to_clocktime writes # durations above 24 hours ('1 03:46:40'); the hour field after it is two @@ -42,7 +47,8 @@ def check_positive(number, name = 'Input') # minutes and seconds are two digits below 60 # - M:SS, where the minutes have any number of digits and keep counting past # the hour ('75:00', '123:45') and the seconds are two digits below 60 - # Nothing else: no '+', no blanks or surrounding whitespace, ASCII digits only. + # Nothing else: no '+', no blanks or surrounding whitespace, ASCII digits only, + # and only in a String whose encoding is valid and ASCII-compatible. CLOCK_FORMAT = / \A(?-)? (?:(?\d+)[ ](?=(?:[01]\d|2[0-3]):[0-5]\d:))? @@ -86,7 +92,7 @@ def check_time(time_string) # @return [MatchData] the CLOCK_FORMAT match for a valid clock # @raise [Calcpace::InvalidTimeFormatError] otherwise def clock_match(time_string) - match = CLOCK_FORMAT.match(time_string) if time_string.is_a?(String) + match = CLOCK_FORMAT.match(time_string) if clock_encoding?(time_string) return match if match raise Calcpace::InvalidTimeFormatError, @@ -94,6 +100,13 @@ def clock_match(time_string) '(seconds below 60, and minutes too when hours are given)' end + # Only a String in a valid, ASCII-compatible encoding can be a clock: the + # pattern cannot match UTF-16/UTF-32, and a broken byte sequence raises + # ArgumentError inside the regexp engine instead of a clock error + def clock_encoding?(time_string) + time_string.is_a?(String) && time_string.valid_encoding? && time_string.encoding.ascii_compatible? + end + # Age in whole years, 18 or over — the rule AgeGrading and Vo2maxNorms share # # @raise [ArgumentError] if age is not an integer or is under 18 diff --git a/lib/calcpace/converter.rb b/lib/calcpace/converter.rb index a2723a9..9a01fc4 100644 --- a/lib/calcpace/converter.rb +++ b/lib/calcpace/converter.rb @@ -79,7 +79,7 @@ def convert(value, unit) # # Every method in the gem that takes a time or pace string goes through # here, so they all read the same clocks: the ones Checker#check_time - # accepts, which are exactly the ones the gem writes — signed track_splits + # accepts, which cover every clock the gem writes — signed track_splits # paces ('-0:40' is -40), minutes past the hour ('75:00'), any number of # hours ('400:00:00') and the day prefix of convert_to_clocktime # ('1 03:46:40'). Methods that need a positive time reject a negative one diff --git a/test/calcpace/test_checker.rb b/test/calcpace/test_checker.rb index 0594317..87d7dc2 100644 --- a/test/calcpace/test_checker.rb +++ b/test/calcpace/test_checker.rb @@ -59,4 +59,13 @@ def test_invalid_clock_is_rejected_by_public_methods assert_raises(Calcpace::InvalidTimeFormatError) { @calc.checked_pace('00:19:99', 5) } assert_raises(Calcpace::InvalidTimeFormatError) { @calc.checked_velocity('1:60:00', 10) } end + + # A bignum is finite, but one too large for a Float overflows to Infinity as + # soon as a formula calls to_f on it + def test_check_positive_rejects_integers_that_overflow_a_float + assert_error_with_message(Calcpace::NonPositiveInputError, 'Time must be a finite positive number') do + @calc.check_positive(10**400, 'Time') + end + assert_nil @calc.check_positive(10**300) + end end diff --git a/test/calcpace/test_clock_validation.rb b/test/calcpace/test_clock_validation.rb index e77fcf0..e9a1b10 100644 --- a/test/calcpace/test_clock_validation.rb +++ b/test/calcpace/test_clock_validation.rb @@ -176,4 +176,28 @@ def test_race_splits_round_trip assert_equal @calc.convert_to_seconds(time), seconds.last end end + + # The clock grammar takes any number of digits, so a clock can parse to an + # Integer too large for a Float; it must not come back as Infinity + def test_a_clock_too_large_for_a_float_is_rejected + huge = "#{'9' * 400}:00:00" + + assert_operator @calc.convert_to_seconds(huge), :>, 10**400 + %i[checked_pace race_pace predict_time race_splits predict_time_cameron age_grade estimate_vo2max].each do |name| + assert_raises(Calcpace::NonPositiveInputError, name.to_s) { PATHS.fetch(name).call(@calc, huge) } + end + end + + def test_strings_in_other_encodings_are_not_clocks + ['05:00'.encode('UTF-16LE'), '05:00'.encode('UTF-32BE'), (+"05:00\xFF").force_encoding('UTF-8'), + (+"\xFF05:00").force_encoding('UTF-8')].each do |clock| + assert_raises(Calcpace::InvalidTimeFormatError, clock.inspect) { @calc.convert_to_seconds(clock) } + assert_raises(Calcpace::InvalidTimeFormatError, clock.inspect) { @calc.check_time(clock) } + end + end + + def test_ascii_compatible_encodings_still_parse + assert_equal 300, @calc.convert_to_seconds((+'05:00').force_encoding('ASCII-8BIT')) + assert_equal 300, @calc.convert_to_seconds('05:00'.encode('ISO-8859-1')) + end end