Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 28 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.12.0] - 2026-08-01

### Added
- Fitness predictor — race times from a VO2max value, the inverse of
`estimate_vo2max`
- `predict_time_from_vo2max(vo2max, race, distance_unit: nil)`: predicted
finish time in seconds. `race` accepts a standard race name ('5k',
'marathon', '5mile', ...) or a numeric distance in kilometres (miles via
`distance_unit: :mi`), same semantics as `training_paces_from_race`
- `predict_time_from_vo2max_clock(...)`: same prediction as `HH:MM:SS`
- `race_times_from_vo2max(vo2max, races: nil, unit: :km)`: one call returns a
table of `time`, `time_clock`, `pace`, and `pace_clock` per race (default
races: 5k, 10k, half marathon, marathon; `unit: :mi` for paces per mile)
- VO2max inputs outside 10–100 ml/kg/min raise `ArgumentError`, where the
Daniels & Gilbert model stops being physiologically meaningful

Predictions come from bisecting the Daniels & Gilbert curve on the time axis
(it has no closed-form inverse), so
`estimate_vo2max(d, predict_time_from_vo2max(v, d))` returns `v` back. Times
match Daniels' published VDOT table within a few seconds for the shorter races
and about a minute for the marathon.

No existing behaviour changed: the Riegel (`predict_time`) and Cameron
predictors are untouched.

## [1.11.0] - 2026-07-25

### Added
Expand Down Expand Up @@ -269,7 +294,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

See git history for changes in earlier versions.

[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.10.0...HEAD
[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.12.0...HEAD
[1.12.0]: https://github.com/0jonjo/calcpace/compare/v1.11.0...v1.12.0
[1.11.0]: https://github.com/0jonjo/calcpace/compare/v1.10.0...v1.11.0
[1.10.0]: https://github.com/0jonjo/calcpace/compare/v1.9.10...v1.10.0
[1.9.6]: https://github.com/0jonjo/calcpace/compare/v1.9.5...v1.9.6
[1.9.5]: https://github.com/0jonjo/calcpace/compare/v1.9.4...v1.9.5
Expand Down
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Calcpace [![Gem Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=rb&r=r&ts=1683906897&type=6e&v=1.10.0&x2=0)](https://badge.fury.io/rb/calcpace)
# 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.

## Installation

```ruby
gem 'calcpace', '~> 1.10.0'
gem 'calcpace', '~> 1.12.0'
```

## Usage
Expand Down Expand Up @@ -323,6 +323,42 @@ pace bands, and age-grading tolerances agree to the metre.

---

### Fitness Predictor (race times from VO2max)

The inverse of `estimate_vo2max`: what a given fitness is worth over a race.

```ruby
calc.predict_time_from_vo2max(50, '5k') # => 1196.02 (seconds)
calc.predict_time_from_vo2max_clock(50, 'marathon') # => "03:10:39"

calc.predict_time_from_vo2max(50, 10.0) # numeric distance in km
calc.predict_time_from_vo2max_clock(50, 6.2, distance_unit: :mi) # => "00:41:13"

calc.race_times_from_vo2max(50)['10k']
# => { time: 2479.6, time_clock: "00:41:19", pace: 247.96, pace_clock: "00:04:07" }

calc.race_times_from_vo2max(50, races: %w[5k 10mile], unit: :mi)['5k']
# => { time: 1196.02, time_clock: "00:19:56", pace: 384.96, pace_clock: "00:06:24" }
```

`race_times_from_vo2max` returns the whole table in one call — default races are
`5k`, `10k`, `half_marathon`, and `marathon`, and `unit:` sets the pace unit. In
`predict_time_from_vo2max`, `distance_unit:` sets the unit of a numeric distance;
combining it with a race name raises `ArgumentError`, as elsewhere in the gem.

The Daniels & Gilbert curve has no closed-form inverse, so the time is found by
bisection — which makes the round trip exact:

Comment on lines +349 to +351
```ruby
calc.estimate_vo2max(5.0, calc.predict_time_from_vo2max(50, '5k')) # => 50.0
```

Predictions match Daniels' published VDOT table within a few seconds for the shorter
races and about a minute for the marathon. VO2max values outside 10–100 ml/kg/min
raise `ArgumentError` — beyond that range the model stops describing running.

---

### Other Utilities

```ruby
Expand Down
2 changes: 2 additions & 0 deletions lib/calcpace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require_relative 'calcpace/converter'
require_relative 'calcpace/converter_chain'
require_relative 'calcpace/errors'
require_relative 'calcpace/fitness_predictor'
require_relative 'calcpace/pace_calculator'
require_relative 'calcpace/pace_converter'
require_relative 'calcpace/race_predictor'
Expand Down Expand Up @@ -45,6 +46,7 @@ class Calcpace
include Checker
include Converter
include ConverterChain
include FitnessPredictor
include PaceCalculator
include PaceConverter
include RacePredictor
Expand Down
4 changes: 2 additions & 2 deletions lib/calcpace/converter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ def convert_to_seconds(time)
# convert_to_clocktime(3600) #=> '01:00:00' (1 hour)
# convert_to_clocktime(100000) #=> '1 03:46:40' (1 day, 3 hours, 46 minutes, 40 seconds)
def convert_to_clocktime(seconds)
days = seconds / 86_400
format = days.to_i.positive? ? "#{days} %H:%M:%S" : '%H:%M:%S'
days = (seconds / 86_400).to_i
format = days.positive? ? "#{days} %H:%M:%S" : '%H:%M:%S'
Time.at(seconds).utc.strftime(format)
end

Expand Down
175 changes: 175 additions & 0 deletions lib/calcpace/fitness_predictor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# frozen_string_literal: true

# Module for predicting race performances from a VO2max value
#
# This is the inverse of Vo2maxEstimator#estimate_vo2max: instead of asking
# "what fitness does this race result imply?", it asks "what race result does
# this fitness imply?".
#
# The Daniels & Gilbert (1979) model cannot be inverted in closed form — the
# %VO2max term mixes two exponentials of time with a quadratic in velocity —
# so the finish time is found by bisection on the time axis. VO2max decreases
# monotonically with time for a fixed distance, which makes the search exact
# to within the tolerance and guarantees the round trip:
#
# estimate_vo2max(distance, predict_time_from_vo2max(vo2max, distance)) == vo2max
#
Comment on lines +12 to +16
# Predicted times reproduce Daniels' published VDOT table within a few seconds
# for the shorter races and about a minute for the marathon.
module FitnessPredictor
# Range of VO2max values the model is meaningful for. Below it the effort is
# slower than a walk, above it faster than any human has run — in both cases
# the resulting "prediction" would be arithmetic, not physiology.
SUPPORTED_VO2MAX_RANGE = (10.0..100.0)

# Bisection bounds, as seconds per kilometre: from 1:00/km (well beyond world
# record pace) to 20:00/km (slower than walking). They bracket every VO2max
# in SUPPORTED_VO2MAX_RANGE at any distance.
FASTEST_PACE_SECONDS_PER_KM = 60.0
SLOWEST_PACE_SECONDS_PER_KM = 1200.0

# Search stops when the bracket is tighter than this many seconds or when the
# VO2max at the midpoint is this close to the target
TIME_TOLERANCE_SECONDS = 0.001
VO2MAX_TOLERANCE = 1e-6

# Races reported by #race_times_from_vo2max when none are given
DEFAULT_RACES = %w[5k 10k half_marathon marathon].freeze

# Predicts the finish time a given VO2max is worth over a given race
#
# @param vo2max [Numeric] VO2max in ml/kg/min (must be within SUPPORTED_VO2MAX_RANGE)
# @param race [Numeric, String, Symbol] race distance in kilometres (or in
# miles via distance_unit: :mi), either numeric or as a numeric string
# ('10', '21.0975'), or a standard race name ('5k', '10k', 'half_marathon',
# 'marathon', '1mile', '5mile', '10mile', '100k' — see
# PaceCalculator::RACE_DISTANCES)
# @param distance_unit [Symbol, nil] unit of a numeric race distance — :km
# (default) or :mi. Rejected when race is a race name: standard races
# already carry their own distance
# @return [Float] predicted finish time in seconds
# @raise [Calcpace::NonPositiveInputError] if vo2max or distance are not positive
# @raise [ArgumentError] if vo2max is outside the supported range, if a race
# name is not recognized, or if distance_unit is combined with a race name
# @raise [Calcpace::UnsupportedUnitError] if distance_unit is not :km or :mi
#
# @note estimate_vo2max can report values below 10 for very slow efforts
# (walking pace); those estimates are outside this predictor's supported
# range on purpose — a race plan built on them would be meaningless
#
# @example
# calc.predict_time_from_vo2max(50, '5k') #=> 1196.02 (≈19:56)
# calc.predict_time_from_vo2max(50, 'marathon') #=> 11439.74 (≈3:10:39)
# calc.predict_time_from_vo2max(50, 6.2, distance_unit: :mi)
def predict_time_from_vo2max(vo2max, race, distance_unit: nil)
target = validated_vo2max(vo2max)
distance_km = predicted_race_distance_km(race, distance_unit)
check_positive(distance_km, 'Distance')

solve_time_for_vo2max(target, distance_km)
end

# Predicts the finish time and returns it as a clock time string
#
# @param (see #predict_time_from_vo2max)
# @return [String] predicted finish time in HH:MM:SS format
#
# @example
# calc.predict_time_from_vo2max_clock(50, 'marathon') #=> '03:10:39'
def predict_time_from_vo2max_clock(vo2max, race, distance_unit: nil)
convert_to_clocktime(predict_time_from_vo2max(vo2max, race, distance_unit: distance_unit))
end

# Builds a full race-time table for one VO2max — one call per dashboard
#
# @param vo2max [Numeric] VO2max in ml/kg/min
# @param races [Array<String, Symbol>, nil] race names to report
# (default: 5k, 10k, half marathon, marathon)
# @param unit [Symbol] unit of the returned paces — :km (default) or :mi
# @return [Hash{String => Hash}] race name => { time: seconds,
# time_clock: 'HH:MM:SS', pace: seconds per unit, pace_clock: 'HH:MM:SS' }
# @raise [ArgumentError] if a race name is not recognized or vo2max is out of range
# @raise [Calcpace::NonPositiveInputError] if vo2max is not positive
# @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
#
# @example
# calc.race_times_from_vo2max(50)['10k']
# #=> { time: 2479.6, time_clock: '00:41:19', pace: 247.96, pace_clock: '00:04:07' }
# calc.race_times_from_vo2max(50, races: %w[5k 10mile], unit: :mi)
def race_times_from_vo2max(vo2max, races: nil, unit: :km)
meters = pace_unit_meters(unit)
validated_vo2max(vo2max)

Array(races || DEFAULT_RACES).to_h do |race|
[normalize_race_key(race), race_time_entry(vo2max, race, meters)]
end
end

private

def race_time_entry(vo2max, race, meters)
seconds = predict_time_from_vo2max(vo2max, race)
pace = (seconds / (race_distance(race) * Converter::Distance::KM_TO_METERS / meters)).round(2)

{
time: seconds,
time_clock: convert_to_clocktime(seconds),
pace: pace,
pace_clock: convert_to_clocktime(pace)
}
end

# Same distance semantics as TrainingZones#training_paces_from_race: numeric
# strings ('10') stay distances, only race names fall through to the lookup
def predicted_race_distance_km(race, distance_unit)
numeric = race.is_a?(Numeric) ? race : Float(race, exception: false)
return normalize_distance_km(numeric, distance_unit || :km) if numeric

reject_distance_unit_with_race_name!(distance_unit, race)
race_distance(race)
end

def validated_vo2max(vo2max)
value = vo2max.to_f
check_positive(value, 'VO2max')
return value if SUPPORTED_VO2MAX_RANGE.cover?(value)

raise ArgumentError,
"VO2max #{value} is outside the supported range " \
"(#{SUPPORTED_VO2MAX_RANGE.min}–#{SUPPORTED_VO2MAX_RANGE.max} ml/kg/min)"
end

# Bisects the time axis for the finish time whose VO2max equals the target
def solve_time_for_vo2max(target, distance_km)
low = distance_km * FASTEST_PACE_SECONDS_PER_KM
high = distance_km * SLOWEST_PACE_SECONDS_PER_KM
ensure_vo2max_reachable!(target, distance_km, low, high)

while high - low > TIME_TOLERANCE_SECONDS
mid = (low + high) / 2.0
value = raw_vo2max(distance_km, mid)
return mid.round(2) if (value - target).abs < VO2MAX_TOLERANCE

# VO2max falls as time grows: a midpoint fitter than the target means
# the answer is a slower time (raise low), otherwise a faster one
if value > target
low = mid
else
high = mid
end
end

((low + high) / 2.0).round(2)
end

# Defensive bracket check: the search bounds cover the whole supported range
# at every distance, so this only fires if those constants are ever widened
def ensure_vo2max_reachable!(target, distance_km, low, high)
reachable = raw_vo2max(distance_km, high)..raw_vo2max(distance_km, low)
return if reachable.cover?(target)

raise ArgumentError,
"VO2max #{target} is not reachable over #{distance_km} km within the search bounds " \
"(#{reachable.begin.round(1)}–#{reachable.end.round(1)} ml/kg/min)"
end
end
2 changes: 1 addition & 1 deletion lib/calcpace/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

class Calcpace
VERSION = '1.11.0'
VERSION = '1.12.0'
end
11 changes: 11 additions & 0 deletions lib/calcpace/vo2max_estimator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ def vo2_at_velocity(velocity)
-4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2))
end

# Unrounded Daniels & Gilbert VO2max for a distance/time pair. Lives next to
# the formula it composes so the two cannot drift apart: FitnessPredictor
# bisects against this to guarantee an exact round trip with the public
# estimator, which only differs by rounding to one decimal.
def raw_vo2max(distance_km, seconds)
time_min = seconds / 60.0
velocity = distance_km * Converter::Distance::KM_TO_METERS / time_min

vo2_at_velocity(velocity) / percent_vo2max(time_min)
end

def percent_vo2max(time_min)
0.8 +
(0.1894393 * Math.exp(-0.012778 * time_min)) +
Expand Down
6 changes: 6 additions & 0 deletions test/calcpace/test_converter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ def test_convert_to_clocktime_more_than_24_hours
assert_equal '1 03:46:40', @calc.convert_to_clocktime(100_000)
end

def test_convert_to_clocktime_more_than_24_hours_with_float_seconds
# Float seconds must not leak fractional days into the day prefix
# (predictors return Float times, e.g. a 100k at VO2max 10)
assert_equal '1 03:46:40', @calc.convert_to_clocktime(100_000.75)
end

def test_mile_factors_derive_from_a_single_canonical_value
# One international mile is exactly 1609.344 m — every mile factor derives
# from it, so no two call sites can disagree about how long a mile is
Expand Down
Loading
Loading