feat: fitness predictor (race times from VO2max) v1.12.0 - #82
Conversation
The gem could turn a race result into a VO2max but not the other way
round, which is the question runners actually ask ("I'm a 50 VDOT — what
should I run?"). FitnessPredictor closes the loop.
Daniels & Gilbert has no closed-form inverse: the %VO2max term mixes two
exponentials of time with a quadratic in velocity. Since VO2max falls
monotonically with time at a fixed distance, bisecting the time axis
solves it exactly to tolerance, and the round trip through
estimate_vo2max returns the original value.
The search runs on the unrounded formula rather than estimate_vo2max
itself, whose one-decimal rounding turns the curve into steps and would
cap round-trip accuracy at the size of a step.
VO2max is constrained to 10-100 ml/kg/min: outside it the bisection
still converges, but on a "prediction" that is arithmetic, not
physiology.
race_times_from_vo2max returns times and paces for several races at once
so a dashboard needs a single call.
The badge URL pinned a cached CDN render of v1.10.0, so the README kept showing an old version after every release. The plain badge.fury.io SVG always reflects the latest published gem.
There was a problem hiding this comment.
Pull request overview
Adds a new FitnessPredictor capability to Calcpace that predicts race finish times (and derived paces) from a given VO2max value by numerically inverting the existing Daniels & Gilbert VO2max estimation curve.
Changes:
- Introduces
FitnessPredictorwithpredict_time_from_vo2max,predict_time_from_vo2max_clock, andrace_times_from_vo2max, mixed intoCalcpace. - Adds a dedicated test suite covering round-trip behavior, validation rules, and table output.
- Updates documentation, changelog, and bumps gem version to
1.12.0.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/calcpace/fitness_predictor.rb | New predictor implementation using bisection inversion of the VO2max model. |
| lib/calcpace.rb | Wires the new module into the public Calcpace API (require + include). |
| test/calcpace/test_fitness_predictor.rb | Adds tests for round-trip accuracy, inputs/validation, and race-time table output. |
| README.md | Updates gem version and documents the new Fitness Predictor API. |
| CHANGELOG.md | Adds the 1.12.0 release notes describing the new predictor. |
| lib/calcpace/version.rb | Bumps version constant to 1.12.0. |
Suppressed comments (3)
lib/calcpace/fitness_predictor.rb:59
- The method examples show specific numeric outputs that don’t match the README/examples in this PR and are likely to drift if constants change. Consider using approximate values (or aligning to the documented outputs) to keep the docs consistent.
# @example
# calc.predict_time_from_vo2max(50, '5k') #=> 1194.32 (≈19:54)
# calc.predict_time_from_vo2max(50, 'marathon') #=> 11430.5 (≈3:10:30)
# calc.predict_time_from_vo2max(50, 6.2, distance_unit: :mi)
lib/calcpace/fitness_predictor.rb:94
- The
race_times_from_vo2maxexample values (time/time_clock) don’t match the README section added in this PR. Keeping these examples consistent avoids confusion for users copying snippets.
# @example
# calc.race_times_from_vo2max(50)['10k']
# #=> { time: 2477.4, time_clock: '00:41:17', pace: 247.74, pace_clock: '00:04:07' }
# calc.race_times_from_vo2max(50, races: %w[5k 10mile], unit: :mi)
lib/calcpace/fitness_predictor.rb:74
- The clock-time example output appears inconsistent with the README examples in this PR. Updating the example (or making it approximate) would keep the public docs aligned.
# calc.predict_time_from_vo2max_clock(50, 'marathon') #=> '03:10:30'
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 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 | ||
| # |
| The Daniels & Gilbert curve has no closed-form inverse, so the time is found by | ||
| bisection — which makes the round trip exact: | ||
|
|
Code-review follow-ups on the fitness predictor:
- convert_to_clocktime leaked the raw Float day count into the day
prefix ('1.0426... 01:01:27') whenever seconds came in as a Float,
which every predictor produces; the day count is now an integer
- race_times_from_vo2max validates the VO2max before iterating, so an
empty race list no longer skips validation, and a bare race name is
accepted where an array was required
- YARD examples updated to the values the code actually returns, and
the README paragraph no longer attributes distance_unit: to
race_times_from_vo2max, which does not accept it
- raw_vo2max moves into Vo2maxEstimator, next to the formula it composes — the round-trip contract can no longer drift if the estimator changes - bracket-check error message uses Range#begin/#end (Range#min returns nil on a descending range, which turned the guard's only failure scenario into a NoMethodError) - table paces rounded to two decimals like every other public value - VDOT sanity tolerances tightened to the measured error (was up to 300 s slack on a marathon for a model that is within 32 s) - bisection branch rewritten as if/else with an accurate comment, and a @note documents that sub-10 estimates from estimate_vo2max are rejected here on purpose - CHANGELOG link references added for 1.11.0 and 1.12.0
They were untracked scratch files from the review pass; git ls-files feeds the gemspec, so anything tracked at the root ships in the gem.
Review cycle summaryAn adversarial code review ran against this branch (full report kept locally in What the review verified by execution (not by reading)
Fixed after review
Accepted, documented trade-offs
State: 339 tests green, RuboCop clean. Reminder: merging publishes 1.12.0 to RubyGems automatically. |
Summary
Adds
FitnessPredictor, the inverse ofVo2maxEstimator#estimate_vo2max: instead of "what fitness does this race result imply?", it answers "what race result does this fitness imply?" — the question runners actually ask.New public methods (all mixed into
Calcpace):predict_time_from_vo2max(vo2max, race, distance_unit: nil)— predicted finish time in secondspredict_time_from_vo2max_clock(...)— same prediction asHH:MM:SSrace_times_from_vo2max(vo2max, races: nil, unit: :km)— a full table (time,time_clock,pace,pace_clock) for several races in one callraceaccepts a standard race name ('5k','marathon','5mile', ...) or a numeric distance in kilometres (miles viadistance_unit: :mi) — the same semantics and validation astraining_paces_from_race, including theArgumentErrorwhendistance_unit:is combined with a race name.Approach
Daniels & Gilbert has no closed-form inverse: the
%VO2maxterm mixes two exponentials of time with a quadratic in velocity. Since VO2max decreases monotonically with time at a fixed distance, the finish time is found by bisecting the time axis between 1:00/km and 20:00/km, which is exact to tolerance.Two details worth calling out:
estimate_vo2maxitself. The public method rounds to one decimal, which turns the curve into steps and would cap round-trip accuracy at the size of a step.ArgumentErroroutside). The bisection still converges beyond that, but on a number that is arithmetic rather than physiology.Round trip is guaranteed:
estimate_vo2max(d, predict_time_from_vo2max(v, d)) == v.Accuracy vs Daniels' published VDOT table: within a few seconds for the shorter races, about a minute for the marathon (VDOT 50 → 5k 19:56 vs 19:57; marathon 3:10:39 vs 3:10:49).
Usage
Checklist
test/calcpace/test_fitness_predictor.rb: round trip (delta 0.05) across five distance/VO2max pairs, VDOT-table sanity at 30/50/70, race names + numeric + numeric-string + symbol inputs,distance_unit: :mi,unit: :mipaces, monotonicity (5k < 10k < half < marathon; higher VO2max → faster), and every error pathbundle exec rake test— 335 runs, 0 failures (was 311)bundle exec rubocop— 39 files inspected, no offensespredict_time) and Cameron predictors untouchedmainpublishes to RubyGems.