Skip to content

Commit ff55183

Browse files
authored
Feat/wma age grading v1.9.5 (#74)
* Add WMA 2023 age grading data and tests - Introduced new YAML files for WMA 2023 open standards and road age grading data. - Updated version to 1.9.5 in version.rb. - Added comprehensive tests for the AgeGrading module, covering various scenarios including age grading calculations, time formats, and error handling. * chore: Update release date for version 1.9.5 in CHANGELOG * Refactor age grade labels to use data from WMA 2023 standards and add corresponding test * fix(age-grading): address copilot review and rubocop issues
1 parent 7d96e3d commit ff55183

8 files changed

Lines changed: 408 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
## [1.9.5] - 2026-05-02
11+
12+
### Added
13+
- Age-grading module (`AgeGrading`) with:
14+
- `age_grade(distance_km, time, age:, sex:)`
15+
- `age_grade_percent(distance_km, time, age:, sex:)`
16+
- `age_grade_label(percent)`
17+
- Versioned data file loader using YAML + `YAML.safe_load` from
18+
`lib/calcpace/data/wma_2023_road.yml`
19+
- Interpolation support for in-between ages (e.g., 57 between 55 and 60)
20+
- Initial road-race support: 5K, 10K, half marathon, marathon
21+
- WMA 2023 one-year age factors integrated for `M`/`F` road distances in meters
22+
- Source: World Masters Athletics (WMA) competition rules documents
23+
https://world-masters-athletics.org/documents/competition-rules/
24+
- Race-style age-factored time rounding up to the next hundredth
25+
- Test suite for validation, interpolation, and error handling
26+
827
## [1.9.4] - 2026-04-18
928

1029
### Added
@@ -145,5 +164,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
145164

146165
See git history for changes in earlier versions.
147166

148-
[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.6.0...HEAD
167+
[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.9.5...HEAD
168+
[1.9.5]: https://github.com/0jonjo/calcpace/compare/v1.9.4...v1.9.5
149169
[1.6.0]: https://github.com/0jonjo/calcpace/releases/tag/v1.6.0

README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Calcpace [![Gem Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=rb&r=r&ts=1683906897&type=6e&v=1.9.3&x2=0)](https://badge.fury.io/rb/calcpace)
1+
# Calcpace [![Gem Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=rb&r=r&ts=1683906897&type=6e&v=1.9.5&x2=0)](https://badge.fury.io/rb/calcpace)
22

33
A Ruby gem for running and cycling calculations: pace, time, distance, unit conversions, race predictions, GPS track analysis, and VO2max estimation.
44

@@ -124,6 +124,44 @@ calc.track_splits(points, 1.0) # => [{ km: 1, elapsed: 312, pace: "05:12" }, ...
124124

125125
---
126126

127+
### Age Grading (Road Races)
128+
129+
Age grading compares race results across different ages and sexes by using
130+
age factors and open standards.
131+
132+
```ruby
133+
result = calc.age_grade(10.0, '00:45:00', age: 55, sex: :male)
134+
# => {
135+
# age_grade_percent: 64.6,
136+
# category: "Local Class",
137+
# age_graded_time_seconds: 2376.0,
138+
# age_graded_time_clock: "00:39:36",
139+
# open_standard_seconds: 1571.0,
140+
# open_standard_clock: "00:26:11",
141+
# factor: 0.88,
142+
# table_version: "WMA_2023_ONE_YEAR_FACTORS_V1"
143+
# }
144+
145+
calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 74.1
146+
calc.age_grade_label(74.1) # => "Regional Class"
147+
```
148+
149+
Supported distances: 5K, 10K, half marathon, marathon.
150+
151+
Age factors are based on WMA 2023 one-year age grading tables:
152+
https://world-masters-athletics.org/documents/competition-rules/
153+
154+
Open standards used in `open_standard_seconds` / `open_standard_clock` are loaded
155+
from the bundled WMA 2023 open standards dataset
156+
(`lib/calcpace/data/wma_2023_open_standards.yml`).
157+
158+
Field meanings:
159+
- `age_graded_time_clock`: your result after applying the WMA age factor (normalized performance time).
160+
- `open_standard_clock`: the open standard reference time used to compute the percentage for that distance/sex.
161+
- `age_grade_percent`: `(open_standard_seconds / age_graded_time_seconds) * 100`.
162+
163+
---
164+
127165
### VO2max Estimation
128166

129167
Estimate aerobic fitness from a race result using the **Daniels & Gilbert formula** (1979):

lib/calcpace.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require_relative 'calcpace/calculator'
44
require_relative 'calcpace/cameron_predictor'
5+
require_relative 'calcpace/age_grading'
56
require_relative 'calcpace/checker'
67
require_relative 'calcpace/converter'
78
require_relative 'calcpace/converter_chain'
@@ -35,6 +36,7 @@
3536
#
3637
# @see https://github.com/0jonjo/calcpace
3738
class Calcpace
39+
include AgeGrading
3840
include Calculator
3941
include CameronPredictor
4042
include Checker

lib/calcpace/age_grading.rb

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# frozen_string_literal: true
2+
3+
require 'yaml'
4+
5+
# Module for age-grading race performances with a versioned table
6+
#
7+
# Age grading allows fairer comparison across ages by applying an age factor
8+
# to the raw performance time.
9+
#
10+
# Current scope:
11+
# - Common road distances: 5K, 10K, half marathon, marathon
12+
# - Sex: male/female
13+
# - Age: 18+
14+
# - Data file is versioned and replaceable (`lib/calcpace/data/wma_2023_road.yml`)
15+
#
16+
# Returned values include:
17+
# - age grade percentage
18+
# - age-graded time
19+
# - open standard time for the selected distance/sex
20+
# - performance category
21+
# rubocop:disable Metrics/ModuleLength
22+
module AgeGrading
23+
DATA_PATH = File.expand_path('data/wma_2023_road.yml', __dir__).freeze
24+
OPEN_STANDARDS_DATA_PATH = File.expand_path('data/wma_2023_open_standards.yml', __dir__).freeze
25+
WMA_DATA = YAML.safe_load_file(DATA_PATH, permitted_classes: [],
26+
aliases: false).freeze
27+
OPEN_STANDARDS_DATA = YAML.safe_load_file(OPEN_STANDARDS_DATA_PATH, permitted_classes: [],
28+
aliases: false).freeze
29+
TABLE_VERSION = OPEN_STANDARDS_DATA.fetch('meta').fetch('table_version').freeze
30+
31+
AGE_GRADE_LABELS = OPEN_STANDARDS_DATA.fetch('age_grade_classifications').map do |entry|
32+
{ min: entry.fetch('min').to_f, label: entry.fetch('label') }
33+
end.freeze
34+
35+
DISTANCE_TO_METERS = {
36+
5.0 => '5000',
37+
10.0 => '10000',
38+
21.0975 => '21097',
39+
42.195 => '42195'
40+
}.freeze
41+
RACE_TO_METERS = {
42+
'5k' => '5000',
43+
'10k' => '10000',
44+
'half_marathon' => '21097',
45+
'marathon' => '42195'
46+
}.freeze
47+
48+
SUPPORTED_DISTANCES_KM = DISTANCE_TO_METERS.keys.freeze
49+
50+
# Returns a full age-grading report for a race performance
51+
#
52+
# @param distance_km [Numeric, String, Symbol] race distance in kilometres
53+
# (5.0, 10.0, 21.0975, 42.195) or race key (:5k, :10k, :half_marathon, :marathon)
54+
# @param time [String, Numeric] performance time as HH:MM:SS / MM:SS, or total seconds
55+
# @param age [Integer] athlete age (must be >= 18)
56+
# @param sex [String, Symbol] male or female
57+
# @return [Hash] age-grading result details
58+
def age_grade(distance_km, time, age:, sex:)
59+
distance_m = normalize_distance(distance_km)
60+
seconds = parse_time_seconds(time)
61+
age_value = normalize_age(age)
62+
sex_value = normalize_sex(sex)
63+
64+
check_positive(seconds, 'Time')
65+
66+
factor = interpolated_factor(sex_value, age_value, distance_m)
67+
age_graded_time = round_up_hundredth(seconds * factor)
68+
open_standard = open_standard_seconds(sex_value, distance_m)
69+
grade_percent = (open_standard / age_graded_time) * 100.0
70+
rounded_percent = grade_percent.round(1)
71+
72+
{
73+
age_grade_percent: rounded_percent,
74+
category: age_grade_label(rounded_percent),
75+
age_graded_time_seconds: age_graded_time,
76+
age_graded_time_clock: convert_to_clocktime(age_graded_time),
77+
open_standard_seconds: open_standard,
78+
open_standard_clock: convert_to_clocktime(open_standard),
79+
factor: factor.round(4),
80+
table_version: TABLE_VERSION
81+
}
82+
end
83+
84+
# Returns only the age-grade percentage
85+
#
86+
# @param distance_km [Numeric] race distance in kilometres
87+
# @param time [String, Numeric] performance time
88+
# @param age [Integer] athlete age
89+
# @param sex [String, Symbol] male or female
90+
# @return [Float] age-grade percentage
91+
def age_grade_percent(distance_km, time, age:, sex:)
92+
age_grade(distance_km, time, age: age, sex: sex)[:age_grade_percent]
93+
end
94+
95+
# Returns a descriptive label for an age-grade percentage
96+
#
97+
# @param percent [Numeric] age-grade percentage
98+
# @return [String] category label
99+
def age_grade_label(percent)
100+
percent_value = begin
101+
Float(percent)
102+
rescue ArgumentError, TypeError
103+
raise ArgumentError, 'Age-grade percent must be a numeric value greater than or equal to 0'
104+
end
105+
106+
raise ArgumentError, 'Age-grade percent must be greater than or equal to 0' if percent_value.negative?
107+
108+
AGE_GRADE_LABELS.find { |entry| percent_value >= entry[:min] }[:label]
109+
end
110+
111+
private
112+
113+
def normalize_distance(distance_km)
114+
if distance_km.is_a?(String) || distance_km.is_a?(Symbol)
115+
key = distance_km.to_s.strip.downcase
116+
return RACE_TO_METERS.fetch(key) if RACE_TO_METERS.key?(key)
117+
118+
raise ArgumentError,
119+
"Unsupported race '#{distance_km}'. Supported: #{RACE_TO_METERS.keys.join(', ')}"
120+
end
121+
122+
distance = distance_km.to_f
123+
check_positive(distance, 'Distance')
124+
125+
match = SUPPORTED_DISTANCES_KM.find { |value| (distance - value).abs <= 0.001 }
126+
return DISTANCE_TO_METERS.fetch(match) if match
127+
128+
raise ArgumentError,
129+
"Unsupported distance #{distance_km}km. Supported: #{SUPPORTED_DISTANCES_KM.join(', ')}"
130+
end
131+
132+
def parse_time_seconds(time)
133+
return time.to_f if time.is_a?(Numeric)
134+
135+
check_time(time.to_s)
136+
convert_to_seconds(time.to_s)
137+
end
138+
139+
def normalize_age(age)
140+
age_value = Integer(age)
141+
rescue ArgumentError, TypeError
142+
raise ArgumentError, 'Age must be an integer greater than or equal to 18'
143+
else
144+
raise ArgumentError, 'Age must be at least 18' if age_value < 18
145+
146+
age_value
147+
end
148+
149+
def normalize_sex(sex)
150+
normalized = sex.to_s.strip.downcase.to_sym
151+
return normalized if %i[male female].include?(normalized)
152+
153+
raise ArgumentError, "Sex must be 'male' or 'female'"
154+
end
155+
156+
def interpolated_factor(sex, age, distance_m)
157+
table = factor_table(sex, distance_m)
158+
ages = table.keys.map(&:to_i).sort
159+
160+
return table.fetch(ages.first).to_f if age <= ages.first
161+
return table.fetch(ages.last).to_f if age >= ages.last
162+
163+
lower_age, upper_age = neighboring_ages(ages, age)
164+
return table.fetch(lower_age).to_f if lower_age == upper_age
165+
166+
interpolated_value(table, lower_age, upper_age, age)
167+
end
168+
169+
def factor_table(sex, distance_m)
170+
WMA_DATA.fetch(sex_key(sex)).fetch(distance_m)
171+
end
172+
173+
def sex_key(sex)
174+
sex == :male ? 'M' : 'F'
175+
end
176+
177+
def open_standard_seconds(sex, distance_m)
178+
OPEN_STANDARDS_DATA.fetch('open_standards_seconds').fetch(sex_key(sex)).fetch(distance_m).to_f
179+
end
180+
181+
def round_up_hundredth(value)
182+
(value * 100.0).ceil / 100.0
183+
end
184+
185+
def neighboring_ages(ages, age)
186+
lower_age = ages.select { |value| value <= age }.max
187+
upper_age = ages.select { |value| value >= age }.min
188+
[lower_age, upper_age]
189+
end
190+
191+
def interpolated_value(table, lower_age, upper_age, age)
192+
lower_factor = table.fetch(lower_age).to_f
193+
upper_factor = table.fetch(upper_age).to_f
194+
ratio = (age - lower_age).to_f / (upper_age - lower_age)
195+
lower_factor + ((upper_factor - lower_factor) * ratio)
196+
end
197+
end
198+
# rubocop:enable Metrics/ModuleLength
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
meta:
2+
source: "WMA/Masters Rankings age grading tables (2023)"
3+
url: "https://howardgrubb.co.uk/athletics/wmatnf23.html"
4+
table_version: "WMA_2023_ONE_YEAR_FACTORS_V1"
5+
6+
age_grade_classifications:
7+
- min: 100.0
8+
label: "Approximate World Record Level"
9+
- min: 90.0
10+
label: "World Class"
11+
- min: 80.0
12+
label: "National Class"
13+
- min: 70.0
14+
label: "Regional Class"
15+
- min: 60.0
16+
label: "Local Class"
17+
- min: 0.0
18+
label: "Developing"
19+
20+
open_standards_seconds:
21+
M:
22+
"5000": 755.0
23+
"10000": 1571.0
24+
"21097": 3451.0
25+
"42195": 7269.0
26+
F:
27+
"5000": 846.0
28+
"10000": 1741.0
29+
"21097": 3772.0
30+
"42195": 8044.0

0 commit comments

Comments
 (0)