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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.10.0] - 2026-07-11

### Added
- Training Zones module (`TrainingZones`)
- `training_paces(vo2max)`: personalized Easy/Marathon/Threshold/Interval/Repetition
pace bands per km, inverting the Daniels & Gilbert velocity equation
- `training_paces_from_race(distance_km, time)`: pace bands straight from a race result
- `hr_zones(hr_max:, hr_rest:)`: five Karvonen (Heart Rate Reserve) heart-rate zones
- Structured results (`PaceBand`, `HrZone`) with seconds and clock formats

## [1.9.10] - 2026-07-09

### Changed
Expand Down Expand Up @@ -205,7 +215,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

See git history for changes in earlier versions.

[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.9.6...HEAD
[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.10.0...HEAD
[1.10.0]: https://github.com/0jonjo/calcpace/compare/v1.9.10...v1.10.0
[1.9.6]: https://github.com/0jonjo/calcpace/compare/v1.9.5...v1.9.6
[1.9.5]: https://github.com/0jonjo/calcpace/compare/v1.9.4...v1.9.5
[1.6.0]: https://github.com/0jonjo/calcpace/releases/tag/v1.6.0
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Calcpace [![Gem Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=rb&r=r&ts=1683906897&type=6e&v=1.9.8&x2=0)](https://badge.fury.io/rb/calcpace)
# Calcpace [![Gem Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=rb&r=r&ts=1683906897&type=6e&v=1.10.0&x2=0)](https://badge.fury.io/rb/calcpace)

A Ruby gem for running and cycling calculations: pace, time, distance, unit conversions, race predictions, GPS track analysis, and VO2max estimation.
A Ruby gem for runners: pace, time, and distance calculations, unit conversions, race predictions, GPS track analysis, age grading, VO2max estimation, and training zones.

## Installation

```ruby
gem 'calcpace', '~> 1.9.8'
gem 'calcpace', '~> 1.10.0'
```

## Usage
Expand Down Expand Up @@ -276,6 +276,34 @@ easy.value # => 29.3 (underestimates real aerobic capacity)

---

### Training Zones

Personalized training paces (Daniels' Running Formula) and Karvonen heart-rate zones:

```ruby
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.hr_zones(hr_max: 190, hr_rest: 55)
# => [#<struct zone=1, min_bpm=123, max_bpm=136>, ... zone=5, max_bpm=190]
```

| Zone | %VO2max | Purpose |
|------|---------|---------|
| Easy | 59–74% | Base building, recovery |
| Marathon | 75–84% | 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).

---

### Other Utilities

```ruby
Expand Down
9 changes: 5 additions & 4 deletions calcpace.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ Gem::Specification.new do |spec|
spec.authors = ['João Gilberto Saraiva']
spec.email = ['joaogilberto@tuta.io']

spec.summary = 'Pace, distance, GPS track analysis, and VO2max calculations for runners and cyclists.'
spec.description = 'Ruby gem for running and cycling calculations: pace, time, distance, ' \
'unit conversions (30+ units), race predictions (Riegel & Cameron), ' \
spec.summary = 'Running calculations: pace, race predictions, GPS track analysis, 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), ' \
'and VO2max estimation (Daniels & Gilbert).'
'age grading (WMA 2023), VO2max estimation (Daniels & Gilbert), 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'
Expand Down
2 changes: 2 additions & 0 deletions lib/calcpace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
require_relative 'calcpace/race_predictor'
require_relative 'calcpace/race_splits'
require_relative 'calcpace/track_calculator'
require_relative 'calcpace/training_zones'
require_relative 'calcpace/vo2max_estimator'

# Calcpace - A Ruby gem for pace, distance, and time calculations
Expand Down Expand Up @@ -49,6 +50,7 @@ class Calcpace
include RacePredictor
include RaceSplits
include TrackCalculator
include TrainingZones
include Vo2maxEstimator

# Creates a new Calcpace instance
Expand Down
123 changes: 123 additions & 0 deletions lib/calcpace/training_zones.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# frozen_string_literal: true

# Module for deriving personalized training zones from VO2max
#
# Training paces invert the Daniels & Gilbert (1979) velocity equation:
# VO2 = -4.60 + 0.182258 * v + 0.000104 * v² (v in m/min)
# Solving the quadratic for v at a target %VO2max gives the running
# velocity for each training intensity (Daniels' Running Formula).
#
# Heart rate zones use the Karvonen method (Heart Rate Reserve):
# target = hr_rest + pct * (hr_max - hr_rest)
module TrainingZones
# Training intensities as fraction of VO2max (Daniels' Running Formula)
TRAINING_INTENSITIES = {
easy: { low: 0.59, high: 0.74 },
marathon: { low: 0.75, high: 0.84 },
threshold: { low: 0.83, high: 0.88 },
interval: { low: 0.95, high: 1.00 },
repetition: { low: 1.05, high: 1.10 }
}.freeze

# A pace band for one training zone (paces per kilometre).
# slow = lower-intensity end of the band, fast = higher-intensity end.
PaceBand = Struct.new(:slow_seconds, :fast_seconds, :slow_clock, :fast_clock)

# Heart-rate zone boundaries as fractions of Heart Rate Reserve (Karvonen)
HR_ZONE_BOUNDARIES = [0.50, 0.60, 0.70, 0.80, 0.90, 1.00].freeze

# One heart-rate training zone (1 = recovery … 5 = maximal)
HrZone = Struct.new(:zone, :min_bpm, :max_bpm)

# Derives training pace bands from a VO2max value
#
# @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0)
# @return [Hash{Symbol => PaceBand}] keys: :easy, :marathon, :threshold,
# :interval, :repetition — paces per km
# @raise [Calcpace::NonPositiveInputError] if vo2max is not positive
#
# @example
# calc.training_paces(50.0)[:threshold].fast_clock #=> "00:04:15"
def training_paces(vo2max)
check_positive(vo2max.to_f, 'VO2max')

TRAINING_INTENSITIES.transform_values do |band|
slow = pace_seconds_at_pct(vo2max.to_f, band[:low])
fast = pace_seconds_at_pct(vo2max.to_f, band[:high])

PaceBand.new(
slow_seconds: slow,
fast_seconds: fast,
slow_clock: convert_to_clocktime(slow),
fast_clock: convert_to_clocktime(fast)
)
end
end

# Derives training pace bands from a recent race result
#
# Convenience wrapper: estimates VO2max via Daniels & Gilbert
# (see Vo2maxEstimator#estimate_vo2max) and derives the bands from it.
#
# @param distance_km [Numeric] race distance in kilometres (must be > 0)
# @param time [String, Integer] finish time as "HH:MM:SS" / "MM:SS" or total seconds
# @return [Hash{Symbol => PaceBand}] same shape as #training_paces
# @raise [Calcpace::NonPositiveInputError] if distance or time are not positive
# @raise [Calcpace::InvalidTimeFormatError] if time string is malformed
#
# @example
# calc.training_paces_from_race(10.0, '00:40:00')[:easy].slow_clock #=> "00:05:42"
def training_paces_from_race(distance_km, time)
training_paces(estimate_vo2max(distance_km, time))
end

# Computes the five Karvonen heart-rate training zones
#
# target_bpm = hr_rest + pct * (hr_max - hr_rest)
#
# @param hr_max [Numeric] maximum heart rate in bpm (must be > 0)
# @param hr_rest [Numeric] resting heart rate in bpm (must be > 0 and < hr_max)
# @return [Array<HrZone>] five contiguous zones from Z1 (50–60% HRR) to Z5 (90–100% HRR)
# @raise [Calcpace::NonPositiveInputError] if any rate is not positive
# @raise [Calcpace::Error] if hr_rest >= hr_max
#
# @example
# calc.hr_zones(hr_max: 190, hr_rest: 55).last.max_bpm #=> 190
def hr_zones(hr_max:, hr_rest:)
max = hr_max.to_f
rest = hr_rest.to_f
check_heart_rates(max, rest)

reserve = max - rest
points = HR_ZONE_BOUNDARIES.map { |pct| (rest + (pct * reserve)).round }

points.each_cons(2).with_index(1).map do |(min_bpm, max_bpm), zone|
HrZone.new(zone: zone, min_bpm: min_bpm, max_bpm: max_bpm)
end
end

private

def check_heart_rates(hr_max, hr_rest)
check_positive(hr_max, 'Maximum heart rate')
check_positive(hr_rest, 'Resting heart rate')
return if hr_rest < hr_max

raise Calcpace::Error,
"Resting heart rate (#{hr_rest}) must be lower than maximum heart rate (#{hr_max})"
end

# Inverts Daniels & Gilbert: velocity (m/min) that demands a given VO2
def velocity_at_vo2(vo2)
a = 0.000104
b = 0.182258
c = -(4.60 + vo2)

(-b + Math.sqrt((b**2) - (4 * a * c))) / (2 * a)
end

def pace_seconds_at_pct(vo2max, pct)
velocity = velocity_at_vo2(vo2max * pct)
(60_000.0 / velocity).round
end
end
2 changes: 1 addition & 1 deletion lib/calcpace/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

class Calcpace
VERSION = '1.9.10'
VERSION = '1.10.0'
end
123 changes: 123 additions & 0 deletions test/calcpace/test_training_zones.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# frozen_string_literal: true

require_relative '../test_helper'

# Tests for TrainingZones module — Daniels training paces and Karvonen HR zones
class TestTrainingZones < CalcpaceTest
# --- training_paces ---
def test_training_paces_returns_all_five_zones
zones = @calc.training_paces(50.0)

assert_equal %i[easy marathon threshold interval repetition], zones.keys
end

def test_threshold_fast_pace_matches_daniels_vdot_table
zones = @calc.training_paces(50.0)

# VDOT 50 → T-pace 04:15/km in Daniels' official table
assert_in_delta 255, zones[:threshold].fast_seconds, 2
assert_equal '00:04:15', zones[:threshold].fast_clock
end

def test_easy_band_for_vo2max_fifty
zones = @calc.training_paces(50.0)

assert_in_delta 352, zones[:easy].slow_seconds, 2 # ~05:52/km (59% VO2max)
assert_in_delta 294, zones[:easy].fast_seconds, 2 # ~04:54/km (74% VO2max)
end

def test_interval_band_for_vo2max_fifty
zones = @calc.training_paces(50.0)

assert_in_delta 240, zones[:interval].slow_seconds, 2 # 95% → ~04:00/km
assert_in_delta 230, zones[:interval].fast_seconds, 2 # 100% → ~03:50/km
end

def test_repetition_band_for_vo2max_fifty
zones = @calc.training_paces(50.0)

assert_in_delta 221, zones[:repetition].slow_seconds, 2 # 105% → ~03:41/km
assert_in_delta 213, zones[:repetition].fast_seconds, 2 # 110% → ~03:33/km
end

def test_higher_vo2max_yields_faster_paces
slower = @calc.training_paces(40.0)
faster = @calc.training_paces(60.0)

assert_operator faster[:easy].slow_seconds, :<, slower[:easy].slow_seconds
assert_operator faster[:interval].fast_seconds, :<, slower[:interval].fast_seconds
end

def test_slow_is_always_slower_than_fast_within_each_band
@calc.training_paces(50.0).each_value do |band|
assert_operator band.slow_seconds, :>, band.fast_seconds
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) }
end

# --- training_paces_from_race ---
def test_training_paces_from_race_delegates_to_vo2max_estimation
# 10k in 40:00 → VO2max 51.9 (value already validated in test_vo2max_estimator.rb)
from_race = @calc.training_paces_from_race(10.0, '00:40:00')
from_vo2 = @calc.training_paces(51.9)

assert_equal from_vo2[:threshold].fast_seconds, from_race[:threshold].fast_seconds
assert_equal from_vo2[:easy].slow_clock, from_race[:easy].slow_clock
end

def test_training_paces_from_race_accepts_seconds_input
from_clock = @calc.training_paces_from_race(10.0, '00:40:00')
from_seconds = @calc.training_paces_from_race(10.0, 2400)

assert_equal from_clock[:interval].fast_seconds, from_seconds[:interval].fast_seconds
end

def test_training_paces_from_race_propagates_input_errors
assert_raises(Calcpace::NonPositiveInputError) do
@calc.training_paces_from_race(0, '00:40:00')
end
assert_raises(Calcpace::InvalidTimeFormatError) do
@calc.training_paces_from_race(10.0, 'banana')
end
end

# --- hr_zones ---
def test_hr_zones_returns_five_karvonen_zones
zones = @calc.hr_zones(hr_max: 190, hr_rest: 55)

assert_equal 5, zones.size
assert_equal (1..5).to_a, zones.map(&:zone)
end

def test_hr_zones_karvonen_values
zones = @calc.hr_zones(hr_max: 190, hr_rest: 55) # reserve = 135

assert_equal 123, zones[0].min_bpm # 55 + 0.50*135 = 122.5 → 123
assert_equal 136, zones[0].max_bpm # 55 + 0.60*135
assert_equal 163, zones[3].min_bpm # 55 + 0.80*135
assert_equal 177, zones[3].max_bpm # 55 + 0.90*135 = 176.5 → 177
assert_equal 190, zones[4].max_bpm # Z5 ends at max heart rate
end

def test_hr_zones_are_contiguous
zones = @calc.hr_zones(hr_max: 185, hr_rest: 60)

zones.each_cons(2) do |prev, nxt|
assert_equal prev.max_bpm, nxt.min_bpm
end
end

def test_hr_zones_rejects_rest_greater_or_equal_to_max
error = assert_raises(Calcpace::Error) { @calc.hr_zones(hr_max: 150, hr_rest: 150) }
assert_match(/resting heart rate/i, error.message)
end

def test_hr_zones_rejects_non_positive_values
assert_raises(Calcpace::NonPositiveInputError) { @calc.hr_zones(hr_max: 0, hr_rest: 55) }
assert_raises(Calcpace::NonPositiveInputError) { @calc.hr_zones(hr_max: 190, hr_rest: -5) }
end
end
Loading