diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e1b5f..f96f2a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,358 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0] - 2026-10-02 + +A major release. Four physiological models gave unrealistic numbers and now +give different ones (Cameron prediction, altitude, race splits, heat), the +marathon pace band and age grading move to their proper sources, input +validation is stricter, and there are new personalized predictions, +humidity, grade-adjusted pace and VO2max norms. Method names, signatures and +return shapes are unchanged except where listed under Breaking; the +`environmental_factors.yml` keys are unchanged. + +### Breaking +- **Invalid clocks raise, everywhere.** Every method that takes a time or pace + string now validates it: `convert_to_seconds` itself, and so `checked_*`, + `race_time`/`race_pace` (and `_clock`), `race_splits` (`target_time:`), + `convert_pace`, `pace_km_to_mi`, `pace_mi_to_km`, + `predict_time`/`predict_pace` (and `_clock`), `equivalent_performance`, + `predict_time_adjusted`, every Cameron method, `riegel_exponent`, + `predict_time_personal`, `predict_marathon_from_training`, + `estimate_vo2max`, `estimate_detailed_vo2max`, `training_paces_from_race`, + `age_grade`, `stride_length`, `cadence_for_stride` and `grade_adjusted_pace` + (and `_clock`). Before, `check_time` accepted any two digits per field + (`'05:99'`, `'1:60:00'`), and the methods that did not call it turned such + strings — or garbage like `'abc'` — into the wrong number of seconds, + `convert_to_seconds` returning `0` for anything it could not split and + dropping the sign of `'-0:40'`. + - Accepted — every clock the gem writes (`Checker::CLOCK_FORMAT`): + `H:MM:SS` with any number of hour digits (`'400:00:00'`); `M:SS` with any + number of minute digits, counting past the hour (`'75:00'`, `'123:45'`); the + `'D HH:MM:SS'` day prefix of `convert_to_clocktime` (`'1 03:46:40'`); and a + leading `-` (`track_splits` writes `'-0:40'` for a backwards split; + `convert_to_seconds` returns `-40`). A few clocks the gem never writes + parse too, such as `'-1 03:46:40'`, `'0:00:00'` or `'000000000:00'`. + - Rejected with `Calcpace::InvalidTimeFormatError`: seconds that are not two + digits below 60; minutes that are not two digits below 60 when hours are + given (`'1:60:00'`, `'1:5:00'`); after a day prefix, hours that are not two + digits below 24 (`'1 3:46:40'`, `'1 24:00:00'`); a `+` sign, blanks, + surrounding whitespace, non-ASCII digits, more than three fields, strings + in an invalid or non-ASCII-compatible encoding (broken UTF-8, UTF-16, + UTF-32), and anything that is not a String. + - A negative clock parses, but every method that needs a positive time or + pace rejects it with `Calcpace::NonPositiveInputError`, as it does a + clock too large for a Float (see Fixed). Numeric (seconds) inputs are + unaffected. +- **Cameron predictions are limited to 100 km.** `predict_time_cameron`, + `predict_time_cameron_clock`, `predict_pace_cameron`, + `predict_pace_cameron_clock` and `predict_time_cameron_adjusted` raise + `ArgumentError` when either distance exceeds `CAMERON_MAX_DISTANCE_KM` + (100 km, which keeps the standard `'100k'` race usable). Cameron's model is + fitted from 800 m to the marathon and its `f(d)` crosses zero near 445 km: + without the limit a 500 km target got a negative time. +- **Removed `CameronPredictor::CAMERON_A`, `CAMERON_B` and `CAMERON_C`.** They + described the wrong formula (see Changed). The model's constants are now + `CAMERON_CONSTANT`, `CAMERON_LINEAR_COEFFICIENT`, `CAMERON_POWER_COEFFICIENT` + and `CAMERON_POWER_EXPONENT`. +- **`AgeGrading::WMA_DATA` lost its track keys and changed its age range.** + Each sex now holds only the road distances the gem grades — `"5000"`, + `"10000"`, `"21097"` and `"42195"`; code that read `WMA_DATA["M"]["1500"]` + (or `"3000"`) gets `nil` / `KeyError`. Its factor tables run from age 18 to + 100 (they were 30–110). `table_version` is now + `"MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"` (was + `"WMA_2023_ONE_YEAR_FACTORS_V1"`), and the data files were renamed to + `lib/calcpace/data/mldr_2025_road.yml` and + `lib/calcpace/data/mldr_2025_road_open_standards.yml`. `DATA_PATH`, + `OPEN_STANDARDS_DATA_PATH`, `WMA_DATA`, `OPEN_STANDARDS_DATA` and + `TABLE_VERSION` keep their names. + +### Changed (numbers) +- **Cameron prediction uses Dave Cameron's actual model.** The previous + constants (`a + b·e^(−d/c)` with a = 0.000495, b = 0.000985, c = 1.4485) + were not Cameron's formula and were far more optimistic than Riegel for the + marathon, while the real model is more conservative. The Cameron methods now + use his velocity-ratio function with distances in metres, as in his own + metric version (t-and-f mailing list, 20 Jun 2001) and the had2know.org + calculator: `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905`, + `T2 = T1 · (D2/D1) · f(D1)/f(D2)`. Distances are still passed in km or as + race names. +- **Altitude no longer jumps at 914 m and no longer stops at 2438 m.** The + penalty starts at 300 m (new `300: 0.0` point) and ramps linearly to the + first NCAA point (914.4 m → 1.41%) instead of jumping from 0% at 914 m to + 1.41% at 915 m. The NCAA points up to 2438.4 m (5.90%) are unchanged. Above + it, where everything used to be capped at 5.90%, three points come from a + quadratic fit to the NCAA table (`p = 0.3647·x² + 1.9482·x`, + `x = km − 0.3`): 3000 m → 7.92%, 3500 m → 9.97%, 4000 m → 12.2% (capped + there). The redundant `0: 0.0` point is gone. +- **Negative and positive race splits are ±1% per half instead of ±4%.** A + 3:00:00 marathon with `strategy: :negative` used to go through halfway in + 1:33:36 (a 7-minute negative split); it now splits 1:30:54 + 1:29:06. +- **Heat: base curve and duration factor fitted jointly to marathon data, and + capped at 35 °C.** The 60-minute base was 2.8 / 4.3 / 6.5% at + 20 / 25 / 30 °C, flat from 30 °C up (35 °C and 40 °C read like 30 °C), and + the duration factor was 1.0× (60 min) → 3.0× (3 h) → 4.5× (4 h), its 3 h + point justified by Ely 2007 percentages that the paper's abstract does not + contain. Both are now fitted to El Helou et al. (2012, PLoS One + 7(5):e37407, Table S3; 1.79 M finishers of six majors, 2001–2010): + 1. speed loss at 15, 20 and 25 °C, straight line between the table's points + (each group's optimum −10 … +20 °C); + 2. time penalty against 15 °C, where the gem's curve is zero: + `P = ((1 − loss15) / (1 − lossT) − 1) × 100`; + 3. **base shape**: P25/P20 is 2.70–2.97 in every group, so `P ∝ (T − 15)^p` + with `p = log2(P25/P20)`; the sex-weighted mean (each sex half the + weight) is 1.497 → **1.5**. Base = `4.3 · ((T − 15)/10)^1.5`, keeping the + 25 °C / 60-minute anchor of 4.3%, stored every 2.5 °C from 15 to 40 °C + (linear interpolation stays within 0.08 points of the curve): 0, 0.54, + 1.52, 2.79, 4.3, 6.01, 7.9, 9.95, 12.16, then 12.16 and 12.16 at 37.5 + and 40 °C. **Capped at 35 °C**: 35–40 °C and hotter all read like 35 °C, + because above 25 °C the curve is an extrapolation (El Helou's hottest + race was 25.2 °C) and the uncapped law (14.51 at 37.5 °C, 17.0 at 40 °C) + gave 47.77% for 4 h at 40 °C; + 4. ratio = P ÷ base(T); finish time = 42195 m ÷ the group's speed at its + optimum; + 5. **duration factor**: weighted least squares over the 15 ratios (men P1 at + 25 °C is beyond the table), each sex half the weight, 0.5× (≤30 min) and + 1.0× (60 min) kept, 180 and 240 min free, flat after 240: 1.761 / 2.814 → + **1.76× at 3 h, 2.81× at 4 h** (1.38× at 2 h on the straight line). A + free 150-min point cut the weighted residual by 1%; a free 210-min point + made the curve non-monotonic (2.97 > 2.73 at 240). Neither was kept. + + | Group | Finish | loss@15 | loss@20 | loss@25 | P20 | P25 | P25/P20 | ratio @20 | ratio @25 | + | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + | men P1 | 2:41 | 1.88 | 3.93 | n/a | 2.14 | n/a | n/a | 1.41 | n/a | + | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 2.89 | 1.59 | 1.63 | + | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 2.78 | 2.93 | 2.89 | + | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 2.86 | 3.40 | 3.43 | + | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 2.70 | 1.99 | 1.90 | + | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 2.73 | 2.23 | 2.16 | + | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 2.97 | 3.57 | 3.74 | + | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 2.78 | 2.33 | 2.29 | + + Losses and P in %. With the new base each group's ratio is almost the same + at 20 and 25 °C, so `base(T) × duration_factor(seconds)` fits; the weighted + residual in penalty points drops from 18.3 (linear base, 1.24×/2.18×) to + 8.8, and what remains is mostly sex (see Known limitations). Ely et al. + (2007) stays a qualitative source (top men 1.7 / 2.5 / 3.3 / 4.5% off the + course record across WBGT 5–10 … 20–25 °C, i.e. +2.8 points); the model + gives a 2:10 effort 4.03% at 22.5 °C against 0% at 7.5 °C, a little above + that for elite runners. The duration points live in the new + `EnvironmentalAdjuster::HEAT_DURATION_FACTORS`; `duration_factor(time_seconds)` + is unchanged. + + | Heat penalty (%), 1.18.1 → 2.0.0 | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | + | --- | --- | --- | --- | --- | --- | --- | + | 20 °C | 1.4 → 0.76 | 2.8 → 1.52 | 5.6 → 2.1 | 8.4 → 2.68 | 12.6 → 4.27 | 12.6 → 4.27 | + | 25 °C | 2.15 → 2.15 | 4.3 → 4.3 | 8.6 → 5.93 | 12.9 → 7.57 | 19.35 → 12.08 | 19.35 → 12.08 | + | 30 °C | 3.25 → 3.95 | 6.5 → 7.9 | 13.0 → 10.9 | 19.5 → 13.9 | 29.25 → 22.2 | 29.25 → 22.2 | + | 35 °C | 3.25 → 6.08 | 6.5 → 12.16 | 13.0 → 16.78 | 19.5 → 21.4 | 29.25 → 34.17 | 29.25 → 34.17 | + | 40 °C | 3.25 → 6.08 | 6.5 → 12.16 | 13.0 → 16.78 | 19.5 → 21.4 | 29.25 → 34.17 | 29.25 → 34.17 | + +- **The marathon pace band ends at the runner's predicted marathon pace.** + Daniels' M pace is the predicted marathon race pace, but + `training_paces(50)[:marathon]` ran from 4:50 to 4:25/km (75–84% VO2max) + while the VDOT marathon prediction for VO2max 50 is 3:10:39, 4:31/km. The + fast end now comes from `predict_time_from_vo2max(vo2max, 'marathon')` + (0.800–0.849 of VO2max across VO2max 10–100; 0.805 at 30, 0.830 at 70); the + slow end stays at 75%. Beyond VO2max 10–100 the race-pace fraction of the + nearest bound is used, so `training_paces` still accepts any positive + VO2max. `TRAINING_INTENSITIES` stays all-numeric + (`marathon: { low: 0.75, high: 0.84 }`, the nominal upper bound); the new + `PREDICTED_RACE_PACE_ZONES` (`%i[marathon]`) names the zones whose fast end + is the predicted race pace. The marathon and threshold bands no longer + overlap below VO2max ~69.5 (the threshold band starts at 0.83): M pace is + slower than T pace, as in Daniels. At VO2max 70 they touch (3:24). + + | VO2max | M band before | M band after | Predicted marathon pace | + | --- | --- | --- | --- | + | 30 | 7:15–6:38/km | 7:15–6:52/km | 6:52/km | + | 40 | 5:47–5:17/km | 5:47–5:27/km | 5:27/km | + | 50 | 4:50–4:25/km | 4:50–4:31/km | 4:31/km | + | 60 | 4:11–3:49/km | 4:11–3:52/km | 3:52/km | + | 70 | 3:41–3:22/km | 3:41–3:24/km | 3:24/km | + +- **Age grading uses the 2025 road tables.** Age factors and open standards + come from Alan Jones' 2025 road age-grading tables, approved on 2025-01-10 + by the USATF Masters Long Distance Running Council + ([source spreadsheets](https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files): + `MaleRoadStd2025.xlsx`, `FemaleRoadStd2025.xlsx`). The previous data, + despite the `wma_2023_road.yml` name, came from the WMA 2023 **track and + field** tables: track open standards (5000 m 12:35 / 14:06, 10 000 m + 26:11 / 29:01), an outdated women's marathon standard (2:14:04) and no + factors under age 30, so road age grades were off by about 1–3%. New open + standards — men: 5K 12:49, 10K 26:24, half 57:31, marathon 2:00:35; women: + 5K 13:54, 10K 28:46, half 1:02:52, marathon 2:09:56. There is one factor + per year of age from 18 to 100: runners under 30 get the table's real + factors instead of 1.0 (a male 18-year-old at 5K: 0.9995; a female + 30-year-old at 5K: 0.9959), and ages 101 and over use the age-100 factor. + Category labels are unchanged. + + | Case | 1.18.1 (WMA 2023 track) | 2.0.0 (2025 road) | Official 2025 | + | --- | --- | --- | --- | + | Male 40, marathon 3:30:00 | 58.8% (factor 0.9804) | 58.7% (0.9783) | 58.7% | + | Female 50, marathon 4:00:00 | 62.7% (0.8915) | 60.2% (0.8998) | 60.2% | + | Female 30, 5K 25:00 | 56.4% (1.0) | 55.8% (0.9959) | 55.8% | + | Male 60, half 1:50:00 | 63.3% (0.8264) | 64.7% (0.8082) | 64.7% | + | Male 55, 10K 45:00 | 69.0% (0.8438) | 68.9% (0.8511) | 68.9% | + + "Official" is age standard / time from the spreadsheets' `AgeStdSec` sheet, + at one decimal. + +Summary of the other models: + +| Case | 1.18.1 | 2.0.0 | +| --- | --- | --- | +| Cameron 10K 42:00 → marathon | 02:57:34 | 03:16:46 (Riegel 03:13:12) | +| Cameron 5K 20:00 → marathon | 02:59:25 | 03:15:11 (Riegel 03:11:49) | +| Cameron 5K 20:00 → 10K | 00:42:26 | 00:41:39 | +| Cameron 7.79 km 26:59 → half marathon | 01:13:44 | 01:17:26 | +| Altitude 500 m | 0.0% | 0.46% | +| Altitude 760 m (São Paulo) | 0.0% | 1.06% | +| Altitude 914 m / 915 m | 0.0% / 1.41% | 1.41% / 1.41% | +| Altitude 2800 m | 5.9% | 7.2% | +| Altitude 3600 m | 5.9% | 10.42% | +| Heat 20 °C, 60 min | 2.8% | 1.52% | +| Heat 30 °C, 60 min | 6.5% | 7.9% | +| Heat 35 °C / 40 °C, 60 min | 6.5% | 12.16% | +| Heat 25 °C, 2 h / 3 h / 4 h | 8.6% / 12.9% / 19.35% | 5.93% / 7.57% / 12.08% | +| Heat 30 °C, 4 h | 29.25% | 22.2% | +| Heat 35 °C / 40 °C, 4 h | 29.25% | 34.17% | +| Marathon band, VO2max 50 | 4:50–4:25/km | 4:50–4:31/km | +| Splits marathon 3:00:00 `:negative` (halves) | 1:33:36 + 1:26:24 | 1:30:54 + 1:29:06 | +| Splits marathon 3:00:00 `:positive` (halves) | 1:26:24 + 1:33:36 | 1:29:06 + 1:30:54 | + +### Added +- **Humidity in the heat penalty.** `calculate_penalty` — and therefore + `adjust_time`, `normalize_time`, `predict_time_adjusted` and + `predict_time_cameron_adjusted`, which forward their options — accepts + `humidity:` (relative humidity, 0–100 %) or `dew_point:` (in + `temperature_unit`). 30 °C dry and 30 °C at 80% (typical of coastal Brazil) + used to get the same penalty. The temperature is replaced by an effective + temperature: the air temperature that, at `REFERENCE_HUMIDITY` (50%), has + the same simplified WBGT (Australian Bureau of Meteorology: + `WBGT = 0.567·Ta + 0.393·e + 3.94`, `e` = vapour pressure in hPa) as the + real temperature and humidity, solved exactly by bisection. The heat curve + is read at the unrounded effective temperature, so `humidity: 50` gives + exactly the temperature-only numbers: 50% is the humidity at which that + WBGT equals the air temperature between 20 °C and 35 °C (51–56%), i.e. how + the temperature-only points already read. `factors` gains + `:effective_temperature_celsius` (rounded to 2 decimals) when humidity or + dew point is given. `ArgumentError` for humidity outside 0–100, NaN, + Complex or non-numeric; a dew point above the temperature, below −100 °C or + not a finite real number; both keywords together; or either without a + finite temperature. + + | 30 °C at | Effective temperature | 60 min | 4 h | + | --- | --- | --- | --- | + | 30% RH | 26.69 °C | 5.46% | 15.34% | + | 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% | + | 70% RH | 33.07 °C | 10.46% | 29.39% | + | 90% RH | 35.94 °C (capped at 35 °C) | 12.16% | 34.17% | + +- **Marathon from training volume.** + `predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km)` + predicts a marathon from the mean weekly distance and mean training pace of + the 8 weeks before the race, with Tanda (2011), *Journal of Human Sport and + Exercise* 6(3):511–520: `Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P`. + Returns `:time`, `:time_clock`, `:pace`, `:pace_clock` (in `unit`, `:km` or + `:mi`), `:within_validated_range` and `:out_of_range`, which lists any of + `:weekly_distance` (sample: 40.4–110.7 km/week), `:training_pace` + (253.3–330.6 s/km) and `:marathon_time` (167–216 min) that fall outside the + paper's sample. Out of range is a flag, not an error. + + ```ruby + calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:time_clock] # => "03:19:41" + ``` +- **Personal Riegel exponent.** `riegel_exponent(race1, time1, race2, time2)` + fits `ln(t2/t1) / ln(d2/d1)` to two performances, and + `predict_time_personal(race1, time1, race2, time2, to_race)` predicts with + it. A target between the two races is interpolated along the curve through + both, with the raw exponent (never clamped, independent of argument order); + a target outside the pair is extrapolated from the closer performance in + log-distance, with the exponent clamped to 1.01–1.20. Returns `:time`, + `:time_clock`, `:exponent`, `:raw_exponent` and `:clamped`; an exponent + that needed clamping usually means one of the races was not all-out. + + ```ruby + calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock] # => "03:38:03" + ``` +- **Grade-adjusted pace** from the energy cost of running on gradients of + Minetti et al. (2002), J Appl Physiol 93:1039–1046: + `grade_adjustment_factor(grade)` (Cr(i)/Cr(0), grade as a fraction, clamped + to the measured ±0.45), `grade_adjusted_pace(pace, grade, unit: :km)` and + `grade_adjusted_pace_clock(pace, grade, unit: :km, compact: false)`. +- **`track_grade_adjusted_splits(points, split_km = 1.0, compact: false)`**: + the `track_splits` splits with a `:gap` pace per split, computed segment by + segment from `:ele`. Grades are measured over segments of at least 100 m of + horizontal distance so GPS elevation noise does not become fake climbing; + stretches without `:ele` (or with a non-finite one), and stretches with + elevation too short to grade, are flat. `track_splits` output is unchanged. +- **VO2max norms by age and sex** from the FRIEND registry (Kaminsky, Arena & + Myers, Mayo Clin Proc 2015;90(11):1515–1523, Table 3: treadmill, measured + VO2max), stored in `lib/calcpace/data/friend_2015_vo2max_percentiles.yml`: + - `vo2max_label(value, age: nil, sex: nil)` — optional keywords; with both, + the label comes from the percentile among the same sex and age decade + (≥95th Elite, ≥90th Excellent, ≥75th Very Good, ≥50th Good, ≥25th Fair, + else Beginner). Without them the fixed thresholds and labels are + unchanged. + - `vo2max_percentile(value, age:, sex:)` — linearly interpolated percentile, + bounded to the table's 5–95. Ages 18–19 use the 20–29 row and 80+ the + 70–79 row; under 18 is rejected. +- New public constants (removed ones are under Breaking): + - `CameronPredictor`: `CAMERON_CONSTANT`, `CAMERON_LINEAR_COEFFICIENT`, + `CAMERON_POWER_COEFFICIENT`, `CAMERON_POWER_EXPONENT`, + `CAMERON_MAX_DISTANCE_KM`. + - `Checker::CLOCK_FORMAT` — the clock grammar every time string is read with. + - `EnvironmentalAdjuster`: `HEAT_DURATION_FACTORS`, `REFERENCE_HUMIDITY`, and + the `EnvironmentalAdjuster::Humidity` module with `REFERENCE_HUMIDITY`, + `WBGT_TEMPERATURE_COEFFICIENT`, `WBGT_VAPOUR_PRESSURE_COEFFICIENT`, + `MIN_DEW_POINT_CELSIUS`, `BRACKET_CELSIUS` and `BISECTION_STEPS`. + - `GradeAdjustedPace`: `MINETTI_RUNNING_COEFFICIENTS`, `MINETTI_GRADE_RANGE`. + - `PersonalizedPredictor`: `TANDA_INTERCEPT`, `TANDA_VOLUME_AMPLITUDE`, + `TANDA_VOLUME_DECAY`, `TANDA_PACE_SLOPE`, `TANDA_MARATHON_KM`, + `TANDA_WEEKLY_DISTANCE_RANGE_KM`, `TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM`, + `TANDA_MARATHON_TIME_RANGE_SECONDS`, `PERSONAL_EXPONENT_RANGE`. + - `TrackCalculator`: `GRADE_SEGMENT_MIN_KM`, `GRADE_SEGMENT_TOLERANCE_KM`. + - `TrainingZones::PREDICTED_RACE_PACE_ZONES`. + - `Vo2maxNorms`: `NORMS_DATA_PATH`, `VO2MAX_NORMS`, `VO2MAX_NORMS_VERSION`, + `VO2MAX_NORM_PERCENTILES`, `VO2MAX_PERCENTILE_LABELS`. + +### Fixed +- `check_positive` let `Float::INFINITY` through, so every method guarded by it + accepted an infinite distance or time: an infinite weekly distance became a + finite (and fast) marathon prediction, an infinite pace a `FloatDomainError` + far from the input. Infinity now raises `Calcpace::NonPositiveInputError` + ("must be a finite positive number"), like zero, negatives and NaN already + did, and so does an Integer too large for a Float (e.g. a clock with + hundreds of hour digits), which used to become `Infinity` or a + `FloatDomainError` inside the formulas. +- `Calcpace::VERSION` is defined after `require 'calcpace'`; before, it only + existed once the gemspec had been loaded. +- The `vo2max_label` docstring now documents the error it actually raises for + a non-positive value (`Calcpace::NonPositiveInputError`, not + `ArgumentError`). Behaviour is unchanged. + +### Known limitations +- **The heat model has no sex term.** One curve serves everyone, and El Helou + et al. (2012) Table S3 shows men slowing more than women in the heat: at + 25 °C the model reads men's slower groups low (men's median 11.9% vs 14.76% + observed, Q3 12.08% vs 16.10%) and women's high (women's median 12.08% vs + 9.27%, Q1 12.08% vs 8.16%). +- **Heat above ~25 °C is extrapolated.** The marathon data behind the curve + stops at 25.2 °C; the 35 °C cap is a deliberate choice, not a measurement. + The 25 °C / 60-minute anchor (4.3%) and the 30- and 60-minute duration + factors have no direct published source. +- **The size of the humidity adjustment rests on the WBGT index, not on race + data.** The marathon outcome studies (El Helou 2012, Vihma 2010) found no + humidity effect independent of temperature. +- **Tanda's equation is validated only inside its sample** (22 experienced + runners, 21 of them men): outside it the prediction is still returned and + `out_of_range` says what fell outside. +- **Grade-adjusted pace is a metabolic model.** It does not see the muscular + cost of long descents or technical terrain, and the factor is applied to + horizontal (Haversine) distance without the √(1 + grade²) slope-length + correction (0.5% at 10%). + ## [1.18.1] - 2026-09-06 ### Fixed @@ -649,7 +1001,8 @@ predictors are untouched. See git history for changes in earlier versions. -[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.18.1...HEAD +[Unreleased]: https://github.com/0jonjo/calcpace/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/0jonjo/calcpace/compare/v1.18.1...v2.0.0 [1.18.1]: https://github.com/0jonjo/calcpace/compare/v1.18.0...v1.18.1 [1.18.0]: https://github.com/0jonjo/calcpace/compare/v1.17.0...v1.18.0 [1.17.0]: https://github.com/0jonjo/calcpace/compare/v1.16.0...v1.17.0 diff --git a/README.md b/README.md index 8afc603..85a6a62 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # 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. +A Ruby gem for runners: pace, time, and distance calculations, unit conversions, race predictions (including personalized ones), GPS track analysis with grade-adjusted pace, heat, humidity and altitude adjustments, age grading, VO2max estimation and norms, and training zones. > **See it in action:** [calcpace.app](https://calcpace.app) — free running calculators, race predictors and a training log, all powered by this gem. ## Installation ```ruby -gem 'calcpace', '~> 1.18.1' +gem 'calcpace', '~> 2.0' ``` ## Usage @@ -27,7 +27,7 @@ calc.pace(3665, 12) # => 305.4 (time / distance) calc.time(210, 12) # => 2520 (pace × distance) calc.distance(9660, 120) # => 80.5 (velocity × time) -# Clocktime input/output (HH:MM:SS or MM:SS) +# Clocktime input/output (HH:MM:SS or MM:SS; seconds below 60, and minutes too when hours are given) calc.clock_pace('01:00:00', 10) # => "00:06:00" calc.clock_time('00:05:31', 12.6) # => "01:09:30" calc.checked_distance('01:21:32', '00:06:27') # => 12.64 @@ -37,8 +37,50 @@ calc.checked_distance('01:21:32', '00:06:27') # => 12.64 ### Environmental Performance Adjustments -Adjust race performance based on heat and altitude. Calculations are based on scientific models -(Matthew Ely 2007 for heat, NCAA standards for altitude). +Adjust race performance based on heat, humidity and altitude. Calculations are based on scientific models +(El Helou et al. 2012 and Ely et al. 2007 for heat, the Australian Bureau of Meteorology's +simplified WBGT for humidity, NCAA standards for altitude). + +- **Altitude**: no penalty up to 300 m, then a linear ramp to the first NCAA point + (914.4 m → 1.41%), the NCAA table up to 2438.4 m (5.90%), and an extrapolated + curve beyond it (3000 m → 7.92%, 3500 m → 9.97%, 4000 m → 12.2%, capped there). + São Paulo (760 m) gets ~1.06%. +- **Heat**: a 60-minute baseline `4.3 · ((T − 15) / 10)^1.5` (0% at 15 °C, + 1.52% at 20 °C, 4.3% at 25 °C, 7.9% at 30 °C; extrapolated to 12.16% at + 35 °C and capped there, so 35–40 °C and hotter all read like 35 °C), stored + as points every 2.5 °C, then + scaled by effort duration: 0.5× up to 30 min, 1.0× at 60 min, 1.76× at 3 h, + 2.81× at 4 h and beyond (linear in between, so 1.38× at 2 h). The exponent + and the 3 h / 4 h points are fitted to El Helou et al. (2012, Table S3: eight + finisher groups, 2:41–4:54, time penalty against 15 °C at 20 and 25 °C, + which grows ~2.8× from 20 to 25 °C in every group); the derivation table is + in `lib/calcpace/data/environmental_factors.yml`. The 25 °C / 60-minute + anchor (4.3%) and the 30/60-minute factors have no direct published source. +- **Humidity** (optional): pass `humidity:` (relative humidity, %) or + `dew_point:` (in `temperature_unit`). Without either, the heat curve assumes + 50% humidity. With one, the temperature is replaced by the effective + temperature that has the same simplified WBGT (`0.567·Ta + 0.393·e + 3.94`) + at 50% humidity, and `factors` reports it as `:effective_temperature_celsius`. + +| Heat penalty (%) | 20 min | 60 min | 120 min | 180 min | 240 min | 300 min | +| --- | --- | --- | --- | --- | --- | --- | +| 20 °C | 0.76 | 1.52 | 2.1 | 2.68 | 4.27 | 4.27 | +| 25 °C | 2.15 | 4.3 | 5.93 | 7.57 | 12.08 | 12.08 | +| 30 °C | 3.95 | 7.9 | 10.9 | 13.9 | 22.2 | 22.2 | +| 35 °C | 6.08 | 12.16 | 16.78 | 21.4 | 34.17 | 34.17 | +| 40 °C | 6.08 | 12.16 | 16.78 | 21.4 | 34.17 | 34.17 | + +| 30 °C at | Effective temperature | 60 min | 4 h | +| --- | --- | --- | --- | +| 30% RH | 26.69 °C | 5.46% | 15.34% | +| 50% RH (= no humidity) | 30.0 °C | 7.9% | 22.2% | +| 70% RH | 33.07 °C | 10.46% | 29.39% | +| 90% RH | 35.94 °C (capped at 35 °C) | 12.16% | 34.17% | + +Above ~25 °C the numbers are extrapolations of the fitted curve: the marathon +studies behind it have no data there (El Helou's hottest race was 25.2 °C). The +cap at 35 °C is a deliberate choice for the same reason: the uncapped curve gave +47.77% for 4 h at 40 °C. ```ruby # Calculate penalty for 25°C and 2000m altitude (Defaults to 60-min effort) @@ -50,25 +92,30 @@ penalty = calc.calculate_penalty(temperature: 25, altitude: 2000) # Fahrenheit support calc.calculate_penalty(temperature: 80, temperature_unit: :f) -# => { total_penalty_percent: 5.03, ... } +# => { total_penalty_percent: 5.44, ... } + +# Humidity: 30 °C at 90% hits like 35.94 °C at 50% (which reads like the 35 °C cap) +calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] # => 12.16 +calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] # => 35.94 +calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] # => 11.07 # Adjust a 3:30 marathon time (12600s) for these conditions (High exposure penalty) result = calc.adjust_time(12600, temperature: 25, altitude: 2000) # => { # original_time: 12600, -# adjusted_time: 15176.7, -# adjusted_time_clock: "04:12:56", -# penalty_percent: 20.45, -# factors: { heat: 16.13, altitude: 4.32 } +# adjusted_time: 14382.9, +# adjusted_time_clock: "03:59:42", +# penalty_percent: 14.15, +# factors: { heat: 9.83, altitude: 4.32 } # } # Predicted adjusted times (Riegel formula) calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 28) -# => { adjusted_time: 2599.74, adjusted_time_clock: "00:43:19", penalty_percent: 3.91, ... } +# => { adjusted_time: 2613.0, adjusted_time_clock: "00:43:33", penalty_percent: 4.44, ... } # Predicted adjusted times (Cameron formula) calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 80, temperature_unit: :f) -# => { adjusted_time: 11585.88, adjusted_time_clock: "03:13:05", penalty_percent: 14.18, ... } +# => { adjusted_time: 12400.47, adjusted_time_clock: "03:26:40", penalty_percent: 10.28, ... } ``` --- @@ -139,8 +186,10 @@ calc.race_splits('half_marathon', target_time: '01:30:00', split_distance: '5k') # => ["00:21:20", "00:42:40", "01:03:59", "01:25:19", "01:30:00"] # Strategies: :even (default), :negative (second half faster), :positive (first half faster) +# :negative runs the first half 1% slower than average pace and the second half 1% faster; +# :positive is the mirror image. A 3:00:00 marathon splits 1:30:54 + 1:29:06 (:negative). calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative) -# => ["00:20:48", "00:40:00"] +# => ["00:20:12", "00:40:00"] # The race may be a plain distance too; the last split is always the finish calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k') @@ -157,14 +206,22 @@ calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k') calc.predict_time_clock('5k', '00:20:00', 'marathon') # => "03:11:49" calc.predict_pace_clock('5k', '00:20:00', 'marathon') # => "00:04:32" calc.equivalent_performance('10k', '00:42:00', '5k') -# => { time: 1208.67, time_clock: "00:20:08", pace: 241.73, pace_clock: "00:04:01" } +# => { time: 1208.6727903498331, time_clock: "00:20:08", pace: 241.73455806996662, pace_clock: "00:04:01" } ``` -**Cameron formula** (exponential correction — tends to be more conservative from short distances): +**Cameron formula** (Dave Cameron's velocity-ratio model, fitted to world bests from +800 m to the marathon — more conservative than Riegel when predicting the marathon +from shorter races): + +`T2 = T1 × (D2/D1) × f(D1)/f(D2)`, with `f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905` +and `d` in metres (distances are still passed in km or as race names). +Both distances must be at most `CameronPredictor::CAMERON_MAX_DISTANCE_KM` (100 km): +the model is fitted up to the marathon and breaks down far beyond it, so longer +distances raise `ArgumentError`. ```ruby -calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "02:57:34" -calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:12" +calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "03:16:46" +calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:39" ``` **Any distance, on either end.** Both formulas are arithmetic on two distances, @@ -173,7 +230,7 @@ so neither end has to be a standard race: ```ruby # From a 7.79 km club race in 26:59 calc.predict_time_clock(7.79, '00:26:59', 'half_marathon') # => "01:17:34" -calc.predict_time_cameron_clock(7.79, '00:26:59', 'half_marathon') # => "01:13:44" +calc.predict_time_cameron_clock(7.79, '00:26:59', 'half_marathon') # => "01:17:26" # To an unnamed distance, and between two of them calc.predict_time_clock('10k', '00:42:00', 15) # => "01:04:33" @@ -198,6 +255,85 @@ the caller's, and it always was, standard race names included. --- +### Personalized Predictions + +**Marathon from training volume** — Tanda (2011), no race result needed. The +inputs are the mean weekly distance and the mean training pace over the 8 weeks +ending one week before the race: + +```ruby +calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00') +# => { time: 11981.88, time_clock: "03:19:41", pace: 283.96, pace_clock: "00:04:43", +# within_validated_range: true, out_of_range: [] } + +calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30') +# => { time: 13158.72, time_clock: "03:39:18", pace: 311.86, pace_clock: "00:05:11", +# within_validated_range: false, out_of_range: [:weekly_distance, :marathon_time] } + +# unit: :mi — weekly miles, pace per mile in and out +calc.predict_marathon_from_training(weekly_distance: 25, training_pace: '08:00', unit: :mi)[:pace_clock] # => "00:07:53" +``` + +The equation is `Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P` (Pm marathon +pace in s/km, K km/week, P s/km). Training pace is the plain average of every +run — total time over total distance, warm-ups and easy days included — not the +pace of the hard sessions. The paper reports a standard error of about 4 minutes. +`weekly_distance` may be a number or a numeric string (`'60'`); `training_pace` +is seconds or an `MM:SS` / `HH:MM:SS` string. + +It was fitted on 22 experienced runners (21 men) and 46 marathons, so it is only +validated inside that sample: 40.4–110.7 km/week, training pace 253.3–330.6 s/km (4:13–5:30/km), +finish 2:47–3:36. Outside it the prediction is still returned, and +`out_of_range` names what fell outside (`:weekly_distance`, `:training_pace`, +`:marathon_time`) — a warning, not an error. A low-volume runner at an easy pace +will usually see all three. + +> G. Tanda, "Prediction of marathon performance time on the basis of training +> indices", *Journal of Human Sport and Exercise* 6(3):511–520, 2011. +> doi:10.4100/jhse.2011.63.05 + +**Personal Riegel exponent** — fit the fatigue factor to two of your own races +instead of the population 1.06: + +```ruby +calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') # => 1.0961 + +calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon') +# => { time: 13083.04, time_clock: "03:38:03", exponent: 1.0961, raw_exponent: 1.0961, clamped: false } +``` + +The standard Riegel gives 3:32:39 from that half and 3:27:00 from that 10K; this +runner fades more than average, and the personal exponent says so. + +When the target lies outside the two races, the prediction extrapolates from +whichever race is closer to it (in log-distance), with the exponent clamped to +1.01–1.20: + +```ruby +calc.predict_time_personal('5k', '00:20:00', '10k', '00:50:00', 'half_marathon') +# => { time: 7348.5, time_clock: "02:02:28", exponent: 1.2, raw_exponent: 1.3219, clamped: true } +``` + +When the target lies between them, it interpolates along the curve through both +performances with the raw exponent, never clamped: the runner's own data +already brackets the answer, and the result does not depend on which race comes +first. + +```ruby +calc.predict_time_personal(5, 1200, 20, 3000, 10)[:time] # => 1897.37 +calc.predict_time_personal(20, 3000, 5, 1200, 10)[:time] # => 1897.37 +``` + +An exponent outside that range — or `clamped: true` — usually means one of the +two races was not an all-out effort, or was run on a course or day that does +not compare with the other — the raw exponent in an interpolation +(0.661 above) is worth the same suspicion. Both races may be names or distances +in km; two races at the same distance, or a target equal to one of them, raise +`ArgumentError`. Times are seconds or `HH:MM:SS` / `MM:SS` strings; anything +else raises `Calcpace::InvalidTimeFormatError`. + +--- + ### GPS Track Analysis Accepts an array of hashes with `:lat`, `:lon`, and optionally `:ele` (metres) and `:time` (`Time`): @@ -238,6 +374,62 @@ in both formats (`"-00:40"` / `"-0:40"`) rather than raising. **Haversine formula** — great-circle distance on a sphere (R = 6,371 km). Accuracy: ~0.3% of GPS/WGS84. Best for running and cycling distances; not for geodetic surveying. +#### Grade-adjusted pace (GAP) + +The flat-ground pace that costs the same energy as a pace run on a slope, from +the energy cost of running on gradients measured by **Minetti et al. (2002)**. +The grade is a fraction (rise over horizontal distance): `0.05` is 5% uphill, +`-0.05` is 5% downhill. + +```ruby +calc.grade_adjustment_factor(0.1) # => 1.6578372222222222 (a metre at +10% ≈ 1.66 flat metres) +calc.grade_adjustment_factor(-0.1) # => 0.5976961111111111 + +calc.grade_adjusted_pace(360, 0.1) # => 217.1503903847952 (s/km) +calc.grade_adjusted_pace_clock('06:00', 0.1) # => "00:03:37" +calc.grade_adjusted_pace_clock('06:00', 0.1, compact: true) # => "3:37" +calc.grade_adjusted_pace(480, 0.05, unit: :mi) # => 368.82127811700303 (s/mi) + +# Per-split GAP for a GPS track: the track_splits fields plus :gap +calc.track_grade_adjusted_splits(points, 1.0) +# => [{ km: 1.0, elapsed: 415, pace: "06:55", gap: "06:50" }, +# { km: 1.51, elapsed: 600, pace: "06:04", gap: "06:21" }] +``` + +| Grade | −10% | −5% | 0% | +5% | +10% | +|-------|------|-----|----|-----|------| +| Factor | 0.598 | 0.763 | 1.000 | 1.301 | 1.658 | + +**Formula** (J·kg⁻¹·m⁻¹, R² = 0.999): +``` +Cr(i) = 155.4·i⁵ − 30.4·i⁴ − 43.3·i³ + 46.3·i² + 19.5·i + 3.6 +factor = Cr(i) / Cr(0) +GAP = pace / factor +``` + +- Grades are clamped to **±45%**, the range Minetti et al. measured; nothing + is extrapolated beyond it. Running is cheapest near −20% and gets dearer + again on steeper descents. +- It is a metabolic model: it does not see the muscular cost of long descents + or technical terrain, and field models fitted to heart rate (Strava's, for + instance) are gentler on steep climbs. +- `track_grade_adjusted_splits` leaves `track_splits` untouched: it returns the + same `:km`, `:elapsed` and `:pace` with `:gap` added (formatted like `:pace`, + `compact:` applies to both). GPS elevation is noisy, so grades are measured + over **grade segments of at least 100 m** of horizontal distance — read + between fixes a metre apart, ±2 m of jitter would be a ±400% grade. A short + leftover at the end of a stretch joins the segment before it. Stretches + between points without `:ele` (or with a NaN/infinite one) count as flat, + so a track with no elevation has `:gap` equal to `:pace`; so does a stretch + with elevation shorter than 100 m that has no full segment before it to + join (between missing fixes, or a whole track that short). +- Track distances are horizontal (Haversine), and the factor is applied to + them without the √(1 + grade²) slope-length correction — 0.5% at 10%. +- `estimate_detailed_vo2max` keeps its own flat elevation heuristic (100 m of + gain = 600 m of flat), so its numbers do not change. + +*Minetti, A. E., Moia, C., Roi, G. S., Susta, D., & Ferretti, G. (2002). Energy cost of walking and running at extreme uphill and downhill slopes. Journal of Applied Physiology, 93(3), 1039–1046. https://doi.org/10.1152/japplphysiol.01177.2001* + --- ### Age Grading (Road Races) @@ -249,18 +441,18 @@ age factors and open standards. result = calc.age_grade(10.0, '00:45:00', age: 55, sex: :male) # numeric distances also accepted in miles: calc.age_grade(6.21371, '00:45:00', age: 55, sex: :male, distance_unit: :mi) # => { -# age_grade_percent: 69.0, +# age_grade_percent: 68.9, # category: "Local Class", -# age_graded_time_seconds: 2278.26, -# age_graded_time_clock: "00:37:58", -# open_standard_seconds: 1571.0, -# open_standard_clock: "00:26:11", -# factor: 0.8438, -# table_version: "WMA_2023_ONE_YEAR_FACTORS_V1" +# age_graded_time_seconds: 2297.97, +# age_graded_time_clock: "00:38:17", +# open_standard_seconds: 1584.0, +# open_standard_clock: "00:26:24", +# factor: 0.8511, +# table_version: "MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1" # } -calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.2 -calc.age_grade_label(65.2) # => "Local Class" +calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.0 +calc.age_grade_label(65.0) # => "Local Class" ``` `category` (and `age_grade_label`) returns one of: @@ -276,7 +468,7 @@ calc.age_grade_label(65.2) # => "Local Clas | 40–49.9% | Recreational | | below 40% | Active Beginner | -The WMA / Alan Jones (Howard Grubb) tables are numeric age factors and open +The Alan Jones road tables are numeric age factors and open standards only — they define no categories at all. The bands from Local Class (60%) upward follow the USATF Masters / National Masters News convention; the three bands below 60% are calcpace's own extension — most recreational @@ -293,8 +485,8 @@ A numeric distance within **2%** of one of those is graded as that standard — a GPS watch rarely reads a 5K as exactly 5.000 km: ```ruby -calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # => 51.9 -calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 51.9 +calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # => 54.1 +calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 54.1 calc.age_grade(7.79, '00:26:59', age: 36, sex: :male) # => ArgumentError: Unsupported distance 7.79km. Supported: 5.0, 10.0, 21.0975, 42.195 km @@ -302,20 +494,32 @@ calc.age_grade(7.79, '00:26:59', age: 36, sex: :male) That refusal is deliberate, and it is where age grading parts ways with the predictors above. A prediction is a formula and works at any distance; an age -grade is a lookup in the WMA table, which publishes a factor per *specific* +grade is a lookup in the road table, which publishes a factor per *specific* distance. There is no world standard for 7.79 km, so there is no honest percentage to return — interpolating one would produce a number with the look of an official standard and none of the authority. -Age factors are based on WMA 2023 one-year age grading tables: -https://world-masters-athletics.org/documents/competition-rules/ - -Open standards used in `open_standard_seconds` / `open_standard_clock` are loaded -from the bundled WMA 2023 open standards dataset -(`lib/calcpace/data/wma_2023_open_standards.yml`). +Age factors and open standards come from Alan Jones' **2025 road** age-grading +tables, approved on 2025-01-10 by the USATF Masters Long Distance Running +Council — the standard for road races, the same tables behind Howard Grubb's +MLDR road calculator. The source spreadsheets are `MaleRoadStd2025.xlsx` and +`FemaleRoadStd2025.xlsx`, linked here at the commit the bundled data was +taken from: +https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files +The bundled data has one factor per year of age from 18 to 100 (older ages use +the age-100 factor) and lives in `lib/calcpace/data/mldr_2025_road.yml` (factors) +and `lib/calcpace/data/mldr_2025_road_open_standards.yml` (open standards and +category labels). + +| Distance | Open standard (men) | Open standard (women) | +| --- | --- | --- | +| 5K | 12:49 | 13:54 | +| 10K | 26:24 | 28:46 | +| Half marathon | 57:31 | 1:02:52 | +| Marathon | 2:00:35 | 2:09:56 | Field meanings: -- `age_graded_time_clock`: your result after applying the WMA age factor (normalized performance time). +- `age_graded_time_clock`: your result after applying the age factor (normalized performance time). - `open_standard_clock`: the open standard reference time used to compute the percentage for that distance/sex. - `age_grade_percent`: `(open_standard_seconds / age_graded_time_seconds) * 100`. @@ -355,6 +559,49 @@ VO2max = VO2 / %VO2max Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace. +#### By age and sex + +The fixed thresholds above are the same for everyone. Give `vo2max_label` an +age and a sex and it reads the value against people of the same sex and age +decade instead, using the **FRIEND registry** percentiles of VO2max measured on +a treadmill (Kaminsky, Arena & Myers, 2015): + +```ruby +calc.vo2max_label(45) # => "Good" (fixed thresholds, unchanged) +calc.vo2max_label(45, age: 25, sex: :male) # => "Fair" +calc.vo2max_label(45, age: 60, sex: :male) # => "Elite" +calc.vo2max_label(45, age: 25, sex: :female) # => "Very Good" +calc.vo2max_label(45, age: 60, sex: :female) # => "Elite" + +calc.vo2max_percentile(45, age: 25, sex: :male) # => 40.5 +calc.vo2max_percentile(45, age: 25, sex: :female) # => 75.7 +calc.vo2max_percentile(45, age: 60, sex: :male) # => 95.0 +``` + +| Percentile (same sex and age decade) | Level | +|--------------------------------------|-----------| +| ≥ 95th | Elite | +| 90th–94th | Excellent | +| 75th–89th | Very Good | +| 50th–74th | Good | +| 25th–49th | Fair | +| < 25th | Beginner | + +- The cuts sit on percentiles the table publishes (5th, 10th, 25th, 50th, + 75th, 90th, 95th), so a label never depends on interpolation. They are + calcpace's choice: FRIEND publishes percentiles, not labels. +- `vo2max_percentile` interpolates linearly between the published percentiles, + rounded to one decimal, and is bounded to the table: `5.0` means at or below + the 5th percentile, `95.0` at or above the 95th. +- Age decades (20–29 … 70–79) are used as published, without blending, so a + 29- and a 30-year-old read different rows. Ages 18–19 use the 20–29 row and + 80+ the 70–79 row; under 18 raises `ArgumentError`, as does a sex other than + male/female. Age and sex must be given together. +- The registry measured VO2max in a lab; a VO2max estimated from a race time + carries its own ±3–5 ml/kg/min on top. + +*Kaminsky, L. A., Arena, R., & Myers, J. (2015). Reference Standards for Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: Data From the Fitness Registry and the Importance of Exercise National Database. Mayo Clinic Proceedings, 90(11), 1515–1523, Table 3 (rows "Men/Women from FRIEND"; 7,783 treadmill tests on adults free of known cardiovascular disease). https://doi.org/10.1016/j.mayocp.2015.07.026. The same table also lists the Cooper Clinic norms printed in ACSM's Guidelines for Exercise Testing and Prescription (9th ed., 2014); those are predicted from treadmill time rather than measured, and are not used here.* + #### Contextualized estimation `estimate_detailed_vo2max` returns a richer result that accounts for elevation, heart rate, and formula reliability: @@ -406,6 +653,7 @@ Personalized training paces (Daniels' Running Formula) and Karvonen heart-rate z zones = calc.training_paces(50.0) zones[:threshold].fast_clock # => "00:04:15" per km zones[:easy].slow_clock # => "00:05:52" per km +zones[:marathon].fast_clock # => "00:04:31" per km (the VDOT-predicted marathon pace) calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock # => "00:06:51" per mile @@ -424,13 +672,21 @@ calc.hr_zones_from_max(hr_max: 190) | Zone | %VO2max | Purpose | |------|---------|---------| | Easy | 59–74% | Base building, recovery | -| Marathon | 75–84% | Marathon race pace | +| Marathon | 75% – predicted marathon pace (0.800–0.849) | Marathon race pace | | Threshold | 83–88% | Lactate threshold, tempo runs | | Interval | 95–100% | VO2max development | | Repetition | 105–110% | Speed and running economy | Pace accuracy vs published VDOT tables: within a few seconds per km -(threshold matches exactly; easy band is a range heuristic). +(threshold matches exactly; easy band is a range heuristic). The fast end of the +marathon band is the marathon pace `predict_time_from_vo2max` gives for the same +VO2max (Daniels' M pace is the predicted marathon race pace); that prediction +covers VO2max 10–100, and outside it the race-pace intensity of the nearest bound +is used. That intensity is 0.800–0.849 of VO2max (0.805 at VO2max 30, 0.830 at 70), +so the marathon band stays slower than the threshold band below VO2max ~69.5, as in +Daniels. `TRAINING_INTENSITIES[:marathon][:high]` stays 0.84 as the nominal upper +bound; `PREDICTED_RACE_PACE_ZONES` lists the zones whose fast end is the predicted +race pace. `unit:` sets the unit of the returned pace bands; `distance_unit:` sets the unit of a numeric race distance you pass in. Combining `distance_unit:` with a race name raises @@ -662,6 +918,24 @@ calc.convert_to_clocktime(3600) # => "01:00:00" calc.check_time('01:00:00') # => nil (valid) ``` +Every time or pace string the gem reads goes through `convert_to_seconds`, so +every method reads the same clocks, and every clock the gem writes is among them: + +```ruby +calc.convert_to_seconds('75:00') # => 4500 (MM:SS keeps counting minutes) +calc.convert_to_seconds('400:00:00') # => 1440000 (any number of hours) +calc.convert_to_seconds('1 03:46:40') # => 100000 (convert_to_clocktime's day prefix) +calc.convert_to_seconds('-0:40') # => -40 (a backwards track_splits split) +``` + +Seconds must be two digits below 60, and so must minutes when hours are given; +after a day prefix the hours are two digits below 24. A leading `-` is the only +sign, blanks or surrounding whitespace are not trimmed, and the string must be +in a valid, ASCII-compatible encoding (UTF-8, not UTF-16). Anything else — +`'05:99'`, `'1:60:00'`, `'1 3:46:40'`, `' 05:00'`, `'abc'` — raises +`Calcpace::InvalidTimeFormatError`. A negative clock parses, but every method +that needs a positive time or pace rejects it with `Calcpace::NonPositiveInputError`. + `convert_to_clocktime` takes a `compact:` keyword for the format a runner reads on a screen — no zero hour, no leading zero on the most significant component: @@ -687,8 +961,10 @@ call without it returns exactly what it returned before. All errors inherit from `Calcpace::Error`: -- `Calcpace::NonPositiveInputError` — numeric input is zero or negative -- `Calcpace::InvalidTimeFormatError` — time string not in `HH:MM:SS` or `MM:SS` format +- `Calcpace::NonPositiveInputError` — numeric input is zero, negative, NaN or infinite +- `Calcpace::InvalidTimeFormatError` — time string that is not a clock the gem writes + (`[-][D ]H:MM:SS` or `[-]M:SS`, see Other Utilities): seconds must be below 60, and + so must minutes when hours are given (`'05:99'` and `'1:60:00'` raise) - `Calcpace::UnsupportedUnitError` — unknown conversion (`convert`) or unknown `unit:` / `distance_unit:` keyword - `Calcpace::InvalidDataError` — the bundled data table failed its load-time @@ -705,7 +981,7 @@ unknown race names, unsupported age-grading distances, and invalid `age` / `sex` bundle exec rake ``` -Requires Ruby >= 3.2.0. Tested with Ruby 3.2, 3.3, 3.4, and 4.0. +Requires Ruby >= 3.3.0. Tested with Ruby 3.3, 3.4, and 4.0. ## Contributing diff --git a/calcpace.gemspec b/calcpace.gemspec index cc3f8b9..9e40f96 100644 --- a/calcpace.gemspec +++ b/calcpace.gemspec @@ -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' diff --git a/lib/calcpace.rb b/lib/calcpace.rb index 363c153..95dba0d 100644 --- a/lib/calcpace.rb +++ b/lib/calcpace.rb @@ -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' @@ -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 # @@ -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 # diff --git a/lib/calcpace/age_grading.rb b/lib/calcpace/age_grading.rb index 1cd47da..02695ea 100644 --- a/lib/calcpace/age_grading.rb +++ b/lib/calcpace/age_grading.rb @@ -2,6 +2,7 @@ require 'yaml' require_relative 'errors' +require_relative 'checker' # Module for age-grading race performances with a versioned table # @@ -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 @@ -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: [], @@ -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 @@ -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 diff --git a/lib/calcpace/cameron_predictor.rb b/lib/calcpace/cameron_predictor.rb index 78822bf..c05c8d8 100644 --- a/lib/calcpace/cameron_predictor.rb +++ b/lib/calcpace/cameron_predictor.rb @@ -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 # @@ -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 @@ -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 @@ -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 @@ -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 @@ -100,8 +120,19 @@ 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, **) @@ -109,11 +140,26 @@ def predict_time_cameron_adjusted(from_race, from_time, to_race, **) 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] 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 diff --git a/lib/calcpace/checker.rb b/lib/calcpace/checker.rb index d90eb54..d85d31c 100644 --- a/lib/calcpace/checker.rb +++ b/lib/calcpace/checker.rb @@ -7,46 +7,126 @@ # This module provides validation methods for numeric inputs and time format strings # used throughout the Calcpace gem. module Checker - # Validates that a number is positive (greater than zero) + # Validates that a number is positive (greater than zero) and finite + # + # NaN and infinity are rejected too: NaN is not positive, and an infinite + # distance or time would flow through the formulas into nonsense (a finite + # "prediction") or a FloatDomainError far from the input that caused it. So + # is an Integer too large for a Float (a clock with hundreds of hour digits + # parses to one), which would turn into Infinity inside the formulas. # # @param number [Numeric] the number to validate # @param name [String] the name of the parameter for error messages - # @raise [Calcpace::NonPositiveInputError] if number is not positive + # @raise [Calcpace::NonPositiveInputError] if number is not positive or not finite # @return [void] # # @example - # check_positive(10, 'Distance') #=> nil (valid) - # check_positive(-5, 'Time') #=> raises NonPositiveInputError - # check_positive(0, 'Speed') #=> raises NonPositiveInputError + # check_positive(10, 'Distance') #=> nil (valid) + # check_positive(-5, 'Time') #=> raises NonPositiveInputError + # check_positive(0, 'Speed') #=> raises NonPositiveInputError + # check_positive(Float::INFINITY, 'Distance') #=> raises NonPositiveInputError def check_positive(number, name = 'Input') - return if number.is_a?(Numeric) && number.positive? + unless number.is_a?(Numeric) && number.positive? + raise Calcpace::NonPositiveInputError, "#{name} must be a positive number" + end + # An Integer is always finite, but one past Float::MAX overflows to + # Infinity the moment a formula calls to_f on it + return if number.finite? && number.to_f.finite? - raise Calcpace::NonPositiveInputError, - "#{name} must be a positive number" + raise Calcpace::NonPositiveInputError, "#{name} must be a finite positive number" end - # Validates that a time string is in the correct format + # The clock grammar every time and pace string in the gem is read with. It + # covers every clock the gem writes (and a few it never writes, such as + # '-1 03:46:40' or '000000000:00'): + # - an optional leading '-' (track_splits reports a backwards split as '-0:40') + # - an optional day prefix, 'D HH:MM:SS', as convert_to_clocktime writes + # durations above 24 hours ('1 03:46:40'); the hour field after it is two + # digits below 24 + # - H:MM:SS, where the hours have any number of digits ('400:00:00') and the + # minutes and seconds are two digits below 60 + # - M:SS, where the minutes have any number of digits and keep counting past + # the hour ('75:00', '123:45') and the seconds are two digits below 60 + # Nothing else: no '+', no blanks or surrounding whitespace, ASCII digits only, + # and only in a String whose encoding is valid and ASCII-compatible. + CLOCK_FORMAT = / + \A(?-)? + (?:(?\d+)[ ](?=(?:[01]\d|2[0-3]):[0-5]\d:))? + (?:(?\d+):(?[0-5]\d)|(?\d+)) + :(?[0-5]\d)\z + /x + + # Validates that a time string is a well-formed clock # - # Accepted formats: - # - HH:MM:SS (hours:minutes:seconds) - e.g., "01:30:45" - # - MM:SS (minutes:seconds) - e.g., "05:30" - # - H:MM:SS or M:SS (single digit hours/minutes) - e.g., "1:30:45" + # Accepts what CLOCK_FORMAT describes, i.e. every clock the gem formats: + # H:MM:SS / HH:MM:SS with any number of hour digits, M:SS / MM:SS with any + # number of minute digits, the 'D HH:MM:SS' day prefix of convert_to_clocktime + # and an optional leading '-'. Seconds must be below 60, and so must minutes + # once an hour field is present ("1:60:00" is not a clock); MM:SS keeps + # counting minutes past the hour, so "75:00" is a valid 75-minute time. + # + # A negative clock is well formed (track_splits writes one for a backwards + # split); methods that need a positive time or pace reject it with + # Calcpace::NonPositiveInputError after parsing. # # @param time_string [String] the time string to validate # @raise [Calcpace::InvalidTimeFormatError] if format is invalid # @return [void] # # @example - # check_time('01:30:45') #=> nil (valid) - # check_time('5:30') #=> nil (valid) - # check_time('invalid') #=> raises InvalidTimeFormatError + # check_time('01:30:45') #=> nil (valid) + # check_time('5:30') #=> nil (valid) + # check_time('75:00') #=> nil (valid, 75 minutes) + # check_time('1 03:46:40') #=> nil (valid, 1 day 3:46:40) + # check_time('-0:40') #=> nil (valid, a backwards split) + # check_time('05:99') #=> raises InvalidTimeFormatError + # check_time('1:60:00') #=> raises InvalidTimeFormatError + # check_time('invalid') #=> raises InvalidTimeFormatError def check_time(time_string) - # Check if string is valid and matches expected patterns - return if time_string.is_a?(String) && - (time_string =~ /\A\d{1,2}:\d{2}:\d{2}\z/ || - time_string =~ /\A\d{1,2}:\d{2}\z/) + clock_match(time_string) + nil + end + + private + + # @return [MatchData] the CLOCK_FORMAT match for a valid clock + # @raise [Calcpace::InvalidTimeFormatError] otherwise + def clock_match(time_string) + match = CLOCK_FORMAT.match(time_string) if clock_encoding?(time_string) + return match if match raise Calcpace::InvalidTimeFormatError, - 'It must be a valid time in the XX:XX:XX or XX:XX format' + 'It must be a valid time in the XX:XX:XX or XX:XX format ' \ + '(seconds below 60, and minutes too when hours are given)' + end + + # Only a String in a valid, ASCII-compatible encoding can be a clock: the + # pattern cannot match UTF-16/UTF-32, and a broken byte sequence raises + # ArgumentError inside the regexp engine instead of a clock error + def clock_encoding?(time_string) + time_string.is_a?(String) && time_string.valid_encoding? && time_string.encoding.ascii_compatible? + end + + # Age in whole years, 18 or over — the rule AgeGrading and Vo2maxNorms share + # + # @raise [ArgumentError] if age is not an integer or is under 18 + 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 + + # :male or :female, from any case of a String or Symbol — shared like normalize_age + # + # @raise [ArgumentError] for anything else + 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 end diff --git a/lib/calcpace/converter.rb b/lib/calcpace/converter.rb index 3785065..9a01fc4 100644 --- a/lib/calcpace/converter.rb +++ b/lib/calcpace/converter.rb @@ -77,24 +77,29 @@ def convert(value, unit) # Converts a time string to total seconds # + # Every method in the gem that takes a time or pace string goes through + # here, so they all read the same clocks: the ones Checker#check_time + # accepts, which cover every clock the gem writes — signed track_splits + # paces ('-0:40' is -40), minutes past the hour ('75:00'), any number of + # hours ('400:00:00') and the day prefix of convert_to_clocktime + # ('1 03:46:40'). Methods that need a positive time reject a negative one + # afterwards. + # # @param time [String] time string in HH:MM:SS or MM:SS format - # @return [Integer] total seconds + # @return [Integer] total seconds (negative for a '-' clock) + # @raise [Calcpace::InvalidTimeFormatError] if the string is not a valid clock # # @example - # convert_to_seconds('01:30:00') #=> 5400 (1 hour 30 minutes) - # convert_to_seconds('05:30') #=> 330 (5 minutes 30 seconds) + # convert_to_seconds('01:30:00') #=> 5400 (1 hour 30 minutes) + # convert_to_seconds('05:30') #=> 330 (5 minutes 30 seconds) + # convert_to_seconds('1 03:46:40') #=> 100000 + # convert_to_seconds('-0:40') #=> -40 + # convert_to_seconds('05:99') #=> raises InvalidTimeFormatError def convert_to_seconds(time) - parts = time.split(':').map(&:to_i) - case parts.length - when 2 - minute, seconds = parts - (minute * 60) + seconds - when 3 - hour, minute, seconds = parts - (hour * 3600) + (minute * 60) + seconds - else - 0 - end + clock = clock_match(time) + minutes = clock[:total_minutes] || clock[:minutes] + total = (clock[:days].to_i * 86_400) + (clock[:hours].to_i * 3600) + (minutes.to_i * 60) + clock[:seconds].to_i + clock[:sign] ? -total : total end # Converts seconds to a clocktime string diff --git a/lib/calcpace/data/environmental_factors.yml b/lib/calcpace/data/environmental_factors.yml index 116f5e9..389f1e2 100644 --- a/lib/calcpace/data/environmental_factors.yml +++ b/lib/calcpace/data/environmental_factors.yml @@ -3,29 +3,109 @@ # Sources: # - Altitude: NCAA Altitude Adjustment Factors (TFRRS) # Ref: ~3.76% penalty for 1828.8m (6000ft). -# - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance" +# - Heat: Ely et al. (2007) "Impact of Weather on Marathon-Running Performance", +# MSSE 39(3):487-493 — qualitative source: marathon times slow progressively +# from 5 to 25 °C WBGT, and more for slower runners (top men 1.7 / 2.5 / 3.3 / +# 4.5% off the course record in WBGT 5-10 / 10-15 / 15-20 / 20-25 °C). # NOTE: These heat factors are the BASELINE for a 60-minute effort. -# The final penalty is scaled by a DurationFactor (0.5x to 4.5x) based on total exposure time. +# The final penalty is scaled by a DurationFactor (0.5x at 30 min, 1.0x at +# 60 min, 1.76x at 3 h, 2.81x at 4 h and beyond; see +# EnvironmentalAdjuster::HEAT_DURATION_FACTORS) based on total exposure time. +# Both the shape of the base curve and the 3 h / 4 h factors are fitted to +# El Helou et al. (2012), PLoS One 7(5):e37407, Table S3 (1.79 M finishers of +# Berlin, Boston, Chicago, London, New York and Paris, 2001-2010). +# Derivation (reproduced by the El Helou tests in test_environmental_adjuster.rb): +# 1. Speed loss (%) at 15, 20 and 25 °C: straight line between the table's +# points (each group's optimum -10 ... +20 °C, every 5 °C). +# 2. Time penalty against 15 °C (where the curve is zero): +# P = ((1 - loss15) / (1 - lossT) - 1) x 100. +# 3. Base shape: P25 / P20 is 2.70-2.97 in all seven groups that have both +# (table below). With P ∝ (T - 15)^p, p = log2(P25 / P20); the sex-weighted +# mean (each sex half the weight) is p = 1.497 -> 1.5. The base is +# 4.3 · ((T - 15) / 10)^1.5, anchored at the original base(25) = 4.3, +# which is the only value available for a 60-minute effort. +# 4. Ratio = P / base(T) (1.52 at 20 °C, 4.3 at 25 °C); finish time = +# 42195 m / the group's speed at its optimum. +# 5. Weighted least squares over the 15 ratios (men P1 at 25 °C is beyond +# the table), each sex half the weight (men 7, women 8), 1.0x at 60 min +# fixed, 180 and 240 min free, flat after 240: 1.761 and 2.814 -> 1.76 and +# 2.81. An extra free point at 150 min cut the weighted residual by 1% +# only; one at 210 min made the curve non-monotonic (2.97 at 210 > 2.73 +# at 240). Neither was kept. +# +# | Group | Finish | loss@15 | loss@20 | loss@25 | P20 | P25 | P25/P20 | ratio @20 | @25 | +# | men P1 | 2:41 | 1.88 | 3.93 | n/a* | 2.14 | n/a* | n/a* | 1.41 | n/a* | +# | women P1 | 3:06 | 0.79 | 3.13 | 7.27 | 2.42 | 6.99 | 2.89 | 1.59 | 1.63 | +# | men Q1 | 3:31 | 2.86 | 7.00 | 13.58 | 4.46 | 12.41 | 2.78 | 2.93 | 2.89 | +# | men median | 3:57 | 3.18 | 7.93 | 15.63 | 5.17 | 14.76 | 2.86 | 3.40 | 3.43 | +# | women Q1 | 4:00 | 1.86 | 4.73 | 9.26 | 3.02 | 8.16 | 2.70 | 1.99 | 1.90 | +# | women median | 4:25 | 2.09 | 5.30 | 10.40 | 3.39 | 9.27 | 2.73 | 2.23 | 2.16 | +# | men Q3 | 4:28 | 2.92 | 7.91 | 16.38 | 5.42 | 16.10 | 2.97 | 3.57 | 3.74 | +# | women Q3 | 4:54 | 2.03 | 5.37 | 10.80 | 3.54 | 9.83 | 2.78 | 2.33 | 2.29 | +# * men P1's optimum is 3.81 °C, so 25 °C is beyond its last point (+20 °C). +# (P1 = first percentile, Q1/Q3 = quartiles; losses and P in %.) +# Caveat: the model has no sex input. With one curve, men's slower groups are +# under-read at 25 °C (median 11.9% vs 14.76% observed) and women's over-read +# (median 12.08% vs 9.27%). +# - Humidity: the points are read at air temperature with ~50% relative +# humidity (EnvironmentalAdjuster::REFERENCE_HUMIDITY). With humidity: or +# dew_point:, the temperature is first moved to the effective temperature +# with the same simplified WBGT (Australian Bureau of Meteorology). +# +# Interpolation: at or below threshold_meters the altitude penalty is 0; above it +# both tables are interpolated linearly between points and clamped to the first and +# last point. -# Altitude adjustments (NCAA standards) +# Altitude adjustments +# - 300 m: start of the curve. Below ~300 m altitude has no measurable effect; the +# ramp from 300 m to the first NCAA point (914.4 m) replaces the old step, where +# 914 m gave 0% and 915 m gave 1.41%. +# - 914.4 m .. 2438.4 m: NCAA table (3000 ft .. 8000 ft), unchanged. +# - 3000 m, 3500 m, 4000 m: EXTRAPOLATION beyond the NCAA table, from a quadratic +# fit to the NCAA points: p = 0.3647·x² + 1.9482·x, with x = altitude_km − 0.3. +# The penalty is capped at the 4000 m value. altitude: - threshold_meters: 914.4 + threshold_meters: 300 data_points: - 0: 0.0 + 300: 0.0 914.4: 1.41 1219.2: 2.15 1524.0: 2.90 1828.8: 3.76 2133.6: 4.75 2438.4: 5.90 + 3000: 7.92 # extrapolated (quadratic fit), not NCAA + 3500: 9.97 # extrapolated (quadratic fit), not NCAA + 4000: 12.2 # extrapolated (quadratic fit), not NCAA # Heat adjustments (60-minute baseline) # These values represent the penalty for a 1-hour run. # For longer/shorter runs, the DurationFactor will scale these up/down. +# Points follow base(T) = 4.3 · ((T − 15) / 10)^1.5, every 2.5 °C so that the +# straight lines between them stay within 0.08 points of the curve: +# - exponent 1.5: El Helou et al. (2012) Table S3, see the derivation above; +# the penalty against 15 °C grows 2.70-2.97x from 20 to 25 °C in every +# group (2^1.497 = 2.82). +# - anchor base(25) = 4.3: kept from the original model; it is the only value +# available for short efforts and has no direct published source. +# - 27.5-35 °C: EXTRAPOLATION of the same law. The marathon data stop at +# ~25 °C (El Helou's hottest race: 25.2 °C). +# - Above 35 °C: CAPPED at the 35 °C value (12.16), a deliberate choice, not +# data. Everything beyond 25.2 °C is extrapolation, and the uncapped law +# (14.51 at 37.5 °C, 17.0 at 40 °C) gave 47.77% for 4 h at 40 °C. 37.5 °C and +# 40 °C therefore read like 35 °C, and anything hotter is clamped to the last +# point as before. heat: ideal_range_celsius: [10.0, 15.0] data_points: 15: 0.0 - 20: 2.8 # Base for 60m. For 3h (3.0x) = 8.4% (Ely: 9%) - 25: 4.3 # Base for 60m. For 3h (3.0x) = 12.9% (Ely: 12%) - 30: 6.5 # Base for 60m. For 3h (3.0x) = 19.5% + 17.5: 0.54 + 20: 1.52 # Base for 60m. For 3h (1.76x) = 2.68%, for 4h (2.81x) = 4.27% + 22.5: 2.79 + 25: 4.3 # Base for 60m (anchor). For 3h = 7.57%, for 4h = 12.08% + 27.5: 6.01 # EXTRAPOLATION from here on + 30: 7.9 # For 3h = 13.9%, for 4h = 22.2% + 32.5: 9.95 + 35: 12.16 # For 3h = 21.4%, for 4h = 34.17%; the cap starts here + 37.5: 12.16 # CAP: same as 35 °C (uncapped law: 14.51) + 40: 12.16 # CAP: same as 35 °C (uncapped law: 17.0); clamped above diff --git a/lib/calcpace/data/friend_2015_vo2max_percentiles.yml b/lib/calcpace/data/friend_2015_vo2max_percentiles.yml new file mode 100644 index 0000000..28af5a5 --- /dev/null +++ b/lib/calcpace/data/friend_2015_vo2max_percentiles.yml @@ -0,0 +1,49 @@ +# VO2max percentiles by age and sex (ml/kg/min), treadmill CPX with measured VO2max +# +# Source: Kaminsky LA, Arena R, Myers J. Reference Standards for +# Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: +# Data From the Fitness Registry and the Importance of Exercise National +# Database (FRIEND). Mayo Clinic Proceedings 2015;90(11):1515-1523. +# doi:10.1016/j.mayocp.2015.07.026 (author manuscript: PMC4919021) +# +# Table 3, "Sex-Specific Percentiles for CRF From Treadmill Exercise Tests With +# Measured VO2max Obtained From FRIEND [...]", rows "Men from FRIEND" and +# "Women from FRIEND". 7,783 treadmill tests (4,611 on men, 3,172 on women) +# on US adults free of known cardiovascular disease (Table 3 footnote; the +# paper notes some had other conditions, e.g. diabetes or obesity), VO2max +# measured by cardiopulmonary exercise testing (not predicted). +# +# The same Table 3 also reproduces the Cooper Clinic percentiles as printed in +# ACSM's Guidelines for Exercise Testing and Prescription, 9th ed. (2014), +# pp. 88-93 — the paper's reference 13 and, in its words, "the only widely +# cited reference data in the United States". They are not used here: those +# VO2max values were predicted from Balke treadmill time rather than measured, +# and their spread is much narrower (95th percentile for men aged 20-29: 55.5 +# predicted against 66.3 measured). +# +# Each row lists the VO2max at the percentiles in `percentiles`, in order. +# Age bands are keyed by their lower bound and span ten years (20 = 20-29). + +meta: + table_version: "FRIEND 2015 (Kaminsky et al., Mayo Clin Proc 90:1515, Table 3)" + doi: "10.1016/j.mayocp.2015.07.026" + units: "ml/kg/min" + modality: "treadmill, measured VO2max" + +percentiles: [5, 10, 25, 50, 75, 90, 95] + +M: + 20: [29.0, 32.1, 40.1, 48.0, 55.2, 61.8, 66.3] + 30: [27.2, 30.2, 35.9, 42.4, 49.2, 56.5, 59.8] + 40: [24.2, 26.8, 31.9, 37.8, 45.0, 52.1, 55.6] + 50: [20.9, 22.8, 27.1, 32.6, 39.7, 45.6, 50.7] + 60: [17.4, 19.8, 23.7, 28.2, 34.5, 40.3, 43.0] + 70: [16.3, 17.1, 20.4, 24.4, 30.4, 36.6, 39.7] + +F: + 20: [21.7, 23.9, 30.5, 37.6, 44.7, 51.3, 56.0] + 30: [19.0, 20.9, 25.3, 30.2, 36.1, 41.4, 45.8] + 40: [17.0, 18.8, 22.1, 26.7, 32.4, 38.4, 41.7] + 50: [16.0, 17.3, 19.9, 23.4, 27.6, 32.0, 35.9] + 60: [13.4, 14.6, 17.2, 20.0, 23.8, 27.0, 29.4] + 70: [13.1, 13.6, 15.6, 18.3, 20.8, 23.1, 24.1] diff --git a/lib/calcpace/data/mldr_2025_road.yml b/lib/calcpace/data/mldr_2025_road.yml new file mode 100644 index 0000000..ea1a7ad --- /dev/null +++ b/lib/calcpace/data/mldr_2025_road.yml @@ -0,0 +1,21 @@ +# Road-running age factors, one per single year of age, from Alan Jones' +# 2025 road age-grading tables (approved 2025-01-10 by the USATF Masters Long +# Distance Running Council). Source, sheet "Age Factors" of: +# https://github.com/AlanLyttonJones/Age-Grade-Tables/blob/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files/MaleRoadStd2025.xlsx +# https://github.com/AlanLyttonJones/Age-Grade-Tables/blob/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files/FemaleRoadStd2025.xlsx +# Columns used: "5 km" -> 5000, "10 km" -> 10000, "H. Mar" (21.0975 km) -> 21097, +# "Marathon" (42.195 km) -> 42195. The tables start at age 5; ages under 18 are +# left out because the gem refuses them. Factor = open standard / age standard, +# so age-graded time = time * factor. See the open standards file for the meta. + +F: + "5000": { 18: 0.9953, 19: 1.0000, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 0.9997, 28: 0.9990, 29: 0.9977, 30: 0.9959, 31: 0.9936, 32: 0.9908, 33: 0.9875, 34: 0.9836, 35: 0.9793, 36: 0.9744, 37: 0.9691, 38: 0.9632, 39: 0.9568, 40: 0.9499, 41: 0.9425, 42: 0.9346, 43: 0.9262, 44: 0.9172, 45: 0.9078, 46: 0.8980, 47: 0.8883, 48: 0.8786, 49: 0.8689, 50: 0.8592, 51: 0.8495, 52: 0.8398, 53: 0.8301, 54: 0.8204, 55: 0.8107, 56: 0.8009, 57: 0.7912, 58: 0.7815, 59: 0.7718, 60: 0.7621, 61: 0.7524, 62: 0.7427, 63: 0.7330, 64: 0.7233, 65: 0.7136, 66: 0.7038, 67: 0.6941, 68: 0.6844, 69: 0.6747, 70: 0.6650, 71: 0.6553, 72: 0.6456, 73: 0.6359, 74: 0.6262, 75: 0.6165, 76: 0.6067, 77: 0.5970, 78: 0.5868, 79: 0.5758, 80: 0.5640, 81: 0.5515, 82: 0.5382, 83: 0.5242, 84: 0.5094, 85: 0.4938, 86: 0.4775, 87: 0.4604, 88: 0.4426, 89: 0.4240, 90: 0.4046, 91: 0.3845, 92: 0.3636, 93: 0.3419, 94: 0.3195, 95: 0.2964, 96: 0.2725, 97: 0.2478, 98: 0.2223, 99: 0.1961, 100: 0.1692 } + "10000": { 18: 0.9820, 19: 0.9920, 20: 0.9980, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9991, 30: 0.9980, 31: 0.9964, 32: 0.9944, 33: 0.9920, 34: 0.9891, 35: 0.9857, 36: 0.9819, 37: 0.9777, 38: 0.9730, 39: 0.9679, 40: 0.9623, 41: 0.9563, 42: 0.9499, 43: 0.9429, 44: 0.9356, 45: 0.9278, 46: 0.9195, 47: 0.9109, 48: 0.9017, 49: 0.8921, 50: 0.8822, 51: 0.8723, 52: 0.8623, 53: 0.8524, 54: 0.8425, 55: 0.8325, 56: 0.8226, 57: 0.8126, 58: 0.8027, 59: 0.7928, 60: 0.7828, 61: 0.7729, 62: 0.7629, 63: 0.7530, 64: 0.7431, 65: 0.7331, 66: 0.7232, 67: 0.7132, 68: 0.7033, 69: 0.6934, 70: 0.6834, 71: 0.6735, 72: 0.6635, 73: 0.6536, 74: 0.6437, 75: 0.6337, 76: 0.6234, 77: 0.6123, 78: 0.6005, 79: 0.5879, 80: 0.5745, 81: 0.5604, 82: 0.5455, 83: 0.5299, 84: 0.5135, 85: 0.4963, 86: 0.4784, 87: 0.4597, 88: 0.4403, 89: 0.4201, 90: 0.3991, 91: 0.3774, 92: 0.3549, 93: 0.3317, 94: 0.3077, 95: 0.2829, 96: 0.2574, 97: 0.2311, 98: 0.2041, 99: 0.1763, 100: 0.1477 } + "21097": { 18: 0.9680, 19: 0.9820, 20: 0.9920, 21: 0.9980, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9991, 30: 0.9979, 31: 0.9962, 32: 0.9941, 33: 0.9915, 34: 0.9884, 35: 0.9849, 36: 0.9809, 37: 0.9764, 38: 0.9714, 39: 0.9660, 40: 0.9601, 41: 0.9537, 42: 0.9468, 43: 0.9395, 44: 0.9317, 45: 0.9234, 46: 0.9147, 47: 0.9055, 48: 0.8958, 49: 0.8856, 50: 0.8753, 51: 0.8649, 52: 0.8546, 53: 0.8442, 54: 0.8339, 55: 0.8235, 56: 0.8132, 57: 0.8028, 58: 0.7925, 59: 0.7821, 60: 0.7718, 61: 0.7614, 62: 0.7511, 63: 0.7407, 64: 0.7304, 65: 0.7200, 66: 0.7097, 67: 0.6993, 68: 0.6890, 69: 0.6786, 70: 0.6683, 71: 0.6579, 72: 0.6476, 73: 0.6372, 74: 0.6269, 75: 0.6165, 76: 0.6059, 77: 0.5945, 78: 0.5822, 79: 0.5692, 80: 0.5554, 81: 0.5407, 82: 0.5253, 83: 0.5091, 84: 0.4921, 85: 0.4742, 86: 0.4556, 87: 0.4362, 88: 0.4159, 89: 0.3949, 90: 0.3731, 91: 0.3504, 92: 0.3270, 93: 0.3028, 94: 0.2778, 95: 0.2519, 96: 0.2253, 97: 0.1979, 98: 0.1696, 99: 0.1406, 100: 0.1108 } + "42195": { 18: 0.9217, 19: 0.9391, 20: 0.9553, 21: 0.9689, 22: 0.9801, 23: 0.9888, 24: 0.9950, 25: 0.9988, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9992, 30: 0.9983, 31: 0.9970, 32: 0.9953, 33: 0.9932, 34: 0.9907, 35: 0.9879, 36: 0.9847, 37: 0.9811, 38: 0.9771, 39: 0.9727, 40: 0.9680, 41: 0.9629, 42: 0.9574, 43: 0.9515, 44: 0.9453, 45: 0.9386, 46: 0.9316, 47: 0.9242, 48: 0.9165, 49: 0.9083, 50: 0.8998, 51: 0.8909, 52: 0.8816, 53: 0.8720, 54: 0.8619, 55: 0.8515, 56: 0.8407, 57: 0.8297, 58: 0.8186, 59: 0.8076, 60: 0.7965, 61: 0.7854, 62: 0.7744, 63: 0.7633, 64: 0.7523, 65: 0.7412, 66: 0.7301, 67: 0.7191, 68: 0.7080, 69: 0.6970, 70: 0.6859, 71: 0.6748, 72: 0.6638, 73: 0.6527, 74: 0.6413, 75: 0.6290, 76: 0.6159, 77: 0.6021, 78: 0.5874, 79: 0.5720, 80: 0.5557, 81: 0.5386, 82: 0.5208, 83: 0.5021, 84: 0.4827, 85: 0.4624, 86: 0.4413, 87: 0.4195, 88: 0.3968, 89: 0.3734, 90: 0.3491, 91: 0.3240, 92: 0.2982, 93: 0.2715, 94: 0.2441, 95: 0.2158, 96: 0.1867, 97: 0.1569, 98: 0.1262, 99: 0.0948, 100: 0.0625 } + +M: + "5000": { 18: 0.9995, 19: 1.0000, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 0.9999, 31: 0.9988, 32: 0.9965, 33: 0.9930, 34: 0.9883, 35: 0.9824, 36: 0.9755, 37: 0.9685, 38: 0.9615, 39: 0.9545, 40: 0.9475, 41: 0.9405, 42: 0.9335, 43: 0.9265, 44: 0.9195, 45: 0.9125, 46: 0.9055, 47: 0.8985, 48: 0.8915, 49: 0.8845, 50: 0.8775, 51: 0.8705, 52: 0.8635, 53: 0.8565, 54: 0.8495, 55: 0.8425, 56: 0.8355, 57: 0.8285, 58: 0.8215, 59: 0.8145, 60: 0.8075, 61: 0.8005, 62: 0.7935, 63: 0.7865, 64: 0.7795, 65: 0.7725, 66: 0.7655, 67: 0.7585, 68: 0.7514, 69: 0.7436, 70: 0.7353, 71: 0.7264, 72: 0.7169, 73: 0.7068, 74: 0.6960, 75: 0.6847, 76: 0.6728, 77: 0.6603, 78: 0.6472, 79: 0.6334, 80: 0.6191, 81: 0.6042, 82: 0.5887, 83: 0.5726, 84: 0.5558, 85: 0.5385, 86: 0.5206, 87: 0.5021, 88: 0.4830, 89: 0.4632, 90: 0.4429, 91: 0.4220, 92: 0.4005, 93: 0.3784, 94: 0.3556, 95: 0.3323, 96: 0.3084, 97: 0.2839, 98: 0.2588, 99: 0.2330, 100: 0.2067 } + "10000": { 18: 0.9818, 19: 0.9903, 20: 0.9968, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 0.9996, 32: 0.9985, 33: 0.9967, 34: 0.9942, 35: 0.9909, 36: 0.9869, 37: 0.9822, 38: 0.9767, 39: 0.9705, 40: 0.9636, 41: 0.9561, 42: 0.9486, 43: 0.9411, 44: 0.9336, 45: 0.9261, 46: 0.9186, 47: 0.9111, 48: 0.9036, 49: 0.8961, 50: 0.8886, 51: 0.8811, 52: 0.8736, 53: 0.8661, 54: 0.8586, 55: 0.8511, 56: 0.8436, 57: 0.8361, 58: 0.8286, 59: 0.8211, 60: 0.8136, 61: 0.8061, 62: 0.7986, 63: 0.7911, 64: 0.7836, 65: 0.7761, 66: 0.7686, 67: 0.7611, 68: 0.7536, 69: 0.7461, 70: 0.7386, 71: 0.7308, 72: 0.7223, 73: 0.7131, 74: 0.7033, 75: 0.6928, 76: 0.6816, 77: 0.6697, 78: 0.6572, 79: 0.6440, 80: 0.6301, 81: 0.6156, 82: 0.6004, 83: 0.5845, 84: 0.5680, 85: 0.5508, 86: 0.5329, 87: 0.5143, 88: 0.4951, 89: 0.4752, 90: 0.4546, 91: 0.4334, 92: 0.4115, 93: 0.3889, 94: 0.3657, 95: 0.3418, 96: 0.3172, 97: 0.2919, 98: 0.2660, 99: 0.2394, 100: 0.2121 } + "21097": { 18: 0.9850, 19: 0.9950, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 1.0000, 32: 0.9996, 33: 0.9982, 34: 0.9960, 35: 0.9928, 36: 0.9888, 37: 0.9839, 38: 0.9781, 39: 0.9714, 40: 0.9638, 41: 0.9560, 42: 0.9483, 43: 0.9405, 44: 0.9327, 45: 0.9249, 46: 0.9171, 47: 0.9094, 48: 0.9016, 49: 0.8938, 50: 0.8860, 51: 0.8782, 52: 0.8705, 53: 0.8627, 54: 0.8549, 55: 0.8471, 56: 0.8393, 57: 0.8316, 58: 0.8238, 59: 0.8160, 60: 0.8082, 61: 0.8004, 62: 0.7927, 63: 0.7849, 64: 0.7771, 65: 0.7693, 66: 0.7615, 67: 0.7538, 68: 0.7460, 69: 0.7382, 70: 0.7304, 71: 0.7223, 72: 0.7135, 73: 0.7040, 74: 0.6938, 75: 0.6830, 76: 0.6715, 77: 0.6593, 78: 0.6464, 79: 0.6328, 80: 0.6185, 81: 0.6036, 82: 0.5880, 83: 0.5717, 84: 0.5547, 85: 0.5370, 86: 0.5186, 87: 0.4996, 88: 0.4799, 89: 0.4595, 90: 0.4384, 91: 0.4167, 92: 0.3942, 93: 0.3711, 94: 0.3473, 95: 0.3228, 96: 0.2976, 97: 0.2718, 98: 0.2452, 99: 0.2180, 100: 0.1901 } + "42195": { 18: 0.9680, 19: 0.9820, 20: 0.9920, 21: 0.9980, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 0.9999, 37: 0.9979, 38: 0.9934, 39: 0.9865, 40: 0.9783, 41: 0.9701, 42: 0.9619, 43: 0.9537, 44: 0.9455, 45: 0.9373, 46: 0.9291, 47: 0.9209, 48: 0.9127, 49: 0.9045, 50: 0.8963, 51: 0.8881, 52: 0.8799, 53: 0.8717, 54: 0.8635, 55: 0.8553, 56: 0.8471, 57: 0.8389, 58: 0.8307, 59: 0.8225, 60: 0.8143, 61: 0.8061, 62: 0.7979, 63: 0.7897, 64: 0.7815, 65: 0.7733, 66: 0.7651, 67: 0.7569, 68: 0.7487, 69: 0.7405, 70: 0.7323, 71: 0.7241, 72: 0.7155, 73: 0.7063, 74: 0.6963, 75: 0.6857, 76: 0.6743, 77: 0.6623, 78: 0.6495, 79: 0.6361, 80: 0.6219, 81: 0.6071, 82: 0.5915, 83: 0.5753, 84: 0.5583, 85: 0.5407, 86: 0.5223, 87: 0.5033, 88: 0.4835, 89: 0.4631, 90: 0.4419, 91: 0.4201, 92: 0.3975, 93: 0.3743, 94: 0.3503, 95: 0.3257, 96: 0.3003, 97: 0.2743, 98: 0.2475, 99: 0.2201, 100: 0.1919 } diff --git a/lib/calcpace/data/mldr_2025_road_open_standards.yml b/lib/calcpace/data/mldr_2025_road_open_standards.yml new file mode 100644 index 0000000..0265736 --- /dev/null +++ b/lib/calcpace/data/mldr_2025_road_open_standards.yml @@ -0,0 +1,46 @@ +meta: + source: "Alan Jones, 2025 road age-grading tables (single-age bests by Tom Bernhard), approved 2025-01-10 by the USATF Masters Long Distance Running Council" + url: "https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files" + files: + - "2025 Files/MaleRoadStd2025.xlsx (sheet \"Age Factors\", row \"OC sec\")" + - "2025 Files/FemaleRoadStd2025.xlsx (sheet \"Age Facctors\", row \"OC sec\")" + table_version: "MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1" + +# The Alan Jones road tables (also served by Howard Grubb's calculators) are +# numeric age factors and open standards only — they define no categories at +# all. The bands from Local Class (60%) upward follow the USATF Masters / +# National Masters News convention; the three bands under 60% are Calcpace's +# own extension, added to give recreational runners a meaningful label instead +# of a single catch-all. +age_grade_classifications: + - min: 100.0 + label: "Approximate World Record Level" + - min: 90.0 + label: "World Class" + - min: 80.0 + label: "National Class" + - min: 70.0 + label: "Regional Class" + - min: 60.0 + label: "Local Class" + - min: 50.0 + label: "Intermediate" + - min: 40.0 + label: "Recreational" + - min: 0.0 + label: "Active Beginner" + +# Open-class (OC) road standards, in seconds: the best road time for the +# distance at any age. 21097 is the half marathon (21.0975 km), 42195 the +# marathon (42.195 km). +open_standards_seconds: + M: + "5000": 769.0 # 12:49 + "10000": 1584.0 # 26:24 + "21097": 3451.0 # 57:31 + "42195": 7235.0 # 2:00:35 + F: + "5000": 834.0 # 13:54 + "10000": 1726.0 # 28:46 + "21097": 3772.0 # 1:02:52 + "42195": 7796.0 # 2:09:56 diff --git a/lib/calcpace/data/wma_2023_open_standards.yml b/lib/calcpace/data/wma_2023_open_standards.yml deleted file mode 100644 index 0caf513..0000000 --- a/lib/calcpace/data/wma_2023_open_standards.yml +++ /dev/null @@ -1,40 +0,0 @@ -meta: - source: "WMA/Masters Rankings age grading tables (2023)" - url: "https://howardgrubb.co.uk/athletics/wmatnf23.html" - table_version: "WMA_2023_ONE_YEAR_FACTORS_V1" - -# The WMA / Alan Jones (Howard Grubb) tables are numeric age factors and open -# standards only — they define no categories at all. The bands from Local -# Class (60%) upward follow the USATF Masters / National Masters News -# convention; the three bands under 60% are Calcpace's own extension, added -# to give recreational runners a meaningful label instead of a single -# catch-all. -age_grade_classifications: - - min: 100.0 - label: "Approximate World Record Level" - - min: 90.0 - label: "World Class" - - min: 80.0 - label: "National Class" - - min: 70.0 - label: "Regional Class" - - min: 60.0 - label: "Local Class" - - min: 50.0 - label: "Intermediate" - - min: 40.0 - label: "Recreational" - - min: 0.0 - label: "Active Beginner" - -open_standards_seconds: - M: - "5000": 755.0 - "10000": 1571.0 - "21097": 3451.0 - "42195": 7269.0 - F: - "5000": 846.0 - "10000": 1741.0 - "21097": 3772.0 - "42195": 8044.0 diff --git a/lib/calcpace/data/wma_2023_road.yml b/lib/calcpace/data/wma_2023_road.yml deleted file mode 100644 index 0ac8eed..0000000 --- a/lib/calcpace/data/wma_2023_road.yml +++ /dev/null @@ -1,16 +0,0 @@ - -F: - "1500": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 0.9960, 34: 0.9886, 35: 0.9812, 36: 0.9738, 37: 0.9664, 38: 0.9590, 39: 0.9515, 40: 0.9441, 41: 0.9367, 42: 0.9293, 43: 0.9218, 44: 0.9144, 45: 0.9069, 46: 0.8995, 47: 0.8921, 48: 0.8846, 49: 0.8772, 50: 0.8697, 51: 0.8623, 52: 0.8548, 53: 0.8473, 54: 0.8399, 55: 0.8324, 56: 0.8249, 57: 0.8175, 58: 0.8100, 59: 0.8025, 60: 0.7951, 61: 0.7876, 62: 0.7801, 63: 0.7726, 64: 0.7651, 65: 0.7576, 66: 0.7501, 67: 0.7427, 68: 0.7352, 69: 0.7277, 70: 0.7202, 71: 0.7127, 72: 0.7052, 73: 0.6976, 74: 0.6897, 75: 0.6812, 76: 0.6722, 77: 0.6628, 78: 0.6529, 79: 0.6425, 80: 0.6316, 81: 0.6202, 82: 0.6083, 83: 0.5960, 84: 0.5832, 85: 0.5698, 86: 0.5561, 87: 0.5418, 88: 0.5270, 89: 0.5118, 90: 0.4960, 91: 0.4798, 92: 0.4631, 93: 0.4460, 94: 0.4283, 95: 0.4102, 96: 0.3915, 97: 0.3724, 98: 0.3528, 99: 0.3327, 100: 0.3122, 101: 0.2911, 102: 0.2696, 103: 0.2476, 104: 0.2251, 105: 0.2021, 106: 0.1787, 107: 0.1547, 108: 0.1303, 109: 0.1054, 110: 0.0800 } - "3000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 1.0000, 37: 1.0000, 38: 0.9930, 39: 0.9849, 40: 0.9767, 41: 0.9685, 42: 0.9603, 43: 0.9521, 44: 0.9438, 45: 0.9355, 46: 0.9271, 47: 0.9188, 48: 0.9104, 49: 0.9019, 50: 0.8935, 51: 0.8850, 52: 0.8765, 53: 0.8680, 54: 0.8594, 55: 0.8509, 56: 0.8423, 57: 0.8336, 58: 0.8250, 59: 0.8163, 60: 0.8076, 61: 0.7989, 62: 0.7901, 63: 0.7813, 64: 0.7725, 65: 0.7637, 66: 0.7549, 67: 0.7460, 68: 0.7371, 69: 0.7282, 70: 0.7193, 71: 0.7103, 72: 0.7013, 73: 0.6923, 74: 0.6833, 75: 0.6743, 76: 0.6652, 77: 0.6561, 78: 0.6470, 79: 0.6379, 80: 0.6288, 81: 0.6196, 82: 0.6098, 83: 0.5993, 84: 0.5881, 85: 0.5762, 86: 0.5637, 87: 0.5505, 88: 0.5366, 89: 0.5220, 90: 0.5067, 91: 0.4908, 92: 0.4742, 93: 0.4569, 94: 0.4390, 95: 0.4204, 96: 0.4010, 97: 0.3811, 98: 0.3604, 99: 0.3391, 100: 0.3171, 101: 0.2944, 102: 0.2711, 103: 0.2470, 104: 0.2223, 105: 0.1970, 106: 0.1709, 107: 0.1442, 108: 0.1168, 109: 0.0887, 110: 0.0600 } - "5000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 0.9974, 36: 0.9904, 37: 0.9833, 38: 0.9761, 39: 0.9689, 40: 0.9615, 41: 0.9541, 42: 0.9467, 43: 0.9392, 44: 0.9316, 45: 0.9239, 46: 0.9162, 47: 0.9084, 48: 0.9006, 49: 0.8926, 50: 0.8847, 51: 0.8766, 52: 0.8685, 53: 0.8603, 54: 0.8521, 55: 0.8438, 56: 0.8355, 57: 0.8271, 58: 0.8186, 59: 0.8101, 60: 0.8015, 61: 0.7929, 62: 0.7842, 63: 0.7755, 64: 0.7667, 65: 0.7578, 66: 0.7489, 67: 0.7399, 68: 0.7309, 69: 0.7219, 70: 0.7128, 71: 0.7036, 72: 0.6944, 73: 0.6851, 74: 0.6758, 75: 0.6665, 76: 0.6571, 77: 0.6476, 78: 0.6381, 79: 0.6286, 80: 0.6190, 81: 0.6094, 82: 0.5997, 83: 0.5893, 84: 0.5783, 85: 0.5665, 86: 0.5541, 87: 0.5410, 88: 0.5272, 89: 0.5127, 90: 0.4975, 91: 0.4816, 92: 0.4650, 93: 0.4478, 94: 0.4299, 95: 0.4112, 96: 0.3919, 97: 0.3719, 98: 0.3513, 99: 0.3299, 100: 0.3079, 101: 0.2852, 102: 0.2618, 103: 0.2377, 104: 0.2129, 105: 0.1875, 106: 0.1613, 107: 0.1345, 108: 0.1070, 109: 0.0788, 110: 0.0500 } - "10000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 0.9937, 35: 0.9869, 36: 0.9801, 37: 0.9731, 38: 0.9661, 39: 0.9591, 40: 0.9519, 41: 0.9447, 42: 0.9374, 43: 0.9301, 44: 0.9227, 45: 0.9152, 46: 0.9077, 47: 0.9001, 48: 0.8925, 49: 0.8848, 50: 0.8770, 51: 0.8692, 52: 0.8613, 53: 0.8533, 54: 0.8453, 55: 0.8373, 56: 0.8291, 57: 0.8210, 58: 0.8127, 59: 0.8045, 60: 0.7961, 61: 0.7877, 62: 0.7793, 63: 0.7708, 64: 0.7623, 65: 0.7537, 66: 0.7450, 67: 0.7363, 68: 0.7276, 69: 0.7188, 70: 0.7100, 71: 0.7011, 72: 0.6922, 73: 0.6832, 74: 0.6742, 75: 0.6651, 76: 0.6560, 77: 0.6468, 78: 0.6376, 79: 0.6284, 80: 0.6191, 81: 0.6098, 82: 0.6004, 83: 0.5903, 84: 0.5795, 85: 0.5679, 86: 0.5556, 87: 0.5425, 88: 0.5287, 89: 0.5142, 90: 0.4990, 91: 0.4830, 92: 0.4662, 93: 0.4488, 94: 0.4306, 95: 0.4116, 96: 0.3920, 97: 0.3716, 98: 0.3505, 99: 0.3286, 100: 0.3060, 101: 0.2827, 102: 0.2586, 103: 0.2339, 104: 0.2084, 105: 0.1821, 106: 0.1551, 107: 0.1275, 108: 0.0990, 109: 0.0699, 110: 0.0400 } - "21097": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 0.9935, 34: 0.9869, 35: 0.9802, 36: 0.9734, 37: 0.9666, 38: 0.9596, 39: 0.9526, 40: 0.9455, 41: 0.9384, 42: 0.9311, 43: 0.9238, 44: 0.9164, 45: 0.9090, 46: 0.9014, 47: 0.8938, 48: 0.8862, 49: 0.8784, 50: 0.8706, 51: 0.8627, 52: 0.8548, 53: 0.8468, 54: 0.8387, 55: 0.8306, 56: 0.8224, 57: 0.8141, 58: 0.8058, 59: 0.7974, 60: 0.7889, 61: 0.7804, 62: 0.7718, 63: 0.7632, 64: 0.7545, 65: 0.7457, 66: 0.7369, 67: 0.7280, 68: 0.7191, 69: 0.7101, 70: 0.7011, 71: 0.6920, 72: 0.6828, 73: 0.6736, 74: 0.6644, 75: 0.6551, 76: 0.6457, 77: 0.6363, 78: 0.6268, 79: 0.6173, 80: 0.6078, 81: 0.5982, 82: 0.5879, 83: 0.5769, 84: 0.5653, 85: 0.5530, 86: 0.5401, 87: 0.5265, 88: 0.5122, 89: 0.4972, 90: 0.4816, 91: 0.4653, 92: 0.4484, 93: 0.4307, 94: 0.4125, 95: 0.3935, 96: 0.3739, 97: 0.3536, 98: 0.3327, 99: 0.3111, 100: 0.2888, 101: 0.2659, 102: 0.2424, 103: 0.2181, 104: 0.1932, 105: 0.1677, 106: 0.1414, 107: 0.1146, 108: 0.0870, 109: 0.0588, 110: 0.0300 } - "42195": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 0.9982, 36: 0.9918, 37: 0.9854, 38: 0.9789, 39: 0.9722, 40: 0.9654, 41: 0.9585, 42: 0.9515, 43: 0.9444, 44: 0.9371, 45: 0.9298, 46: 0.9223, 47: 0.9148, 48: 0.9071, 49: 0.8993, 50: 0.8915, 51: 0.8835, 52: 0.8754, 53: 0.8672, 54: 0.8590, 55: 0.8506, 56: 0.8421, 57: 0.8336, 58: 0.8249, 59: 0.8162, 60: 0.8073, 61: 0.7984, 62: 0.7894, 63: 0.7803, 64: 0.7711, 65: 0.7618, 66: 0.7524, 67: 0.7430, 68: 0.7335, 69: 0.7239, 70: 0.7142, 71: 0.7044, 72: 0.6946, 73: 0.6846, 74: 0.6746, 75: 0.6646, 76: 0.6544, 77: 0.6442, 78: 0.6339, 79: 0.6235, 80: 0.6131, 81: 0.6025, 82: 0.5914, 83: 0.5795, 84: 0.5670, 85: 0.5538, 86: 0.5400, 87: 0.5254, 88: 0.5103, 89: 0.4944, 90: 0.4779, 91: 0.4608, 92: 0.4429, 93: 0.4245, 94: 0.4053, 95: 0.3855, 96: 0.3651, 97: 0.3439, 98: 0.3222, 99: 0.2997, 100: 0.2767, 101: 0.2529, 102: 0.2285, 103: 0.2035, 104: 0.1778, 105: 0.1515, 106: 0.1245, 107: 0.0968, 108: 0.0685, 109: 0.0396, 110: 0.0100 } - -M: - "1500": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 0.9973, 34: 0.9911, 35: 0.9849, 36: 0.9786, 37: 0.9723, 38: 0.9660, 39: 0.9596, 40: 0.9532, 41: 0.9468, 42: 0.9403, 43: 0.9338, 44: 0.9272, 45: 0.9206, 46: 0.9140, 47: 0.9073, 48: 0.9006, 49: 0.8939, 50: 0.8871, 51: 0.8803, 52: 0.8734, 53: 0.8666, 54: 0.8596, 55: 0.8527, 56: 0.8457, 57: 0.8387, 58: 0.8316, 59: 0.8246, 60: 0.8174, 61: 0.8103, 62: 0.8031, 63: 0.7959, 64: 0.7887, 65: 0.7814, 66: 0.7741, 67: 0.7668, 68: 0.7594, 69: 0.7520, 70: 0.7446, 71: 0.7371, 72: 0.7296, 73: 0.7221, 74: 0.7146, 75: 0.7070, 76: 0.6994, 77: 0.6918, 78: 0.6835, 79: 0.6746, 80: 0.6651, 81: 0.6549, 82: 0.6440, 83: 0.6325, 84: 0.6204, 85: 0.6076, 86: 0.5942, 87: 0.5801, 88: 0.5654, 89: 0.5501, 90: 0.5341, 91: 0.5175, 92: 0.5002, 93: 0.4823, 94: 0.4638, 95: 0.4446, 96: 0.4248, 97: 0.4043, 98: 0.3832, 99: 0.3614, 100: 0.3390, 101: 0.3160, 102: 0.2923, 103: 0.2680, 104: 0.2431, 105: 0.2175, 106: 0.1913, 107: 0.1644, 108: 0.1369, 109: 0.1088, 110: 0.0800 } - "3000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 0.9993, 36: 0.9922, 37: 0.9851, 38: 0.9780, 39: 0.9708, 40: 0.9636, 41: 0.9564, 42: 0.9491, 43: 0.9419, 44: 0.9345, 45: 0.9272, 46: 0.9199, 47: 0.9125, 48: 0.9051, 49: 0.8976, 50: 0.8901, 51: 0.8827, 52: 0.8751, 53: 0.8676, 54: 0.8600, 55: 0.8524, 56: 0.8448, 57: 0.8372, 58: 0.8295, 59: 0.8218, 60: 0.8141, 61: 0.8064, 62: 0.7986, 63: 0.7908, 64: 0.7830, 65: 0.7752, 66: 0.7674, 67: 0.7595, 68: 0.7516, 69: 0.7437, 70: 0.7357, 71: 0.7278, 72: 0.7198, 73: 0.7118, 74: 0.7038, 75: 0.6957, 76: 0.6877, 77: 0.6796, 78: 0.6715, 79: 0.6634, 80: 0.6552, 81: 0.6464, 82: 0.6367, 83: 0.6263, 84: 0.6151, 85: 0.6032, 86: 0.5905, 87: 0.5771, 88: 0.5629, 89: 0.5480, 90: 0.5323, 91: 0.5158, 92: 0.4986, 93: 0.4806, 94: 0.4619, 95: 0.4425, 96: 0.4222, 97: 0.4013, 98: 0.3795, 99: 0.3570, 100: 0.3338, 101: 0.3098, 102: 0.2851, 103: 0.2596, 104: 0.2333, 105: 0.2063, 106: 0.1785, 107: 0.1500, 108: 0.1208, 109: 0.0908, 110: 0.0600 } - "5000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 1.0000, 37: 0.9943, 38: 0.9863, 39: 0.9782, 40: 0.9701, 41: 0.9621, 42: 0.9540, 43: 0.9460, 44: 0.9380, 45: 0.9299, 46: 0.9219, 47: 0.9139, 48: 0.9059, 49: 0.8980, 50: 0.8900, 51: 0.8820, 52: 0.8740, 53: 0.8661, 54: 0.8582, 55: 0.8502, 56: 0.8423, 57: 0.8344, 58: 0.8265, 59: 0.8185, 60: 0.8106, 61: 0.8028, 62: 0.7949, 63: 0.7870, 64: 0.7791, 65: 0.7713, 66: 0.7634, 67: 0.7556, 68: 0.7477, 69: 0.7399, 70: 0.7321, 71: 0.7242, 72: 0.7164, 73: 0.7086, 74: 0.7008, 75: 0.6930, 76: 0.6852, 77: 0.6775, 78: 0.6697, 79: 0.6619, 80: 0.6541, 81: 0.6464, 82: 0.6367, 83: 0.6263, 84: 0.6151, 85: 0.6032, 86: 0.5905, 87: 0.5771, 88: 0.5629, 89: 0.5480, 90: 0.5323, 91: 0.5158, 92: 0.4986, 93: 0.4806, 94: 0.4619, 95: 0.4425, 96: 0.4222, 97: 0.4013, 98: 0.3795, 99: 0.3570, 100: 0.3338, 101: 0.3098, 102: 0.2851, 103: 0.2596, 104: 0.2333, 105: 0.2063, 106: 0.1785, 107: 0.1500, 108: 0.1208, 109: 0.0908, 110: 0.0600 } - "10000": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 0.9973, 35: 0.9897, 36: 0.9822, 37: 0.9747, 38: 0.9672, 39: 0.9597, 40: 0.9523, 41: 0.9449, 42: 0.9375, 43: 0.9301, 44: 0.9228, 45: 0.9155, 46: 0.9082, 47: 0.9009, 48: 0.8937, 49: 0.8865, 50: 0.8793, 51: 0.8722, 52: 0.8650, 53: 0.8579, 54: 0.8509, 55: 0.8438, 56: 0.8368, 57: 0.8298, 58: 0.8228, 59: 0.8158, 60: 0.8089, 61: 0.8019, 62: 0.7950, 63: 0.7882, 64: 0.7813, 65: 0.7745, 66: 0.7677, 67: 0.7609, 68: 0.7541, 69: 0.7474, 70: 0.7407, 71: 0.7340, 72: 0.7273, 73: 0.7206, 74: 0.7140, 75: 0.7073, 76: 0.7007, 77: 0.6942, 78: 0.6868, 79: 0.6787, 80: 0.6698, 81: 0.6601, 82: 0.6496, 83: 0.6383, 84: 0.6263, 85: 0.6135, 86: 0.5999, 87: 0.5856, 88: 0.5704, 89: 0.5545, 90: 0.5378, 91: 0.5203, 92: 0.5021, 93: 0.4830, 94: 0.4632, 95: 0.4426, 96: 0.4213, 97: 0.3991, 98: 0.3762, 99: 0.3524, 100: 0.3279, 101: 0.3027, 102: 0.2766, 103: 0.2498, 104: 0.2221, 105: 0.1937, 106: 0.1646, 107: 0.1346, 108: 0.1038, 109: 0.0723, 110: 0.0400 } - "21097": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 0.9953, 36: 0.9885, 37: 0.9816, 38: 0.9748, 39: 0.9680, 40: 0.9612, 41: 0.9544, 42: 0.9476, 43: 0.9408, 44: 0.9340, 45: 0.9272, 46: 0.9204, 47: 0.9137, 48: 0.9069, 49: 0.9002, 50: 0.8934, 51: 0.8867, 52: 0.8799, 53: 0.8732, 54: 0.8665, 55: 0.8598, 56: 0.8531, 57: 0.8464, 58: 0.8397, 59: 0.8331, 60: 0.8264, 61: 0.8197, 62: 0.8131, 63: 0.8064, 64: 0.7998, 65: 0.7931, 66: 0.7865, 67: 0.7799, 68: 0.7733, 69: 0.7666, 70: 0.7600, 71: 0.7534, 72: 0.7468, 73: 0.7396, 74: 0.7318, 75: 0.7233, 76: 0.7142, 77: 0.7044, 78: 0.6941, 79: 0.6831, 80: 0.6715, 81: 0.6592, 82: 0.6463, 83: 0.6328, 84: 0.6187, 85: 0.6039, 86: 0.5885, 87: 0.5725, 88: 0.5558, 89: 0.5385, 90: 0.5206, 91: 0.5021, 92: 0.4829, 93: 0.4631, 94: 0.4426, 95: 0.4216, 96: 0.3999, 97: 0.3776, 98: 0.3546, 99: 0.3310, 100: 0.3068, 101: 0.2820, 102: 0.2565, 103: 0.2304, 104: 0.2036, 105: 0.1763, 106: 0.1483, 107: 0.1197, 108: 0.0904, 109: 0.0605, 110: 0.0300 } - "42195": { 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 1.0000, 37: 1.0000, 38: 0.9947, 39: 0.9876, 40: 0.9804, 41: 0.9733, 42: 0.9661, 43: 0.9589, 44: 0.9517, 45: 0.9445, 46: 0.9372, 47: 0.9299, 48: 0.9226, 49: 0.9153, 50: 0.9079, 51: 0.9005, 52: 0.8931, 53: 0.8857, 54: 0.8783, 55: 0.8708, 56: 0.8633, 57: 0.8558, 58: 0.8483, 59: 0.8407, 60: 0.8331, 61: 0.8255, 62: 0.8179, 63: 0.8103, 64: 0.8026, 65: 0.7950, 66: 0.7873, 67: 0.7796, 68: 0.7718, 69: 0.7641, 70: 0.7563, 71: 0.7485, 72: 0.7407, 73: 0.7329, 74: 0.7250, 75: 0.7165, 76: 0.7074, 77: 0.6976, 78: 0.6871, 79: 0.6760, 80: 0.6643, 81: 0.6519, 82: 0.6388, 83: 0.6251, 84: 0.6108, 85: 0.5958, 86: 0.5801, 87: 0.5638, 88: 0.5469, 89: 0.5293, 90: 0.5110, 91: 0.4921, 92: 0.4726, 93: 0.4524, 94: 0.4316, 95: 0.4101, 96: 0.3879, 97: 0.3651, 98: 0.3417, 99: 0.3176, 100: 0.2929, 101: 0.2675, 102: 0.2415, 103: 0.2148, 104: 0.1875, 105: 0.1595, 106: 0.1309, 107: 0.1017, 108: 0.0718, 109: 0.0412, 110: 0.0100 } \ No newline at end of file diff --git a/lib/calcpace/environmental_adjuster.rb b/lib/calcpace/environmental_adjuster.rb index 52b72a9..e7081ae 100644 --- a/lib/calcpace/environmental_adjuster.rb +++ b/lib/calcpace/environmental_adjuster.rb @@ -1,34 +1,71 @@ # frozen_string_literal: true require 'yaml' +require_relative 'humidity' # Module for adjusting race performance based on environmental conditions # # Scientific basis: -# - Heat: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance" +# - Heat: Ely et al. (2007) "Impact of Weather on Marathon-Running Performance" +# (qualitative: slowing grows with WBGT, more for slower runners) and +# El Helou et al. (2012) "Impact of Environmental Parameters on Marathon +# Running Performance" (duration scaling, see HEAT_DURATION_FACTORS) # - Altitude: NCAA Altitude Adjustment Factors (TFRRS) +# - Humidity: Australian Bureau of Meteorology simplified WBGT +# (WBGT = 0.567·Ta + 0.393·e + 3.94, e = vapour pressure in hPa) module EnvironmentalAdjuster DATA_PATH = File.expand_path('data/environmental_factors.yml', __dir__).freeze FACTORS = YAML.safe_load_file(DATA_PATH, permitted_classes: [], aliases: false).freeze + # Heat duration scaling: [minutes, factor] points, joined by straight lines + # and flat outside the first and last point. The base heat penalty in + # environmental_factors.yml is for a 60-minute effort (factor 1.0). + # - 30 min (0.5x) and 60 min (1.0x): kept from the original model; no + # marathon dataset covers efforts this short. + # - 3 h (1.76x) and 4 h (2.81x, flat after): weighted least-squares fit to + # El Helou et al. (2012) Table S3 — the time penalty against 15 °C at + # 20 °C and 25 °C for eight finisher groups (2:41–4:54) divided by the + # 60-minute base (itself fitted to the same table). The 2 h value (1.38x) + # is the straight line 60 → 180 min: no group finishes between 1 h and + # 2:41. Derivation table in environmental_factors.yml. + HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 1.76], [240.0, 2.81]].freeze + + # Relative humidity (%) the temperature-only heat curve stands for + # (see EnvironmentalAdjuster::Humidity) + REFERENCE_HUMIDITY = Humidity::REFERENCE_HUMIDITY + # Calculates the performance penalty percentage for given environmental conditions # # @param temperature [Numeric, nil] ambient temperature # @param temperature_unit [Symbol, String] :c (Celsius) or :f (Fahrenheit) # @param altitude [Numeric, nil] altitude in meters # @param time_seconds [Numeric, nil] duration of the effort in seconds - # @return [Hash] hash with :total_penalty_percent and breakdown in :factors - def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil) - heat_penalty = calculate_heat_penalty(temperature, temperature_unit, time_seconds) + # @param humidity [Numeric, nil] relative humidity in % (0–100). Optional; + # without it (and without dew_point) the heat curve assumes + # REFERENCE_HUMIDITY. With it, the temperature is replaced by the effective + # temperature that has the same simplified WBGT at REFERENCE_HUMIDITY. + # @param dew_point [Numeric, nil] dew point, in temperature_unit. Alternative + # to humidity (pass one or the other), must not exceed the temperature + # @return [Hash] hash with :total_penalty_percent and breakdown in :factors; + # when humidity or dew_point is given, :factors also carries + # :effective_temperature_celsius + # @raise [ArgumentError] if humidity is outside 0–100, dew_point is above the + # temperature, both are given, or either is given without a temperature + # + # @example + # calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] #=> 12.16 + # calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] #=> 35.94 + # calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] #=> 11.07 + def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil, + humidity: nil, dew_point: nil) + effective = effective_temperature(temperature, temperature_unit, humidity, dew_point) + heat_penalty = calculate_heat_penalty(effective, time_seconds) altitude_penalty = calculate_altitude_penalty(altitude) - { - total_penalty_percent: (heat_penalty + altitude_penalty).round(2), - factors: { - heat: heat_penalty, - altitude: altitude_penalty - } - } + factors = { heat: heat_penalty, altitude: altitude_penalty } + factors[:effective_temperature_celsius] = effective.round(2) unless humidity.nil? && dew_point.nil? + + { total_penalty_percent: (heat_penalty + altitude_penalty).round(2), factors: factors } end # Adjusts a given time based on environmental conditions @@ -71,10 +108,19 @@ def normalize_time(time_seconds, **) private - def calculate_heat_penalty(temp, unit, time_seconds) - return 0.0 if temp.nil? + # Air temperature in °C, moved to the temperature that has the same + # simplified WBGT at REFERENCE_HUMIDITY when humidity or dew point is known + def effective_temperature(temp, unit, humidity, dew_point) + Humidity.check_inputs!(temp, humidity, dew_point) + temp_c = temp && normalize_temperature(temp, unit) + return temp_c if temp_c.nil? || (humidity.nil? && dew_point.nil?) + + Humidity.effective_temperature(temp_c, humidity: humidity, + dew_point_c: dew_point && normalize_temperature(dew_point, unit)) + end - temp_c = normalize_temperature(temp, unit) + def calculate_heat_penalty(temp_c, time_seconds) + return 0.0 if temp_c.nil? data = FACTORS.fetch('heat') ideal_min, ideal_max = data.fetch('ideal_range_celsius') @@ -89,23 +135,11 @@ def calculate_heat_penalty(temp, unit, time_seconds) def duration_factor(time_seconds) return 1.0 if time_seconds.nil? - minutes = time_seconds / 60.0 - - # Rule based on Matthew Ely (2007) heat degradation curve. - # Scaled for piecewise linear interpolation to avoid jumps. - if minutes <= 30 - 0.5 - elsif minutes <= 60 - # Scale from 0.5x (30m) up to 1.0x (60m) - 0.5 + (((minutes - 30.0) / 30.0) * 0.5) - elsif minutes <= 180 - # Scale from 1.0x (60m) up to 3.0x (180m / 3h) - 1.0 + (((minutes - 60.0) / 120.0) * 2.0) - else - # Scale from 3.0x (3h) up to 4.5x (4h) - capped_minutes = [minutes, 240.0].min - 3.0 + (((capped_minutes - 180.0) / 60.0) * 1.5) - end + minutes = (time_seconds / 60.0).clamp(HEAT_DURATION_FACTORS.first.first, HEAT_DURATION_FACTORS.last.first) + (from_minutes, from_factor), (to_minutes, to_factor) = + HEAT_DURATION_FACTORS.each_cons(2).find { |_, (upper, _)| minutes <= upper } + + from_factor + (((minutes - from_minutes) / (to_minutes - from_minutes)) * (to_factor - from_factor)) end def normalize_temperature(temp, unit) diff --git a/lib/calcpace/grade_adjusted_pace.rb b/lib/calcpace/grade_adjusted_pace.rb new file mode 100644 index 0000000..da8a97f --- /dev/null +++ b/lib/calcpace/grade_adjusted_pace.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# Module for grade-adjusted pace (GAP): the flat-ground pace that costs the +# same energy as a pace run on a slope +# +# Uses the energy cost of running on gradients measured by Minetti et al. +# (2002) on ten runners on a treadmill inclined from −45% to +45%: +# +# Cr(i) = 155.4·i⁵ − 30.4·i⁴ − 43.3·i³ + 46.3·i² + 19.5·i + 3.6 (R² = 0.999) +# +# where Cr is the metabolic cost in J·kg⁻¹·m⁻¹ and i the gradient as a +# fraction (rise over horizontal run, 0.05 = 5%). The adjustment factor is +# Cr(i) / Cr(0): running a metre of 10% climb costs about 1.66 flat metres, +# a metre of 10% descent about 0.60. Cost is cheapest near −20% and rises +# again on steeper descents, where braking takes over. +# +# The flat-equivalent pace is pace / factor: the speed that, on level ground, +# spends energy at the same rate. Minetti found the cost per metre independent +# of speed, so the factor does not depend on how fast the runner is going. +# +# Limits worth knowing: +# - The polynomial is a fit to treadmill measurements between −0.45 and +0.45; +# grades outside that range are clamped to it rather than extrapolated. +# - It is a metabolic model. It does not account for the muscular cost of long +# descents, technical terrain or the fact that a runner rarely holds the +# metabolic equivalent on a steep climb — field GAP models fitted to heart +# rate (Strava's, for instance) are gentler on climbs. +# - Its constant term (3.6) is the fit's value on the flat; Minetti measured +# 3.40 ± 0.24 J·kg⁻¹·m⁻¹ there. The factor divides by Cr(0) so it is exactly +# 1.0 on the flat. +# +# Reference: Minetti, A. E., Moia, C., Roi, G. S., Susta, D., & Ferretti, G. +# (2002). Energy cost of walking and running at extreme uphill and downhill +# slopes. Journal of Applied Physiology, 93(3), 1039–1046. +# https://doi.org/10.1152/japplphysiol.01177.2001 +module GradeAdjustedPace + # Gradient range of Minetti et al.'s measurements, as fractions + MINETTI_GRADE_RANGE = (-0.45..0.45) + + # Polynomial coefficients for Cr(i), highest power first (J·kg⁻¹·m⁻¹) + MINETTI_RUNNING_COEFFICIENTS = [155.4, -30.4, -43.3, 46.3, 19.5, 3.6].freeze + + # Returns how many flat metres one metre at a given gradient is worth + # + # @param grade [Numeric] gradient as a fraction (0.05 = 5% uphill, −0.05 = 5% + # downhill); clamped to −0.45..0.45, the range Minetti et al. measured + # @return [Float] Cr(grade) / Cr(0) — 1.0 on the flat, above 1 uphill, + # below 1 on moderate descents + # @raise [ArgumentError] if grade is not a finite number + # + # @example + # calc.grade_adjustment_factor(0) #=> 1.0 + # calc.grade_adjustment_factor(0.1) #=> 1.6578372222222222 + # calc.grade_adjustment_factor(-0.1) #=> 0.5976961111111111 + def grade_adjustment_factor(grade) + check_grade(grade) + + minetti_running_cost(grade.to_f.clamp(MINETTI_GRADE_RANGE)) / minetti_running_cost(0.0) + end + + # Converts a pace run on a slope into the flat pace of equal effort + # + # @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS or HH:MM:SS) + # @param grade [Numeric] gradient as a fraction (see #grade_adjustment_factor) + # @param unit [Symbol, String] unit the pace is expressed in — :km (default) or + # :mi. The factor is per distance, so the result comes back in the same unit + # and the unit only has to be a supported one + # @return [Float] flat-equivalent pace in seconds per unit + # @raise [ArgumentError] if grade is not a finite number + # @raise [Calcpace::NonPositiveInputError] if pace is not positive + # @raise [Calcpace::InvalidTimeFormatError] if a string pace is not a valid clock + # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi + # + # @example + # calc.grade_adjusted_pace(360, 0.1) #=> 217.1503903847952 + # calc.grade_adjusted_pace('05:00', -0.05) #=> 393.31023895122013 + # calc.grade_adjusted_pace(480, 0.05, unit: :mi) #=> 368.82127811700303 + def grade_adjusted_pace(pace, grade, unit: :km) + pace_unit_meters(unit) + factor = grade_adjustment_factor(grade) + pace_seconds = pace_seconds_from(pace) + check_positive(pace_seconds, 'Pace') + + pace_seconds.to_f / factor + end + + # Grade-adjusted pace as a clock string + # + # @param pace [Numeric, String] pace in seconds per unit or time string + # @param grade [Numeric] gradient as a fraction + # @param unit [Symbol, String] unit the pace is expressed in — :km (default) or :mi + # @param compact [Boolean] when true, return the compact display format + # @return [String] flat-equivalent pace in HH:MM:SS format, or 'M:SS' / 'H:MM:SS' + # with compact: true + # + # @example + # calc.grade_adjusted_pace_clock('06:00', 0.1) #=> '00:03:37' + # calc.grade_adjusted_pace_clock('06:00', 0.1, compact: true) #=> '3:37' + def grade_adjusted_pace_clock(pace, grade, unit: :km, compact: false) + convert_to_clocktime(grade_adjusted_pace(pace, grade, unit: unit), compact: compact) + end + + private + + def check_grade(grade) + return if grade.is_a?(Numeric) && grade.to_f.finite? + + raise ArgumentError, "Grade must be a finite number (a fraction: 0.05 = 5%), got #{grade.inspect}" + end + + def minetti_running_cost(grade) + MINETTI_RUNNING_COEFFICIENTS.reduce(0.0) { |sum, coefficient| (sum * grade) + coefficient } + end +end diff --git a/lib/calcpace/humidity.rb b/lib/calcpace/humidity.rb new file mode 100644 index 0000000..fac46cd --- /dev/null +++ b/lib/calcpace/humidity.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module EnvironmentalAdjuster + # Turns air temperature plus humidity into the effective temperature the heat + # curve is read at. + # + # The heat curve is keyed on air temperature, but heat stress depends on + # humidity too: sweat evaporates less in humid air. Wet-bulb globe temperature + # (WBGT) captures both. The Australian Bureau of Meteorology's simplified WBGT, + # for outdoor conditions with moderate sun and light wind, is + # + # WBGT = 0.567·Ta + 0.393·e + 3.94 + # e = RH/100 · 6.105·exp(17.27·Ta / (237.7 + Ta)) (hPa) + # + # The effective temperature is the air temperature that, at + # REFERENCE_HUMIDITY, has the same WBGT as the real (temperature, humidity) + # pair: WBGT(T_eff, REFERENCE_HUMIDITY) = WBGT(T, RH). Solving that equation + # (rather than adding ΔWBGT / 0.567) keeps the reference air's own vapour + # pressure rising with temperature, as it does along the temperature-only + # curve; the shortcut would roughly double the humidity effect at 30 °C. + module Humidity + # Relative humidity (%) the temperature-only heat curve stands for. The + # simplified WBGT equals the air temperature at 51–56% RH between 20 °C and + # 35 °C, so at 50% the curve reads the same whether its input is taken as + # air temperature or as WBGT; 50% is also mid-range for the marathons + # behind the duration factor (El Helou et al. 2012: mean race-day RH + # 51–78%). humidity: 50 gives the same numbers as no humidity at all. + REFERENCE_HUMIDITY = 50.0 + + # Simplified WBGT coefficients (Australian Bureau of Meteorology) + WBGT_TEMPERATURE_COEFFICIENT = 0.567 + WBGT_VAPOUR_PRESSURE_COEFFICIENT = 0.393 + + # Bisection: the bracket is ±40 °C around the air temperature (0–100% RH + # moves the effective temperature by far less) and 60 halvings take it + # below 1e-16 °C + BRACKET_CELSIUS = 40.0 + BISECTION_STEPS = 60 + + # Lowest accepted dew point (°C). The Magnus formula has a pole at + # −237.7 °C, and no weather on Earth has a dew point anywhere near −100 °C. + MIN_DEW_POINT_CELSIUS = -100.0 + + module_function + + # @param temp [Numeric, nil] air temperature in any unit (nil = none given) + # @param humidity [Object] relative humidity input + # @param dew_point [Object] dew point input + # @raise [ArgumentError] if the combination or a value is invalid + def check_inputs!(temp, humidity, dew_point) + return if humidity.nil? && dew_point.nil? + raise ArgumentError, 'Pass either humidity or dew_point, not both' if humidity && dew_point + raise ArgumentError, 'humidity and dew_point need a finite temperature' unless finite_number?(temp) + + check_values!(humidity, dew_point) + end + + def check_values!(humidity, dew_point) + unless valid_dew_point?(dew_point) + raise ArgumentError, "dew_point must be a finite number (got #{dew_point.inspect})" + end + return if valid_humidity?(humidity) + + raise ArgumentError, "humidity must be a relative humidity between 0 and 100 (got #{humidity.inspect})" + end + + # @param temp_c [Float] air temperature in °C + # @param humidity [Numeric, nil] relative humidity in % + # @param dew_point_c [Numeric, nil] dew point in °C (used when humidity is nil) + # @return [Float] effective temperature in °C, unrounded (round only for + # display, so that humidity: REFERENCE_HUMIDITY reads the curve at + # exactly the air temperature) + # @raise [ArgumentError] if the dew point is above the air temperature or + # below MIN_DEW_POINT_CELSIUS + def effective_temperature(temp_c, humidity: nil, dew_point_c: nil) + # The exact solution; bisection would land within an ulp of it, which a + # later rounding step can still tip over a boundary + return temp_c if humidity == REFERENCE_HUMIDITY + + vapour = humidity ? humidity / 100.0 * saturation_vapour_pressure(temp_c) : dew_point_vapour(dew_point_c, temp_c) + temperature_at_reference_humidity(temp_c, vapour) + end + + # Saturation vapour pressure in hPa (the Magnus form the Bureau of + # Meteorology pairs with its simplified WBGT) + def saturation_vapour_pressure(temp_c) + 6.105 * Math.exp(17.27 * temp_c / (237.7 + temp_c)) + end + + # The temperature-and-humidity part of the simplified WBGT (the 3.94 + # constant cancels out when two WBGTs are compared) + def wbgt_without_constant(temp_c, vapour_hpa) + (WBGT_TEMPERATURE_COEFFICIENT * temp_c) + (WBGT_VAPOUR_PRESSURE_COEFFICIENT * vapour_hpa) + end + + # Solves WBGT(x, REFERENCE_HUMIDITY) = WBGT(temp_c, vapour) for x. The left + # side rises strictly with x, so bisection converges. + def temperature_at_reference_humidity(temp_c, vapour_hpa) + target = wbgt_without_constant(temp_c, vapour_hpa) + low = temp_c - BRACKET_CELSIUS + high = temp_c + BRACKET_CELSIUS + BISECTION_STEPS.times do + mid = (low + high) / 2.0 + reference_wbgt(mid) < target ? low = mid : high = mid + end + (low + high) / 2.0 + end + + def reference_wbgt(temp_c) + wbgt_without_constant(temp_c, REFERENCE_HUMIDITY / 100.0 * saturation_vapour_pressure(temp_c)) + end + + def dew_point_vapour(dew_point_c, temp_c) + if dew_point_c < MIN_DEW_POINT_CELSIUS + raise ArgumentError, "dew_point (#{dew_point_c} °C) is below #{MIN_DEW_POINT_CELSIUS} °C" + end + if dew_point_c > temp_c + raise ArgumentError, "dew_point (#{dew_point_c} °C) cannot be above the temperature (#{temp_c} °C)" + end + + saturation_vapour_pressure(dew_point_c) + end + + def valid_dew_point?(dew_point) + dew_point.nil? || finite_number?(dew_point) + end + + def valid_humidity?(humidity) + humidity.nil? || (finite_number?(humidity) && humidity.to_f.between?(0.0, 100.0)) + end + + # Real numbers only: Complex is Numeric too, and has no order to compare + def finite_number?(value) + value.is_a?(Numeric) && value.real? && value.to_f.finite? + end + end +end diff --git a/lib/calcpace/personalized_predictor.rb b/lib/calcpace/personalized_predictor.rb new file mode 100644 index 0000000..5376073 --- /dev/null +++ b/lib/calcpace/personalized_predictor.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +# Module for race predictions built from the runner's own data rather than from +# a population-wide constant +# +# Two models live here: +# +# - Tanda (2011): marathon time from the volume and pace of the last weeks of +# training — no race result needed. +# - A personal Riegel exponent fitted to two of the runner's own performances, +# used instead of the fixed 1.06 of RacePredictor#predict_time. +module PersonalizedPredictor + # Tanda (2011) regression coefficients for marathon pace in seconds per km: + # + # Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P + # + # K = mean weekly distance (km/week), P = mean training pace (s/km), both + # averaged over the 8 weeks ending one week before the race. + # + # @see https://doi.org/10.4100/jhse.2011.63.05 G. Tanda, "Prediction of + # marathon performance time on the basis of training indices", Journal of + # Human Sport and Exercise 6(3):511–520, 2011 + TANDA_INTERCEPT = 17.1 + TANDA_VOLUME_AMPLITUDE = 140.0 + TANDA_VOLUME_DECAY = 0.0053 + TANDA_PACE_SLOPE = 0.55 + + # Ranges spanned by the paper's sample (Table 2: 22 runners, 21 of them men, + # 46 marathons). The equation was fitted inside them; outside, it is an + # extrapolation. The author names the finish-time range as the validity range. + TANDA_WEEKLY_DISTANCE_RANGE_KM = (40.4..110.7) + TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM = (253.3..330.6) + TANDA_MARATHON_TIME_RANGE_SECONDS = ((167 * 60.0)..(216 * 60.0)) + + TANDA_MARATHON_KM = 42.195 + + # Personal Riegel exponents outside this range almost never describe fitness: + # below 1.01 the longer race was run at practically the shorter one's pace, + # above 1.20 the runner slows down more than even an untrained endurance + # profile would — in both cases one of the two races was most likely not an + # all-out effort (or not on a comparable course or day). + PERSONAL_EXPONENT_RANGE = (1.01..1.20) + + # Predicts marathon time from training volume and pace — Tanda (2011) + # + # Uses Pm = 17.1 + 140.0 · exp(−0.0053 · K) + 0.55 · P, where Pm is marathon + # pace (s/km), K the mean weekly distance (km/week) and P the mean training + # pace (s/km) over the 8 weeks ending one week before the race. Training pace + # is the plain average of every run, warm-ups and recoveries included (total + # time ÷ total distance), not the pace of the quality sessions. The paper + # reports a standard error of about 4 minutes on the finish time. + # + # Inputs or a predicted time outside the paper's sample do not raise — the + # prediction is still returned, flagged in :out_of_range. + # + # @param weekly_distance [Numeric, String] mean weekly training distance, in + # unit per week — a number or a numeric string ('60') + # @param training_pace [Numeric, String] mean training pace per unit, in + # seconds or as a clock string ('05:30') + # @param unit [Symbol, String] :km (default) or :mi — applies to both inputs + # and to the returned pace + # @return [Hash] :time (seconds), :time_clock (HH:MM:SS), :pace (seconds per + # unit), :pace_clock, :within_validated_range (Boolean) and :out_of_range + # (Array of :weekly_distance, :training_pace and/or :marathon_time) + # @raise [Calcpace::NonPositiveInputError] if an input is not a positive, + # finite number (or numeric string, for weekly_distance) + # @raise [Calcpace::InvalidTimeFormatError] if training_pace is neither a + # number nor an HH:MM:SS / MM:SS string + # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi + # + # @example + # calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:time_clock] # => "03:19:41" + # calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00')[:pace] # => 283.96 + # calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30')[:out_of_range] + # # => [:weekly_distance, :marathon_time] + def predict_marathon_from_training(weekly_distance:, training_pace:, unit: :km) + weekly = personal_number(weekly_distance) + check_positive(weekly, 'Weekly distance') + pace_seconds = personal_time_seconds(training_pace) + check_positive(pace_seconds, 'Training pace') + + km_per_unit = normalize_distance_km(1, unit) + weekly_km = weekly * km_per_unit + pace_km = pace_seconds / km_per_unit + + marathon_pace_km = tanda_marathon_pace(weekly_km, pace_km) + tanda_result(marathon_pace_km, km_per_unit, tanda_out_of_range(weekly_km, pace_km, marathon_pace_km)) + end + + # Fits a personal Riegel exponent to two performances + # + # k = ln(t2 / t1) / ln(d2 / d1). The fixed 1.06 of RacePredictor is a + # population average; a runner's own k says how much they slow down as the + # distance grows. A value far from 1.06 — below about 1.01 or above about + # 1.20 — usually means one of the two races was not an all-out effort, or + # was run on a course or day that is not comparable. + # + # @param race1 [Numeric, String, Symbol] distance in km or race name + # @param time1 [String, Numeric] time at race1 (HH:MM:SS or seconds) + # @param race2 [Numeric, String, Symbol] distance in km or race name + # @param time2 [String, Numeric] time at race2 (HH:MM:SS or seconds) + # @return [Float] the exponent (order of the two performances does not matter) + # @raise [ArgumentError] if both races are the same distance or a race name is unknown + # @raise [Calcpace::NonPositiveInputError] if a distance or time is not positive + # + # @example + # calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') # => 1.0961 + def riegel_exponent(race1, time1, race2, time2) + distance1, seconds1 = personal_performance(race1, time1) + distance2, seconds2 = personal_performance(race2, time2) + ensure_different_distances!(distance1, distance2) + + Math.log(seconds2 / seconds1) / Math.log(distance2 / distance1) + end + + # Predicts a race time with a personal Riegel exponent + # + # Fits k to the two performances (see #riegel_exponent), then: + # + # - Target between the two races: interpolates along the Riegel curve that + # passes through both performances, with the raw k and no clamping — the + # runner's own data already brackets the answer, and it is the same + # whichever performance it is scaled from or in which order they are given. + # - Target outside the pair: extrapolates from the performance closer to it + # in log-distance (the shorter extrapolation), with k clamped to + # PERSONAL_EXPONENT_RANGE. + # + # @param race1 [Numeric, String, Symbol] distance in km or race name + # @param time1 [String, Numeric] time at race1 (HH:MM:SS or seconds) + # @param race2 [Numeric, String, Symbol] distance in km or race name + # @param time2 [String, Numeric] time at race2 (HH:MM:SS or seconds) + # @param to_race [Numeric, String, Symbol] target distance in km or race name + # @return [Hash] :time (seconds), :time_clock (HH:MM:SS), :exponent (the one + # used, after any clamping), :raw_exponent (as fitted), :clamped (Boolean, + # always false when the target lies between the two races) + # @raise [ArgumentError] if the two races are the same distance, the target + # is one of them, or a race name is unknown + # @raise [Calcpace::NonPositiveInputError] if a distance or time is not positive + # @raise [Calcpace::InvalidTimeFormatError] if a time is neither a number nor + # an HH:MM:SS / MM:SS string + # + # @example + # calc.predict_time_personal(5, 1200, 20, 3000, 10)[:time] # => 1897.37 + # calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:time_clock] + # # => "03:38:03" + # calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon')[:clamped] # => false + def predict_time_personal(race1, time1, race2, time2, to_race) + raw = riegel_exponent(race1, time1, race2, time2) + target = race_distance(to_race) + performances = [personal_performance(race1, time1), personal_performance(race2, time2)].sort + performances.map(&:first).each { |distance| ensure_different_distances!(distance, target) } + (anchor_distance, anchor_seconds), exponent = personal_prediction_basis(performances, target, raw) + + time = (anchor_seconds * ((target / anchor_distance)**exponent)).round(2) + { time: time, time_clock: convert_to_clocktime(time), exponent: exponent.round(4), + raw_exponent: raw.round(4), clamped: exponent != raw } + end + + private + + def tanda_marathon_pace(weekly_km, pace_km) + TANDA_INTERCEPT + (TANDA_VOLUME_AMPLITUDE * Math.exp(-TANDA_VOLUME_DECAY * weekly_km)) + + (TANDA_PACE_SLOPE * pace_km) + end + + def tanda_out_of_range(weekly_km, pace_km, marathon_pace_km) + checks = { + weekly_distance: TANDA_WEEKLY_DISTANCE_RANGE_KM.cover?(weekly_km), + training_pace: TANDA_TRAINING_PACE_RANGE_SECONDS_PER_KM.cover?(pace_km), + marathon_time: TANDA_MARATHON_TIME_RANGE_SECONDS.cover?(marathon_pace_km * TANDA_MARATHON_KM) + } + checks.reject { |_name, inside| inside }.keys + end + + def tanda_result(marathon_pace_km, km_per_unit, out_of_range) + time = (marathon_pace_km * TANDA_MARATHON_KM).round(2) + pace = (marathon_pace_km * km_per_unit).round(2) + + { + time: time, + time_clock: convert_to_clocktime(time), + pace: pace, + pace_clock: convert_to_clocktime(pace), + within_validated_range: out_of_range.empty?, + out_of_range: out_of_range + } + end + + # A performance as [distance in km, time in seconds], validated + def personal_performance(race, time) + seconds = personal_time_seconds(time) + check_positive(seconds, 'Time') + [race_distance(race), seconds.to_f] + end + + # Seconds from a number, or from a strictly validated clock string — the same + # rule as Calculator, AgeGrading and Vo2maxEstimator + def personal_time_seconds(time) + return time if time.is_a?(Numeric) + + check_time(time) + convert_to_seconds(time) + end + + # A number, or a numeric string read the way race distances are; nil otherwise + def personal_number(value) + value.is_a?(Numeric) ? value : Float(value, exception: false) + end + + # [anchor performance, exponent] for a target, given performances sorted by + # distance. Between the two, the raw curve through both; outside, the closer + # performance with the clamped exponent + def personal_prediction_basis(performances, target, raw) + shorter, longer = performances + return [shorter, raw] if target.between?(shorter.first, longer.first) + + closer = performances.min_by { |distance, _seconds| Math.log(target / distance).abs } + [closer, raw.clamp(PERSONAL_EXPONENT_RANGE.min, PERSONAL_EXPONENT_RANGE.max)] + end +end diff --git a/lib/calcpace/race_predictor.rb b/lib/calcpace/race_predictor.rb index 026a199..c748fe0 100644 --- a/lib/calcpace/race_predictor.rb +++ b/lib/calcpace/race_predictor.rb @@ -128,15 +128,20 @@ def equivalent_performance(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: + # @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 # # @example Predict marathon time from 5K adjusted for heat (25C) # predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25) - # #=> { adjusted_time: 13140.19, penalty_percent: 14.17, ... } + # #=> { adjusted_time: 12483.01, penalty_percent: 8.46, ... } + # calc.predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25)[:penalty_percent] #=> 8.46 + # calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 25, humidity: 80)[:penalty_percent] #=> 4.87 def predict_time_adjusted(from_race, from_time, to_race, **) predicted_seconds = predict_time(from_race, from_time, to_race) adjust_time(predicted_seconds, **) diff --git a/lib/calcpace/race_splits.rb b/lib/calcpace/race_splits.rb index f53a54a..557d05c 100644 --- a/lib/calcpace/race_splits.rb +++ b/lib/calcpace/race_splits.rb @@ -22,7 +22,7 @@ module RaceSplits # # @example Negative splits (second half faster) # race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative) - # #=> ["00:20:48", "00:40:00"] (first 5k slower, second 5k faster) + # #=> ["00:20:12", "00:40:00"] (first 5k 1% slower, second 5k 1% faster) def race_splits(race, target_time:, split_distance:, strategy: :even) total_distance = race_distance(race) target_seconds = target_time.is_a?(String) ? convert_to_seconds(target_time) : target_time @@ -133,25 +133,27 @@ def calculate_even_splits(total_distance, target_seconds, split_km) end # Calculates negative splits (second half faster than first half) - # First half is ~4% slower, second half is ~4% faster + # First half is run at a pace 1% slower than average, second half 1% faster + # (e.g. a 3:00:00 marathon goes through halfway in 1:30:54, then 1:29:06) # # @param total_distance [Float] total race distance in kilometers # @param target_seconds [Float] target finish time in seconds # @param split_km [Float] split distance in kilometers # @return [Array] array of cumulative split times def calculate_negative_splits(total_distance, target_seconds, split_km) - calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 1.04, second_factor: 0.96) + calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 1.01, second_factor: 0.99) end # Calculates positive splits (first half faster than second half) - # First half is ~4% faster, second half is ~4% slower + # First half is run at a pace 1% faster than average, second half 1% slower + # (e.g. a 3:00:00 marathon goes through halfway in 1:29:06, then 1:30:54) # # @param total_distance [Float] total race distance in kilometers # @param target_seconds [Float] target finish time in seconds # @param split_km [Float] split distance in kilometers # @return [Array] array of cumulative split times def calculate_positive_splits(total_distance, target_seconds, split_km) - calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 0.96, second_factor: 1.04) + calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 0.99, second_factor: 1.01) end # Shared logic for variable pace split strategies diff --git a/lib/calcpace/track_calculator.rb b/lib/calcpace/track_calculator.rb index a50d257..8d80e86 100644 --- a/lib/calcpace/track_calculator.rb +++ b/lib/calcpace/track_calculator.rb @@ -27,6 +27,19 @@ module TrackCalculator # Mean radius of the Earth in kilometers (IAU standard) EARTH_RADIUS_KM = 6371.0 + # Shortest horizontal distance a grade is measured over in + # #track_grade_adjusted_splits. GPS elevation is noisy — a few metres between + # consecutive fixes even from a barometric altimeter — so a grade read + # between points 1 m apart can be ±300% on flat ground, and the grade factor's + # curvature turns that symmetric noise into a fake climb. Over 100 m, ±1–2 m + # of noise is ±1–2% of grade, while a hill longer than a track straight still + # shows up. + GRADE_SEGMENT_MIN_KM = 0.1 + + # Slack on GRADE_SEGMENT_MIN_KM so ten 10 m steps, which sum to a hair under + # 0.1 km in floating point, still close a 100 m grade segment (1 mm) + GRADE_SEGMENT_TOLERANCE_KM = 1e-6 + # Computes the great-circle distance between two GPS coordinates using # the Haversine formula. # @@ -156,6 +169,49 @@ def track_splits(points, split_km = 1.0, compact: false) collect_splits(points, split_km, compact: compact) end + # Pace splits with a grade-adjusted pace (GAP) for each split. + # + # Each split is the hash #track_splits returns — same :km, :elapsed and :pace, + # split boundaries computed the same way — plus :gap, the flat-ground pace of + # equal effort for that split (Minetti et al., 2002; see GradeAdjustedPace). + # #track_splits itself is unchanged. + # + # How :gap is computed: + # - The track is cut into grade segments of at least GRADE_SEGMENT_MIN_KM + # (100 m) of horizontal distance, and each gets one grade: its net + # elevation change over its length. A leftover shorter than that at the end + # of a stretch is merged into the segment before it. Grades are clamped to + # ±45%, the range the model was measured on. + # - A stretch between points without :ele (or with a NaN/infinite one) is + # flat (factor 1.0), and a missing :ele ends the grade segment in progress. + # A stretch with elevation shorter than one grade segment and with no full + # segment before it to join — or a whole track that short — is flat too. + # - Distances are the horizontal (Haversine) ones; the factor is applied to + # them as is, without the √(1 + grade²) slope correction (0.5% at 10%). + # - Each split's distance is weighted by the grade factor of the segments it + # covers, and :gap is the split's time over that flat-equivalent distance. + # A split on flat ground, or with no elevation data, has :gap equal to :pace. + # + # @param points [Array] points with :lat, :lon and :time keys, and + # optionally :ele (metres), as for #track_splits + # @param split_km [Numeric] split interval in kilometers (default: 1.0) + # @param compact [Boolean] when true, :pace and :gap use the compact display format + # @return [Array] split hashes with :km, :elapsed, :pace and :gap + # (:gap formatted like :pace) + # @raise [ArgumentError] if split_km is not positive + # @raise [ArgumentError] if any point is missing a :time key + # + # @example a steady 5% climb at 5:00/km + # calc.track_grade_adjusted_splits(points, 1.0) + # #=> [{ km: 1.0, elapsed: 300, pace: "05:00", gap: "03:51" }, ...] + def track_grade_adjusted_splits(points, split_km = 1.0, compact: false) + raise ArgumentError, 'split_km must be positive' unless split_km.is_a?(Numeric) && split_km.positive? + return [] if points.nil? || points.size < 2 + + validate_points_have_time(points) + collect_splits(points, split_km, compact: compact, grade_factors: segment_grade_factors(points)) + end + private def haversine_km(lat1, lon1, lat2, lon2) @@ -253,41 +309,56 @@ def format_pace(pace_seconds, compact:) format('%02d:%02d', min: pace_seconds / 60, sec: pace_seconds % 60) end - def collect_splits(points, split_km, compact:) + # grade_factors, when given, holds one grade factor per segment (see + # #segment_grade_factors) and turns on the :gap field; without it the splits + # are exactly the ones #track_splits has always returned. + def collect_splits(points, split_km, compact:, grade_factors: nil) state = { splits: [], start_time: point_time(points.first), split_start_time: point_time(points.first), - accumulated_km: 0.0, split_number: 1, compact: compact } + accumulated_km: 0.0, split_number: 1, compact: compact, + grade_factors: grade_factors, flat_equivalent_km: 0.0 } - points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) } + if grade_factors + points.each_cons(2).with_index { |(a, b), index| process_segment(a, b, split_km, state, index) } + else + points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) } + end append_partial_split(points.last, split_km, state) state[:splits] end - def process_segment(point_a, point_b, split_km, state) - segment_km = haversine_distance(dig_key(point_a, :lat), dig_key(point_a, :lon), - dig_key(point_b, :lat), dig_key(point_b, :lon)) + # index is only given, and the segment only tracked, when computing :gap — + # track_splits keeps its original per-segment cost + def process_segment(point_a, point_b, split_km, state, index = nil) + segment_km = segment_distance_km(point_a, point_b) state[:accumulated_km] += segment_km + state[:segment] = { assigned_km: 0.0, factor: state[:grade_factors].fetch(index) } if index while state[:accumulated_km] >= split_km * state[:split_number] record_split(point_a, point_b, segment_km, split_km, state) end + + accumulate_flat_equivalent(state, segment_km) end def record_split(point_a, point_b, segment_km, split_km, state) offset = (split_km * state[:split_number]) - (state[:accumulated_km] - segment_km) + accumulate_flat_equivalent(state, offset) boundary_time = interpolate_time(point_a, point_b, segment_km, offset) state[:splits] << build_split_entry(boundary_time, split_km, state) state[:split_start_time] = boundary_time state[:split_number] += 1 + state[:flat_equivalent_km] = 0.0 end def build_split_entry(boundary_time, split_km, state) split_elapsed = (boundary_time - state[:split_start_time]).round - { + entry = { km: (split_km * state[:split_number]).round(2), elapsed: (boundary_time - state[:start_time]).round, pace: seconds_to_pace(split_elapsed, split_km, compact: state[:compact]) } + with_gap(entry, split_elapsed, state) end def append_partial_split(last_point, split_km, state) @@ -295,11 +366,113 @@ def append_partial_split(last_point, split_km, state) return unless remaining_km > 0.001 last_time = point_time(last_point) - state[:splits] << { + split_elapsed = (last_time - state[:split_start_time]).round + entry = { km: state[:accumulated_km].round(2), elapsed: (last_time - state[:start_time]).round, - pace: seconds_to_pace((last_time - state[:split_start_time]).round, remaining_km, - compact: state[:compact]) + pace: seconds_to_pace(split_elapsed, remaining_km, compact: state[:compact]) } + state[:splits] << with_gap(entry, split_elapsed, state) + end + + def segment_distance_km(point_a, point_b) + haversine_distance(dig_key(point_a, :lat), dig_key(point_a, :lon), + dig_key(point_b, :lat), dig_key(point_b, :lon)) + end + + # Adds the flat-equivalent distance of the current segment up to + # distance_into_segment, for the part not yet credited to an earlier split + def accumulate_flat_equivalent(state, distance_into_segment) + return unless state[:grade_factors] + + segment = state[:segment] + + state[:flat_equivalent_km] += (distance_into_segment - segment[:assigned_km]) * segment[:factor] + segment[:assigned_km] = distance_into_segment + end + + def with_gap(entry, split_elapsed, state) + return entry unless state[:grade_factors] + + entry.merge(gap: seconds_to_pace(split_elapsed, state[:flat_equivalent_km], compact: state[:compact])) + end + + # One grade factor per segment (consecutive point pair). Segments are grouped + # into grade segments of at least GRADE_SEGMENT_MIN_KM, each with one grade + # (net elevation change over horizontal distance); see + # #track_grade_adjusted_splits for the rules. + def segment_grade_factors(points) + factors = Array.new(points.size - 1, 1.0) + window = new_grade_window + + points.each_cons(2).with_index do |(a, b), index| + window = extend_grade_window(window, factors, a, b, index) + end + close_grade_window(window, factors, final: true) + factors + end + + def new_grade_window(previous = nil) + { indexes: [], km: 0.0, start_ele: nil, end_ele: nil, previous: previous } + end + + def extend_grade_window(window, factors, point_a, point_b, index) + ele_a = finite_ele(point_a) + ele_b = finite_ele(point_b) + if ele_a.nil? || ele_b.nil? + close_grade_window(window, factors, final: true) + return new_grade_window + end + + add_to_grade_window(window, index, segment_distance_km(point_a, point_b), ele_a, ele_b) + return window unless full_grade_window?(window) + + close_grade_window(window, factors, final: false) + new_grade_window(window.except(:previous)) + end + + # Grade segments treat a NaN or infinite elevation like a missing one + def finite_ele(point) + ele = fetch_ele(point) + ele if ele&.finite? + end + + def add_to_grade_window(window, index, segment_km, ele_a, ele_b) + window[:start_ele] ||= ele_a + window[:end_ele] = ele_b + window[:indexes] << index + window[:km] += segment_km + end + + # A full window gets its own grade. A short leftover — the end of the track + # or of a stretch with elevation — is merged into the full window before it; + # with no full window before it (a stretch shorter than a grade segment + # between missing elevations, or a whole track that short) it stays flat + # rather than being graded over a few noisy metres. + def close_grade_window(window, factors, final:) + return if window[:indexes].empty? + + unless full_grade_window?(window) + return unless final && window[:previous] + + window = merge_grade_windows(window[:previous], window) + end + factor = grade_adjustment_factor(window_grade(window)) + window[:indexes].each { |index| factors[index] = factor } + end + + def full_grade_window?(window) + window[:km] >= GRADE_SEGMENT_MIN_KM - GRADE_SEGMENT_TOLERANCE_KM + end + + def merge_grade_windows(previous, window) + { indexes: previous[:indexes] + window[:indexes], km: previous[:km] + window[:km], + start_ele: previous[:start_ele], end_ele: window[:end_ele] } + end + + def window_grade(window) + return 0.0 unless window[:km].positive? + + (window[:end_ele] - window[:start_ele]) / (window[:km] * 1000.0) end end diff --git a/lib/calcpace/training_zones.rb b/lib/calcpace/training_zones.rb index 2fe7146..784a999 100644 --- a/lib/calcpace/training_zones.rb +++ b/lib/calcpace/training_zones.rb @@ -9,8 +9,15 @@ # # Heart rate zones use the Karvonen method (Heart Rate Reserve): # target = hr_rest + pct * (hr_max - hr_rest) +# +# Not standalone: it is a part of Calcpace and calls into its siblings — +# Vo2maxEstimator (estimate_vo2max, vo2_at_velocity), FitnessPredictor +# (predict_time_from_vo2max, for the marathon band), PaceCalculator +# (race_distance), Converter and Checker. module TrainingZones - # Training intensities as fraction of VO2max (Daniels' Running Formula) + # Training intensities as fraction of VO2max (Daniels' Running Formula). + # The marathon :high (0.84) is the nominal upper bound only: the band's fast + # end is the predicted race pace (see PREDICTED_RACE_PACE_ZONES). TRAINING_INTENSITIES = { easy: { low: 0.59, high: 0.74 }, marathon: { low: 0.75, high: 0.84 }, @@ -19,6 +26,14 @@ module TrainingZones repetition: { low: 1.05, high: 1.10 } }.freeze + # Zones whose fast end is the VDOT-predicted race pace instead of + # TRAINING_INTENSITIES[zone][:high]. Daniels' M pace is the runner's + # predicted marathon race pace (FitnessPredictor#predict_time_from_vo2max), + # which is 0.800–0.849 of VO2max across VO2max 10–100 (0.805 at 30, 0.830 + # at 70). That keeps the marathon band slower than the threshold band + # (from 0.83) below VO2max ~69.5. + PREDICTED_RACE_PACE_ZONES = %i[marathon].freeze + # A pace band for one training zone (paces per kilometre or mile). # slow = lower-intensity end of the band, fast = higher-intensity end. PaceBand = Struct.new(:slow_seconds, :fast_seconds, :slow_clock, :fast_clock) @@ -46,27 +61,27 @@ module TrainingZones # @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0) # @param unit [Symbol] pace unit — :km (default) or :mi # @return [Hash{Symbol => PaceBand}] keys: :easy, :marathon, :threshold, - # :interval, :repetition — paces per chosen unit + # :interval, :repetition — paces per chosen unit. The marathon band runs + # from 75% of VO2max to the VDOT-predicted marathon pace; the prediction + # covers VO2max 10–100, and outside that range the race-pace intensity of + # the nearest bound is used # @raise [Calcpace::NonPositiveInputError] if vo2max is not positive # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi # # @example # calc.training_paces(50.0)[:threshold].fast_clock #=> "00:04:15" # calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock #=> "00:06:51" + # calc.training_paces(50.0)[:marathon].fast_clock #=> "00:04:31" def training_paces(vo2max, unit: :km) check_positive(vo2max.to_f, 'VO2max') meters = pace_unit_meters(unit) - TRAINING_INTENSITIES.transform_values do |band| + TRAINING_INTENSITIES.to_h do |zone, band| slow = pace_seconds_at_pct(vo2max.to_f, band[:low], meters) - fast = pace_seconds_at_pct(vo2max.to_f, band[:high], meters) - - PaceBand.new( - slow_seconds: slow, - fast_seconds: fast, - slow_clock: convert_to_clocktime(slow), - fast_clock: convert_to_clocktime(fast) - ) + fast = pace_seconds_at_pct(vo2max.to_f, fast_intensity(zone, band, vo2max.to_f), meters) + + [zone, PaceBand.new(slow_seconds: slow, fast_seconds: fast, + slow_clock: convert_to_clocktime(slow), fast_clock: convert_to_clocktime(fast))] end end @@ -336,6 +351,22 @@ def check_heart_rates(hr_max, hr_rest) "Resting heart rate (#{hr_rest}) must be lower than maximum heart rate (#{hr_max})" end + def fast_intensity(zone, band, vo2max) + PREDICTED_RACE_PACE_ZONES.include?(zone) ? marathon_race_intensity(vo2max) : band[:high] + end + + # Fraction of VO2max a runner holds at the VDOT-predicted marathon pace. + # The prediction only covers FitnessPredictor::SUPPORTED_VO2MAX_RANGE, so + # beyond it the fraction of the nearest bound is used (it barely moves + # there: 0.800 at VO2max 10, 0.849 at 100). + def marathon_race_intensity(vo2max) + range = FitnessPredictor::SUPPORTED_VO2MAX_RANGE + vo2 = vo2max.clamp(range.min, range.max) + seconds = predict_time_from_vo2max(vo2, 'marathon') + + vo2_at_velocity(race_distance('marathon') * Converter::Distance::KM_TO_METERS * 60.0 / seconds) / vo2 + end + # Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2 def velocity_at_vo2(vo2) a = 0.000104 diff --git a/lib/calcpace/version.rb b/lib/calcpace/version.rb index 032ee39..a593e72 100644 --- a/lib/calcpace/version.rb +++ b/lib/calcpace/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Calcpace - VERSION = '1.18.1' + VERSION = '2.0.0' end diff --git a/lib/calcpace/vo2max_estimator.rb b/lib/calcpace/vo2max_estimator.rb index 87584e5..f9a7b92 100644 --- a/lib/calcpace/vo2max_estimator.rb +++ b/lib/calcpace/vo2max_estimator.rb @@ -61,6 +61,13 @@ def estimate_vo2max(distance, time, distance_unit: :km) # Estimates a detailed and contextualized VO2max # + # Elevation is folded in with a flat heuristic — every 100 m of gain adds + # 600 m of equivalent flat distance — that ignores where the climbing is and + # gives nothing back for descents. It is kept as is so results stay + # comparable across versions. For a profile-aware view of a GPS track, see + # GradeAdjustedPace#grade_adjusted_pace and + # TrackCalculator#track_grade_adjusted_splits (Minetti et al., 2002). + # # @param distance [Numeric] race distance, in kilometres by default or in # the unit given by distance_unit # @param time [String, Integer] finish time @@ -95,16 +102,34 @@ def estimate_detailed_vo2max(distance, time, elevation_gain_m: 0, hr_avg: nil, h # Returns a descriptive label for a given VO2max value # + # Without age and sex, the label comes from fixed thresholds that are the same + # for everyone (see VO2MAX_LABELS) — exactly as it always has. With both, it + # comes from where the value sits among people of the same sex and age + # decade, measured on a treadmill in the FRIEND registry (see + # Vo2maxNorms#vo2max_percentile and Vo2maxNorms::VO2MAX_PERCENTILE_LABELS): + # the same 45 ml/kg/min is "Fair" for a 25-year-old man and "Elite" for a + # 60-year-old woman. + # # @param value [Numeric] VO2max in ml/kg/min + # @param age [Integer, nil] age in years (18 or over, a fractional age is + # truncated); give it with sex + # @param sex [String, Symbol, nil] male or female; give it with age # @return [String] label: "Beginner", "Fair", "Good", "Very Good", "Excellent", or "Elite" - # @raise [ArgumentError] if value is not positive + # @raise [Calcpace::NonPositiveInputError] if value is not positive + # @raise [ArgumentError] if only one of age and sex is given, or either is invalid # # @example - # calc.vo2max_label(51.9) #=> "Very Good" - def vo2max_label(value) + # calc.vo2max_label(51.9) #=> "Very Good" + # calc.vo2max_label(45) #=> "Good" + # calc.vo2max_label(45, age: 25, sex: :male) #=> "Fair" + # calc.vo2max_label(45, age: 60, sex: :female) #=> "Elite" + def vo2max_label(value, age: nil, sex: nil) check_positive(value.to_f, 'VO2max value') + return VO2MAX_LABELS.find { |entry| value.to_f >= entry[:min] }[:label] if age.nil? && sex.nil? + raise ArgumentError, 'Age and sex must be provided together' if age.nil? || sex.nil? - VO2MAX_LABELS.find { |entry| value.to_f >= entry[:min] }[:label] + percentile = raw_vo2max_percentile(value.to_f, age, sex) + Vo2maxNorms::VO2MAX_PERCENTILE_LABELS.find { |entry| percentile >= entry[:min] }[:label] end private diff --git a/lib/calcpace/vo2max_norms.rb b/lib/calcpace/vo2max_norms.rb new file mode 100644 index 0000000..715a612 --- /dev/null +++ b/lib/calcpace/vo2max_norms.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'yaml' +require_relative 'errors' +require_relative 'checker' + +# Module for reading a VO2max against people of the same age and sex +# +# Uses the FRIEND registry (Fitness Registry and the Importance of Exercise +# National Database) percentiles of VO2max measured by cardiopulmonary +# exercise testing in 7,783 treadmill tests on US adults free of known +# cardiovascular disease: +# +# Kaminsky, L. A., Arena, R., & Myers, J. (2015). Reference Standards for +# Cardiorespiratory Fitness Measured With Cardiopulmonary Exercise Testing: +# Data From the Fitness Registry and the Importance of Exercise National +# Database. Mayo Clinic Proceedings, 90(11), 1515–1523, Table 3. +# https://doi.org/10.1016/j.mayocp.2015.07.026 +# +# The table is stored, with its provenance, in +# `lib/calcpace/data/friend_2015_vo2max_percentiles.yml`. +# Vo2maxEstimator#vo2max_label uses it when given age and sex. +module Vo2maxNorms + # check_positive, normalize_age and normalize_sex: the same rules as AgeGrading + include Checker + + # Percentile norms by age and sex: FRIEND registry, measured treadmill VO2max + # (Kaminsky, Arena & Myers, Mayo Clin Proc 2015;90(11):1515–1523, Table 3). + # See the data file for the full provenance. + NORMS_DATA_PATH = File.expand_path('data/friend_2015_vo2max_percentiles.yml', __dir__).freeze + norms_data = YAML.safe_load_file(NORMS_DATA_PATH, permitted_classes: [], aliases: false) + VO2MAX_NORMS_VERSION = norms_data.fetch('meta').fetch('table_version').freeze + VO2MAX_NORM_PERCENTILES = norms_data.fetch('percentiles').map(&:to_f).freeze + VO2MAX_NORMS = %w[M F].to_h do |sex| + bands = norms_data.fetch(sex).to_h { |age, row| [Integer(age), row.map(&:to_f).freeze] } + [sex.freeze, bands.freeze] + end.freeze + + VO2MAX_NORMS.each do |sex, bands| + bands.each do |age, row| + next if row.size == VO2MAX_NORM_PERCENTILES.size && row.each_cons(2).all? { |low, high| high > low } + + raise Calcpace::InvalidDataError, + "VO2max norms #{sex} #{age}: expected #{VO2MAX_NORM_PERCENTILES.size} rising values, got #{row.inspect}" + end + end + + # Percentile cut for each label when vo2max_label is given age and sex. The + # cuts sit on percentiles the table publishes, so a label never depends on + # interpolation: at or above the 95th percentile is Elite, the 90th + # Excellent, the 75th Very Good, the median Good, the 25th Fair, and below + # the 25th Beginner. These cuts are calcpace's choice — FRIEND publishes + # percentiles, not labels. + VO2MAX_PERCENTILE_LABELS = [ + { min: 95, label: 'Elite' }, + { min: 90, label: 'Excellent' }, + { min: 75, label: 'Very Good' }, + { min: 50, label: 'Good' }, + { min: 25, label: 'Fair' }, + { min: 0, label: 'Beginner' } + ].freeze + + # Approximate percentile of a VO2max among people of the same sex and age + # + # Reads the FRIEND registry percentiles (Kaminsky, Arena & Myers, 2015, + # Table 3: 5th, 10th, 25th, 50th, 75th, 90th and 95th, by decade from 20–29 to + # 70–79) and interpolates linearly between the two published percentiles + # around the value. The registry measured VO2max in a lab; an estimate from a + # race time carries its own ±3–5 ml/kg/min on top. + # + # - Age decades are used as published, without blending across them, so a + # 29- and a 30-year-old read different rows. Ages 18–19 use the 20–29 row and + # 80 or over the 70–79 row; under 18 is rejected (adult norms do not apply). + # - The result is bounded to the table: 5.0 means at or below the 5th + # percentile, 95.0 at or above the 95th. + # + # @param value [Numeric] VO2max in ml/kg/min + # @param age [Integer] age in years (18 or over; a fractional age is + # truncated, as in AgeGrading) + # @param sex [String, Symbol] male or female + # @return [Float] percentile between 5.0 and 95.0, rounded to one decimal + # @raise [Calcpace::NonPositiveInputError] if value is not positive + # @raise [ArgumentError] if age is under 18 or not a number, or sex is not male/female + # + # @example + # calc.vo2max_percentile(48.0, age: 25, sex: :male) #=> 50.0 + # calc.vo2max_percentile(45, age: 25, sex: :male) #=> 40.5 + # calc.vo2max_percentile(45, age: 25, sex: :female) #=> 75.7 + # calc.vo2max_percentile(45, age: 60, sex: :male) #=> 95.0 + def vo2max_percentile(value, age:, sex:) + check_positive(value.to_f, 'VO2max value') + + raw_vo2max_percentile(value.to_f, age, sex).round(1) + end + + private + + # Unrounded percentile, so a label is decided against the published values + # themselves rather than a rounded reading of them. normalize_age and + # normalize_sex come from Checker — one rule for age and sex gem-wide. + def raw_vo2max_percentile(value, age, sex) + row = vo2max_norm_row(normalize_age(age), normalize_sex(sex)) + return VO2MAX_NORM_PERCENTILES.first if value <= row.first + return VO2MAX_NORM_PERCENTILES.last if value >= row.last + + upper = row.index { |norm| norm > value } + interpolate_percentile(row, upper, value) + end + + def vo2max_norm_row(age, sex) + bands = VO2MAX_NORMS.fetch(sex == :male ? 'M' : 'F') + band = bands.keys.select { |lower| lower <= age }.max || bands.keys.min + bands.fetch(band) + end + + def interpolate_percentile(row, upper, value) + low_norm, high_norm = row.values_at(upper - 1, upper) + low_pct, high_pct = VO2MAX_NORM_PERCENTILES.values_at(upper - 1, upper) + low_pct + ((high_pct - low_pct) * (value - low_norm) / (high_norm - low_norm)) + end +end diff --git a/test/calcpace/test_age_grading.rb b/test/calcpace/test_age_grading.rb index d4a1884..ff56de1 100644 --- a/test/calcpace/test_age_grading.rb +++ b/test/calcpace/test_age_grading.rb @@ -8,7 +8,7 @@ def test_age_grade_returns_expected_shape result = @calc.age_grade(10.0, '00:45:00', age: 55, sex: :male) assert_kind_of Hash, result - assert_equal 'WMA_2023_ONE_YEAR_FACTORS_V1', result[:table_version] + assert_equal 'MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1', result[:table_version] assert_includes result.keys, :age_grade_percent assert_includes result.keys, :category assert_includes result.keys, :age_graded_time_seconds @@ -56,7 +56,7 @@ def test_age_factored_time_rounds_up_to_hundredth assert_equal result[:age_graded_time_seconds], (scaled / 100.0) end - def test_interpolates_factor_for_in_between_age + def test_factor_decreases_with_age_through_the_masters_years result_fifty_five = @calc.age_grade(10.0, '00:45:00', age: 55, sex: :male) result_fifty_seven = @calc.age_grade(10.0, '00:45:00', age: 57, sex: :male) result_sixty = @calc.age_grade(10.0, '00:45:00', age: 60, sex: :male) @@ -65,6 +65,97 @@ def test_interpolates_factor_for_in_between_age assert result_fifty_seven[:factor] > result_sixty[:factor] end + def test_interpolates_linearly_between_the_ages_of_a_sparse_table + # The bundled table has every age from 18 to 100, so interpolation only + # matters for a replacement table with gaps (e.g. five-year steps) + sparse = { 30 => 1.0, 40 => 0.9, 50 => 0.8 } + @calc.define_singleton_method(:factor_table) { |_sex, _distance_m| sparse } + + assert_equal 0.97, @calc.age_grade(10.0, '00:45:00', age: 33, sex: :male)[:factor] + assert_equal 0.85, @calc.age_grade(10.0, '00:45:00', age: 45, sex: :male)[:factor] + assert_equal 0.9, @calc.age_grade(10.0, '00:45:00', age: 40, sex: :male)[:factor] + assert_equal 1.0, @calc.age_grade(10.0, '00:45:00', age: 25, sex: :male)[:factor] + assert_equal 0.8, @calc.age_grade(10.0, '00:45:00', age: 70, sex: :male)[:factor] + end + + # --- 2025 road tables (Alan Jones, USATF MLDR) --- + # + # Expected values come from the official spreadsheets in + # github.com/AlanLyttonJones/Age-Grade-Tables, "2025 Files": the factor from + # sheet "Age Factors" and the percentage as age standard / time, with the age + # standard from sheet "AgeStdSec" (MaleRoadStd2025.xlsx / + # FemaleRoadStd2025.xlsx). Percentages are compared at the gem's one decimal. + OFFICIAL_2025_ROAD_CASES = [ + { race: :marathon, time: '03:30:00', age: 40, sex: :male, + factor: 0.9783, open_standard: 7235.0, age_standard: 7395, percent: 58.7 }, + { race: :marathon, time: '04:00:00', age: 50, sex: :female, + factor: 0.8998, open_standard: 7796.0, age_standard: 8664, percent: 60.2 }, + { race: :'5k', time: '00:25:00', age: 30, sex: :female, + factor: 0.9959, open_standard: 834.0, age_standard: 837, percent: 55.8 }, + { race: :half_marathon, time: '01:50:00', age: 60, sex: :male, + factor: 0.8082, open_standard: 3451.0, age_standard: 4270, percent: 64.7 } + ].freeze + + def test_matches_the_official_2025_road_tables + OFFICIAL_2025_ROAD_CASES.each do |official| + result = @calc.age_grade(official[:race], official[:time], age: official[:age], sex: official[:sex]) + label = official.values_at(:sex, :age, :race, :time).join(' ') + official_percent = official[:age_standard] * 100.0 / @calc.convert_to_seconds(official[:time]) + + assert_equal official[:factor], result[:factor], label + assert_equal official[:open_standard], result[:open_standard_seconds], label + assert_equal official[:percent], official_percent.round(1), label + assert_equal official[:percent], result[:age_grade_percent], label + end + end + + def test_uses_the_2025_road_open_standards + expected = { + male: { '5k' => 769.0, '10k' => 1584.0, 'half_marathon' => 3451.0, 'marathon' => 7235.0 }, + female: { '5k' => 834.0, '10k' => 1726.0, 'half_marathon' => 3772.0, 'marathon' => 7796.0 } + } + + expected.each do |sex, races| + races.each do |race, seconds| + assert_equal seconds, @calc.age_grade(race, '01:00:00', age: 25, sex: sex)[:open_standard_seconds], + "#{sex} #{race}" + end + end + end + + def test_open_standard_clock_for_the_marathon + assert_equal '02:00:35', @calc.age_grade(:marathon, '03:00:00', age: 25, sex: :male)[:open_standard_clock] + assert_equal '02:09:56', @calc.age_grade(:marathon, '03:00:00', age: 25, sex: :female)[:open_standard_clock] + end + + def test_young_adults_get_their_own_road_factors + # The road table has real factors under 30: the peak is in the twenties and + # an 18-year-old is graded slightly up, like a masters runner + assert_equal 0.9995, @calc.age_grade(:'5k', '00:20:00', age: 18, sex: :male)[:factor] + assert_equal 0.9680, @calc.age_grade(:marathon, '03:00:00', age: 18, sex: :male)[:factor] + assert_equal 0.9217, @calc.age_grade(:marathon, '03:00:00', age: 18, sex: :female)[:factor] + assert_equal 1.0, @calc.age_grade(:'10k', '00:45:00', age: 25, sex: :male)[:factor] + assert_equal 0.9959, @calc.age_grade(:'5k', '00:25:00', age: 30, sex: :female)[:factor] + end + + def test_factor_table_covers_every_age_from_eighteen_to_one_hundred + AgeGrading::WMA_DATA.each do |sex, distances| + assert_equal %w[5000 10000 21097 42195], distances.keys, sex + distances.each do |distance, table| + assert_equal (18..100).to_a, table.keys.sort, "#{sex} #{distance}" + assert table.values.all? { |factor| factor.positive? && factor <= 1.0 }, "#{sex} #{distance}" + end + end + end + + def test_ages_past_the_table_end_use_the_last_factor + at_hundred = @calc.age_grade(:'10k', '01:30:00', age: 100, sex: :female) + beyond = @calc.age_grade(:'10k', '01:30:00', age: 104, sex: :female) + + assert_equal 0.1477, at_hundred[:factor] + assert_equal at_hundred, beyond + end + def test_label_classification assert_equal 'Approximate World Record Level', @calc.age_grade_label(100.0) assert_equal 'World Class', @calc.age_grade_label(90.0) @@ -85,7 +176,7 @@ def test_age_grade_label_rounding_moves_the_boundary assert_equal 'Intermediate', @calc.age_grade_label(59.9886) assert_equal 'Local Class', @calc.age_grade_label(59.9886.round(1)) - result = @calc.age_grade(10.0, 2750, age: 40, sex: :male) + result = @calc.age_grade(10.0, 2741, age: 40, sex: :male) assert_equal 60.0, result[:age_grade_percent] assert_equal 'Local Class', result[:category] end @@ -208,7 +299,7 @@ def test_age_grade_tolerance_borders_on_both_sides end def test_age_grade_still_rejects_a_non_standard_distance - # 7.79 km is 22% off a 10K. The WMA publishes a factor per specific + # 7.79 km is 22% off a 10K. The road table publishes a factor per specific # distance, so there is no honest number to return here: interpolating one # would invent a value with the look of an official standard error = assert_raises(ArgumentError) do diff --git a/test/calcpace/test_cameron_predictor.rb b/test/calcpace/test_cameron_predictor.rb index 305751c..dd7b2bf 100644 --- a/test/calcpace/test_cameron_predictor.rb +++ b/test/calcpace/test_cameron_predictor.rb @@ -8,10 +8,10 @@ class TestCameronPredictor < CalcpaceTest def test_predict_time_5k_to_10k # 5K in 20:00 → 10K - # Cameron formula: 1200 × (10/5) × [factor(5) / factor(10)] ≈ 2544s ≈ 42:24 + # Cameron formula: 1200 × (10000/5000) × [f(5000) / f(10000)] ≈ 2499.66s ≈ 41:39 result = @calc.predict_time_cameron('5k', '00:20:00', '10k') - assert_in_delta 2544, result, 10 + assert_in_delta 2499.66, result, 0.01 end def test_predict_time_10k_to_half_marathon @@ -23,10 +23,10 @@ def test_predict_time_10k_to_half_marathon end def test_predict_time_10k_to_marathon - # 10K in 42:00 → marathon + # 10K in 42:00 → marathon ≈ 11,806.76s (3:16:46) result = @calc.predict_time_cameron('10k', '00:42:00', 'marathon') - assert_in_delta 10_666, result, 100 + assert_in_delta 11_806.76, result, 0.01 end def test_predict_time_half_to_marathon @@ -71,10 +71,7 @@ def test_predict_time_clock_returns_hhmmss end def test_predict_time_clock_10k_to_marathon - result = @calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') - - parts = result.split(':').map(&:to_i) - assert_equal 2, parts[0], 'Should be 2 hours' + assert_equal '03:16:46', @calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') end # ── predict_pace_cameron ────────────────────────────────────────────────── @@ -107,6 +104,51 @@ def test_cameron_differs_from_riegel refute_in_delta cameron, riegel, 1, 'Cameron and Riegel should produce different predictions' end + def test_cameron_is_more_conservative_than_riegel_for_the_marathon_from_5k + cameron = @calc.predict_time_cameron('5k', '00:20:00', 'marathon') + riegel = @calc.predict_time('5k', '00:20:00', 'marathon') + + assert_operator cameron, :>, riegel, 'Cameron 5K→marathon should be slower than Riegel' + end + + def test_cameron_is_more_conservative_than_riegel_for_the_marathon_from_10k + cameron = @calc.predict_time_cameron('10k', '00:42:00', 'marathon') + riegel = @calc.predict_time('10k', '00:42:00', 'marathon') + + assert_operator cameron, :>, riegel, 'Cameron 10K→marathon should be slower than Riegel' + end + + # ── reference predictions (Cameron's metric model) ─────────────────────── + + def test_reference_5k_to_marathon + # 5K 20:00 → marathon ≈ 3:15:11 + assert_in_delta 11_711.47, @calc.predict_time_cameron('5k', '00:20:00', 'marathon'), 0.01 + end + + def test_reference_half_marathon_to_marathon + # Half 1:30:00 → marathon ≈ 3:11:15 + assert_in_delta 11_475.12, @calc.predict_time_cameron('half_marathon', '01:30:00', 'marathon'), 0.01 + end + + def test_reference_marathon_to_5k + # Marathon 3:30:00 → 5K ≈ 21:31 + assert_in_delta 1291.04, @calc.predict_time_cameron('marathon', '03:30:00', '5k'), 0.01 + end + + def test_reference_matches_had2know_worked_example + # had2know.org Cameron page: 3.5 mi (5632.704 m) in 51:30 → 5 mi (8046.72 m) ≈ 75:08 + result = @calc.predict_time_cameron(5.632704, '00:51:30', 8.04672) + + assert_equal '01:15:08', @calc.convert_to_clocktime(result) + end + + def test_velocity_function_matches_published_constants + # f(d) = 13.49681 − 0.000030363·d + 835.7114 / d^0.7905, d in metres + expected = 13.49681 - (0.000030363 * 10_000) + (835.7114 / (10_000**0.7905)) + + assert_in_delta expected, @calc.send(:cameron_factor, 10.0), 1e-12 + end + # ── consistency ────────────────────────────────────────────────────────── def test_round_trip_consistency @@ -150,18 +192,68 @@ def test_zero_time_raises end end + # ── valid distance range ───────────────────────────────────────────────── + # f(d) crosses zero near 445 km, so beyond the fitted range the formula returns + # negative or absurd times. Distances above CAMERON_MAX_DISTANCE_KM raise. + + CAMERON_VARIANTS = %i[predict_time_cameron predict_time_cameron_clock + predict_pace_cameron predict_pace_cameron_clock + predict_time_cameron_adjusted].freeze + + def test_max_distance_constant + assert_in_delta 100.0, CameronPredictor::CAMERON_MAX_DISTANCE_KM, 0.0 + end + + def test_max_distance_is_accepted_as_source_and_target + CAMERON_VARIANTS.each do |method| + @calc.public_send(method, 100, '08:00:00', 'marathon') + @calc.public_send(method, '100k', '08:00:00', 'marathon') + @calc.public_send(method, 'marathon', '03:30:00', 100) + @calc.public_send(method, 'marathon', '03:30:00', '100k') + end + end + + def test_max_distance_prediction_is_sane + # Marathon 3:30:00 → 100 km should be slower than 2.37× the marathon time + result = @calc.predict_time_cameron('marathon', '03:30:00', '100k') + + assert_operator result, :>, 12_600 * (100 / 42.195) + end + + def test_distances_above_the_max_raise_as_source + [100.1, 445.5, 1000].each do |distance| + CAMERON_VARIANTS.each do |method| + error = assert_raises(ArgumentError, "#{method} from #{distance} km") do + @calc.public_send(method, distance, '10:00:00', 'marathon') + end + assert_match(/Cameron.*100\.0 km/, error.message) + end + end + end + + def test_distances_above_the_max_raise_as_target + [100.1, 445.5, 1000].each do |distance| + CAMERON_VARIANTS.each do |method| + error = assert_raises(ArgumentError, "#{method} to #{distance} km") do + @calc.public_send(method, '10k', '00:42:00', distance) + end + assert_match(/Cameron.*100\.0 km/, error.message) + end + end + end + # ── adjusted predictions ─────────────────────────────────────────────────── def test_predict_time_cameron_adjusted_with_heat # 5K in 20:00 to 10K - # Normal Cameron: ~2544s - # Duration factor for ~42:24 (2544s) is ~0.707x - # Adjusted for 20°C (Base 2.8% * 0.707 ≈ 1.98% penalty): 2544 * 1.0198 ≈ 2594.4s + # Normal Cameron: ~2499.66s + # Duration factor for ~41:40 (2499.66s) is ~0.694x + # Adjusted for 20°C (Base 1.52% * 0.694 ≈ 1.06% penalty): 2499.66 * 1.0106 ≈ 2526.16s result = @calc.predict_time_cameron_adjusted('5k', '00:20:00', '10k', temperature: 20) assert_kind_of Hash, result - assert_in_delta 2594.4, result[:adjusted_time], 10 - assert_equal 1.98, result[:penalty_percent] + assert_in_delta 2526.16, result[:adjusted_time], 0.01 + assert_equal 1.06, result[:penalty_percent] end # --- free distances (v1.15.0) --- @@ -170,7 +262,7 @@ def test_predict_time_cameron_accepts_a_free_distance # Alagoas Abel: 7.79 km in 26:59 -> half marathon result = @calc.predict_time_cameron(7.79, '00:26:59', 'half_marathon') - assert_in_delta 4424.99, result, 0.01 + assert_in_delta 4646.36, result, 0.01 end def test_predict_time_cameron_numeric_distance_matches_the_named_race diff --git a/test/calcpace/test_checker.rb b/test/calcpace/test_checker.rb index 10af000..87d7dc2 100644 --- a/test/calcpace/test_checker.rb +++ b/test/calcpace/test_checker.rb @@ -9,9 +9,63 @@ def test_check_positive assert_nil @calc.check_positive(1) end + def test_check_positive_rejects_non_finite_numbers + assert_raises(Calcpace::NonPositiveInputError) { @calc.check_positive(Float::INFINITY) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.check_positive(-Float::INFINITY) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.check_positive(Float::NAN) } + assert_nil @calc.check_positive(1e300) + assert_nil @calc.check_positive(Rational(1, 3)) + end + + def test_check_positive_names_the_input_when_it_is_not_finite + assert_error_with_message(Calcpace::NonPositiveInputError, 'Distance must be a finite positive number') do + @calc.check_positive(Float::INFINITY, 'Distance') + end + end + def test_check_time assert_raises(Calcpace::InvalidTimeFormatError) { @calc.check_time('') } assert_raises(Calcpace::InvalidTimeFormatError) { @calc.check_time('1-2-3') } assert_nil @calc.check_time('00:00:00') end + + def test_check_time_accepts_valid_clocks + %w[05:00 5:00 05:59 1:05:00 01:05:00 23:59:59 99:59:59 0:00].each do |time| + assert_nil @calc.check_time(time), "expected #{time} to be valid" + end + end + + # MM:SS keeps counting minutes past the hour: '75:00' is a 75-minute run and + # track_splits itself emits paces like '66:33' in the padded format + def test_check_time_accepts_minutes_past_the_hour_in_mm_ss + %w[60:00 66:33 75:00 99:59].each do |time| + assert_nil @calc.check_time(time), "expected #{time} to be valid" + end + end + + def test_check_time_rejects_seconds_of_sixty_or_more + %w[05:60 05:99 19:99 1:00:60 01:30:99].each do |time| + assert_raises(Calcpace::InvalidTimeFormatError, "expected #{time} to be invalid") { @calc.check_time(time) } + end + end + + def test_check_time_rejects_minutes_of_sixty_or_more_when_hours_are_given + %w[1:60:00 01:75:00 0:99:59].each do |time| + assert_raises(Calcpace::InvalidTimeFormatError, "expected #{time} to be invalid") { @calc.check_time(time) } + end + end + + def test_invalid_clock_is_rejected_by_public_methods + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.checked_pace('00:19:99', 5) } + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.checked_velocity('1:60:00', 10) } + end + + # A bignum is finite, but one too large for a Float overflows to Infinity as + # soon as a formula calls to_f on it + def test_check_positive_rejects_integers_that_overflow_a_float + assert_error_with_message(Calcpace::NonPositiveInputError, 'Time must be a finite positive number') do + @calc.check_positive(10**400, 'Time') + end + assert_nil @calc.check_positive(10**300) + end end diff --git a/test/calcpace/test_clock_validation.rb b/test/calcpace/test_clock_validation.rb new file mode 100644 index 0000000..e9a1b10 --- /dev/null +++ b/test/calcpace/test_clock_validation.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Every public method that reads a time or pace string applies the same clock +# rule as check_time: seconds below 60, and minutes too when hours are given. +# MM:SS keeps counting minutes past the hour ('75:00' is 75 minutes). +class TestClockValidation < CalcpaceTest + # Each entry builds a call from one clock string, so the same path can be + # exercised with an invalid clock and with a valid one + PATHS = { + convert_to_seconds: ->(c, t) { c.convert_to_seconds(t) }, + checked_pace: ->(c, t) { c.checked_pace(t, 10) }, + checked_velocity: ->(c, t) { c.checked_velocity(t, 10) }, + checked_distance: ->(c, t) { c.checked_distance(t, '00:05:00') }, + race_splits: ->(c, t) { c.race_splits('half_marathon', target_time: t, split_distance: '5k') }, + pace_km_to_mi: ->(c, t) { c.pace_km_to_mi(t) }, + pace_mi_to_km: ->(c, t) { c.pace_mi_to_km(t) }, + convert_pace: ->(c, t) { c.convert_pace(t, :km_to_mi) }, + race_time: ->(c, t) { c.race_time(t, 'marathon') }, + race_time_clock: ->(c, t) { c.race_time_clock(t, 'marathon') }, + race_pace: ->(c, t) { c.race_pace(t, 'marathon') }, + race_pace_clock: ->(c, t) { c.race_pace_clock(t, 'marathon') }, + predict_time: ->(c, t) { c.predict_time('half_marathon', t, 'marathon') }, + predict_time_clock: ->(c, t) { c.predict_time_clock('half_marathon', t, 'marathon') }, + predict_pace: ->(c, t) { c.predict_pace('half_marathon', t, 'marathon') }, + predict_pace_clock: ->(c, t) { c.predict_pace_clock('half_marathon', t, 'marathon') }, + equivalent_performance: ->(c, t) { c.equivalent_performance('half_marathon', t, 'marathon') }, + predict_time_adjusted: ->(c, t) { c.predict_time_adjusted('half_marathon', t, 'marathon', temperature: 25) }, + predict_time_cameron: ->(c, t) { c.predict_time_cameron('half_marathon', t, 'marathon') }, + predict_time_cameron_clock: ->(c, t) { c.predict_time_cameron_clock('half_marathon', t, 'marathon') }, + predict_pace_cameron: ->(c, t) { c.predict_pace_cameron('half_marathon', t, 'marathon') }, + predict_pace_cameron_clock: ->(c, t) { c.predict_pace_cameron_clock('half_marathon', t, 'marathon') }, + predict_time_cameron_adjusted: lambda { |c, t| + c.predict_time_cameron_adjusted('half_marathon', t, 'marathon', temperature: 25) + }, + predict_time_personal: ->(c, t) { c.predict_time_personal('10k', '00:45:00', 'half_marathon', t, 'marathon') }, + riegel_exponent: ->(c, t) { c.riegel_exponent('10k', '00:45:00', 'half_marathon', t) }, + predict_marathon_from_training: ->(c, t) { c.predict_marathon_from_training(weekly_distance: 60, training_pace: t) }, + estimate_vo2max: ->(c, t) { c.estimate_vo2max(21.0975, t) }, + estimate_detailed_vo2max: ->(c, t) { c.estimate_detailed_vo2max(21.0975, t) }, + training_paces_from_race: ->(c, t) { c.training_paces_from_race('half_marathon', t) }, + age_grade: ->(c, t) { c.age_grade('half_marathon', t, age: 40, sex: :male) }, + stride_length: ->(c, t) { c.stride_length(t, 170) }, + cadence_for_stride: ->(c, t) { c.cadence_for_stride(t, 1.18) }, + grade_adjusted_pace: ->(c, t) { c.grade_adjusted_pace(t, 0.05) }, + grade_adjusted_pace_clock: ->(c, t) { c.grade_adjusted_pace_clock(t, 0.05) } + }.freeze + + # A valid clock for each path: paces get a pace, everything else a race time + PACE_PATHS = %i[pace_km_to_mi pace_mi_to_km convert_pace race_time race_time_clock + predict_marathon_from_training stride_length cadence_for_stride + grade_adjusted_pace grade_adjusted_pace_clock].freeze + + PATHS.each do |name, call| + define_method(:"test_#{name}_rejects_seconds_of_sixty_or_more") do + bad = PACE_PATHS.include?(name) ? '05:99' : '1:40:99' + assert_raises(Calcpace::InvalidTimeFormatError, "#{name} accepted #{bad}") { call.call(@calc, bad) } + end + + define_method(:"test_#{name}_rejects_minutes_of_sixty_or_more_with_hours") do + assert_raises(Calcpace::InvalidTimeFormatError, "#{name} accepted 1:60:00") { call.call(@calc, '1:60:00') } + end + + define_method(:"test_#{name}_accepts_a_valid_clock") do + good = PACE_PATHS.include?(name) ? '05:00' : '1:40:00' + call.call(@calc, good) + end + end + + def test_minutes_past_the_hour_stay_valid_in_mm_ss + assert_equal 4500, @calc.convert_to_seconds('75:00') + assert_equal '01:15:00', @calc.race_splits('10k', target_time: '75:00', split_distance: '10k').last + assert_in_delta 450.0, @calc.race_pace('75:00', '10k') + assert_equal '00:12:04', @calc.pace_km_to_mi('07:30') + assert_operator @calc.predict_time('10k', '75:00', 'half_marathon'), :>, 4500 + end + + def test_numeric_inputs_are_not_affected + assert_in_delta 450.0, @calc.race_pace(4500, '10k') + assert_equal '00:08:02', @calc.convert_pace(300, :km_to_mi) + end + + def test_garbage_strings_raise_instead_of_becoming_zero_seconds + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.convert_to_seconds('abc') } + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.race_splits('10k', target_time: '40', split_distance: '5k') } + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.predict_time('5k', '20:00:00:00', '10k') } + end + + # The gem must read every clock it writes: signed track_splits paces, padded + # paces past 100 minutes, compact durations past 100 hours and the day + # prefix of convert_to_clocktime above 24 hours + def test_convert_to_seconds_reads_the_gems_own_formats + { + '-0:40' => -40, '-00:40' => -40, '-5:12' => -312, '-1:06:33' => -3993, + '123:45' => 7425, '400:00:00' => 1_440_000, '1 03:46:40' => 100_000, + '12 00:00:00' => 1_036_800, '-1 03:46:40' => -100_000, '0:00' => 0 + }.each do |clock, seconds| + assert_equal seconds, @calc.convert_to_seconds(clock), clock + assert_nil @calc.check_time(clock), clock + end + end + + def test_malformed_clocks_still_raise + ['', '-', '--05:00', '+05:00', ' 05:00', '05:00 ', '5:0', '1:5:00', '05:60', '1:60:00', + '1 3:46:40', '1 24:00:00', '1 03:46', '1 03:60:00', '1 03:46:40', '1:02:03:04', '05:00:', + "05:00\n", 'abc', '05:00'].each do |clock| + assert_raises(Calcpace::InvalidTimeFormatError, clock.inspect) { @calc.convert_to_seconds(clock) } + assert_raises(Calcpace::InvalidTimeFormatError, clock.inspect) { @calc.check_time(clock) } + end + end + + def test_non_strings_are_not_clocks + [nil, 300, :'05:00'].each do |value| + assert_raises(Calcpace::InvalidTimeFormatError, value.inspect) { @calc.check_time(value) } + end + end + + # A negative clock parses, but every method that needs a positive time or + # pace still refuses it + PATHS.each do |name, call| + define_method(:"test_#{name}_rejects_a_negative_clock") do + next assert_equal(-300, call.call(@calc, '-05:00')) if name == :convert_to_seconds + + negative = PACE_PATHS.include?(name) ? '-05:00' : '-1:40:00' + assert_raises(Calcpace::NonPositiveInputError, "#{name} accepted #{negative}") { call.call(@calc, negative) } + end + end + + ROUND_TRIP_SECONDS = [0, 1, 40, 59, 60, 61, 312, 3599, 3600, 3993, 5999, 6000, 7425, 35_999, 86_399, + 86_400, 100_000, 359_999, 360_000, 1_440_000, 1_000_000_007].freeze + + def test_convert_to_clocktime_round_trips_in_both_formats + (ROUND_TRIP_SECONDS + ROUND_TRIP_SECONDS.map { |s| s + 0.75 }).each do |seconds| + [false, true].each do |compact| + clock = @calc.convert_to_clocktime(seconds, compact: compact) + assert_equal seconds.to_i, @calc.convert_to_seconds(clock), "#{seconds} -> #{clock}" + end + end + end + + def test_track_split_paces_round_trip_in_both_formats + ROUND_TRIP_SECONDS.flat_map { |s| [s, -s] }.each do |pace| + [false, true].each do |compact| + clock = @calc.send(:seconds_to_pace, pace, 1.0, compact: compact) + assert_equal pace, @calc.convert_to_seconds(clock), "#{pace} -> #{clock}" + end + end + end + + def test_track_splits_output_round_trips_including_backwards_and_slow_splits + start = Time.utc(2026, 1, 1, 7) + points = [ + { lat: 0.0, lon: 0.0, time: start }, + { lat: 0.0, lon: 0.009, time: start + 7425 }, # ~1 km in 2:03:45 + { lat: 0.0, lon: 0.018, time: start + 7385 }, # ~1 km, 40 s backwards + { lat: 0.0, lon: 0.0185, time: start + 7700 } + ] + [false, true].each do |compact| + splits = @calc.track_splits(points, 1.0, compact: compact) + paces = splits.map { |split| @calc.convert_to_seconds(split[:pace]) } + + assert_predicate paces.min, :negative?, splits.inspect + assert_operator paces.max, :>, 6000, splits.inspect + splits.zip(paces).each do |split, seconds| + assert_equal split[:pace], @calc.send(:seconds_to_pace, seconds, 1.0, compact: compact) + end + end + end + + def test_race_splits_round_trip + [['marathon', '400:00:00'], ['10k', '00:40:00'], ['marathon', '1 03:46:40'], [100, '99:59:59']].each do |race, time| + splits = @calc.race_splits(race, target_time: time, split_distance: '5k') + seconds = splits.map { |clock| @calc.convert_to_seconds(clock) } + assert_equal seconds.sort, seconds, splits.inspect + assert_equal @calc.convert_to_seconds(time), seconds.last + end + end + + # The clock grammar takes any number of digits, so a clock can parse to an + # Integer too large for a Float; it must not come back as Infinity + def test_a_clock_too_large_for_a_float_is_rejected + huge = "#{'9' * 400}:00:00" + + assert_operator @calc.convert_to_seconds(huge), :>, 10**400 + %i[checked_pace race_pace predict_time race_splits predict_time_cameron age_grade estimate_vo2max].each do |name| + assert_raises(Calcpace::NonPositiveInputError, name.to_s) { PATHS.fetch(name).call(@calc, huge) } + end + end + + def test_strings_in_other_encodings_are_not_clocks + ['05:00'.encode('UTF-16LE'), '05:00'.encode('UTF-32BE'), (+"05:00\xFF").force_encoding('UTF-8'), + (+"\xFF05:00").force_encoding('UTF-8')].each do |clock| + assert_raises(Calcpace::InvalidTimeFormatError, clock.inspect) { @calc.convert_to_seconds(clock) } + assert_raises(Calcpace::InvalidTimeFormatError, clock.inspect) { @calc.check_time(clock) } + end + end + + def test_ascii_compatible_encodings_still_parse + assert_equal 300, @calc.convert_to_seconds((+'05:00').force_encoding('ASCII-8BIT')) + assert_equal 300, @calc.convert_to_seconds('05:00'.encode('ISO-8859-1')) + end +end diff --git a/test/calcpace/test_environmental_adjuster.rb b/test/calcpace/test_environmental_adjuster.rb index 5442d83..a1381aa 100644 --- a/test/calcpace/test_environmental_adjuster.rb +++ b/test/calcpace/test_environmental_adjuster.rb @@ -12,10 +12,10 @@ def test_calculate_penalty_returns_zero_for_ideal_conditions end def test_calculate_penalty_with_heat - # 20°C for 60 min should have 2.8% penalty (Updated scientific baseline) + # 20°C for 60 min: base 4.3 · (5/10)^1.5 = 1.52% result = @calc.calculate_penalty(temperature: 20, time_seconds: 3600) - assert_equal 2.8, result[:factors][:heat] - assert_equal 2.8, result[:total_penalty_percent] + assert_equal 1.52, result[:factors][:heat] + assert_equal 1.52, result[:total_penalty_percent] end def test_calculate_penalty_with_altitude @@ -26,11 +26,11 @@ def test_calculate_penalty_with_altitude end def test_calculate_penalty_combined - # 20°C at 60m (2.8%) and 1828.8m (3.76%) + # 20°C at 60m (1.52%) and 1828.8m (3.76%) result = @calc.calculate_penalty(temperature: 20, altitude: 1828.8, time_seconds: 3600) - assert_equal 2.8, result[:factors][:heat] + assert_equal 1.52, result[:factors][:heat] assert_equal 3.76, result[:factors][:altitude] - assert_equal 6.56, result[:total_penalty_percent] + assert_equal 5.28, result[:total_penalty_percent] end def test_adjust_time_with_no_penalties @@ -42,20 +42,20 @@ def test_adjust_time_with_no_penalties end def test_adjust_time_with_heat_and_altitude - # 3600s at 20°C (Base 2.8% * 1.0x = 2.8%) and 1828.8m (3.76%) = 6.56% penalty - # 3600 * 1.0656 = 3836.16 + # 3600s at 20°C (Base 1.52% * 1.0x) and 1828.8m (3.76%) = 5.28% penalty + # 3600 * 1.0528 = 3790.08 result = @calc.adjust_time(3600, temperature: 20, altitude: 1828.8) assert_equal 3600, result[:original_time] - assert_in_delta 3836.16, result[:adjusted_time], 0.01 - assert_equal 6.56, result[:penalty_percent] - assert_equal '01:03:56', result[:adjusted_time_clock] + assert_in_delta 3790.08, result[:adjusted_time], 0.01 + assert_equal 5.28, result[:penalty_percent] + assert_equal '01:03:10', result[:adjusted_time_clock] end def test_calculate_penalty_with_fahrenheit - # 68°F is 20°C. 20°C for 60m should have 2.8% penalty. + # 68°F is 20°C. 20°C for 60m should have 1.52% penalty. result = @calc.calculate_penalty(temperature: 68, temperature_unit: :f, time_seconds: 3600) - assert_equal 2.8, result[:factors][:heat] - assert_equal 2.8, result[:total_penalty_percent] + assert_equal 1.52, result[:factors][:heat] + assert_equal 1.52, result[:total_penalty_percent] end def test_normalize_time_returns_original_for_ideal_conditions @@ -65,13 +65,12 @@ def test_normalize_time_returns_original_for_ideal_conditions end def test_normalize_time_with_heat - # If I ran 3600s at 20C (Base 2.8% penalty) + # If I ran 3600s at 20C (Base 1.52% penalty) # Duration factor for 60 min is 1.0x - # Effective penalty: 2.8 * 1.0 = 2.8% - # Ideal time: 3600 / 1.028 = 3501.945... + # Ideal time: 3600 / 1.0152 = 3546.10... result = @calc.normalize_time(3600, temperature: 20) - assert_in_delta 3501.95, result[:normalized_time], 0.01 - assert_equal 2.8, result[:penalty_percent] + assert_in_delta 3546.1, result[:normalized_time], 0.01 + assert_equal 1.52, result[:penalty_percent] end def test_environmental_round_trip_consistency @@ -90,17 +89,448 @@ def test_environmental_round_trip_consistency def test_calculate_penalty_with_duration # 25C at 180 min (Marathon sub-3) - # Factor: 3.0x - # Penalty: 4.3 * 3.0 = 12.9% + # Factor: 1.76x (least-squares fit to El Helou et al. 2012, Table S3) + # Penalty: 4.3 * 1.76 = 7.57% result = @calc.calculate_penalty(temperature: 25, time_seconds: 10_800) - assert_equal 12.9, result[:factors][:heat] + assert_equal 7.57, result[:factors][:heat] end def test_calculate_penalty_with_long_duration # 25C at 240 min (Amateur Marathon) - # Factor: 4.5x - # Penalty: 4.3 * 4.5 = 19.35% + # Factor: 2.81x + # Penalty: 4.3 * 2.81 = 12.08% result = @calc.calculate_penalty(temperature: 25, time_seconds: 14_400) - assert_equal 19.35, result[:factors][:heat] + assert_equal 12.08, result[:factors][:heat] + end + + # --- heat duration factor (fit to El Helou et al. 2012) --- + + def test_duration_factor_keeps_the_short_effort_points + assert_in_delta 0.5, @calc.send(:duration_factor, 1200), 1e-12 + assert_in_delta 0.5, @calc.send(:duration_factor, 1800), 1e-12 + assert_in_delta 1.0, @calc.send(:duration_factor, 3600), 1e-12 + end + + def test_duration_factor_points_for_two_three_and_four_hours + assert_in_delta 1.38, @calc.send(:duration_factor, 7200), 1e-12 + assert_in_delta 1.76, @calc.send(:duration_factor, 10_800), 1e-12 + assert_in_delta 2.285, @calc.send(:duration_factor, 12_600), 1e-12 + assert_in_delta 2.81, @calc.send(:duration_factor, 14_400), 1e-12 + end + + def test_duration_factor_is_flat_beyond_four_hours + assert_in_delta 2.81, @calc.send(:duration_factor, 18_000), 1e-12 + assert_in_delta 2.81, @calc.send(:duration_factor, 36_000), 1e-12 + end + + # El Helou et al. (2012) PLoS One 7(5):e37407, Table S3: optimum °C, speed at + # the optimum (m/s) and speed loss (%) at optimum −10, −5, 0, +5, +10, +15, +20 °C + EL_HELOU_TABLE_S3 = { + 'men P1' => [3.81, 4.36, [1.41, 0.35, 0, 0.36, 1.44, 3.29, 6.00]], + 'men Q1' => [6.02, 3.32, [3.27, 0.82, 0, 0.82, 3.38, 7.93, 15.03]], + 'men median' => [6.24, 2.96, [3.77, 0.94, 0, 0.95, 3.91, 9.26, 17.73]], + 'men Q3' => [7.42, 2.62, [4.41, 1.10, 0, 1.12, 4.61, 11.01, 21.42]], + 'women P1' => [9.91, 3.78, [2.97, 0.74, 0, 0.75, 3.06, 7.16, 13.47]], + 'women Q1' => [6.85, 2.93, [2.51, 0.63, 0, 0.63, 2.58, 6.00, 11.18]], + 'women median' => [6.75, 2.65, [2.76, 0.69, 0, 0.70, 2.84, 6.63, 12.43]], + 'women Q3' => [7.35, 2.39, [3.04, 0.76, 0, 0.77, 3.14, 7.35, 13.85]] + }.freeze + + # Reproduces the derivation documented in environmental_factors.yml, step 1: + # the time penalty against 15 °C grows 2^p times from 20 to 25 °C in every + # group; p is the sex-weighted mean of log2(P25 / P20), and the 60-minute + # base follows 4.3 · ((T − 15) / 10)^p, anchored at base(25) = 4.3 + def test_heat_base_points_follow_the_el_helou_exponent + exponent = el_helou_exponent + assert_in_delta 1.497, exponent, 0.001 + + points = EnvironmentalAdjuster::FACTORS.fetch('heat').fetch('data_points') + points.each do |temperature, base| + # Cap A: flat from 35 °C (beyond El Helou's hottest race, 25.2 °C) + capped = [temperature, 35].min + expected = (4.3 * (((capped - 15) / 10.0)**exponent.round(2))).round(2) + assert_in_delta expected, base, 1e-9, "#{temperature} °C" + end + end + + def test_heat_base_is_flat_from_thirty_five + [35, 36.3, 37.5, 40, 45].each do |temperature| + assert_equal 12.16, @calc.calculate_penalty(temperature: temperature, time_seconds: 3600)[:factors][:heat] + end + end + + def test_heat_base_points_track_the_power_law_within_a_tenth + exponent = el_helou_exponent.round(2) + + (1500..3500).each do |hundredths| + temperature = hundredths / 100.0 + law = 4.3 * (((temperature - 15) / 10.0)**exponent) + base = @calc.calculate_penalty(temperature: temperature, time_seconds: 3600)[:factors][:heat] + + assert_in_delta law, base, 0.1, "#{temperature} °C" + end + end + + # Step 2: those penalties divided by the base, fitted by weighted least + # squares (each sex half the weight) with the 3 h and 4 h points free and + # 1.0 at 60 min fixed + def test_duration_factor_points_are_the_least_squares_fit_of_el_helou_table_s3 + observations = el_helou_ratios + assert_equal 15, observations.size # men P1 at 25 °C lies beyond the table + + f180, f240 = weighted_two_point_fit(observations) + points = EnvironmentalAdjuster::HEAT_DURATION_FACTORS.to_h + + assert_in_delta f180, points.fetch(180.0), 0.005 + assert_in_delta f240, points.fetch(240.0), 0.005 + end + + def test_duration_factor_is_continuous_and_monotonic + factors = (0..21_600).step(30).map { |seconds| @calc.send(:duration_factor, seconds) } + + factors.each_cons(2) do |a, b| + assert_operator b, :>=, a + assert_operator b - a, :<, 0.02 + end + end + + def test_extreme_heat_for_four_hours + # 35 °C / 4 h: 12.16 * 2.81 = 34.17%; 40 °C is capped at the 35 °C value + assert_equal 34.17, @calc.calculate_penalty(temperature: 35, time_seconds: 14_400)[:factors][:heat] + assert_equal 34.17, @calc.calculate_penalty(temperature: 40, time_seconds: 14_400)[:factors][:heat] + end + + # --- altitude curve (v1.19.0) --- + + def test_altitude_at_or_below_threshold_has_no_penalty + assert_equal 0.0, @calc.calculate_penalty(altitude: 0)[:factors][:altitude] + assert_equal 0.0, @calc.calculate_penalty(altitude: 300)[:factors][:altitude] + end + + def test_altitude_is_continuous_just_above_the_threshold + at_threshold = @calc.calculate_penalty(altitude: 300)[:factors][:altitude] + ten_above = @calc.calculate_penalty(altitude: 310)[:factors][:altitude] + + assert_operator ten_above, :>, at_threshold, 'penalty should start growing right above the threshold' + assert_operator ten_above - at_threshold, :<, 0.05 + end + + def test_altitude_is_continuous_across_the_first_ncaa_point + below = @calc.calculate_penalty(altitude: 914.3)[:factors][:altitude] + above = @calc.calculate_penalty(altitude: 914.5)[:factors][:altitude] + + assert_operator (above - below).abs, :<, 0.05 + end + + def test_altitude_is_continuous_around_the_first_ncaa_point + below = @calc.calculate_penalty(altitude: 914)[:factors][:altitude] + at = @calc.calculate_penalty(altitude: 914.4)[:factors][:altitude] + above = @calc.calculate_penalty(altitude: 915)[:factors][:altitude] + + assert_equal 1.41, at + assert_in_delta at, below, 0.01 + assert_in_delta at, above, 0.01 + end + + def test_altitude_sao_paulo + # 760 m: interpolated between 300 m (0.0) and 914.4 m (1.41) + assert_equal 1.06, @calc.calculate_penalty(altitude: 760)[:factors][:altitude] + end + + def test_altitude_keeps_the_ncaa_points + assert_equal 2.15, @calc.calculate_penalty(altitude: 1219.2)[:factors][:altitude] + assert_equal 5.9, @calc.calculate_penalty(altitude: 2438.4)[:factors][:altitude] + end + + def test_altitude_keeps_growing_beyond_the_ncaa_table + assert_equal 7.92, @calc.calculate_penalty(altitude: 3000)[:factors][:altitude] + + result = @calc.calculate_penalty(altitude: 3600)[:factors][:altitude] + assert_operator result, :>, 9.97 + assert_operator result, :<, 12.2 + end + + def test_altitude_is_capped_at_4000_meters + assert_equal 12.2, @calc.calculate_penalty(altitude: 4000)[:factors][:altitude] + assert_equal 12.2, @calc.calculate_penalty(altitude: 5000)[:factors][:altitude] + end + + # --- heat beyond 30 °C (v1.19.0) --- + + def test_heat_at_thirty_five_is_worse_than_at_thirty + at30 = @calc.calculate_penalty(temperature: 30, time_seconds: 3600)[:factors][:heat] + at35 = @calc.calculate_penalty(temperature: 35, time_seconds: 3600)[:factors][:heat] + + assert_equal 7.9, at30 + assert_equal 12.16, at35 + end + + def test_heat_at_forty_reads_like_thirty_five + assert_equal 12.16, @calc.calculate_penalty(temperature: 40, time_seconds: 3600)[:factors][:heat] + end + + def test_heat_above_forty_stays_capped + assert_equal 12.16, @calc.calculate_penalty(temperature: 45, time_seconds: 3600)[:factors][:heat] + end + + # --- humidity / dew point (effective temperature) --- + + def test_humidity_at_the_reference_reproduces_the_temperature_only_numbers + [3600, 14_400].each do |seconds| + plain = @calc.calculate_penalty(temperature: 30, time_seconds: seconds) + humid = @calc.calculate_penalty(temperature: 30, humidity: 50, time_seconds: seconds) + + assert_equal plain[:factors][:heat], humid[:factors][:heat] + end + end + + def test_humidity_at_the_reference_is_exact_for_random_inputs + rng = Random.new(20_261_002) + + 5000.times do + temperature = (rng.rand * 50) - 5 + seconds = rng.rand * 20_000 + plain = @calc.calculate_penalty(temperature: temperature, time_seconds: seconds) + humid = @calc.calculate_penalty(temperature: temperature, humidity: 50, time_seconds: seconds) + + assert_equal plain[:total_penalty_percent], humid[:total_penalty_percent], "T=#{temperature} s=#{seconds}" + end + end + + def test_penalty_is_interpolated_on_the_unrounded_effective_temperature + # 30.42271454 °C used to be rounded to 30.42 before interpolating + plain = @calc.calculate_penalty(temperature: 30.42271454, time_seconds: 3600) + humid = @calc.calculate_penalty(temperature: 30.42271454, humidity: 50, time_seconds: 3600) + + assert_equal plain[:factors][:heat], humid[:factors][:heat] + assert_in_delta 30.42, humid[:factors][:effective_temperature_celsius], 0.0 + end + + def test_dew_point_below_minus_one_hundred_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, dew_point: -240) } + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, dew_point: -100.01) } + assert_kind_of Hash, @calc.calculate_penalty(temperature: 30, dew_point: -100) + end + + def test_complex_humidity_or_dew_point_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, humidity: Complex(80, 0)) } + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, dew_point: Complex(20, 0)) } + end + + def test_non_finite_temperature_with_humidity_is_rejected + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |temperature| + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: temperature, humidity: 50) } + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: temperature, dew_point: 10) } + end + end + + def test_reference_humidity_constant + assert_in_delta 50.0, EnvironmentalAdjuster::REFERENCE_HUMIDITY, 0.0 + end + + def test_humid_air_raises_the_effective_temperature_and_the_penalty + # WBGT(30 °C, 70%) = WBGT(33.0746 °C, 50%) → base 9.95 + 0.5746/2.5 × 2.21 = 10.46 + result = @calc.calculate_penalty(temperature: 30, humidity: 70, time_seconds: 3600) + + assert_in_delta 33.07, result[:factors][:effective_temperature_celsius], 0.01 + assert_equal 10.46, result[:factors][:heat] + assert_equal 10.46, result[:total_penalty_percent] + end + + def test_very_humid_air_reaches_the_heat_cap + # WBGT(30 °C, 90%) = WBGT(35.94 °C, 50%), above 35 °C → capped base 12.16 + result = @calc.calculate_penalty(temperature: 30, humidity: 90, time_seconds: 3600) + + assert_in_delta 35.94, result[:factors][:effective_temperature_celsius], 0.01 + assert_equal 12.16, result[:factors][:heat] + end + + def test_dry_air_lowers_the_effective_temperature_and_the_penalty + # WBGT(30 °C, 30%) = WBGT(26.6946 °C, 50%) → base 4.3 + 1.6946/2.5 × 1.71 = 5.46 + result = @calc.calculate_penalty(temperature: 30, humidity: 30, time_seconds: 3600) + + assert_in_delta 26.69, result[:factors][:effective_temperature_celsius], 0.01 + assert_equal 5.46, result[:factors][:heat] + end + + def test_humidity_scales_with_duration_like_temperature + result = @calc.calculate_penalty(temperature: 30, humidity: 90, time_seconds: 7200) + + assert_equal (12.16 * @calc.send(:duration_factor, 7200)).round(2), result[:factors][:heat] + end + + def test_humid_air_can_lift_an_ideal_temperature_out_of_the_ideal_range + # 15 °C at 90% behaves like 18.3304 °C at 50% → 0.54 + 0.8304/2.5 × 0.98 = 0.87 + result = @calc.calculate_penalty(temperature: 15, humidity: 90, time_seconds: 3600) + + assert_in_delta 18.33, result[:factors][:effective_temperature_celsius], 0.01 + assert_equal 0.87, result[:factors][:heat] + end + + def test_dry_cool_air_stays_penalty_free + result = @calc.calculate_penalty(temperature: 12, humidity: 20, time_seconds: 3600) + + assert_equal 0.0, result[:factors][:heat] + end + + def test_effective_temperature_is_only_reported_when_humidity_is_given + refute @calc.calculate_penalty(temperature: 30)[:factors].key?(:effective_temperature_celsius) + assert_equal %i[heat altitude], @calc.calculate_penalty(temperature: 30)[:factors].keys + end + + def test_dew_point_equal_to_temperature_is_saturated_air + saturated = @calc.calculate_penalty(temperature: 30, dew_point: 30, time_seconds: 3600) + full = @calc.calculate_penalty(temperature: 30, humidity: 100, time_seconds: 3600) + + assert_equal full, saturated + end + + def test_dew_point_matches_the_equivalent_relative_humidity + # Td 20 °C at 30 °C → e = 23.37 hPa of es = 42.43 hPa → RH 55.08% + from_dew = @calc.calculate_penalty(temperature: 30, dew_point: 20, time_seconds: 3600) + from_rh = @calc.calculate_penalty(temperature: 30, humidity: 55.08, time_seconds: 3600) + + assert_in_delta from_rh[:factors][:effective_temperature_celsius], + from_dew[:factors][:effective_temperature_celsius], 0.01 + end + + def test_dew_point_follows_the_temperature_unit + fahrenheit = @calc.calculate_penalty(temperature: 86, dew_point: 68, temperature_unit: :f, time_seconds: 3600) + celsius = @calc.calculate_penalty(temperature: 30, dew_point: 20, time_seconds: 3600) + + assert_equal celsius, fahrenheit + end + + def test_humidity_outside_zero_to_one_hundred_is_rejected + [-1, 100.5, Float::NAN].each do |rh| + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, humidity: rh) } + end + end + + def test_non_numeric_humidity_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, humidity: '80') } + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, dew_point: '20') } + end + + def test_humidity_and_dew_point_together_are_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 30, humidity: 60, dew_point: 20) } + end + + def test_dew_point_above_temperature_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(temperature: 20, dew_point: 21) } + end + + def test_humidity_without_temperature_is_rejected + assert_raises(ArgumentError) { @calc.calculate_penalty(humidity: 60) } + assert_raises(ArgumentError) { @calc.calculate_penalty(dew_point: 10) } + end + + def test_adjust_and_normalize_forward_humidity + adjusted = @calc.adjust_time(3600, temperature: 30, humidity: 90) + normalized = @calc.normalize_time(3600, temperature: 30, humidity: 90) + + assert_equal 12.16, adjusted[:penalty_percent] + assert_equal 12.16, normalized[:penalty_percent] + assert_in_delta 35.94, adjusted[:factors][:effective_temperature_celsius], 0.01 + end + + def test_predictions_forward_humidity + dry = @calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 30, humidity: 30) + humid = @calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 30, humidity: 90) + cameron = @calc.predict_time_cameron_adjusted('5k', '00:20:00', '10k', temperature: 30, humidity: 90) + + assert_operator humid[:adjusted_time], :>, dry[:adjusted_time] + assert cameron[:factors].key?(:effective_temperature_celsius) + end + + def test_environmental_data_keeps_the_structure_the_site_reads + altitude = EnvironmentalAdjuster::FACTORS.fetch('altitude') + heat = EnvironmentalAdjuster::FACTORS.fetch('heat') + + assert_kind_of Numeric, altitude.fetch('threshold_meters') + assert_kind_of Hash, altitude.fetch('data_points') + assert_equal [10.0, 15.0], heat.fetch('ideal_range_celsius') + assert_kind_of Hash, heat.fetch('data_points') + + [altitude, heat].each do |table| + table.fetch('data_points').each do |key, value| + assert_kind_of Numeric, key + assert_kind_of Numeric, value + end + end + + first_key, first_value = altitude.fetch('data_points').min_by { |key, _| key } + assert_in_delta altitude.fetch('threshold_meters'), first_key, 0.0 + assert_in_delta 0.0, first_value, 0.0 + end + + private + + # [sex, finish minutes, temperature, time penalty (%) against 15 °C] + def el_helou_penalties + EL_HELOU_TABLE_S3.flat_map do |group, (optimum, speed, losses)| + minutes = 42_195 / speed / 60 + loss15 = table_loss(optimum, losses, 15) + [20, 25].filter_map do |temperature| + loss = table_loss(optimum, losses, temperature) + next unless loss + + [group.split.first, minutes, temperature, (((1 - (loss15 / 100)) / (1 - (loss / 100))) - 1) * 100] + end + end + end + + def el_helou_exponent + pairs = el_helou_penalties.group_by { |sex, minutes, *| [sex, minutes] }.values.select { |rows| rows.size == 2 } + per_sex = pairs.map { |rows| rows.first.first }.tally + pairs.sum do |(at20, at25)| + Math.log2(at25.last / at20.last) / per_sex[at20.first] / per_sex.size + end + end + + def el_helou_ratios + base = EnvironmentalAdjuster::FACTORS.fetch('heat').fetch('data_points') + el_helou_penalties.map do |sex, minutes, temperature, penalty| + [sex, minutes, penalty / base.fetch(temperature)] + end + end + + # Straight line between the published points; nil beyond them + def table_loss(optimum, losses, temperature) + xs = (-10..20).step(5).map { |delta| optimum + delta } + index = xs.each_cons(2).find_index { |low, high| temperature.between?(low, high) } + return nil unless index + + losses[index] + ((temperature - xs[index]) / 5.0 * (losses[index + 1] - losses[index])) + end + + # factor(m) = c0 + c1·f180 + c2·f240 on the 60 → 180 → 240 min segments + def segment_weights(minutes) + if minutes <= 180 + w = (minutes - 60) / 120.0 + [1 - w, w, 0.0] + elsif minutes <= 240 + w = (minutes - 180) / 60.0 + [0.0, 1 - w, w] + else + [0.0, 0.0, 1.0] + end + end + + def weighted_two_point_fit(observations) + per_sex = observations.map(&:first).tally + a11 = a12 = a22 = b1 = b2 = 0.0 + observations.each do |sex, minutes, ratio| + weight = 1.0 / per_sex[sex] + c0, c1, c2 = segment_weights(minutes) + y = ratio - c0 + a11 += weight * c1 * c1 + a12 += weight * c1 * c2 + a22 += weight * c2 * c2 + b1 += weight * c1 * y + b2 += weight * c2 * y + end + det = (a11 * a22) - (a12**2) + [((b1 * a22) - (a12 * b2)) / det, ((a11 * b2) - (a12 * b1)) / det] end end diff --git a/test/calcpace/test_grade_adjusted_pace.rb b/test/calcpace/test_grade_adjusted_pace.rb new file mode 100644 index 0000000..01af2dd --- /dev/null +++ b/test/calcpace/test_grade_adjusted_pace.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Tests for grade-adjusted pace (Minetti et al., 2002) and its GPS-track splits +class TestGradeAdjustedPace < CalcpaceTest + # Minetti et al. (2002) running polynomial, evaluated independently of the + # library so a typo in a coefficient cannot pass by agreeing with itself + def minetti(grade) + (155.4 * (grade**5)) - (30.4 * (grade**4)) - (43.3 * (grade**3)) + + (46.3 * (grade**2)) + (19.5 * grade) + 3.6 + end + + # --------------------------------------------------------------------------- + # grade_adjustment_factor + # --------------------------------------------------------------------------- + + def test_factor_is_one_on_the_flat + assert_in_delta 1.0, @calc.grade_adjustment_factor(0), 1e-12 + end + + def test_factor_follows_the_minetti_polynomial + [-0.3, -0.1, -0.05, 0.05, 0.1, 0.3].each do |grade| + assert_in_delta minetti(grade) / 3.6, @calc.grade_adjustment_factor(grade), 1e-12 + end + end + + def test_factor_known_values + assert_in_delta 1.658, @calc.grade_adjustment_factor(0.10), 0.001 + assert_in_delta 0.598, @calc.grade_adjustment_factor(-0.10), 0.001 + end + + def test_factor_is_cheapest_around_minus_twenty_percent + assert_operator @calc.grade_adjustment_factor(-0.20), :<, @calc.grade_adjustment_factor(-0.10) + assert_operator @calc.grade_adjustment_factor(-0.20), :<, @calc.grade_adjustment_factor(-0.30) + end + + def test_factor_clamps_to_the_measured_range + assert_equal @calc.grade_adjustment_factor(0.45), @calc.grade_adjustment_factor(0.6) + assert_equal @calc.grade_adjustment_factor(-0.45), @calc.grade_adjustment_factor(-1) + end + + def test_factor_rejects_non_numeric_or_non_finite_grades + [nil, '0.05', Float::NAN, Float::INFINITY, -Float::INFINITY].each do |grade| + assert_raises(ArgumentError) { @calc.grade_adjustment_factor(grade) } + end + end + + # --------------------------------------------------------------------------- + # grade_adjusted_pace / grade_adjusted_pace_clock + # --------------------------------------------------------------------------- + + def test_uphill_pace_is_faster_on_the_flat + assert_in_delta 360 / (minetti(0.10) / 3.6), @calc.grade_adjusted_pace(360, 0.10), 1e-9 + end + + def test_downhill_pace_is_slower_on_the_flat + assert_operator @calc.grade_adjusted_pace(300, -0.05), :>, 300 + end + + def test_flat_pace_is_unchanged + assert_in_delta 300.0, @calc.grade_adjusted_pace(300, 0), 1e-12 + end + + def test_accepts_a_clock_pace + assert_equal @calc.grade_adjusted_pace(360, 0.05), @calc.grade_adjusted_pace('06:00', 0.05) + end + + def test_returns_a_float + assert_kind_of Float, @calc.grade_adjusted_pace(300, 0) + end + + def test_unit_does_not_change_the_math + assert_equal @calc.grade_adjusted_pace(480, 0.05), @calc.grade_adjusted_pace(480, 0.05, unit: :mi) + end + + def test_rejects_unknown_unit + assert_raises(Calcpace::UnsupportedUnitError) { @calc.grade_adjusted_pace(300, 0.05, unit: :furlong) } + end + + def test_rejects_non_positive_pace + assert_raises(Calcpace::NonPositiveInputError) { @calc.grade_adjusted_pace(0, 0.05) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.grade_adjusted_pace(-300, 0.05) } + end + + def test_rejects_malformed_clock_pace + assert_raises(Calcpace::InvalidTimeFormatError) { @calc.grade_adjusted_pace('06:xx', 0.05) } + end + + def test_rejects_invalid_grade + assert_raises(ArgumentError) { @calc.grade_adjusted_pace(300, nil) } + end + + def test_clock_formats + assert_equal '00:03:37', @calc.grade_adjusted_pace_clock('06:00', 0.10) + assert_equal '3:37', @calc.grade_adjusted_pace_clock('06:00', 0.10, compact: true) + end + + # --------------------------------------------------------------------------- + # track_grade_adjusted_splits + # --------------------------------------------------------------------------- + + # Degrees of latitude per kilometre on the gem's spherical Earth + KM_PER_DEGREE = TrackCalculator::EARTH_RADIUS_KM * Math::PI / 180.0 + + # A straight track going north, one point every step_m metres, run at a + # steady pace. ele: is a lambda of the horizontal distance in metres, or nil + # to leave elevation out entirely. + def build_track(distance_m:, step_m: 10, pace_sec_per_km: 300, ele: ->(_m) { 100.0 }) + start = Time.new(2026, 1, 1, 7, 0, 0) + (0..(distance_m / step_m)).map do |i| + metres = i * step_m + point = { lat: metres / 1000.0 / KM_PER_DEGREE, lon: 0.0, time: start + (metres / 1000.0 * pace_sec_per_km) } + point[:ele] = ele.call(metres) if ele + point + end + end + + def pace_seconds(clock) + sign = clock.start_with?('-') ? -1 : 1 + minutes, seconds = clock.delete_prefix('-').split(':').map(&:to_i) + sign * ((minutes * 60) + seconds) + end + + def test_keeps_track_splits_fields_identical + points = build_track(distance_m: 2500, ele: ->(m) { 100 + (0.05 * m) }) + + gap_splits = @calc.track_grade_adjusted_splits(points, 1.0) + + assert_equal(@calc.track_splits(points, 1.0), + gap_splits.map { |split| split.except(:gap) }) + end + + def test_track_splits_output_is_unchanged_by_elevation + flat = build_track(distance_m: 2500) + hilly = build_track(distance_m: 2500, ele: ->(m) { 100 + (0.05 * m) }) + + assert_equal @calc.track_splits(flat, 1.0), @calc.track_splits(hilly, 1.0) + assert_equal %i[km elapsed pace], @calc.track_splits(hilly, 1.0).first.keys + end + + def test_adds_a_gap_field_to_every_split + points = build_track(distance_m: 2500) + result = @calc.track_grade_adjusted_splits(points, 1.0) + + assert_equal 3, result.size + result.each { |split| assert_equal %i[km elapsed pace gap], split.keys } + end + + def test_flat_track_gap_equals_pace + points = build_track(distance_m: 2500) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal split[:pace], split[:gap] + end + end + + def test_track_without_elevation_is_treated_as_flat + points = build_track(distance_m: 2500, ele: nil) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal split[:pace], split[:gap] + end + end + + def test_steady_climb_gap_matches_the_single_grade_formula + points = build_track(distance_m: 3000, ele: ->(m) { 100 + (0.05 * m) }) + expected = @calc.grade_adjusted_pace(300, 0.05) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal '05:00', split[:pace] + assert_in_delta expected, pace_seconds(split[:gap]), 1 + end + end + + def test_steady_descent_gap_is_slower_than_pace + points = build_track(distance_m: 2000, ele: ->(m) { 500 - (0.05 * m) }) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_operator pace_seconds(split[:gap]), :>, pace_seconds(split[:pace]) + end + end + + # One-metre GPS points with up to ±2 m of elevation jitter on flat ground. + # Read point to point that is a grade of up to ±400% on every segment — + # clamped to ±45%, and the factor's curvature turns that symmetric noise into + # a fake climb worth a GAP around 1:30/km. Over 100 m grade segments what is + # left is the noise at the split's two ends (up to 4 m of net "climb" over + # 1 km, about 2%) and a little curvature: a few seconds + def test_elevation_noise_on_short_segments_does_not_create_fake_grades + points = build_track(distance_m: 2000, step_m: 1, ele: ->(m) { 100 + (2.0 * Math.sin(m * 1.7)) }) + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_in_delta pace_seconds(split[:pace]), pace_seconds(split[:gap]), 10 + end + end + + def test_climb_then_descent_lands_in_the_right_splits + points = build_track(distance_m: 2000, ele: ->(m) { m <= 1000 ? 100 + (0.08 * m) : 180 - (0.08 * (m - 1000)) }) + first, second = @calc.track_grade_adjusted_splits(points, 1.0) + + assert_in_delta @calc.grade_adjusted_pace(300, 0.08), pace_seconds(first[:gap]), 1 + assert_in_delta @calc.grade_adjusted_pace(300, -0.08), pace_seconds(second[:gap]), 1 + end + + def test_points_missing_elevation_are_flat_segments + # Elevation dropped for the whole second kilometre (a GPS fix without + # altitude): that kilometre counts as flat, the first keeps its climb + points = build_track(distance_m: 2000, ele: ->(m) { 100 + (0.05 * m) }) + points.each { |point| point.delete(:ele) if point[:lat] * KM_PER_DEGREE > 1.0 } + first, second = @calc.track_grade_adjusted_splits(points, 1.0) + + assert_operator pace_seconds(first[:gap]), :<, pace_seconds(first[:pace]) + assert_equal second[:pace], second[:gap] + end + + # Every third fix without altitude leaves 20 m stretches with elevation: + # too short to grade, with no full segment before them to join, so they are + # flat rather than read as ±20% from 2 m of jitter + def test_short_isolated_stretches_with_elevation_are_flat + points = build_track(distance_m: 3000, ele: ->(m) { 100 + (2.0 * Math.sin(m * 1.7)) }) + points.each_with_index { |point, i| point.delete(:ele) if (i % 3) == 2 } + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal split[:pace], split[:gap] + end + end + + def test_track_shorter_than_one_grade_segment_is_flat + points = build_track(distance_m: 50, ele: ->(m) { 100 + (0.1 * m) }) + split = @calc.track_grade_adjusted_splits(points, 1.0).first + + assert_equal split[:pace], split[:gap] + end + + def test_non_finite_elevation_counts_as_missing + points = build_track(distance_m: 2000) + points[50][:ele] = Float::NAN + points[120][:ele] = Float::INFINITY + + @calc.track_grade_adjusted_splits(points, 1.0).each do |split| + assert_equal split[:pace], split[:gap] + end + end + + def test_string_keys_are_accepted + points = build_track(distance_m: 1000, ele: ->(m) { 100 + (0.05 * m) }) + .map { |point| point.transform_keys(&:to_s) } + + split = @calc.track_grade_adjusted_splits(points, 1.0).first + assert_operator pace_seconds(split[:gap]), :<, pace_seconds(split[:pace]) + end + + def test_compact_gap_format + points = build_track(distance_m: 1000, ele: ->(m) { 100 + (0.05 * m) }) + padded = @calc.track_grade_adjusted_splits(points, 1.0).first + compact = @calc.track_grade_adjusted_splits(points, 1.0, compact: true).first + + assert_equal padded[:gap].delete_prefix('0'), compact[:gap] + assert_equal '5:00', compact[:pace] + end + + def test_partial_split_gets_a_gap + points = build_track(distance_m: 1500, ele: ->(m) { 100 + (0.05 * m) }) + partial = @calc.track_grade_adjusted_splits(points, 1.0).last + + assert_equal 1.5, partial[:km] + assert_in_delta @calc.grade_adjusted_pace(300, 0.05), pace_seconds(partial[:gap]), 1 + end + + def test_empty_or_single_point_tracks_return_empty + assert_equal [], @calc.track_grade_adjusted_splits([], 1.0) + assert_equal [], @calc.track_grade_adjusted_splits(nil, 1.0) + assert_equal [], @calc.track_grade_adjusted_splits(build_track(distance_m: 0), 1.0) + end + + def test_validates_like_track_splits + points = build_track(distance_m: 100) + assert_raises(ArgumentError) { @calc.track_grade_adjusted_splits(points, 0) } + assert_raises(ArgumentError) { @calc.track_grade_adjusted_splits([{ lat: 0, lon: 0 }, { lat: 0.001, lon: 0 }], 1.0) } + end +end diff --git a/test/calcpace/test_module_composition.rb b/test/calcpace/test_module_composition.rb new file mode 100644 index 0000000..f0b7b69 --- /dev/null +++ b/test/calcpace/test_module_composition.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Calcpace is one class made of many modules. A method defined in two of them +# is silently overwritten by whichever comes first in the ancestors, so the +# composition itself is tested here. +class TestModuleComposition < CalcpaceTest + CALCPACE_MODULES = (Calcpace.ancestors.take_while { |mod| mod != Object } - [Calcpace]).freeze + + def test_no_two_modules_define_the_same_method + owners = Hash.new { |hash, name| hash[name] = [] } + CALCPACE_MODULES.each do |mod| + (mod.instance_methods(false) + mod.private_instance_methods(false)).each { |name| owners[name] << mod } + end + shared = owners.select { |_name, mods| mods.size > 1 } + + assert_empty shared, "Methods defined in more than one module: #{shared.inspect}" + end + + # Vo2maxNorms declares what it needs (Checker), so it works on its own + def test_vo2max_norms_works_included_alone + norms = Class.new { include Vo2maxNorms }.new + + assert_in_delta 40.5, norms.vo2max_percentile(45, age: 25, sex: :male) + assert_raises(ArgumentError) { norms.vo2max_percentile(45, age: 17, sex: :male) } + assert_raises(ArgumentError) { norms.vo2max_percentile(45, age: 25, sex: :other) } + assert_raises(Calcpace::NonPositiveInputError) { norms.vo2max_percentile(0, age: 25, sex: :male) } + end + + def test_age_and_sex_rules_are_shared_by_age_grading_and_vo2max_norms + assert_equal Checker, Calcpace.instance_method(:normalize_age).owner + assert_equal Checker, Calcpace.instance_method(:normalize_sex).owner + end +end diff --git a/test/calcpace/test_personalized_predictor.rb b/test/calcpace/test_personalized_predictor.rb new file mode 100644 index 0000000..44503be --- /dev/null +++ b/test/calcpace/test_personalized_predictor.rb @@ -0,0 +1,304 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Tests for predictions built from the runner's own data: training volume +# (Tanda 2011) and a personal Riegel exponent fitted to two performances +class TestPersonalizedPredictor < CalcpaceTest + # --- Tanda (2011): marathon from training volume --------------------------- + + # Pm = 17.1 + 140.0 * exp(-0.0053 * 60) + 0.55 * 300 = 283.96 s/km + def test_marathon_from_training_applies_the_tanda_equation + result = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300) + + assert_in_delta 283.96, result[:pace], 0.01 + assert_in_delta 283.9644 * 42.195, result[:time], 0.01 + assert_equal '03:19:41', result[:time_clock] + assert_equal '00:04:43', result[:pace_clock] + end + + def test_the_sample_means_reproduce_the_sample_mean_marathon_pace + # Table 2 of the paper: K = 65.9 km/week and P = 284.6 s/km on average, + # mean race pace 271.8 s/km. A regression evaluated at the means of its own + # predictors has to land next to the mean of its response. + result = @calc.predict_marathon_from_training(weekly_distance: 65.9, training_pace: 284.6) + + assert_in_delta 271.8, result[:pace], 1.0 + end + + def test_training_pace_accepts_a_clock_string + numeric = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300) + clock = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: '05:00') + + assert_equal numeric, clock + end + + def test_more_volume_and_faster_training_both_predict_a_faster_marathon + base = @calc.predict_marathon_from_training(weekly_distance: 50, training_pace: 300)[:time] + more_volume = @calc.predict_marathon_from_training(weekly_distance: 80, training_pace: 300)[:time] + faster_training = @calc.predict_marathon_from_training(weekly_distance: 50, training_pace: 280)[:time] + + assert_operator more_volume, :<, base + assert_operator faster_training, :<, base + end + + def test_inputs_inside_the_paper_sample_are_flagged_as_validated + result = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300) + + assert result[:within_validated_range] + assert_empty result[:out_of_range] + end + + def test_low_volume_is_flagged_but_still_predicted + # 40 km/week is just under the sample minimum of 40.4 km/week, and the + # resulting 3:39 marathon is slower than the slowest one in the sample (3:36) + result = @calc.predict_marathon_from_training(weekly_distance: 40, training_pace: '05:30') + + refute result[:within_validated_range] + assert_equal %i[weekly_distance marathon_time], result[:out_of_range] + assert_equal '03:39:18', result[:time_clock] + end + + def test_slow_training_pace_is_flagged + result = @calc.predict_marathon_from_training(weekly_distance: 25, training_pace: '06:30') + + refute result[:within_validated_range] + assert_equal %i[weekly_distance training_pace marathon_time], result[:out_of_range] + end + + def test_fast_training_pace_is_flagged + result = @calc.predict_marathon_from_training(weekly_distance: 100, training_pace: '04:00') + + assert_includes result[:out_of_range], :training_pace + end + + def test_boundaries_of_the_sample_are_inside_the_validated_range + low = @calc.predict_marathon_from_training(weekly_distance: 40.4, training_pace: 330.6) + high = @calc.predict_marathon_from_training(weekly_distance: 110.7, training_pace: 253.3) + + refute_includes low[:out_of_range], :weekly_distance + refute_includes low[:out_of_range], :training_pace + refute_includes high[:out_of_range], :weekly_distance + refute_includes high[:out_of_range], :training_pace + end + + def test_miles_convert_both_inputs_and_report_pace_per_mile + # 25 mi/week at 8:00/mi is 40.23 km/week at 298.26 s/km + result = @calc.predict_marathon_from_training(weekly_distance: 25, training_pace: '08:00', unit: :mi) + km = @calc.predict_marathon_from_training(weekly_distance: 25 * 1.609344, training_pace: 480 / 1.609344) + + assert_in_delta km[:time], result[:time], 0.01 + assert_in_delta km[:pace] * 1.609344, result[:pace], 0.01 + assert_in_delta 473.56, result[:pace], 0.01 + assert_equal '00:07:53', result[:pace_clock] + assert_equal km[:out_of_range], result[:out_of_range] + end + + def test_unit_is_case_insensitive + upper = @calc.predict_marathon_from_training(weekly_distance: 25, training_pace: 480, unit: 'MI') + lower = @calc.predict_marathon_from_training(weekly_distance: 25, training_pace: 480, unit: :mi) + + assert_equal lower, upper + end + + def test_unsupported_unit_raises + assert_raises(Calcpace::UnsupportedUnitError) do + @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300, unit: :furlong) + end + end + + def test_non_positive_inputs_raise + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: 0, training_pace: 300) + end + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: -1) + end + end + + def test_malformed_training_pace_raises_invalid_time_format + ['5:3x', 'abc', '', :'05:00', nil].each do |pace| + assert_raises(Calcpace::InvalidTimeFormatError, "expected #{pace.inspect} to be rejected") do + @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: pace) + end + end + end + + def test_numeric_string_weekly_distance_is_accepted + string = @calc.predict_marathon_from_training(weekly_distance: '60', training_pace: 300) + numeric = @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: 300) + + assert_equal numeric, string + end + + def test_non_numeric_weekly_distance_raises + ['abc', '', nil, :'60'].each do |distance| + assert_raises(Calcpace::NonPositiveInputError, "expected #{distance.inspect} to be rejected") do + @calc.predict_marathon_from_training(weekly_distance: distance, training_pace: 300) + end + end + end + + def test_non_finite_inputs_raise + [Float::INFINITY, Float::NAN].each do |value| + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: value, training_pace: 300) + end + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: 60, training_pace: value) + end + end + assert_raises(Calcpace::NonPositiveInputError) do + @calc.predict_marathon_from_training(weekly_distance: 'Infinity', training_pace: 300) + end + end + + # --- Personal Riegel exponent ---------------------------------------------- + + def test_riegel_exponent_from_two_performances + # ln(6120 / 2700) / ln(21.0975 / 10) + exponent = @calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') + + assert_in_delta 1.096094, exponent, 1e-6 + end + + def test_riegel_exponent_recovers_the_standard_exponent + half = @calc.predict_time('10k', 2700, 'half_marathon') + + assert_in_delta 1.06, @calc.riegel_exponent('10k', 2700, 'half_marathon', half), 1e-9 + end + + def test_riegel_exponent_does_not_depend_on_order + forward = @calc.riegel_exponent('10k', '00:45:00', 'half_marathon', '01:42:00') + backward = @calc.riegel_exponent('half_marathon', '01:42:00', '10k', '00:45:00') + + assert_in_delta forward, backward, 1e-12 + end + + def test_riegel_exponent_accepts_numeric_distances_and_seconds + named = @calc.riegel_exponent('5k', '00:20:00', '10k', '00:42:00') + numeric = @calc.riegel_exponent(5, 1200, '10', 2520) + + assert_in_delta named, numeric, 1e-12 + end + + def test_riegel_exponent_rejects_the_same_distance + assert_error_with_message(ArgumentError, 'different distances') do + @calc.riegel_exponent('10k', '00:45:00', 10, '00:46:00') + end + end + + def test_riegel_exponent_rejects_unknown_race_and_non_positive_time + assert_raises(ArgumentError) { @calc.riegel_exponent('10q', 2700, '5k', 1300) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.riegel_exponent('10k', 0, '5k', 1300) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.riegel_exponent('10k', Float::INFINITY, '5k', 1300) } + end + + def test_malformed_race_times_raise_invalid_time_format + ['45:0x', 'abc', :'00:45:00', nil].each do |time| + assert_raises(Calcpace::InvalidTimeFormatError, "expected #{time.inspect} to be rejected") do + @calc.riegel_exponent('10k', time, '5k', 1300) + end + assert_raises(Calcpace::InvalidTimeFormatError) do + @calc.predict_time_personal('10k', 2700, '5k', time, 'marathon') + end + end + end + + def test_personal_prediction_uses_the_performance_closest_to_the_target + # The half marathon is closer to the marathon than the 10K, so the + # prediction scales 1:42:00 by (42.195 / 21.0975)^1.096094 + result = @calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'marathon') + + assert_in_delta 6120 * (2**1.096094), result[:time], 0.1 + assert_equal '03:38:03', result[:time_clock] + assert_in_delta 1.0961, result[:exponent], 1e-4 + assert_in_delta 1.0961, result[:raw_exponent], 1e-4 + refute result[:clamped] + end + + def test_personal_prediction_anchor_does_not_depend_on_argument_order + forward = @calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', '5k') + backward = @calc.predict_time_personal('half_marathon', '01:42:00', '10k', '00:45:00', '5k') + + assert_in_delta forward[:time], backward[:time], 1e-6 + # 5K is anchored on the 10K: 2700 * (5 / 10)^1.096094 + assert_in_delta 2700 * (0.5**1.096094), forward[:time], 0.1 + end + + def test_an_implausibly_low_exponent_is_clamped + # A 10K at 3:00/km followed by a half at the same pace: k = 1.0 + result = @calc.predict_time_personal('10k', 1800, 'half_marathon', 3797.55, 'marathon') + + assert result[:clamped] + assert_in_delta 1.0, result[:raw_exponent], 1e-4 + assert_in_delta 1.01, result[:exponent], 1e-12 + assert_in_delta 3797.55 * (2**1.01), result[:time], 0.1 + end + + def test_an_implausibly_high_exponent_is_clamped + result = @calc.predict_time_personal('5k', '00:20:00', '10k', '00:50:00', 'half_marathon') + + assert result[:clamped] + assert_operator result[:raw_exponent], :>, 1.2 + assert_in_delta 1.2, result[:exponent], 1e-12 + end + + def test_a_longer_race_run_faster_is_clamped_not_raised + result = @calc.predict_time_personal('5k', '00:25:00', '10k', '00:24:00', 'marathon') + + assert result[:clamped] + assert_operator result[:raw_exponent], :<, 0 + assert_in_delta 1.01, result[:exponent], 1e-12 + end + + def test_a_target_between_the_two_races_uses_the_raw_exponent + # k = ln(3000 / 1200) / ln(20 / 5) = 0.661, far below the clamp: but the 10K + # lies between the two known races, so the curve through both of them is + # used as is — clamping would contradict the runner's own data + result = @calc.predict_time_personal(5, 1200, 20, 3000, 10) + + refute result[:clamped] + assert_in_delta 0.6610, result[:exponent], 1e-4 + assert_equal result[:raw_exponent], result[:exponent] + assert_in_delta 1200 * (2**(Math.log(2.5) / Math.log(4))), result[:time], 0.01 + end + + def test_interpolation_does_not_depend_on_argument_order + # 10 km is the geometric mean of 5 and 20: both races are equally close + forward = @calc.predict_time_personal(5, 1200, 20, 3000, 10) + backward = @calc.predict_time_personal(20, 3000, 5, 1200, 10) + + assert_equal forward, backward + end + + def test_interpolation_passes_through_both_known_performances + # Between a 10K in 45:00 and a half in 1:42:00, a 15K sits on the same + # curve whichever performance it is scaled from + result = @calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 15) + exponent = Math.log(6120.0 / 2700) / Math.log(21.0975 / 10) + + assert_in_delta 2700 * (1.5**exponent), result[:time], 0.01 + assert_in_delta 6120 * ((15 / 21.0975)**exponent), result[:time], 0.01 + end + + def test_extrapolation_outside_the_pair_is_still_clamped + result = @calc.predict_time_personal(5, 1200, 20, 3000, 'marathon') + + assert result[:clamped] + assert_in_delta 1.01, result[:exponent], 1e-12 + assert_in_delta 3000 * ((42.195 / 20)**1.01), result[:time], 0.01 + end + + def test_personal_prediction_rejects_a_target_already_known + assert_raises(ArgumentError) do + @calc.predict_time_personal('10k', '00:45:00', 'half_marathon', '01:42:00', 'half_marathon') + end + end + + def test_personal_prediction_rejects_two_performances_at_the_same_distance + assert_raises(ArgumentError) do + @calc.predict_time_personal('10k', '00:45:00', '10k', '00:44:00', 'marathon') + end + end +end diff --git a/test/calcpace/test_race_predictor.rb b/test/calcpace/test_race_predictor.rb index 100c4bd..906ec08 100644 --- a/test/calcpace/test_race_predictor.rb +++ b/test/calcpace/test_race_predictor.rb @@ -228,12 +228,12 @@ def test_predict_time_adjusted_with_heat # 5K in 20:00 to 10K # Normal: ~2501s # Duration factor for ~41:41 (2501s) is: 0.5 + ((41.68 - 30) / 30) * 0.5 ≈ 0.695x - # Adjusted for 20°C (Base 2.8% * 0.695 ≈ 1.95% penalty): 2501.9 * 1.0195 ≈ 2550.7s + # Adjusted for 20°C (Base 1.52% * 0.695 ≈ 1.06% penalty): 2501.9 * 1.0106 ≈ 2528.4s result = @calc.predict_time_adjusted('5k', '00:20:00', '10k', temperature: 20) assert_kind_of Hash, result - assert_in_delta 2550.7, result[:adjusted_time], 10 - assert_equal 1.95, result[:penalty_percent] + assert_in_delta 2528.44, result[:adjusted_time], 0.01 + assert_equal 1.06, result[:penalty_percent] end def test_predict_time_adjusted_with_altitude diff --git a/test/calcpace/test_race_splits.rb b/test/calcpace/test_race_splits.rb index 212f741..c4d12c3 100644 --- a/test/calcpace/test_race_splits.rb +++ b/test/calcpace/test_race_splits.rb @@ -102,6 +102,25 @@ def test_race_splits_positive_10k assert_equal '00:40:00', result[1] end + # Negative/positive splits are ±1% pace per half (v1.19.0) + def test_race_splits_negative_marathon_is_one_percent_per_half + result = @calc.race_splits('marathon', target_time: '03:00:00', split_distance: 21.0975, strategy: :negative) + + assert_equal %w[01:30:54 03:00:00], result + end + + def test_race_splits_positive_marathon_is_one_percent_per_half + result = @calc.race_splits('marathon', target_time: '03:00:00', split_distance: 21.0975, strategy: :positive) + + assert_equal %w[01:29:06 03:00:00], result + end + + def test_race_splits_negative_10k_by_5k + result = @calc.race_splits('10k', target_time: '00:50:00', split_distance: '5k', strategy: :negative) + + assert_equal %w[00:25:15 00:50:00], result + end + # Test with string time format def test_race_splits_with_string_time result = @calc.race_splits('5k', target_time: '25:00', split_distance: '1k') diff --git a/test/calcpace/test_training_zones.rb b/test/calcpace/test_training_zones.rb index fbb1726..62f6c51 100644 --- a/test/calcpace/test_training_zones.rb +++ b/test/calcpace/test_training_zones.rb @@ -54,6 +54,77 @@ def test_slow_is_always_slower_than_fast_within_each_band end end + # --- marathon band: fast end = VDOT-predicted marathon pace --- + + def test_marathon_band_for_vo2max_fifty + zones = @calc.training_paces(50.0) + + assert_equal 290, zones[:marathon].slow_seconds # 75% → 04:50/km (unchanged) + assert_equal 271, zones[:marathon].fast_seconds # VDOT 50 marathon pace, 04:31/km + assert_equal '00:04:31', zones[:marathon].fast_clock + end + + def test_marathon_fast_end_is_the_predicted_marathon_pace + [30, 40, 50, 60, 70].each do |vo2| + predicted = @calc.predict_time_from_vo2max(vo2, 'marathon') / 42.195 + + assert_in_delta predicted, @calc.training_paces(vo2)[:marathon].fast_seconds, 1, "VO2 #{vo2}" + end + end + + def test_marathon_fast_end_in_miles_is_the_predicted_marathon_pace_per_mile + predicted = @calc.predict_time_from_vo2max(50, 'marathon') / 42.195 * 1.609344 + + assert_in_delta predicted, @calc.training_paces(50, unit: :mi)[:marathon].fast_seconds, 1 + end + + def test_marathon_fast_end_is_never_faster_than_threshold + [10, 30, 50, 70, 100].each do |vo2| + zones = @calc.training_paces(vo2) + + assert_operator zones[:marathon].fast_seconds, :>=, zones[:threshold].fast_seconds, "VO2 #{vo2}" + end + end + + def test_training_intensities_stay_numeric + TrainingZones::TRAINING_INTENSITIES.each_value do |band| + assert_kind_of Numeric, band[:low] + assert_kind_of Numeric, band[:high] + end + assert_in_delta 0.84, TrainingZones::TRAINING_INTENSITIES[:marathon][:high], 0.0 + end + + def test_marathon_is_the_zone_whose_fast_end_is_the_predicted_race_pace + assert_equal %i[marathon], TrainingZones::PREDICTED_RACE_PACE_ZONES + end + + def test_marathon_and_threshold_bands_do_not_overlap_below_vo2max_sixty_nine + (30..69).each do |vo2| + zones = @calc.training_paces(vo2) + + assert_operator zones[:marathon].fast_seconds, :>=, zones[:threshold].slow_seconds, "VO2 #{vo2}" + end + end + + def test_marathon_band_works_outside_the_vdot_prediction_range + # predict_time_from_vo2max supports 10–100; outside it the race-pace + # intensity of the nearest bound is used + low = @calc.training_paces(5)[:marathon] + high = @calc.training_paces(120)[:marathon] + + assert_operator low.slow_seconds, :>, low.fast_seconds + assert_operator high.slow_seconds, :>, high.fast_seconds + assert_operator high.fast_seconds, :<, @calc.training_paces(100)[:marathon].fast_seconds + assert_operator low.fast_seconds, :>, @calc.training_paces(10)[:marathon].fast_seconds + end + + def test_marathon_band_is_continuous_at_the_range_bounds + [[10, 9.99], [100, 100.01]].each do |inside, outside| + assert_in_delta @calc.training_paces(inside)[:marathon].fast_seconds, + @calc.training_paces(outside)[:marathon].fast_seconds, 1 + end + end + def test_training_paces_rejects_non_positive_vo2max assert_raises(Calcpace::NonPositiveInputError) { @calc.training_paces(0) } assert_raises(Calcpace::NonPositiveInputError) { @calc.training_paces(-10) } diff --git a/test/calcpace/test_version.rb b/test/calcpace/test_version.rb new file mode 100644 index 0000000..1ebdd16 --- /dev/null +++ b/test/calcpace/test_version.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative '../test_helper' +require 'open3' +require 'rbconfig' + +class TestVersion < CalcpaceTest + LIB = File.expand_path('../../lib', __dir__) + + # In a fresh process, so nothing else the suite loaded can define it + def test_require_calcpace_defines_the_version + out, status = Open3.capture2e(RbConfig.ruby, '-I', LIB, '-e', "require 'calcpace'; print Calcpace::VERSION") + + assert_predicate status, :success?, out + assert_match(/\A\d+\.\d+\.\d+\z/, out) + end + + def test_version_matches_the_gem_version_file + assert_equal File.read(File.join(LIB, 'calcpace/version.rb'))[/VERSION = '([^']+)'/, 1], Calcpace::VERSION + end +end diff --git a/test/calcpace/test_vo2max_norms.rb b/test/calcpace/test_vo2max_norms.rb new file mode 100644 index 0000000..3bf7185 --- /dev/null +++ b/test/calcpace/test_vo2max_norms.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +# Tests for VO2max percentiles and labels by age and sex (FRIEND 2015, Table 3) +class TestVo2maxNorms < CalcpaceTest + # --- vo2max_label without age/sex: unchanged --- + + def test_label_without_age_and_sex_keeps_the_fixed_thresholds + { 72.0 => 'Elite', 70 => 'Elite', 65.0 => 'Excellent', 51.9 => 'Very Good', + 45 => 'Good', 35.0 => 'Fair', 29.9 => 'Beginner' }.each do |value, label| + assert_equal label, @calc.vo2max_label(value) + assert_equal label, @calc.vo2max_label(value, age: nil, sex: nil) + end + end + + def test_label_needs_age_and_sex_together + assert_raises(ArgumentError) { @calc.vo2max_label(45, age: 30) } + assert_raises(ArgumentError) { @calc.vo2max_label(45, sex: :male) } + end + + def test_label_with_norms_still_rejects_non_positive_values + assert_raises(Calcpace::NonPositiveInputError) { @calc.vo2max_label(0, age: 30, sex: :male) } + end + + # --- vo2max_label with age/sex --- + + def test_same_value_reads_differently_by_age_and_sex + assert_equal 'Fair', @calc.vo2max_label(45, age: 25, sex: :male) + assert_equal 'Elite', @calc.vo2max_label(45, age: 60, sex: :male) + assert_equal 'Very Good', @calc.vo2max_label(45, age: 25, sex: :female) + assert_equal 'Elite', @calc.vo2max_label(45, age: 60, sex: :female) + end + + # Men 40–49 from FRIEND: 25th 31.9, 50th 37.8, 75th 45.0, 90th 52.1, 95th 55.6 + def test_label_cuts_sit_on_the_published_percentiles + { + 55.6 => 'Elite', 55.59 => 'Excellent', + 52.1 => 'Excellent', 52.09 => 'Very Good', + 45.0 => 'Very Good', 44.99 => 'Good', + 37.8 => 'Good', 37.79 => 'Fair', + 31.9 => 'Fair', 31.89 => 'Beginner', + 10.0 => 'Beginner' + }.each do |value, label| + assert_equal label, @calc.vo2max_label(value, age: 45, sex: :male), "VO2max #{value}" + end + end + + def test_label_uses_the_same_six_labels + labels = (10..90).map { |value| @calc.vo2max_label(value, age: 35, sex: :female) }.uniq + assert_empty labels - Vo2maxEstimator::VO2MAX_LABELS.map { |entry| entry[:label] } + end + + # --- vo2max_percentile --- + + def test_percentile_on_a_published_point + assert_equal 50.0, @calc.vo2max_percentile(48.0, age: 25, sex: :male) + assert_equal 75.0, @calc.vo2max_percentile(44.7, age: 25, sex: :female) + assert_equal 10.0, @calc.vo2max_percentile(14.6, age: 65, sex: :female) + end + + def test_percentile_interpolates_linearly_between_published_points + # Men 20–29: 25th 40.1, 50th 48.0 + assert_in_delta 40.5, @calc.vo2max_percentile(45, age: 25, sex: :male), 0.05 + assert_equal 37.5, @calc.vo2max_percentile(44.05, age: 25, sex: :male) + end + + def test_percentile_is_bounded_to_the_table + assert_equal 95.0, @calc.vo2max_percentile(80, age: 25, sex: :male) + assert_equal 95.0, @calc.vo2max_percentile(66.3, age: 25, sex: :male) + assert_equal 5.0, @calc.vo2max_percentile(29.0, age: 25, sex: :male) + assert_equal 5.0, @calc.vo2max_percentile(15, age: 25, sex: :male) + end + + def test_percentile_is_rounded_to_one_decimal + value = @calc.vo2max_percentile(45, age: 25, sex: :female) + assert_kind_of Float, value + assert_equal value.round(1), value + end + + def test_age_bands_are_decades_used_as_published + assert_equal 50.0, @calc.vo2max_percentile(42.4, age: 30, sex: :male) + assert_equal 50.0, @calc.vo2max_percentile(42.4, age: 39, sex: :male) + refute_equal 50.0, @calc.vo2max_percentile(42.4, age: 29, sex: :male) + end + + def test_ages_outside_the_table_use_the_nearest_band + assert_equal @calc.vo2max_percentile(45, age: 25, sex: :male), @calc.vo2max_percentile(45, age: 18, sex: :male) + assert_equal @calc.vo2max_percentile(25, age: 75, sex: :female), + @calc.vo2max_percentile(25, age: 92, sex: :female) + end + + def test_rejects_minors_and_non_integer_ages + assert_raises(ArgumentError) { @calc.vo2max_percentile(45, age: 17, sex: :male) } + assert_raises(ArgumentError) { @calc.vo2max_percentile(45, age: 'old', sex: :male) } + assert_raises(ArgumentError) { @calc.vo2max_percentile(45, age: nil, sex: :male) } + end + + def test_sex_accepts_strings_and_rejects_anything_else + assert_equal @calc.vo2max_percentile(40, age: 30, sex: :female), + @calc.vo2max_percentile(40, age: 30, sex: ' Female ') + assert_raises(ArgumentError) { @calc.vo2max_percentile(40, age: 30, sex: :x) } + assert_raises(ArgumentError) { @calc.vo2max_percentile(40, age: 30, sex: nil) } + end + + def test_percentile_rejects_non_positive_values + assert_raises(Calcpace::NonPositiveInputError) { @calc.vo2max_percentile(0, age: 30, sex: :male) } + assert_raises(Calcpace::NonPositiveInputError) { @calc.vo2max_percentile(-1, age: 30, sex: :male) } + end + + # --- data file --- + + def test_norms_cover_both_sexes_and_six_decades + %w[M F].each do |sex| + assert_equal [20, 30, 40, 50, 60, 70], Vo2maxNorms::VO2MAX_NORMS.fetch(sex).keys.sort + end + end + + def test_norms_rows_rise_with_the_percentile + Vo2maxNorms::VO2MAX_NORMS.each_value do |bands| + bands.each_value do |row| + assert_equal Vo2maxNorms::VO2MAX_NORM_PERCENTILES.size, row.size + assert_equal row.sort.uniq, row + end + end + end + + def test_norms_version_is_exposed + assert_includes Vo2maxNorms::VO2MAX_NORMS_VERSION, 'FRIEND 2015' + end +end