|
| 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 |
0 commit comments