Skip to content

feat: fitness predictor (race times from VO2max) v1.12.0 - #82

Merged
0jonjo merged 6 commits into
mainfrom
feat/fitness-predictor-v1.12.0
Aug 1, 2026
Merged

feat: fitness predictor (race times from VO2max) v1.12.0#82
0jonjo merged 6 commits into
mainfrom
feat/fitness-predictor-v1.12.0

Conversation

@0jonjo

@0jonjo 0jonjo commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Adds FitnessPredictor, the inverse of Vo2maxEstimator#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 seconds
  • predict_time_from_vo2max_clock(...) — same prediction as HH:MM:SS
  • race_times_from_vo2max(vo2max, races: nil, unit: :km) — a full table (time, time_clock, pace, pace_clock) for several races in one call

race accepts a standard race name ('5k', 'marathon', '5mile', ...) or a numeric distance in kilometres (miles via distance_unit: :mi) — the same semantics and validation as training_paces_from_race, including the ArgumentError when distance_unit: is combined with a race name.

Approach

Daniels & Gilbert has no closed-form inverse: the %VO2max term 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:

  • The search runs on the unrounded formula, not on estimate_vo2max itself. 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.
  • VO2max is constrained to 10–100 ml/kg/min (ArgumentError outside). 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

calc = Calcpace.new

calc.predict_time_from_vo2max(50, '5k')             # => 1196.02
calc.predict_time_from_vo2max_clock(50, 'marathon') # => "03:10:39"
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" }

Checklist

  • TDD — tests written before the implementation
  • 24 new tests in 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: :mi paces, monotonicity (5k < 10k < half < marathon; higher VO2max → faster), and every error path
  • bundle exec rake test — 335 runs, 0 failures (was 311)
  • bundle exec rubocop — 39 files inspected, no offenses
  • No existing behaviour changed; Riegel (predict_time) and Cameron predictors untouched
  • Version bumped to 1.12.0, CHANGELOG entry, README section

⚠️ Draft — do not merge: merging to main publishes to RubyGems.

0jonjo added 2 commits August 1, 2026 08:38
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.
@0jonjo 0jonjo self-assigned this Aug 1, 2026
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.
@0jonjo
0jonjo marked this pull request as ready for review August 1, 2026 11:46
Copilot AI review requested due to automatic review settings August 1, 2026 11:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 FitnessPredictor with predict_time_from_vo2max, predict_time_from_vo2max_clock, and race_times_from_vo2max, mixed into Calcpace.
  • 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_vo2max example 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.

Comment on lines +12 to +16
# 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 thread README.md
Comment on lines +349 to +351
The Daniels & Gilbert curve has no closed-form inverse, so the time is found by
bisection — which makes the round trip exact:

0jonjo added 3 commits August 1, 2026 09:30
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.
@0jonjo

0jonjo commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Review cycle summary

An adversarial code review ran against this branch (full report kept locally in calcpace_code_review.md). Verdict: approved with reservations — no critical findings, 5 important, 9 minor, 7 nits. Everything actionable has been addressed in the three follow-up commits (733bf1b, c43cf6a, a337928).

What the review verified by execution (not by reading)

  • Monotonicity of the Daniels & Gilbert curve over time: 4,000 samples × 10 distances (0.1–250 km) → 0 violations, so bisection is sound.
  • Bracket safety: the 60–1200 s/km bounds cover VO2max ≈219–352 (fast end) down to ≈3.7–6.0 (slow end) at every distance — the whole supported 10–100 range is always bracketed.
  • Round trip: 5,406 cases (VO2max 10.0→100.0 step 0.1 × 6 races) → estimate_vo2max(d, predict_time_from_vo2max(v, d)) == v with 0 divergences.
  • VDOT accuracy: max error vs Daniels' published table is +31.9 s (marathon at VDOT 30); short races within ±2 s.

Fixed after review

  • convert_to_clocktime leaked a raw Float day count ("1.0426... 01:01:27") for any Float input over 24h — pre-existing bug the predictor put on an announced route; day count is now an integer, with tests.
  • YARD examples updated to the values the code actually returns; README no longer documents distance_unit: on race_times_from_vo2max, which does not accept it.
  • race_times_from_vo2max validates VO2max before iterating (an empty race list used to skip validation) and accepts a bare race name.
  • raw_vo2max moved into Vo2maxEstimator, next to the formula it composes — the round-trip contract can no longer drift.
  • Bracket-guard message used Range#min/#max, which return nil on a descending range — its only failure scenario raised NoMethodError instead of the intended error; now #begin/#end.
  • Table paces rounded to 2 decimals; VDOT test tolerances tightened from up-to-300 s slack to the measured error; bisection branch rewritten as if/else with an accurate comment; CHANGELOG link refs added; review scratch files removed from the tree (git ls-files feeds the gemspec).

Accepted, documented trade-offs

  • estimate_vo2max can report values below 10 for walking-pace efforts; the predictor rejects those on purpose (documented in a @note) — a race plan built on them would be meaningless.
  • races: takes race names only; numeric distances belong to predict_time_from_vo2max.

State: 339 tests green, RuboCop clean. Reminder: merging publishes 1.12.0 to RubyGems automatically.

@0jonjo
0jonjo merged commit a8d668a into main Aug 1, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants