Skip to content

feat: grade-adjusted pace and VO2max norms by age and sex - #96

Closed
0jonjo wants to merge 3 commits into
mainfrom
feat/grade-adjusted-pace-vo2-norms
Closed

0jonjo wants to merge 3 commits into
mainfrom
feat/grade-adjusted-pace-vo2-norms

Conversation

@0jonjo

@0jonjo 0jonjo commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Phase F4 of the physiology review. Purely additive: no version bump (CHANGELOG under [Unreleased]), and the existing methods return the same values as before. baseline.rb output is identical to baseline_1.18.1.txt.

D. Grade-adjusted pace (Minetti et al., 2002)

Cr(i) = 155.4·i⁵ − 30.4·i⁴ − 43.3·i³ + 46.3·i² + 19.5·i + 3.6 (J·kg⁻¹·m⁻¹, R² = 0.999), checked against the paper (Fig. 1 legend; treadmill range −0.45 to +0.45; measured level cost 3.40 ± 0.24).

  • grade_adjustment_factor(grade): Cr(i)/Cr(0). The grade is a fraction, clamped to ±0.45 (no extrapolation). Non-finite or non-numeric grades raise ArgumentError.
  • grade_adjusted_pace(pace, grade, unit: :km): pace / factor in s/unit. unit: is validated only, because the factor is per distance and doesn't depend on the unit.
  • grade_adjusted_pace_clock(pace, grade, unit: :km, compact: false).
  • track_grade_adjusted_splits(points, split_km = 1.0, compact: false): the same :km/:elapsed/:pace as track_splits (same boundaries), plus :gap. Each split's distance is weighted by the grade factor of the segments it covers. Noise handling: each grade is measured over a segment of at least 100 m of horizontal distance. A short leftover at the end of a stretch merges into the segment before it. If there is no full segment before it to merge into (it sits between missing fixes, or the whole track is that short), it counts as flat. A missing or NaN/infinite :ele also counts as flat and closes the current segment. Distances are horizontal, with no √(1+i²) slope correction (0.5% at 10%).
  • estimate_detailed_vo2max keeps its heuristic; its docstring now points to GAP.
Grade −10% −5% 0% +5% +10%
Factor 0.5977 0.7628 1.0000 1.3014 1.6578

E. VO2max norms by age and sex

Source: Kaminsky LA, Arena R, Myers J. Mayo Clin Proc 2015;90(11):1515–1523, Table 3, rows "Men/Women from FRIEND". VO2max was measured on a treadmill (CPX) in 7,783 tests on adults free of known CVD; DOI 10.1016/j.mayocp.2015.07.026, PMC4919021. The data is stored in lib/calcpace/data/friend_2015_vo2max_percentiles.yml.

The same Table 3 also has the Cooper Clinic norms from ACSM GETP 9th ed. (2014, pp. 88–93). I didn't use them for two reasons:

  • they are predicted from Balke treadmill time, not measured;

  • their spread is much narrower (the 95th percentile for men 20–29 is 55.5, against 66.3 measured).

  • vo2max_label(value, age: nil, sex: nil): without age/sex the result is exactly what it was before. With both, it uses the same six labels on published percentile cuts: ≥95 Elite, ≥90 Excellent, ≥75 Very Good, ≥50 Good, ≥25 Fair, else Beginner. Passing only one of the two raises ArgumentError.

  • vo2max_percentile(value, age:, sex:): linear interpolation between the published percentiles, bounded to 5–95 and rounded to 1 decimal. Age bands are the published decades with no blending between them. Ages 18–19 use the 20–29 row, 80+ use the 70–79 row, and under 18 raises. Age and sex are validated with AgeGrading's validators.

VO2 45 M25 M60 F25 F60
percentile 40.5 95.0 75.7 95.0
label Fair Elite Very Good Elite

The new code lives in its own module (Vo2maxNorms) to stay under the ModuleLength limit.

Review

An adversarial review agent found no blockers. It did a byte-level comparison of track_splits, track_distance, elevation_gain, vo2max_label and estimate_detailed_vo2max against main on 3000 random tracks, and the output was identical. It also checked the GAP apportionment against an exact reference and the YAML against PMC4919021. Its should-fix items are addressed in fd54e47:

  • short isolated stretches with elevation are now flat;
  • non-finite :ele is handled;
  • the default track_splits speed is back (it was 48% slower; now about 5%);
  • the citation wording is fixed.

Uncertainties

  • Minetti's model is metabolic. On steep climbs it is more aggressive than field models fitted to heart rate (e.g. Strava's).
  • The 100 m segment is a judgement call. Elevation noise at a split's two endpoints still moves a 1 km split's GAP by a few seconds.
  • FRIEND only has 7 percentiles per row. Linear interpolation is an approximation, and anything beyond the 95th percentile reads as 95.0.
  • I couldn't verify the ACSM 10th/11th edition table numbers (paywalled), so I cite the primary paper instead.

0jonjo added 3 commits October 2, 2026 06:35
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.
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.
- 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.
@0jonjo

0jonjo commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #97 (released as 2.0.0).

@0jonjo 0jonjo closed this Oct 2, 2026
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.

1 participant