calcpace 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -1
- data/README.md +29 -1
- data/calcpace.gemspec +1 -1
- data/lib/calcpace/errors.rb +7 -0
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace/vo2max_effort.rb +43 -0
- data/lib/calcpace/vo2max_estimator.rb +32 -12
- data/lib/calcpace.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25afa206e47316900eab46723efcccac5586d0026dbdd81316566798ffff58c3
|
|
4
|
+
data.tar.gz: e86c8acacd4cda1bdc4c9036bad53249029b285d6fd7a0ed467d8f67534c172d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 220badb3ce50b0ca0cd3ad4e386944b58f09586ab5fb1b0d50cbc961d2cd9705c1a04c98c4b0a68c0bfa371557b2a43bb831a4441b6697240dfedf12a87e1d24
|
|
7
|
+
data.tar.gz: e3458ad4525014a92bb3ca2c923df3e224f5cec0fb06c5957b6da34d675f1ff3a507ffb96bfc7a0293e72c9710a7a96086685cad1bf005adf4a50f412a1d95b5
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.1.0] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`effort:` in `estimate_detailed_vo2max`** — how hard the runner says it
|
|
14
|
+
was, one of `EFFORT_LEVELS` (`:easy`, `:moderate`, `:hard`, `:max`; Symbol
|
|
15
|
+
or String, any case). The value is never changed; only how far it can be
|
|
16
|
+
trusted. `:easy`/`:moderate` → sub-maximal and `:low` whatever the duration
|
|
17
|
+
or heart rate; `:max` → `:high` from five minutes on however long it took,
|
|
18
|
+
unless average heart rate is under 85 % of max; `:hard` and `nil` → the
|
|
19
|
+
2.0.0 rules. An unknown effort raises `Calcpace::InvalidEffortError`.
|
|
20
|
+
- **`Vo2maxResult#basis`** — what set the confidence: `:declared`,
|
|
21
|
+
`:heart_rate` or `:duration`. Added as the last member, so positional
|
|
22
|
+
construction and reading the four existing members are unchanged. It is
|
|
23
|
+
always set, so it does show up in `==` (a result no longer equals a
|
|
24
|
+
four-member `Vo2maxResult.new(...)`), in `to_a`/`to_h` (five entries) and in
|
|
25
|
+
array patterns (`in [value, confidence, sub_maximal, adjusted]` no longer
|
|
26
|
+
matches; use a hash pattern or add a fifth element).
|
|
27
|
+
|
|
28
|
+
Without `effort:` every result is the same as in 2.0.0.
|
|
29
|
+
|
|
10
30
|
## [2.0.0] - 2026-10-02
|
|
11
31
|
|
|
12
32
|
A major release. Four physiological models gave unrealistic numbers and now
|
|
@@ -1001,7 +1021,8 @@ predictors are untouched.
|
|
|
1001
1021
|
|
|
1002
1022
|
See git history for changes in earlier versions.
|
|
1003
1023
|
|
|
1004
|
-
[Unreleased]: https://github.com/0jonjo/calcpace/compare/v2.
|
|
1024
|
+
[Unreleased]: https://github.com/0jonjo/calcpace/compare/v2.1.0...HEAD
|
|
1025
|
+
[2.1.0]: https://github.com/0jonjo/calcpace/compare/v2.0.0...v2.1.0
|
|
1005
1026
|
[2.0.0]: https://github.com/0jonjo/calcpace/compare/v1.18.1...v2.0.0
|
|
1006
1027
|
[1.18.1]: https://github.com/0jonjo/calcpace/compare/v1.18.0...v1.18.1
|
|
1007
1028
|
[1.18.0]: https://github.com/0jonjo/calcpace/compare/v1.17.0...v1.18.0
|
data/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<p align="center"><img src="https://raw.githubusercontent.com/0jonjo/calcpace/main/docs/logo.svg" alt="calcpace logo" width="128"></p>
|
|
2
|
+
|
|
1
3
|
# Calcpace [](https://badge.fury.io/rb/calcpace)
|
|
2
4
|
|
|
3
5
|
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.
|
|
@@ -7,7 +9,7 @@ A Ruby gem for runners: pace, time, and distance calculations, unit conversions,
|
|
|
7
9
|
## Installation
|
|
8
10
|
|
|
9
11
|
```ruby
|
|
10
|
-
gem 'calcpace', '~> 2.
|
|
12
|
+
gem 'calcpace', '~> 2.1'
|
|
11
13
|
```
|
|
12
14
|
|
|
13
15
|
## Usage
|
|
@@ -643,6 +645,32 @@ easy.value # => 29.3 (underestimates real aerobic capacity)
|
|
|
643
645
|
> If you provide heart rate data, both `hr_avg` and `hr_max` must be present.
|
|
644
646
|
> `elevation_gain_m` must be zero or positive.
|
|
645
647
|
|
|
648
|
+
`result.basis` says what set the confidence: `:duration` (the table above), `:heart_rate` (average HR under 85 % of max) or `:declared` (the `effort:` below).
|
|
649
|
+
|
|
650
|
+
#### Effort
|
|
651
|
+
|
|
652
|
+
The formula assumes an all-out effort, and duration is only a proxy for that. When the runner knows how hard it was, pass `effort:` — one of `Calcpace::EFFORT_LEVELS`, easiest first (a Symbol or a String):
|
|
653
|
+
|
|
654
|
+
| `effort` | How it felt | `confidence` | `sub_maximal` |
|
|
655
|
+
|---|---|---|---|
|
|
656
|
+
| `:easy` | Could talk freely | `:low` | `true` |
|
|
657
|
+
| `:moderate` | Short sentences, still comfortable | `:low` | `true` |
|
|
658
|
+
| `:hard` | A few words at a time | duration and heart-rate rules, as without `effort:` | |
|
|
659
|
+
| `:max` | Everything I had: a race or a test | `:high` from 5 min on, however long it took (unless HR < 85 %) | `false` (unless HR < 85 %) |
|
|
660
|
+
| `nil` (default) | — | exactly as in 2.0.0 | |
|
|
661
|
+
|
|
662
|
+
```ruby
|
|
663
|
+
calc.estimate_detailed_vo2max(42.195, '03:30:00').confidence # => :low (over 2 h)
|
|
664
|
+
calc.estimate_detailed_vo2max(42.195, '03:30:00', effort: :max).confidence # => :high (a race says it was all-out)
|
|
665
|
+
calc.estimate_detailed_vo2max(10.0, '00:55:00', effort: :easy).sub_maximal # => true
|
|
666
|
+
calc.estimate_detailed_vo2max(10.0, '00:55:00', effort: :easy).basis # => :declared
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
- **The value never changes.** An easy run still gives the number the formula gives, which then reads as "at least this much"; only `confidence`, `sub_maximal` and `basis` move.
|
|
670
|
+
- An easy or moderate effort is sub-maximal whatever the heart rate says. Heart rate under 85 % of max still overrules a declared `:max` (`basis: :heart_rate`), and a `:max` under five minutes keeps the duration rule, where the formula itself is unreliable.
|
|
671
|
+
- Without heart rate, `:hard` and `:max` are not sub-maximal: nothing says they were.
|
|
672
|
+
- An unknown effort raises `Calcpace::InvalidEffortError`.
|
|
673
|
+
|
|
646
674
|
---
|
|
647
675
|
|
|
648
676
|
### Training Zones
|
data/calcpace.gemspec
CHANGED
|
@@ -27,7 +27,7 @@ Gem::Specification.new do |spec|
|
|
|
27
27
|
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
28
28
|
|
|
29
29
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
30
|
-
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
30
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|docs)/}) }
|
|
31
31
|
end
|
|
32
32
|
spec.bindir = 'exe'
|
|
33
33
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
data/lib/calcpace/errors.rb
CHANGED
|
@@ -20,6 +20,13 @@ class Calcpace
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Raised when an effort is not one of Vo2maxEffort::EFFORT_LEVELS
|
|
24
|
+
class InvalidEffortError < Error
|
|
25
|
+
def initialize(msg = 'Invalid effort. Valid efforts: :easy, :moderate, :hard, :max')
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
23
30
|
# Raised when versioned/static gem data fails an internal consistency check
|
|
24
31
|
class InvalidDataError < Error; end
|
|
25
32
|
|
data/lib/calcpace/version.rb
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# How hard the runner says an effort was, for Vo2maxEstimator#estimate_detailed_vo2max
|
|
4
|
+
#
|
|
5
|
+
# Daniels & Gilbert assume an all-out effort, and the estimator can only guess
|
|
6
|
+
# that from duration and heart rate. A declared effort answers it directly. It
|
|
7
|
+
# never changes the VO2max value — only its confidence, sub_maximal and basis.
|
|
8
|
+
module Vo2maxEffort
|
|
9
|
+
# From easiest. :easy and :moderate are training (talk test: a full
|
|
10
|
+
# conversation / short sentences), :hard is a few words at a time, :max is
|
|
11
|
+
# everything the runner had — a race or a test.
|
|
12
|
+
EFFORT_LEVELS = %i[easy moderate hard max].freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def normalize_effort(effort)
|
|
17
|
+
return if effort.nil?
|
|
18
|
+
|
|
19
|
+
level = effort.to_s.downcase.to_sym
|
|
20
|
+
return level if EFFORT_LEVELS.include?(level)
|
|
21
|
+
|
|
22
|
+
raise Calcpace::InvalidEffortError,
|
|
23
|
+
"Invalid effort: #{effort.inspect}. Valid efforts: #{EFFORT_LEVELS.map(&:inspect).join(', ')}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A declared easy or moderate effort wins over everything: the runner knows
|
|
27
|
+
# they held back, whatever the watch says. Heart rate wins over a declared
|
|
28
|
+
# max, as it did over the duration rule. A max shorter than five minutes stays
|
|
29
|
+
# with the duration rule: there the formula itself is unreliable.
|
|
30
|
+
#
|
|
31
|
+
# duration_confidence is the estimator's verdict from duration alone.
|
|
32
|
+
def effort_verdict(effort, time_min, duration_confidence:, hr_sub_maximal:)
|
|
33
|
+
if %i[easy moderate].include?(effort)
|
|
34
|
+
{ confidence: :low, sub_maximal: true, basis: :declared }
|
|
35
|
+
elsif hr_sub_maximal
|
|
36
|
+
{ confidence: :low, sub_maximal: true, basis: :heart_rate }
|
|
37
|
+
elsif effort == :max && time_min >= 5
|
|
38
|
+
{ confidence: :high, sub_maximal: false, basis: :declared }
|
|
39
|
+
else
|
|
40
|
+
{ confidence: duration_confidence, sub_maximal: false, basis: :duration }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
# Accuracy: ±3–5 ml/kg/min vs laboratory testing. Best results with efforts
|
|
15
15
|
# between 5 and 60 minutes at race pace (i.e. near-maximal effort).
|
|
16
16
|
module Vo2maxEstimator
|
|
17
|
+
include Vo2maxEffort
|
|
18
|
+
|
|
17
19
|
# Classification thresholds based on:
|
|
18
20
|
# Daniels, J. (2014). Daniels' Running Formula (3rd ed.). Human Kinetics.
|
|
19
21
|
# General ranges are consistent with ACSM guidelines and widely cited in
|
|
@@ -28,7 +30,11 @@ module Vo2maxEstimator
|
|
|
28
30
|
].freeze
|
|
29
31
|
|
|
30
32
|
# Represents a contextualized VO2max estimation result
|
|
31
|
-
|
|
33
|
+
#
|
|
34
|
+
# basis names what set confidence and sub_maximal: :declared (the effort
|
|
35
|
+
# given), :heart_rate (average HR under 85 % of max) or :duration (the
|
|
36
|
+
# Daniels & Gilbert window alone)
|
|
37
|
+
Vo2maxResult = Struct.new(:value, :confidence, :sub_maximal, :adjusted_distance_km, :basis)
|
|
32
38
|
|
|
33
39
|
# Estimates VO2max from a race performance using Daniels & Gilbert formula
|
|
34
40
|
#
|
|
@@ -59,6 +65,10 @@ module Vo2maxEstimator
|
|
|
59
65
|
(vo2 / pct_vo2max).round(1)
|
|
60
66
|
end
|
|
61
67
|
|
|
68
|
+
# The public signature: every input is a keyword a caller already passes, and
|
|
69
|
+
# bundling them into an options object would break the 2.x API.
|
|
70
|
+
# rubocop:disable Metrics/ParameterLists
|
|
71
|
+
|
|
62
72
|
# Estimates a detailed and contextualized VO2max
|
|
63
73
|
#
|
|
64
74
|
# Elevation is folded in with a flat heuristic — every 100 m of gain adds
|
|
@@ -75,11 +85,25 @@ module Vo2maxEstimator
|
|
|
75
85
|
# @param hr_avg [Numeric] average heart rate during the effort
|
|
76
86
|
# @param hr_max [Numeric] athlete's maximum heart rate
|
|
77
87
|
# @param distance_unit [Symbol] unit of the distance input — :km (default) or :mi
|
|
88
|
+
# @param effort [Symbol, String, nil] how hard the runner says it was, one of
|
|
89
|
+
# Vo2maxEffort::EFFORT_LEVELS. It never changes the value, only how far it can be
|
|
90
|
+
# trusted: :easy and :moderate make it sub-maximal and low confidence
|
|
91
|
+
# whatever the duration or heart rate; :max makes it high confidence from
|
|
92
|
+
# five minutes on, however long it took, unless heart rate says
|
|
93
|
+
# sub-maximal; :hard and nil leave the duration and heart-rate rules as
|
|
94
|
+
# they are.
|
|
78
95
|
# @return [Vo2maxResult] structured result with value and metadata
|
|
79
96
|
# (adjusted_distance_km is always in kilometres)
|
|
80
97
|
# @raise [Calcpace::NonPositiveInputError] if distance or time are not positive
|
|
81
98
|
# @raise [Calcpace::UnsupportedUnitError] if distance_unit is not :km or :mi
|
|
82
|
-
|
|
99
|
+
# @raise [Calcpace::InvalidEffortError] if effort is not one of Vo2maxEffort::EFFORT_LEVELS
|
|
100
|
+
#
|
|
101
|
+
# @example a marathon run all-out
|
|
102
|
+
# calc.estimate_detailed_vo2max(42.195, '03:30:00').confidence #=> :low
|
|
103
|
+
# calc.estimate_detailed_vo2max(42.195, '03:30:00', effort: :max).confidence #=> :high
|
|
104
|
+
def estimate_detailed_vo2max(distance, time, elevation_gain_m: 0, hr_avg: nil, hr_max: nil, distance_unit: :km,
|
|
105
|
+
effort: nil)
|
|
106
|
+
effort = normalize_effort(effort)
|
|
83
107
|
distance_km = normalize_distance_km(distance, distance_unit)
|
|
84
108
|
# Validated before the elevation adjustment, which would otherwise turn a
|
|
85
109
|
# negative distance into a positive equivalent-flat one
|
|
@@ -87,18 +111,14 @@ module Vo2maxEstimator
|
|
|
87
111
|
|
|
88
112
|
adj_dist_km = adjusted_distance_for_vo2(distance_km, elevation_gain_m)
|
|
89
113
|
vo2max_val = estimate_vo2max(adj_dist_km, time)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
114
|
+
time_min = parse_time_minutes(time)
|
|
115
|
+
hr_data = validate_and_analyze_hr(hr_avg, hr_max)
|
|
116
|
+
verdict = effort_verdict(effort, time_min, duration_confidence: calculate_time_confidence(time_min),
|
|
117
|
+
hr_sub_maximal: hr_data[:sub_maximal])
|
|
94
118
|
|
|
95
|
-
Vo2maxResult.new(
|
|
96
|
-
value: vo2max_val,
|
|
97
|
-
confidence: confidence,
|
|
98
|
-
sub_maximal: hr_data[:sub_maximal],
|
|
99
|
-
adjusted_distance_km: adj_dist_km.round(2)
|
|
100
|
-
)
|
|
119
|
+
Vo2maxResult.new(value: vo2max_val, adjusted_distance_km: adj_dist_km.round(2), **verdict)
|
|
101
120
|
end
|
|
121
|
+
# rubocop:enable Metrics/ParameterLists
|
|
102
122
|
|
|
103
123
|
# Returns a descriptive label for a given VO2max value
|
|
104
124
|
#
|
data/lib/calcpace.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative 'calcpace/race_splits'
|
|
|
20
20
|
require_relative 'calcpace/stride_calculator'
|
|
21
21
|
require_relative 'calcpace/track_calculator'
|
|
22
22
|
require_relative 'calcpace/training_zones'
|
|
23
|
+
require_relative 'calcpace/vo2max_effort'
|
|
23
24
|
require_relative 'calcpace/vo2max_estimator'
|
|
24
25
|
require_relative 'calcpace/vo2max_norms'
|
|
25
26
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: calcpace
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- João Gilberto Saraiva
|
|
@@ -60,6 +60,7 @@ files:
|
|
|
60
60
|
- lib/calcpace/track_calculator.rb
|
|
61
61
|
- lib/calcpace/training_zones.rb
|
|
62
62
|
- lib/calcpace/version.rb
|
|
63
|
+
- lib/calcpace/vo2max_effort.rb
|
|
63
64
|
- lib/calcpace/vo2max_estimator.rb
|
|
64
65
|
- lib/calcpace/vo2max_norms.rb
|
|
65
66
|
homepage: https://github.com/0jonjo/calcpace
|