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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d60f32c2e712a9f2c4ab9a1a8724ac9b932838acab9bbe1db333f557d0cf1797
4
- data.tar.gz: 6bd621feb8849df93ab197bbe77795da67878ddfa46ee493acd8e2ffe53a651e
3
+ metadata.gz: 25afa206e47316900eab46723efcccac5586d0026dbdd81316566798ffff58c3
4
+ data.tar.gz: e86c8acacd4cda1bdc4c9036bad53249029b285d6fd7a0ed467d8f67534c172d
5
5
  SHA512:
6
- metadata.gz: f6b76162fc0e1e575c1c1fc8d06c782cf603775c374c1a75dc5bacab50e2958a99c18ba47e429b65bc154f72d12d127e7c0fae99f9d59cd4dc2d3a54541fe335
7
- data.tar.gz: 260a67baea8e765b473261c0c24bfc12af6672404713f92bec266c070c5c0ae5beaa7da4ef9b3714d9286f868158fddca5c74e9bb0da182d78bd393947191fdb
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.0.0...HEAD
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 [![Gem Version](https://badge.fury.io/rb/calcpace.svg)](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.0'
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) }
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '2.0.0'
4
+ VERSION = '2.1.0'
5
5
  end
@@ -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
- Vo2maxResult = Struct.new(:value, :confidence, :sub_maximal, :adjusted_distance_km)
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
- def estimate_detailed_vo2max(distance, time, elevation_gain_m: 0, hr_avg: nil, hr_max: nil, distance_unit: :km)
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
- confidence = calculate_time_confidence(parse_time_minutes(time))
91
-
92
- hr_data = validate_and_analyze_hr(hr_avg, hr_max)
93
- confidence = :low if hr_data[:sub_maximal]
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.0.0
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