calcpace 1.7.0 → 1.8.1

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: ba8948e4e291f5381e58be3bc289ad8f54aa480a1cef744fc121dbc43449b523
4
- data.tar.gz: a9e4b3da8526c245adfaed895f460b820d737ca4a3eb805a4e2c9b1510f1a6ca
3
+ metadata.gz: f8e3c9a491b95568c00e5fabb1a9ba33be46c07035f72a24e51155b60bd6ecaa
4
+ data.tar.gz: 7c644cc582c27f1204a98bf86238c45b5a236520350c97786d04d119698a2840
5
5
  SHA512:
6
- metadata.gz: 61cea9354aa1343b9db15e92a1a11c06f053158ed72a48c5d1062e4b7d733888a7a8eaf3b88c7696cb879b7a92d196891c01e85369947dd4dead497b322b69b9
7
- data.tar.gz: fcf1db5f88479865c65ce64d7c788a3762ea01f39c28f10fa3604e268bce1737589cec4eb8b66f94a10f70a1acb12b7ab73b082990e0bc9a1a0c11d0461159d6
6
+ metadata.gz: a503fad2dcb76fc61459315f9f6d866dc47cad64fff8459c5ea94ba3b82f22664e2b04a2e7a105de4130e169661de7c01b7b6dc54950bdf2b5179279d30e75d4
7
+ data.tar.gz: 28e0d2c909e0a7c811d0f992a397b63de2be086ed76a64e29839884a5597d6f1ce161dca9b3092a308947fb49c0bf7d7aca4fdfb351c51fe56253a5096e92efa
@@ -8,12 +8,24 @@ on:
8
8
  branches: [ "main" ]
9
9
 
10
10
  jobs:
11
+ lint:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Checkout code
15
+ uses: actions/checkout@v4
16
+ - name: Set up Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: .ruby-version
20
+ bundler-cache: true
21
+ - name: Run RuboCop
22
+ run: bundle exec rubocop
11
23
  test:
12
24
  runs-on: ubuntu-latest
13
25
  strategy:
14
26
  fail-fast: false
15
27
  matrix:
16
- ruby: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4']
28
+ ruby: ['3.2', '3.3', '3.4', '4.0']
17
29
  steps:
18
30
  - name: Checkout code
19
31
  uses: actions/checkout@v4
data/.gitignore CHANGED
@@ -1,2 +1,5 @@
1
+ coverage/
2
+
1
3
  calcpace-*.gem
2
4
  !calcpace.gemspec
5
+ improvements_plan.md
data/.rubocop.yml CHANGED
@@ -4,7 +4,7 @@
4
4
  # Following Ruby best practices and style guidelines
5
5
 
6
6
  AllCops:
7
- TargetRubyVersion: 2.7
7
+ TargetRubyVersion: 3.2
8
8
  NewCops: enable
9
9
  Exclude:
10
10
  - 'vendor/**/*'
@@ -55,6 +55,14 @@ Metrics/BlockLength:
55
55
  - 'test/**/*'
56
56
  - '*.gemspec'
57
57
 
58
+ Metrics/ClassLength:
59
+ Exclude:
60
+ - 'test/**/*'
61
+
62
+ Metrics/ModuleLength:
63
+ Exclude:
64
+ - 'lib/calcpace/race_splits.rb'
65
+
58
66
  # Allow both single and double quotes for strings
59
67
  Style/StringLiteralsInInterpolation:
60
68
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -5,7 +5,57 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [1.7.0] - Unreleased
8
+ ## [1.8.1] - 2026-03-06
9
+
10
+ ### Added
11
+ - SimpleCov integration for code coverage measurement
12
+ - RuboCop lint job to CI pipeline
13
+ - YARD documentation for all previously undocumented public methods in `Calculator` (`checked_velocity`, `clock_velocity`, `checked_pace`, `clock_pace`, `time`, `checked_time`, `clock_time`, `distance`, `checked_distance`)
14
+
15
+ ### Changed
16
+ - Minimum required Ruby version bumped from 2.7 to 3.2
17
+ - CI matrix updated: removed EOL Ruby versions (2.7, 3.0, 3.1), added Ruby 4.0
18
+ - CI lint job uses `.ruby-version` file instead of a hardcoded version
19
+ - Bundler updated to 4.0.6
20
+ - `Rakefile.rb` renamed to `Rakefile` (standard convention)
21
+ - `PaceConverter` constants `MI_TO_KM` and `KM_TO_MI` consolidated into `Converter::Distance`
22
+ - Negative and positive split calculations refactored to share common logic
23
+ - Test files refactored to inherit from shared `CalcpaceTest` base class
24
+
25
+ ## [1.8.0] - 2026-02-14
26
+
27
+ ### Added
28
+ - Pace conversion module for converting running pace between kilometers and miles
29
+ - `convert_pace` method with support for both symbol and string format conversions
30
+ - `pace_km_to_mi` convenience method for kilometers to miles conversion
31
+ - `pace_mi_to_km` convenience method for miles to kilometers conversion
32
+ - Support for both numeric (seconds) and string (MM:SS) input formats
33
+ - Race splits calculator for pacing strategies
34
+ - `race_splits` method to calculate cumulative split times for races
35
+ - Support for even pace, negative splits (progressive), and positive splits (conservative) strategies
36
+ - Flexible split distances: standard race distances ('5k', '1mile') or custom distances (numeric km)
37
+ - Works with all standard race distances including marathon, half marathon, 10K, 5K, and mile races
38
+ - Race time predictor using Riegel formula
39
+ - `predict_time` and `predict_time_clock` methods to predict race times at different distances
40
+ - `predict_pace` and `predict_pace_clock` methods to calculate predicted pace for target races
41
+ - `equivalent_performance` method to compare performances across different race distances
42
+ - Based on proven Riegel formula: T2 = T1 × (D2/D1)^1.06
43
+ - Detailed explanation of the formula and its applications in README
44
+ - Additional race distances for international races
45
+ - `1mile` - 1.60934 kilometers
46
+ - `5mile` - 8.04672 kilometers
47
+ - `10mile` - 16.0934 kilometers
48
+ - Comprehensive test suites
49
+ - 30+ test cases for pace conversions
50
+ - 30+ test cases for race splits covering all strategies and edge cases
51
+ - 35+ test cases for race predictions covering various scenarios
52
+
53
+ ### Changed
54
+ - Expanded `RACE_DISTANCES` to include popular US/UK race distances
55
+ - Updated README with pace conversion, race splits, and race prediction examples
56
+ - Improved documentation with practical examples, use cases, and formula explanations
57
+
58
+ ## [1.7.0] - Released
9
59
 
10
60
  ### Added
11
61
  - RuboCop configuration for code style consistency
data/Gemfile CHANGED
@@ -7,3 +7,4 @@ gem 'rake', '~> 13.2'
7
7
  gem 'rake-compiler', '~> 1.0'
8
8
  gem 'rdoc', '~> 6.2'
9
9
  gem 'rubocop', '~> 1.69'
10
+ gem 'simplecov', '~> 0.22', require: false
data/Gemfile.lock CHANGED
@@ -1,45 +1,68 @@
1
1
  GEM
2
2
  remote: https://rubygems.org/
3
3
  specs:
4
- ast (2.4.2)
5
- date (3.4.1)
6
- json (2.10.2)
7
- language_server-protocol (3.17.0.4)
4
+ addressable (2.8.9)
5
+ public_suffix (>= 2.0.2, < 8.0)
6
+ ast (2.4.3)
7
+ bigdecimal (4.0.1)
8
+ date (3.5.1)
9
+ docile (1.4.1)
10
+ erb (6.0.2)
11
+ json (2.19.0)
12
+ json-schema (6.2.0)
13
+ addressable (~> 2.8)
14
+ bigdecimal (>= 3.1, < 5)
15
+ language_server-protocol (3.17.0.5)
8
16
  lint_roller (1.1.0)
9
- minitest (5.25.4)
10
- parallel (1.26.3)
11
- parser (3.3.7.1)
17
+ mcp (0.8.0)
18
+ json-schema (>= 4.1)
19
+ minitest (5.27.0)
20
+ parallel (1.27.0)
21
+ parser (3.3.10.2)
12
22
  ast (~> 2.4.1)
13
23
  racc
14
- psych (5.2.3)
24
+ prism (1.9.0)
25
+ psych (5.3.1)
15
26
  date
16
27
  stringio
28
+ public_suffix (7.0.5)
17
29
  racc (1.8.1)
18
30
  rainbow (3.1.1)
19
- rake (13.2.1)
20
- rake-compiler (1.2.9)
31
+ rake (13.3.1)
32
+ rake-compiler (1.3.1)
21
33
  rake
22
- rdoc (6.12.0)
34
+ rdoc (6.17.0)
35
+ erb
23
36
  psych (>= 4.0.0)
24
- regexp_parser (2.10.0)
25
- rubocop (1.73.2)
37
+ tsort
38
+ regexp_parser (2.11.3)
39
+ rubocop (1.85.1)
26
40
  json (~> 2.3)
27
41
  language_server-protocol (~> 3.17.0.2)
28
42
  lint_roller (~> 1.1.0)
43
+ mcp (~> 0.6)
29
44
  parallel (~> 1.10)
30
45
  parser (>= 3.3.0.2)
31
46
  rainbow (>= 2.2.2, < 4.0)
32
47
  regexp_parser (>= 2.9.3, < 3.0)
33
- rubocop-ast (>= 1.38.0, < 2.0)
48
+ rubocop-ast (>= 1.49.0, < 2.0)
34
49
  ruby-progressbar (~> 1.7)
35
50
  unicode-display_width (>= 2.4.0, < 4.0)
36
- rubocop-ast (1.38.1)
37
- parser (>= 3.3.1.0)
51
+ rubocop-ast (1.49.0)
52
+ parser (>= 3.3.7.2)
53
+ prism (~> 1.7)
38
54
  ruby-progressbar (1.13.0)
39
- stringio (3.1.5)
40
- unicode-display_width (3.1.4)
41
- unicode-emoji (~> 4.0, >= 4.0.4)
42
- unicode-emoji (4.0.4)
55
+ simplecov (0.22.0)
56
+ docile (~> 1.1)
57
+ simplecov-html (~> 0.11)
58
+ simplecov_json_formatter (~> 0.1)
59
+ simplecov-html (0.13.2)
60
+ simplecov_json_formatter (0.1.4)
61
+ stringio (3.2.0)
62
+ tsort (0.2.0)
63
+ unicode-display_width (3.2.0)
64
+ unicode-emoji (~> 4.1)
65
+ unicode-emoji (4.2.0)
43
66
 
44
67
  PLATFORMS
45
68
  ruby
@@ -50,6 +73,7 @@ DEPENDENCIES
50
73
  rake-compiler (~> 1.0)
51
74
  rdoc (~> 6.2)
52
75
  rubocop (~> 1.69)
76
+ simplecov (~> 0.22)
53
77
 
54
78
  BUNDLED WITH
55
- 2.4.22
79
+ 4.0.6
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Calcpace [![Gem Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=rb&r=r&ts=1683906897&type=6e&v=1.7.0&x2=0)](https://badge.fury.io/rb/calcpace)
1
+ # Calcpace [![Gem Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=rb&r=r&ts=1683906897&type=6e&v=1.8.1&x2=0)](https://badge.fury.io/rb/calcpace)
2
2
 
3
3
  Calcpace is a Ruby gem designed for calculations and conversions related to distance and time. It can calculate velocity, pace, total time, and distance, accepting time in various formats, including HH:MM:SS. The gem supports conversion to 42 different units, including kilometers, miles, meters, and feet. It also provides methods to validate input.
4
4
 
@@ -7,7 +7,7 @@ Calcpace is a Ruby gem designed for calculations and conversions related to dist
7
7
  ### Add to your Gemfile
8
8
 
9
9
  ```ruby
10
- gem 'calcpace', '~> 1.7.0'
10
+ gem 'calcpace', '~> 1.8.1'
11
11
  ```
12
12
 
13
13
  Then run:
@@ -176,6 +176,137 @@ Supported race distances:
176
176
  - `10k` - 10 kilometers
177
177
  - `half_marathon` - 21.0975 kilometers
178
178
  - `marathon` - 42.195 kilometers
179
+ - `1mile` - 1.60934 kilometers
180
+ - `5mile` - 8.04672 kilometers
181
+ - `10mile` - 16.0934 kilometers
182
+
183
+ ### Pace Conversions
184
+
185
+ Convert running pace between kilometers and miles:
186
+
187
+ ```ruby
188
+ calc = Calcpace.new
189
+
190
+ # Convert pace from km to miles
191
+ calc.pace_km_to_mi('05:00') # => '00:08:02' (5:00/km = 8:02/mi)
192
+ calc.convert_pace('05:00', :km_to_mi) # => '00:08:02' (same as above)
193
+
194
+ # Convert pace from miles to km
195
+ calc.pace_mi_to_km('08:00') # => '00:04:58' (8:00/mi ≈ 4:58/km)
196
+ calc.convert_pace('08:00', :mi_to_km) # => '00:04:58' (same as above)
197
+
198
+ # Works with numeric input (seconds) as well
199
+ calc.pace_km_to_mi(300) # => '00:08:02' (300 seconds/km)
200
+ calc.pace_mi_to_km(480) # => '00:04:58' (480 seconds/mi)
201
+
202
+ # String format also supported
203
+ calc.convert_pace('05:00', 'km to mi') # => '00:08:02'
204
+ ```
205
+
206
+ The pace conversions are particularly useful when:
207
+ - Planning races in different countries (metric vs imperial)
208
+ - Comparing training paces with international runners
209
+ - Converting workout plans between pace formats
210
+
211
+ ### Race Splits
212
+
213
+ Calculate split times for races to help pace your race strategy:
214
+
215
+ ```ruby
216
+ calc = Calcpace.new
217
+
218
+ # Even pace splits for half marathon (every 5k)
219
+ calc.race_splits('half_marathon', target_time: '01:30:00', split_distance: '5k')
220
+ # => ["00:21:20", "00:42:40", "01:03:59", "01:25:19", "01:30:00"]
221
+
222
+ # Kilometer splits for 10K
223
+ calc.race_splits('10k', target_time: '00:40:00', split_distance: '1k')
224
+ # => ["00:04:00", "00:08:00", "00:12:00", ..., "00:40:00"]
225
+
226
+ # Mile splits for marathon
227
+ calc.race_splits('marathon', target_time: '03:30:00', split_distance: '1mile')
228
+ # => ["00:08:02", "00:16:04", ..., "03:30:00"]
229
+
230
+ # Negative splits strategy (second half faster)
231
+ calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative)
232
+ # => ["00:20:48", "00:40:00"] # First 5k slower, second 5k faster
233
+
234
+ # Positive splits strategy (first half faster)
235
+ calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :positive)
236
+ # => ["00:19:12", "00:40:00"] # First 5k faster, second 5k slower
237
+ ```
238
+
239
+ Supported strategies:
240
+ - `:even` - Constant pace throughout (default)
241
+ - `:negative` - Second half ~4% faster (progressive race)
242
+ - `:positive` - First half ~4% faster (conservative start)
243
+
244
+ Split distances can be:
245
+ - Standard race distances: `'5k'`, `'10k'`, `'1mile'`, etc.
246
+ - Custom distances: `2.5` (in kilometers), `'3k'`, etc.
247
+
248
+ ### Race Time Predictions
249
+
250
+ Predict your race times at different distances based on a recent performance using the **Riegel formula**:
251
+
252
+ ```ruby
253
+ calc = Calcpace.new
254
+
255
+ # Predict marathon time from a 5K result
256
+ calc.predict_time_clock('5k', '00:20:00', 'marathon')
257
+ # => "03:11:49" (predicts 3:11:49 marathon from 20:00 5K)
258
+
259
+ # Predict 10K time from half marathon
260
+ calc.predict_time_clock('half_marathon', '01:30:00', '10k')
261
+ # => "00:40:47"
262
+
263
+ # Get predicted pace for target race
264
+ calc.predict_pace_clock('5k', '00:20:00', 'marathon')
265
+ # => "00:04:32" (4:32/km pace for predicted marathon)
266
+
267
+ # Get complete equivalent performance info
268
+ calc.equivalent_performance('10k', '00:42:00', '5k')
269
+ # => {
270
+ # time: 1209.0,
271
+ # time_clock: "00:20:09",
272
+ # pace: 241.8,
273
+ # pace_clock: "00:04:02"
274
+ # }
275
+ ```
276
+
277
+ #### How the Riegel Formula Works
278
+
279
+ The Riegel formula is a mathematical model that predicts race performance across different distances:
280
+
281
+ **Formula:** `T2 = T1 × (D2/D1)^1.06`
282
+
283
+ Where:
284
+ - **T1** = your known time at distance D1
285
+ - **T2** = predicted time at distance D2
286
+ - **D1** = known race distance (in km)
287
+ - **D2** = target race distance (in km)
288
+ - **1.06** = fatigue/endurance factor
289
+
290
+ **The 1.06 exponent** represents how pace slows as distance increases. If endurance was perfect (1.0), doubling distance would simply double time. But in reality, you slow down slightly - the 1.06 factor accounts for this accumulated fatigue.
291
+
292
+ **Example:** From a 5K in 20:00, predict marathon time:
293
+ - T1 = 1200 seconds (20:00)
294
+ - D1 = 5 km
295
+ - D2 = 42.195 km (marathon)
296
+ - T2 = 1200 × (42.195/5)^1.06
297
+ - T2 = 1200 × 9.591 = 11,509 seconds
298
+ - T2 = **3:11:49**
299
+
300
+ The formula works best for:
301
+ - Distances from 5K to marathon
302
+ - Recent race results (within 6-8 weeks)
303
+ - Similar terrain and weather conditions
304
+ - Well-trained runners with consistent pacing
305
+
306
+ **Important notes:**
307
+ - Predictions assume equal training and effort across distances
308
+ - Results are estimates - actual performance varies by individual fitness, training focus, and race conditions
309
+ - The formula is most accurate when predicting between similar distance ranges (e.g., 10K to half marathon)
179
310
 
180
311
  ### Other Useful Methods
181
312
 
data/calcpace.gemspec CHANGED
@@ -9,11 +9,11 @@ Gem::Specification.new do |spec|
9
9
  spec.email = ['joaogilberto@tuta.io']
10
10
 
11
11
  spec.summary = 'A Ruby gem for pace, distance, and time calculations.'
12
- spec.description = 'Calcpace provides methods to calculate and convert values related to pace, distance, time, and speed. It supports various time formats and unit conversions.'
12
+ spec.description = 'Ruby gem for pace, distance, time, and speed calculations. Supports multiple units and formats.'
13
13
  spec.homepage = 'https://github.com/0jonjo/calcpace'
14
14
  spec.metadata['source_code_uri'] = spec.homepage
15
15
  spec.license = 'MIT'
16
- spec.required_ruby_version = '>= 2.7.0'
16
+ spec.required_ruby_version = '>= 3.2.0'
17
17
 
18
18
  spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
19
19
  spec.metadata['rubygems_mfa_required'] = 'true'
@@ -21,11 +21,31 @@ module Calculator
21
21
  distance.to_f / time
22
22
  end
23
23
 
24
+ # Calculates velocity from a time string
25
+ #
26
+ # @param time [String] time in HH:MM:SS or MM:SS format
27
+ # @param distance [Numeric] distance in any unit (e.g., meters, kilometers)
28
+ # @return [Float] velocity (distance/time)
29
+ # @raise [Calcpace::InvalidTimeFormatError] if time string is invalid
30
+ # @raise [Calcpace::NonPositiveInputError] if distance is not positive
31
+ #
32
+ # @example
33
+ # checked_velocity('01:00:00', 10_000) #=> 2.778 (10000 meters / 3600 seconds)
24
34
  def checked_velocity(time, distance)
25
35
  seconds = convert_to_seconds(validate_time(time))
26
36
  velocity(seconds, distance)
27
37
  end
28
38
 
39
+ # Calculates velocity from a time string and returns result as a clock time string
40
+ #
41
+ # @param time [String] time in HH:MM:SS or MM:SS format
42
+ # @param distance [Numeric] distance in any unit
43
+ # @return [String] velocity in HH:MM:SS format
44
+ # @raise [Calcpace::InvalidTimeFormatError] if time string is invalid
45
+ # @raise [Calcpace::NonPositiveInputError] if distance is not positive
46
+ #
47
+ # @example
48
+ # clock_velocity('01:00:00', 10_000) #=> '00:00:00'
29
49
  def clock_velocity(time, distance)
30
50
  convert_to_clocktime(checked_velocity(time, distance))
31
51
  end
@@ -44,34 +64,101 @@ module Calculator
44
64
  time.to_f / distance
45
65
  end
46
66
 
67
+ # Calculates pace from a time string
68
+ #
69
+ # @param time [String] time in HH:MM:SS or MM:SS format
70
+ # @param distance [Numeric] distance in any unit (e.g., kilometers, miles)
71
+ # @return [Float] pace (time/distance) in seconds
72
+ # @raise [Calcpace::InvalidTimeFormatError] if time string is invalid
73
+ # @raise [Calcpace::NonPositiveInputError] if distance is not positive
74
+ #
75
+ # @example
76
+ # checked_pace('00:50:00', 10) #=> 300.0 (300 seconds/km = 5:00/km)
47
77
  def checked_pace(time, distance)
48
78
  seconds = convert_to_seconds(validate_time(time))
49
79
  pace(seconds, distance)
50
80
  end
51
81
 
82
+ # Calculates pace from a time string and returns result as a clock time string
83
+ #
84
+ # @param time [String] time in HH:MM:SS or MM:SS format
85
+ # @param distance [Numeric] distance in any unit
86
+ # @return [String] pace in HH:MM:SS format
87
+ # @raise [Calcpace::InvalidTimeFormatError] if time string is invalid
88
+ # @raise [Calcpace::NonPositiveInputError] if distance is not positive
89
+ #
90
+ # @example
91
+ # clock_pace('00:50:00', 10) #=> '00:05:00'
52
92
  def clock_pace(time, distance)
53
93
  convert_to_clocktime(checked_pace(time, distance))
54
94
  end
55
95
 
96
+ # Calculates time given velocity and distance
97
+ #
98
+ # @param velocity [Numeric] velocity in any unit
99
+ # @param distance [Numeric] distance in any unit
100
+ # @return [Float] time (velocity * distance)
101
+ # @raise [Calcpace::NonPositiveInputError] if velocity or distance is not positive
102
+ #
103
+ # @example
104
+ # time(300, 10) #=> 3000.0 (300 s/km * 10 km = 3000 seconds)
56
105
  def time(velocity, distance)
57
106
  validate_positive({ velocity: velocity, distance: distance })
58
107
  velocity * distance
59
108
  end
60
109
 
110
+ # Calculates time from a velocity string and distance
111
+ #
112
+ # @param velocity [String] velocity in HH:MM:SS or MM:SS format
113
+ # @param distance [Numeric] distance in any unit
114
+ # @return [Float] time in seconds
115
+ # @raise [Calcpace::InvalidTimeFormatError] if velocity string is invalid
116
+ # @raise [Calcpace::NonPositiveInputError] if distance is not positive
117
+ #
118
+ # @example
119
+ # checked_time('05:00', 10) #=> 3000.0 (5:00/km * 10 km = 3000 seconds)
61
120
  def checked_time(velocity, distance)
62
121
  velocity_seconds = convert_to_seconds(validate_time(velocity))
63
122
  time(velocity_seconds, distance)
64
123
  end
65
124
 
125
+ # Calculates time from a velocity string and returns result as a clock time string
126
+ #
127
+ # @param velocity [String] velocity in HH:MM:SS or MM:SS format
128
+ # @param distance [Numeric] distance in any unit
129
+ # @return [String] time in HH:MM:SS format
130
+ # @raise [Calcpace::InvalidTimeFormatError] if velocity string is invalid
131
+ # @raise [Calcpace::NonPositiveInputError] if distance is not positive
132
+ #
133
+ # @example
134
+ # clock_time('05:00', 10) #=> '00:50:00'
66
135
  def clock_time(velocity, distance)
67
136
  convert_to_clocktime(checked_time(velocity, distance))
68
137
  end
69
138
 
139
+ # Calculates distance given time and velocity
140
+ #
141
+ # @param time [Numeric] time in any unit
142
+ # @param velocity [Numeric] velocity in any unit
143
+ # @return [Float] distance (time/velocity)
144
+ # @raise [Calcpace::NonPositiveInputError] if time or velocity is not positive
145
+ #
146
+ # @example
147
+ # distance(3000, 300) #=> 10.0 (3000 seconds / 300 s/km = 10 km)
70
148
  def distance(time, velocity)
71
149
  validate_positive({ time: time, velocity: velocity })
72
150
  time.to_f / velocity
73
151
  end
74
152
 
153
+ # Calculates distance from time and velocity strings
154
+ #
155
+ # @param time [String] time in HH:MM:SS or MM:SS format
156
+ # @param velocity [String] velocity in HH:MM:SS or MM:SS format
157
+ # @return [Float] distance
158
+ # @raise [Calcpace::InvalidTimeFormatError] if any string is invalid
159
+ #
160
+ # @example
161
+ # checked_distance('00:50:00', '05:00') #=> 10.0 (50 min / 5:00/km = 10 km)
75
162
  def checked_distance(time, velocity)
76
163
  time_seconds = convert_to_seconds(validate_time(time))
77
164
  velocity_seconds = convert_to_seconds(validate_time(velocity))
@@ -39,7 +39,7 @@ module ConverterChain
39
39
  def convert_chain_with_description(value, conversions)
40
40
  initial_value = value
41
41
  result = convert_chain(value, conversions)
42
- conversion_names = conversions.map(&:to_s).join(' → ')
42
+ conversion_names = conversions.join(' → ')
43
43
  description = "#{initial_value} → #{conversion_names} → #{result.round(4)}"
44
44
 
45
45
  { result: result, description: description }
@@ -10,7 +10,10 @@ module PaceCalculator
10
10
  '5k' => 5.0,
11
11
  '10k' => 10.0,
12
12
  'half_marathon' => 21.0975,
13
- 'marathon' => 42.195
13
+ 'marathon' => 42.195,
14
+ '1mile' => 1.60934,
15
+ '5mile' => 8.04672,
16
+ '10mile' => 16.0934
14
17
  }.freeze
15
18
 
16
19
  # Calculates the finish time for a race given a pace per kilometer
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module for converting pace between different distance units
4
+ #
5
+ # This module provides methods to convert running pace between kilometers
6
+ # and miles, maintaining the time per distance unit format.
7
+ module PaceConverter
8
+ # Converts pace from one unit to another
9
+ #
10
+ # @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS)
11
+ # @param conversion [Symbol, String] conversion type (:km_to_mi, :mi_to_km, 'km to mi', 'mi to km')
12
+ # @return [String] converted pace in MM:SS format
13
+ # @raise [ArgumentError] if conversion type is not supported
14
+ # @raise [Calcpace::NonPositiveInputError] if pace is not positive
15
+ #
16
+ # @example
17
+ # convert_pace('05:00', :km_to_mi) #=> '08:02' (5:00/km = 8:02/mi)
18
+ # convert_pace('08:00', :mi_to_km) #=> '04:58' (8:00/mi ≈ 4:58/km)
19
+ # convert_pace(300, 'km to mi') #=> '08:02' (300s/km = 482s/mi)
20
+ def convert_pace(pace, conversion)
21
+ pace_seconds = pace.is_a?(String) ? convert_to_seconds(pace) : pace
22
+ check_positive(pace_seconds, 'Pace')
23
+
24
+ conversion_type = normalize_conversion(conversion)
25
+ converted_seconds = apply_pace_conversion(pace_seconds, conversion_type)
26
+
27
+ convert_to_clocktime(converted_seconds)
28
+ end
29
+
30
+ # Converts pace from kilometers to miles
31
+ #
32
+ # @param pace_per_km [Numeric, String] pace in seconds per km or time string (MM:SS)
33
+ # @return [String] pace per mile in MM:SS format
34
+ #
35
+ # @example
36
+ # pace_km_to_mi('05:00') #=> '08:02' (5:00/km = 8:02/mi)
37
+ # pace_km_to_mi(300) #=> '08:02' (300s/km = 482s/mi)
38
+ def pace_km_to_mi(pace_per_km)
39
+ convert_pace(pace_per_km, :km_to_mi)
40
+ end
41
+
42
+ # Converts pace from miles to kilometers
43
+ #
44
+ # @param pace_per_mi [Numeric, String] pace in seconds per mile or time string (MM:SS)
45
+ # @return [String] pace per kilometer in MM:SS format
46
+ #
47
+ # @example
48
+ # pace_mi_to_km('08:00') #=> '04:58' (8:00/mi ≈ 4:58/km)
49
+ # pace_mi_to_km(480) #=> '04:58' (480s/mi = 298s/km)
50
+ def pace_mi_to_km(pace_per_mi)
51
+ convert_pace(pace_per_mi, :mi_to_km)
52
+ end
53
+
54
+ private
55
+
56
+ # Normalizes conversion string/symbol to standard format
57
+ #
58
+ # @param conversion [Symbol, String] conversion type
59
+ # @return [Symbol] normalized conversion symbol
60
+ # @raise [ArgumentError] if conversion type is not supported
61
+ def normalize_conversion(conversion)
62
+ normalized = if conversion.is_a?(String)
63
+ conversion.downcase.gsub(/\s+/, '_').to_sym
64
+ else
65
+ conversion.to_sym
66
+ end
67
+
68
+ unless %i[km_to_mi mi_to_km].include?(normalized)
69
+ raise ArgumentError,
70
+ "Unsupported pace conversion: #{conversion}. " \
71
+ "Supported conversions: km_to_mi, mi_to_km"
72
+ end
73
+
74
+ normalized
75
+ end
76
+
77
+ # Applies the pace conversion
78
+ #
79
+ # @param pace_seconds [Numeric] pace in seconds
80
+ # @param conversion_type [Symbol] conversion type (:km_to_mi or :mi_to_km)
81
+ # @return [Float] converted pace in seconds
82
+ def apply_pace_conversion(pace_seconds, conversion_type)
83
+ case conversion_type
84
+ when :km_to_mi
85
+ # If running at X seconds per km, pace per mile = X * (miles to km ratio)
86
+ pace_seconds * Converter::Distance::MI_TO_KM
87
+ when :mi_to_km
88
+ # If running at X seconds per mile, pace per km = X / (miles to km ratio)
89
+ pace_seconds * Converter::Distance::KM_TO_MI
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module for predicting race times based on performance at other distances
4
+ #
5
+ # This module uses the Riegel formula to predict race times based on a known
6
+ # performance at a different distance. The formula accounts for the endurance
7
+ # fatigue factor that occurs as race distance increases.
8
+ module RacePredictor
9
+ # Riegel formula exponent (represents endurance/fatigue factor)
10
+ RIEGEL_EXPONENT = 1.06
11
+
12
+ # Predicts race time for a target distance based on a known performance
13
+ #
14
+ # Uses the Riegel formula: T2 = T1 × (D2/D1)^1.06
15
+ # where:
16
+ # - T1 = time at known distance
17
+ # - D1 = known distance
18
+ # - T2 = predicted time at target distance
19
+ # - D2 = target distance
20
+ # - 1.06 = endurance/fatigue factor (longer races require proportionally more time)
21
+ #
22
+ # @param from_race [String, Symbol] known race distance ('5k', '10k', 'half_marathon', 'marathon', etc.)
23
+ # @param from_time [String, Numeric] time achieved at known distance (HH:MM:SS or seconds)
24
+ # @param to_race [String, Symbol] target race distance to predict
25
+ # @return [Float] predicted time in seconds
26
+ # @raise [ArgumentError] if races are invalid or distances are the same
27
+ #
28
+ # @example Predict marathon time from 5K
29
+ # predict_time('5k', '00:20:00', 'marathon')
30
+ # #=> 11123.4 (approximately 3:05:23)
31
+ #
32
+ # @example Predict 10K time from half marathon
33
+ # predict_time('half_marathon', '01:30:00', '10k')
34
+ # #=> 2565.8 (approximately 42:46)
35
+ def predict_time(from_race, from_time, to_race)
36
+ from_distance = race_distance(from_race)
37
+ to_distance = race_distance(to_race)
38
+
39
+ if from_distance == to_distance
40
+ raise ArgumentError,
41
+ "From and to races must be different distances (both are #{from_distance}km)"
42
+ end
43
+
44
+ time_seconds = from_time.is_a?(String) ? convert_to_seconds(from_time) : from_time
45
+ check_positive(time_seconds, 'Time')
46
+
47
+ # Riegel formula: T2 = T1 × (D2/D1)^1.06
48
+ time_seconds * ((to_distance / from_distance)**RIEGEL_EXPONENT)
49
+ end
50
+
51
+ # Predicts race time and returns it as a clock time string
52
+ #
53
+ # @param from_race [String, Symbol] known race distance
54
+ # @param from_time [String, Numeric] time achieved at known distance
55
+ # @param to_race [String, Symbol] target race distance to predict
56
+ # @return [String] predicted time in HH:MM:SS format
57
+ #
58
+ # @example
59
+ # predict_time_clock('5k', '00:20:00', 'marathon')
60
+ # #=> '03:05:23'
61
+ def predict_time_clock(from_race, from_time, to_race)
62
+ predicted_seconds = predict_time(from_race, from_time, to_race)
63
+ convert_to_clocktime(predicted_seconds)
64
+ end
65
+
66
+ # Predicts the pace per kilometer for a target race
67
+ #
68
+ # @param from_race [String, Symbol] known race distance
69
+ # @param from_time [String, Numeric] time achieved at known distance
70
+ # @param to_race [String, Symbol] target race distance to predict
71
+ # @return [Float] predicted pace in seconds per kilometer
72
+ #
73
+ # @example
74
+ # predict_pace('5k', '00:20:00', 'marathon')
75
+ # #=> 263.6 (approximately 4:24/km)
76
+ def predict_pace(from_race, from_time, to_race)
77
+ predicted_seconds = predict_time(from_race, from_time, to_race)
78
+ to_distance = race_distance(to_race)
79
+ predicted_seconds / to_distance
80
+ end
81
+
82
+ # Predicts the pace per kilometer and returns it as a clock time string
83
+ #
84
+ # @param from_race [String, Symbol] known race distance
85
+ # @param from_time [String, Numeric] time achieved at known distance
86
+ # @param to_race [String, Symbol] target race distance to predict
87
+ # @return [String] predicted pace in MM:SS format
88
+ #
89
+ # @example
90
+ # predict_pace_clock('5k', '00:20:00', 'marathon')
91
+ # #=> '00:04:24' (4:24/km)
92
+ def predict_pace_clock(from_race, from_time, to_race)
93
+ pace_seconds = predict_pace(from_race, from_time, to_race)
94
+ convert_to_clocktime(pace_seconds)
95
+ end
96
+
97
+ # Calculates the equivalent performance at a different distance
98
+ #
99
+ # This is useful for comparing performances across different race distances.
100
+ # For example, "My 10K time is equivalent to what 5K time?"
101
+ #
102
+ # @param from_race [String, Symbol] known race distance
103
+ # @param from_time [String, Numeric] time achieved at known distance
104
+ # @param to_race [String, Symbol] target race distance for comparison
105
+ # @return [Hash] hash with :time (seconds), :time_clock (HH:MM:SS), :pace (/km), :pace_clock
106
+ #
107
+ # @example
108
+ # equivalent_performance('10k', '00:42:00', '5k')
109
+ # #=> {
110
+ # time: 1228.5,
111
+ # time_clock: "00:20:28",
112
+ # pace: 245.7,
113
+ # pace_clock: "00:04:06"
114
+ # }
115
+ def equivalent_performance(from_race, from_time, to_race)
116
+ predicted_time = predict_time(from_race, from_time, to_race)
117
+ predicted_pace = predict_pace(from_race, from_time, to_race)
118
+
119
+ {
120
+ time: predicted_time,
121
+ time_clock: convert_to_clocktime(predicted_time),
122
+ pace: predicted_pace,
123
+ pace_clock: convert_to_clocktime(predicted_pace)
124
+ }
125
+ end
126
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module for calculating race splits (partial times)
4
+ #
5
+ # This module provides methods to calculate split times for races,
6
+ # supporting different pacing strategies like even pace, negative splits, etc.
7
+ module RaceSplits
8
+ # Calculates split times for a race
9
+ #
10
+ # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon', etc.)
11
+ # @param target_time [String] target finish time in HH:MM:SS or MM:SS format
12
+ # @param split_distance [String, Numeric] distance for each split ('5k', '1k', '1mile', or numeric in km)
13
+ # @param strategy [Symbol] pacing strategy - :even (default), :negative, or :positive
14
+ # @return [Array<String>] array of cumulative split times in HH:MM:SS format
15
+ # @raise [ArgumentError] if race or split_distance is invalid
16
+ #
17
+ # @example Even pace splits for half marathon
18
+ # race_splits('half_marathon', target_time: '01:30:00', split_distance: '5k')
19
+ # #=> ["00:21:18", "00:42:35", "01:03:53", "01:30:00"]
20
+ #
21
+ # @example Negative splits (second half faster)
22
+ # race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative)
23
+ # #=> ["00:20:48", "00:40:00"] (first 5k slower, second 5k faster)
24
+ def race_splits(race, target_time:, split_distance:, strategy: :even)
25
+ total_distance = race_distance(race)
26
+ target_seconds = target_time.is_a?(String) ? convert_to_seconds(target_time) : target_time
27
+ check_positive(target_seconds, 'Target time')
28
+
29
+ split_km = normalize_split_distance(split_distance)
30
+ validate_split_distance(split_km, total_distance)
31
+
32
+ calculate_splits(total_distance, target_seconds, split_km, strategy)
33
+ end
34
+
35
+ private
36
+
37
+ # Normalizes split distance to kilometers
38
+ #
39
+ # @param split_distance [String, Numeric] distance for each split
40
+ # @return [Float] split distance in kilometers
41
+ def normalize_split_distance(split_distance)
42
+ if split_distance.is_a?(Numeric)
43
+ split_distance.to_f
44
+ elsif split_distance.is_a?(String)
45
+ # Try to get from RACE_DISTANCES first (includes standard distances like '5k', '1mile', etc.)
46
+ distance_key = split_distance.to_s.downcase
47
+
48
+ # Check if it's a standard race distance
49
+ begin
50
+ return race_distance(distance_key)
51
+ rescue ArgumentError
52
+ # Not a race distance, try to parse as numeric
53
+ end
54
+
55
+ # Try to parse as number with optional 'k' or 'km'
56
+ normalized = distance_key.gsub(/km?$/, '').strip
57
+ begin
58
+ Float(normalized)
59
+ rescue StandardError
60
+ raise(ArgumentError, "Invalid split distance: #{split_distance}")
61
+ end
62
+ else
63
+ raise ArgumentError, "Split distance must be a number or string"
64
+ end
65
+ end
66
+
67
+ # Validates that split distance is reasonable for the race
68
+ #
69
+ # @param split_km [Float] split distance in kilometers
70
+ # @param total_distance [Float] total race distance in kilometers
71
+ # @raise [ArgumentError] if split distance is invalid
72
+ def validate_split_distance(split_km, total_distance)
73
+ raise ArgumentError, "Split distance must be positive" if split_km <= 0
74
+
75
+ if split_km > total_distance
76
+ raise ArgumentError,
77
+ "Split distance (#{split_km}km) cannot be greater than race distance (#{total_distance}km)"
78
+ end
79
+
80
+ # Allow some flexibility for non-exact divisions
81
+ return unless (total_distance / split_km) > 100
82
+
83
+ raise ArgumentError,
84
+ "Split distance too small - would generate more than 100 splits"
85
+ end
86
+
87
+ # Calculates split times based on strategy
88
+ #
89
+ # @param total_distance [Float] total race distance in kilometers
90
+ # @param target_seconds [Float] target finish time in seconds
91
+ # @param split_km [Float] split distance in kilometers
92
+ # @param strategy [Symbol] pacing strategy
93
+ # @return [Array<String>] array of cumulative split times
94
+ def calculate_splits(total_distance, target_seconds, split_km, strategy)
95
+ case strategy
96
+ when :even
97
+ calculate_even_splits(total_distance, target_seconds, split_km)
98
+ when :negative
99
+ calculate_negative_splits(total_distance, target_seconds, split_km)
100
+ when :positive
101
+ calculate_positive_splits(total_distance, target_seconds, split_km)
102
+ else
103
+ raise ArgumentError,
104
+ "Unknown strategy: #{strategy}. Supported strategies: :even, :negative, :positive"
105
+ end
106
+ end
107
+
108
+ # Calculates even pace splits (constant pace throughout)
109
+ #
110
+ # @param total_distance [Float] total race distance in kilometers
111
+ # @param target_seconds [Float] target finish time in seconds
112
+ # @param split_km [Float] split distance in kilometers
113
+ # @return [Array<String>] array of cumulative split times
114
+ def calculate_even_splits(total_distance, target_seconds, split_km)
115
+ pace_per_km = target_seconds / total_distance
116
+ splits = []
117
+ distance_covered = 0.0
118
+
119
+ while distance_covered < total_distance - 0.001 # small tolerance for floating point
120
+ distance_covered += split_km
121
+ # Don't exceed total distance
122
+ distance_covered = total_distance if distance_covered > total_distance
123
+
124
+ split_time = (distance_covered * pace_per_km).round
125
+ splits << convert_to_clocktime(split_time)
126
+ end
127
+
128
+ splits
129
+ end
130
+
131
+ # Calculates negative splits (second half faster than first half)
132
+ # First half is ~4% slower, second half is ~4% faster
133
+ #
134
+ # @param total_distance [Float] total race distance in kilometers
135
+ # @param target_seconds [Float] target finish time in seconds
136
+ # @param split_km [Float] split distance in kilometers
137
+ # @return [Array<String>] array of cumulative split times
138
+ def calculate_negative_splits(total_distance, target_seconds, split_km)
139
+ calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 1.04, second_factor: 0.96)
140
+ end
141
+
142
+ # Calculates positive splits (first half faster than second half)
143
+ # First half is ~4% faster, second half is ~4% slower
144
+ #
145
+ # @param total_distance [Float] total race distance in kilometers
146
+ # @param target_seconds [Float] target finish time in seconds
147
+ # @param split_km [Float] split distance in kilometers
148
+ # @return [Array<String>] array of cumulative split times
149
+ def calculate_positive_splits(total_distance, target_seconds, split_km)
150
+ calculate_variable_splits(total_distance, target_seconds, split_km, first_factor: 0.96, second_factor: 1.04)
151
+ end
152
+
153
+ # Shared logic for variable pace split strategies
154
+ #
155
+ # @param total_distance [Float] total race distance in kilometers
156
+ # @param target_seconds [Float] target finish time in seconds
157
+ # @param split_km [Float] split distance in kilometers
158
+ # @param first_factor [Float] pace multiplier for the first half
159
+ # @param second_factor [Float] pace multiplier for the second half
160
+ # @return [Array<String>] array of cumulative split times
161
+ def calculate_variable_splits(total_distance, target_seconds, split_km, first_factor:, second_factor:)
162
+ half_distance = total_distance / 2.0
163
+ avg_pace = target_seconds / total_distance
164
+ first_half_pace = avg_pace * first_factor
165
+ second_half_pace = avg_pace * second_factor
166
+
167
+ splits = []
168
+ distance_covered = 0.0
169
+ cumulative_time = 0.0
170
+
171
+ while distance_covered < total_distance - 0.001
172
+ distance_covered += split_km
173
+ distance_covered = total_distance if distance_covered > total_distance
174
+
175
+ if distance_covered <= half_distance
176
+ cumulative_time = distance_covered * first_half_pace
177
+ else
178
+ time_at_halfway = half_distance * first_half_pace
179
+ distance_in_second_half = distance_covered - half_distance
180
+ cumulative_time = time_at_halfway + (distance_in_second_half * second_half_pace)
181
+ end
182
+
183
+ splits << convert_to_clocktime(cumulative_time.round)
184
+ end
185
+
186
+ splits
187
+ end
188
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.7.0'
4
+ VERSION = '1.8.1'
5
5
  end
data/lib/calcpace.rb CHANGED
@@ -6,6 +6,9 @@ require_relative 'calcpace/converter'
6
6
  require_relative 'calcpace/converter_chain'
7
7
  require_relative 'calcpace/errors'
8
8
  require_relative 'calcpace/pace_calculator'
9
+ require_relative 'calcpace/pace_converter'
10
+ require_relative 'calcpace/race_predictor'
11
+ require_relative 'calcpace/race_splits'
9
12
 
10
13
  # Calcpace - A Ruby gem for pace, distance, and time calculations
11
14
  #
@@ -34,6 +37,9 @@ class Calcpace
34
37
  include Converter
35
38
  include ConverterChain
36
39
  include PaceCalculator
40
+ include PaceConverter
41
+ include RacePredictor
42
+ include RaceSplits
37
43
 
38
44
  # Creates a new Calcpace instance
39
45
  #
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: 1.7.0
4
+ version: 1.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - João Gilberto Saraiva
@@ -9,8 +9,8 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: Calcpace provides methods to calculate and convert values related to
13
- pace, distance, time, and speed. It supports various time formats and unit conversions.
12
+ description: Ruby gem for pace, distance, time, and speed calculations. Supports multiple
13
+ units and formats.
14
14
  email:
15
15
  - joaogilberto@tuta.io
16
16
  executables: []
@@ -26,7 +26,7 @@ files:
26
26
  - Gemfile
27
27
  - Gemfile.lock
28
28
  - README.md
29
- - Rakefile.rb
29
+ - Rakefile
30
30
  - calcpace.gemspec
31
31
  - lib/calcpace.rb
32
32
  - lib/calcpace/calculator.rb
@@ -35,6 +35,9 @@ files:
35
35
  - lib/calcpace/converter_chain.rb
36
36
  - lib/calcpace/errors.rb
37
37
  - lib/calcpace/pace_calculator.rb
38
+ - lib/calcpace/pace_converter.rb
39
+ - lib/calcpace/race_predictor.rb
40
+ - lib/calcpace/race_splits.rb
38
41
  - lib/calcpace/version.rb
39
42
  homepage: https://github.com/0jonjo/calcpace
40
43
  licenses:
@@ -50,14 +53,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
50
53
  requirements:
51
54
  - - ">="
52
55
  - !ruby/object:Gem::Version
53
- version: 2.7.0
56
+ version: 3.2.0
54
57
  required_rubygems_version: !ruby/object:Gem::Requirement
55
58
  requirements:
56
59
  - - ">="
57
60
  - !ruby/object:Gem::Version
58
61
  version: '0'
59
62
  requirements: []
60
- rubygems_version: 3.6.7
63
+ rubygems_version: 4.0.6
61
64
  specification_version: 4
62
65
  summary: A Ruby gem for pace, distance, and time calculations.
63
66
  test_files: []
File without changes