Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6b6cd8c
fix: realistic Cameron, altitude, splits and heat models
0jonjo Oct 2, 2026
94b6eba
chore: release 1.19.0
0jonjo Oct 2, 2026
ff5bf54
feat: age grading with the 2025 road tables
0jonjo Oct 2, 2026
f0a71a3
revert: drop the 1.19.0 release bump
0jonjo Oct 2, 2026
70c1f7d
fix: limit Cameron predictions to 100 km and tighten tests
0jonjo Oct 2, 2026
ab09d4e
feat: marathon from training volume and personal Riegel exponent
0jonjo Oct 2, 2026
57707ae
docs: pin age-grade source commit and note WMA_DATA key removal
0jonjo Oct 2, 2026
3ecc0af
docs: personalized predictions in README and CHANGELOG
0jonjo Oct 2, 2026
4d603bc
feat: grade-adjusted pace from Minetti et al. (2002)
0jonjo Oct 2, 2026
83caffc
fix: reject non-finite numbers in check_positive
0jonjo Oct 2, 2026
ee79142
feat: interpolate personal predictions and validate inputs strictly
0jonjo Oct 2, 2026
9c03c83
feat: VO2max percentiles and labels by age and sex
0jonjo Oct 2, 2026
4866bd9
refactor: express heat duration factor as a point table
0jonjo Oct 2, 2026
8ee0fa5
feat: optional humidity and dew point for the heat penalty
0jonjo Oct 2, 2026
77c1f4f
fix: cap the heat duration factor at 3.5x from 4 h
0jonjo Oct 2, 2026
d9ee512
fix: end the marathon pace band at the predicted marathon pace
0jonjo Oct 2, 2026
1a770e2
docs: humidity, 4 h heat factor and marathon band in README and CHANG…
0jonjo Oct 2, 2026
fd54e47
fix: address review of grade-adjusted splits and VO2max norms
0jonjo Oct 2, 2026
ff58a91
fix: exact reference humidity and stricter humidity input checks
0jonjo Oct 2, 2026
f7d75fe
fix: fit the heat duration factor to El Helou 2012 Table S3
0jonjo Oct 2, 2026
beca82d
fix: keep TRAINING_INTENSITIES numeric
0jonjo Oct 2, 2026
3dd44c9
docs: refit tables, humidity options and numeric intensities
0jonjo Oct 2, 2026
2f380fe
fix: fit the heat base curve and duration factor jointly to El Helou
0jonjo Oct 2, 2026
184080f
fix: cap the heat base at its 35 C value
0jonjo Oct 2, 2026
dd8f1d6
Merge branch 'feat/heat-humidity-mpace' into release/v2.0.0
0jonjo Oct 2, 2026
ca8aa28
Merge branch 'feat/age-grade-road-2025' into release/v2.0.0
0jonjo Oct 2, 2026
9e4676b
Merge branch 'feat/personalized-prediction' into release/v2.0.0
0jonjo Oct 2, 2026
7a66052
Merge branch 'feat/grade-adjusted-pace-vo2-norms' into release/v2.0.0
0jonjo Oct 2, 2026
0b0a325
fix!: reject clocks with seconds or minutes of 60 and above
0jonjo Oct 2, 2026
ad3caa3
docs: bring README in line with the 2.0.0 behaviour
0jonjo Oct 2, 2026
d059832
chore: release 2.0.0
0jonjo Oct 2, 2026
c90238b
fix!: validate clocks in every method that reads a time string
0jonjo Oct 2, 2026
8174ea4
fix: define Calcpace::VERSION when the gem is required
0jonjo Oct 2, 2026
7f4be00
docs: list the VERSION fix in the 2.0.0 changelog
0jonjo Oct 2, 2026
b7faf67
fix: read every clock the gem writes
0jonjo Oct 2, 2026
4ade271
docs: complete the 2.0.0 constants list and compare links
0jonjo Oct 2, 2026
18758dc
refactor: share age/sex validators in Checker and drop a duplicate he…
0jonjo Oct 2, 2026
81904e3
fix: reject clocks too large for a Float and non-ASCII encodings
0jonjo Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
355 changes: 354 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

362 changes: 319 additions & 43 deletions README.md

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions calcpace.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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 (WMA 2023), 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'
Expand Down
7 changes: 7 additions & 0 deletions lib/calcpace.rb
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -9,15 +10,18 @@
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'
require_relative 'calcpace/personalized_predictor'
require_relative 'calcpace/race_predictor'
require_relative 'calcpace/race_splits'
require_relative 'calcpace/stride_calculator'
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
#
Expand Down Expand Up @@ -49,15 +53,18 @@ class Calcpace
include Converter
include ConverterChain
include FitnessPredictor
include GradeAdjustedPace
include LapAnalyzer
include PaceCalculator
include PaceConverter
include PersonalizedPredictor
include RacePredictor
include RaceSplits
include StrideCalculator
include TrackCalculator
include TrainingZones
include Vo2maxEstimator
include Vo2maxNorms

# Creates a new Calcpace instance
#
Expand Down
37 changes: 15 additions & 22 deletions lib/calcpace/age_grading.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'yaml'
require_relative 'errors'
require_relative 'checker'

# Module for age-grading race performances with a versioned table
#
Expand All @@ -11,8 +12,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
Expand All @@ -21,8 +28,11 @@
# - 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
# 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: [],
aliases: false).freeze
OPEN_STANDARDS_DATA = YAML.safe_load_file(OPEN_STANDARDS_DATA_PATH, permitted_classes: [],
Expand Down Expand Up @@ -60,7 +70,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
Expand Down Expand Up @@ -180,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
Expand Down
100 changes: 73 additions & 27 deletions lib/calcpace/cameron_predictor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,40 @@

# 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)
# 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
# - 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
# Longest distance (km), on either end, a Cameron prediction accepts
CAMERON_MAX_DISTANCE_KM = 100.0

# Predicts race time using the Cameron formula
#
Expand All @@ -29,26 +44,28 @@ 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
# 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)

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
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
Expand All @@ -59,10 +76,11 @@ 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')
# #=> '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
Expand All @@ -73,10 +91,11 @@ 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')
# #=> ~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
Expand All @@ -87,10 +106,11 @@ 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')
# #=> '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
Expand All @@ -100,20 +120,46 @@ 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: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, **)
end

private

# Computes the Cameron exponential correction factor for a given distance
# Rejects distances outside the range where Cameron's model is meaningful
#
# @param distances [Array<Float>] 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
# @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
Loading
Loading