Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ Metrics/AbcSize:
Exclude:
- 'test/**/*'

# Keyword arguments are self-documenting, so the ceiling is a little higher than
# the default 5 — but it stays a ceiling: kwargs still count, so a method growing
# to eight or ten of them is flagged.
Metrics/ParameterLists:
Max: 6

Metrics/BlockLength:
Exclude:
- 'test/**/*'
Expand Down
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.11.0] - 2026-07-25

### Added
- Training zones improvements
- `training_paces` and `training_paces_from_race` accept `unit: :mi` for
pace bands per mile (default remains `:km`)
- `hr_zones_from_max(hr_max:)`: five heart-rate zones from maximum heart
rate only (%HRmax method) — fallback when resting heart rate is unknown
- `training_paces_from_race` accepts standard race names ('10k', 'marathon',
'5mile', ...) in addition to numeric kilometres, matching `predict_time`
and `race_pace`
- `distance_unit: :mi` keyword on `estimate_vo2max`, `estimate_detailed_vo2max`,
`age_grade`, `age_grade_percent`, and `training_paces_from_race` — numeric
distance inputs can now be given in miles (default remains kilometres)

### Changed
- `training_paces_from_race` resolves non-numeric distances as race names. Strings
that v1.10.0 silently parsed with `to_f` change meaning: `'5mile'` was 5.0 km and
is now the 5-mile standard distance (8.04672 km). Numeric strings (`'10'`,
`'21.0975'`) keep working as before, in kilometres.
- `training_paces_from_race` with an unparseable distance (`nil`, `'banana'`) now
raises `ArgumentError` ("Unknown race: ...") instead of
`Calcpace::NonPositiveInputError`.
- Unknown `unit:` / `distance_unit:` values raise `Calcpace::UnsupportedUnitError`
(inherits from `Calcpace::Error`) instead of `ArgumentError`. Unit keywords are
now case-insensitive (`'MI'` works) and `nil` raises the same error instead of a
`NoMethodError`.
- `unit: :mi` pace bands are computed natively per mile instead of being converted
from the km bands, so they can differ by ±1 s from `pace_km_to_mi(km_band)` —
the native value is the one without double rounding.
- Every mile-based factor now derives from the exact international mile
(1 mi = 1609.344 m), which was previously truncated to 1.60934 in some places
and exact in others. Affected values move by ~2.5e-6 relative:
`convert(1, :mi_to_km)` 1.60934 → 1.609344, `convert(1, :km_to_mi)` 0.621371 →
0.6213711922…, `convert(1, :mi_to_meters)` 1609.34 → 1609.344, the `mi_h`/`m_s`
speed pairs, and `list_races` entries `'1mile'` (1.609344) and `'10mile'`
(16.09344). Age-grading tolerance and pace bands now agree on mile length.
- Passing `distance_unit:` together with a race name (`training_paces_from_race('10k',
t, distance_unit: :mi)`, `age_grade('10k', …, distance_unit: :mi)`) raises
`ArgumentError` instead of silently ignoring the keyword — a standard race already
carries its own distance.
- Race-name lookup is normalized in one place: `' 10K '` and `:MARATHON` now resolve
everywhere (previously `PaceCalculator` did not strip whitespace), and `AgeGrading`
uses the same "Unknown race: …" message wording as the rest of the gem.

### Fixed
- Age grading accepts mile distances as runners write them (`3.1`, `6.2`, `13.1`,
`26.2` with `distance_unit: :mi`); the previous 0.001 km match window only
accepted 6-decimal conversions.
- Unsupported age-grading distances report the input in the unit it was given
instead of always labelling it "km".
- `estimate_detailed_vo2max` rejects a non-positive distance even when
`elevation_gain_m` is positive (the elevation adjustment used to mask it).

## [1.10.0] - 2026-07-11

### Added
Expand Down
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ calc.predict_time_cameron_adjusted('10k', '00:40:00', 'marathon', temperature: 8
30+ units supported. String or symbol format:

```ruby
calc.convert(10, :km_to_mi) # => 6.21371
calc.convert(10, 'mi to km') # => 16.0934
calc.convert(10, :km_to_mi) # => 6.213711922...
calc.convert(10, 'mi to km') # => 16.09344
calc.convert(1, :m_s_to_km_h) # => 3.6

# Chain conversions
Expand Down Expand Up @@ -169,6 +169,7 @@ age factors and open standards.

```ruby
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: 64.6,
# category: "Local Class",
Expand Down Expand Up @@ -208,6 +209,7 @@ Estimate aerobic fitness from a race result using the **Daniels & Gilbert formul
calc.estimate_vo2max(10.0, '00:40:00') # => 51.9 ml/kg/min
calc.estimate_vo2max(42.195, '03:30:00') # => 44.8
calc.estimate_vo2max(5.0, 2400) # also accepts total seconds
calc.estimate_vo2max(6.21371, '00:40:00', distance_unit: :mi) # => 51.9 (miles input)

calc.vo2max_label(51.9) # => "Very Good"
```
Expand Down Expand Up @@ -285,10 +287,18 @@ zones = calc.training_paces(50.0)
zones[:threshold].fast_clock # => "00:04:15" per km
zones[:easy].slow_clock # => "00:05:52" per km

calc.training_paces_from_race(10.0, '00:40:00') # from a recent race result
calc.training_paces(50.0, unit: :mi)[:threshold].fast_clock # => "00:06:51" per mile

calc.training_paces_from_race(10.0, '00:40:00') # from a recent race result
calc.training_paces_from_race('5mile', '00:35:00', unit: :mi) # race names work too
calc.training_paces_from_race(6.2, '00:40:00', distance_unit: :mi, unit: :mi) # race distance in miles

calc.hr_zones(hr_max: 190, hr_rest: 55)
# => [#<struct zone=1, min_bpm=123, max_bpm=136>, ... zone=5, max_bpm=190]

calc.hr_zones_from_max(hr_max: 190)
# => [#<struct zone=1, min_bpm=95, max_bpm=114>, ... zone=5, max_bpm=190]
# %HRmax fallback — prefer hr_zones (Karvonen) when resting HR is known
```

| Zone | %VO2max | Purpose |
Expand All @@ -302,6 +312,15 @@ calc.hr_zones(hr_max: 190, hr_rest: 55)
Pace accuracy vs published VDOT tables: within a few seconds per km
(threshold matches exactly; easy band is a range heuristic).

`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
`ArgumentError` — `'10k'` already carries its own distance. Mile bands are computed
natively (not converted from the km bands), so they can differ by ±1 s from
`pace_km_to_mi(km_band)`.

All mile factors derive from the exact international mile (1609.344 m), so distances,
pace bands, and age-grading tolerances agree to the metre.

---

### Other Utilities
Expand All @@ -320,6 +339,11 @@ 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::UnsupportedUnitError` — unknown conversion (`convert`) or unknown
`unit:` / `distance_unit:` keyword

Argument validation that is not about units or numbers raises a plain `ArgumentError`:
unknown race names, unsupported age-grading distances, and invalid `age` / `sex` values.

---

Expand Down
56 changes: 39 additions & 17 deletions lib/calcpace/age_grading.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,22 @@ module AgeGrading

# Returns a full age-grading report for a race performance
#
# @param distance_km [Numeric, String, Symbol] race distance in kilometres
# (5.0, 10.0, 21.0975, 42.195) or race key (:5k, :10k, :half_marathon, :marathon)
# @param distance [Numeric, String, Symbol] race distance in kilometres
# (5.0, 10.0, 21.0975, 42.195) or race key (:5k, :10k, :half_marathon, :marathon);
# numeric input can also be given in miles via distance_unit: :mi
# @param time [String, Numeric] performance time as HH:MM:SS / MM:SS, or total seconds
# @param age [Integer] athlete age (must be >= 18)
# @param sex [String, Symbol] male or female
# @param distance_unit [Symbol] unit of a numeric distance input — :km (default) or :mi.
# Rejected when distance is a race key: standard races already carry their own
# distance, so the combination is always a caller mistake
# @return [Hash] age-grading result details
def age_grade(distance_km, time, age:, sex:)
distance_m = normalize_distance(distance_km)
# @raise [ArgumentError] if the distance, race key, age or sex is not supported,
# or if distance_unit is combined with a race key
# @raise [Calcpace::UnsupportedUnitError] if distance_unit is not :km or :mi
# @raise [Calcpace::InvalidTimeFormatError] if time string is malformed
def age_grade(distance, time, age:, sex:, distance_unit: nil)
distance_m = normalize_distance(distance, distance_unit)
seconds = parse_time_seconds(time)
age_value = normalize_age(age)
sex_value = normalize_sex(sex)
Expand All @@ -83,13 +91,15 @@ def age_grade(distance_km, time, age:, sex:)

# Returns only the age-grade percentage
#
# @param distance_km [Numeric] race distance in kilometres
# @param distance [Numeric, String, Symbol] race distance in kilometres or race key
# @param time [String, Numeric] performance time
# @param age [Integer] athlete age
# @param sex [String, Symbol] male or female
# @param distance_unit [Symbol] unit of a numeric distance input — :km (default) or :mi
# (rejected alongside race keys, see #age_grade)
# @return [Float] age-grade percentage
def age_grade_percent(distance_km, time, age:, sex:)
age_grade(distance_km, time, age: age, sex: sex)[:age_grade_percent]
def age_grade_percent(distance, time, age:, sex:, distance_unit: nil)
age_grade(distance, time, age: age, sex: sex, distance_unit: distance_unit)[:age_grade_percent]
end

# Returns a descriptive label for an age-grade percentage
Expand All @@ -110,23 +120,35 @@ def age_grade_label(percent)

private

def normalize_distance(distance_km)
if distance_km.is_a?(String) || distance_km.is_a?(Symbol)
key = distance_km.to_s.strip.downcase
return RACE_TO_METERS.fetch(key) if RACE_TO_METERS.key?(key)

raise ArgumentError,
"Unsupported race '#{distance_km}'. Supported: #{RACE_TO_METERS.keys.join(', ')}"
def normalize_distance(distance_input, distance_unit = nil)
if distance_input.is_a?(String) || distance_input.is_a?(Symbol)
reject_distance_unit_with_race_name!(distance_unit, distance_input)
return race_key_to_meters(distance_input)
end

distance = distance_km.to_f
distance = normalize_distance_km(distance_input, distance_unit || :km)
check_positive(distance, 'Distance')

match = SUPPORTED_DISTANCES_KM.find { |value| (distance - value).abs <= 0.001 }
match = SUPPORTED_DISTANCES_KM.find { |value| standard_distance?(distance, value) }
return DISTANCE_TO_METERS.fetch(match) if match

raise ArgumentError,
"Unsupported distance #{distance_km}km. Supported: #{SUPPORTED_DISTANCES_KM.join(', ')}"
"Unsupported distance #{distance_input}#{(distance_unit || :km).to_s.downcase}. " \
"Supported: #{SUPPORTED_DISTANCES_KM.join(', ')} km"
end

def race_key_to_meters(race_input)
# normalize_race_key is PaceCalculator's — one lookup convention gem-wide
RACE_TO_METERS.fetch(normalize_race_key(race_input)) do
raise ArgumentError,
"Unknown race: #{race_input}. Available races: #{RACE_TO_METERS.keys.join(', ')}"
end
end

# Runners write rounded distances (3.1 mi, 13.1 mi, 26.2 mi), so the match
# window is relative — 0.5% of the standard distance, never below 1 metre
def standard_distance?(distance, standard)
(distance - standard).abs <= [0.001, standard * 0.005].max
end

def parse_time_seconds(time)
Expand Down
57 changes: 44 additions & 13 deletions lib/calcpace/converter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@
# speed units (m/s, km/h, mi/h, knots, etc.).
module Converter
module Distance
KM_TO_MI = 0.621371
MI_TO_KM = 1.60934
# One international mile is exactly 1609.344 m. Every mile-based factor
# derives from this single value so that no two call sites — a pace band, a
# distance conversion, an age-grading tolerance — can disagree about how
# long a mile is.
MI_TO_KM = 1.609344
KM_TO_MI = 1 / MI_TO_KM
NAUTICAL_MI_TO_KM = 1.852
KM_TO_NAUTICAL_MI = 0.539957
METERS_TO_KM = 0.001
KM_TO_METERS = 1000
METERS_TO_MI = 0.000621371
MI_TO_METERS = 1609.34
MI_TO_METERS = MI_TO_KM * 1000
METERS_TO_MI = 1 / MI_TO_METERS
METERS_TO_FEET = 3.28084
FEET_TO_METERS = 0.3048
METERS_TO_YARDS = 1.09361
Expand All @@ -28,30 +32,30 @@ module Distance
KM_TO_INCHES = 39_370.1
INCHES_TO_KM = 0.0000254
MI_TO_YARDS = 1760
YARDS_TO_MI = 0.000568182
YARDS_TO_MI = 1.0 / MI_TO_YARDS
MI_TO_FEET = 5280
FEET_TO_MI = 0.000189394
FEET_TO_MI = 1.0 / MI_TO_FEET
MI_TO_INCHES = 63_360
INCHES_TO_MI = 0.0000157828
INCHES_TO_MI = 1.0 / MI_TO_INCHES
end

module Speed
M_S_TO_KM_H = 3.6
KM_H_TO_M_S = 0.277778
M_S_TO_MI_H = 2.23694
MI_H_TO_M_S = 0.44704
M_S_TO_MI_H = 3.6 / Distance::MI_TO_KM
MI_H_TO_M_S = Distance::MI_TO_METERS / 3600
M_S_TO_NAUTICAL_MI_H = 1.94384
NAUTICAL_MI_H_TO_M_S = 0.514444
M_S_TO_FEET_S = 3.28084
FEET_S_TO_M_S = 0.3048
M_S_TO_KNOTS = 1.94384
KNOTS_TO_M_S = 0.514444
KM_H_TO_MI_H = 0.621371
MI_H_TO_KM_H = 1.60934
KM_H_TO_MI_H = Distance::KM_TO_MI
MI_H_TO_KM_H = Distance::MI_TO_KM
KM_H_TO_NAUTICAL_MI_H = 0.539957
NAUTICAL_MI_H_TO_KM_H = 1.852
MI_H_TO_NAUTICAL_MI_H = 0.868976
NAUTICAL_MI_H_TO_MI_H = 1.15078
MI_H_TO_NAUTICAL_MI_H = Distance::MI_TO_KM / Distance::NAUTICAL_MI_TO_KM
NAUTICAL_MI_H_TO_MI_H = Distance::NAUTICAL_MI_TO_KM / Distance::MI_TO_KM
end

# Converts a value from one unit to another
Expand Down Expand Up @@ -139,8 +143,35 @@ def list_distance
format_list(Distance.constants)
end

# Multipliers from a supported distance-input unit to kilometres
# (used by methods that accept a distance_unit: keyword)
DISTANCE_UNIT_TO_KM = { km: 1.0, mi: Distance::MI_TO_KM }.freeze

private

# Guards the "race name + distance_unit" combination. A standard race already
# carries its own distance, so the keyword can only be a caller mistake —
# better to say so than to ignore it silently.
#
# @raise [ArgumentError] if a distance_unit was given alongside a race name
def reject_distance_unit_with_race_name!(distance_unit, race)
return if distance_unit.nil?

raise ArgumentError,
"distance_unit: #{distance_unit.inspect} cannot be combined with the race name " \
"#{race.inspect} — a standard race already carries its own distance"
end

# Normalizes a numeric distance input to kilometres
#
# @raise [Calcpace::UnsupportedUnitError] if distance_unit is not :km or :mi
def normalize_distance_km(value, distance_unit)
factor = DISTANCE_UNIT_TO_KM.fetch(distance_unit.to_s.downcase.to_sym) do
raise Calcpace::UnsupportedUnitError.new(distance_unit, supported: DISTANCE_UNIT_TO_KM.keys)
end
value.to_f * factor
end

def format_unit(unit)
unit.downcase.gsub(' ', '_').to_sym
end
Expand Down
24 changes: 20 additions & 4 deletions lib/calcpace/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,27 @@ def initialize(msg = 'Input must be a positive number.')
end
end

# Raised when an unsupported unit conversion is requested
# Raised when an unsupported unit or unit conversion is requested
#
# @example conversion pair
# raise UnsupportedUnitError, :km_to_furlong
# @example single unit, with the supported ones listed
# raise UnsupportedUnitError.new(:furlong, supported: %i[km mi])
class UnsupportedUnitError < Error
def initialize(unit = nil)
msg = unit ? "Unsupported unit conversion: #{unit}" : 'Unsupported unit conversion'
super(msg)
def initialize(unit = nil, supported: nil)
super(build_message(unit, supported))
end

private

def build_message(unit, supported)
return conversion_message(unit) unless supported

"Unsupported unit: #{unit.inspect}. Supported units: #{supported.map(&:inspect).join(', ')}"
end

def conversion_message(unit)
unit ? "Unsupported unit conversion: #{unit}" : 'Unsupported unit conversion'
end
end
end
Loading
Loading