calcpace 1.6.0 → 1.8.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: d05362249daa1675ba18bb4f6dfc65b79d3380787d451e64b55bc436e3952381
4
- data.tar.gz: 3374e54393bb54e443c9feb610e05caf36842d5d1c1d5ceb3e517bc79022f6ef
3
+ metadata.gz: fb37c0c8cf8bb5944459915e43549229d6db504b77538729cea721fa041f7266
4
+ data.tar.gz: d6900ff2d3cd7e2a821d4637d96beafa69cd8e656a31f2251b68d9e7a818cefb
5
5
  SHA512:
6
- metadata.gz: bb5a709698a85d9169ec683a47992eb2dfaf4dfdad430ef4a949b525896378814061a7f31a8f906f7475ff39c141e51e939a5847178baea87e1be9a69cc3015a
7
- data.tar.gz: af509b94cb168456939af0329bcc93ee7c7cd0bdb95a8951c34267cfb175681f72ae02c8191671203feb988f30f89d673ef32bb87c197712c960a5febdc4a25b
6
+ metadata.gz: 683f9af0342d287411cc79fd6b2acbf3ebae0d2bf408a282f2309b36a6f62e7478d51983d9ab86bb9c5b524ffe55ab0b445ac9c6b212bd628466adba84e0e249
7
+ data.tar.gz: 95286680a44cceede252bb0b9429ba321be66a895246986339dd261af30c0547f8f95726d36bfc85fc2da1e8333fad9141d3836f2659b0237ef39a56ea8f1f6a
data/.gitignore CHANGED
@@ -1,2 +1,3 @@
1
1
  calcpace-*.gem
2
2
  !calcpace.gemspec
3
+ improvements_plan.md
data/.rubocop.yml ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RuboCop configuration for Calcpace gem
4
+ # Following Ruby best practices and style guidelines
5
+
6
+ AllCops:
7
+ TargetRubyVersion: 2.7
8
+ NewCops: enable
9
+ Exclude:
10
+ - 'vendor/**/*'
11
+ - 'tmp/**/*'
12
+ - 'node_modules/**/*'
13
+
14
+ # Prefer double quotes for consistency
15
+ Style/StringLiterals:
16
+ Enabled: false
17
+
18
+ # Allow longer lines in tests
19
+ Layout/LineLength:
20
+ Max: 120
21
+ Exclude:
22
+ - 'test/**/*'
23
+
24
+ # Prefer modern hash syntax
25
+ Style/HashSyntax:
26
+ EnforcedStyle: ruby19
27
+
28
+ # Documentation is important for public APIs
29
+ Style/Documentation:
30
+ Enabled: true
31
+ Exclude:
32
+ - 'test/**/*'
33
+
34
+ # Prefer explicit return in some cases for clarity
35
+ Style/RedundantReturn:
36
+ Enabled: false
37
+
38
+ # Allow compact module/class definitions
39
+ Style/ClassAndModuleChildren:
40
+ Enabled: false
41
+
42
+ # Metrics
43
+ Metrics/MethodLength:
44
+ Max: 20
45
+ Exclude:
46
+ - 'test/**/*'
47
+
48
+ Metrics/AbcSize:
49
+ Max: 20
50
+ Exclude:
51
+ - 'test/**/*'
52
+
53
+ Metrics/BlockLength:
54
+ Exclude:
55
+ - 'test/**/*'
56
+ - '*.gemspec'
57
+
58
+ # Allow both single and double quotes for strings
59
+ Style/StringLiteralsInInterpolation:
60
+ Enabled: false
61
+
62
+ # Naming
63
+ Naming/MethodParameterName:
64
+ MinNameLength: 1
65
+ AllowedNames:
66
+ - _
67
+ - e
data/CHANGELOG.md ADDED
@@ -0,0 +1,81 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.8.0] - 2026-02-14
9
+
10
+ ### Added
11
+ - Pace conversion module for converting running pace between kilometers and miles
12
+ - `convert_pace` method with support for both symbol and string format conversions
13
+ - `pace_km_to_mi` convenience method for kilometers to miles conversion
14
+ - `pace_mi_to_km` convenience method for miles to kilometers conversion
15
+ - Support for both numeric (seconds) and string (MM:SS) input formats
16
+ - Race splits calculator for pacing strategies
17
+ - `race_splits` method to calculate cumulative split times for races
18
+ - Support for even pace, negative splits (progressive), and positive splits (conservative) strategies
19
+ - Flexible split distances: standard race distances ('5k', '1mile') or custom distances (numeric km)
20
+ - Works with all standard race distances including marathon, half marathon, 10K, 5K, and mile races
21
+ - Race time predictor using Riegel formula
22
+ - `predict_time` and `predict_time_clock` methods to predict race times at different distances
23
+ - `predict_pace` and `predict_pace_clock` methods to calculate predicted pace for target races
24
+ - `equivalent_performance` method to compare performances across different race distances
25
+ - Based on proven Riegel formula: T2 = T1 × (D2/D1)^1.06
26
+ - Detailed explanation of the formula and its applications in README
27
+ - Additional race distances for international races
28
+ - `1mile` - 1.60934 kilometers
29
+ - `5mile` - 8.04672 kilometers
30
+ - `10mile` - 16.0934 kilometers
31
+ - Comprehensive test suites
32
+ - 30+ test cases for pace conversions
33
+ - 30+ test cases for race splits covering all strategies and edge cases
34
+ - 35+ test cases for race predictions covering various scenarios
35
+
36
+ ### Changed
37
+ - Expanded `RACE_DISTANCES` to include popular US/UK race distances
38
+ - Updated README with pace conversion, race splits, and race prediction examples
39
+ - Improved documentation with practical examples, use cases, and formula explanations
40
+
41
+ ## [1.7.0] - Released
42
+
43
+ ### Added
44
+ - RuboCop configuration for code style consistency
45
+ - CHANGELOG.md for tracking project changes
46
+ - Comprehensive YARD documentation for all public methods
47
+ - Race pace calculator for standard distances (5K, 10K, half-marathon, marathon)
48
+ - `race_time` and `race_time_clock` methods for calculating finish times
49
+ - `race_pace` and `race_pace_clock` methods for calculating required paces
50
+ - `list_races` method to see available race distances
51
+ - `UnsupportedUnitError` for better error handling
52
+ - Comprehensive test suite with edge cases and error scenarios
53
+ - Test helper utilities for better test organization
54
+
55
+ ### Changed
56
+ - Improved error messages with more context throughout the gem
57
+ - Enhanced validation for edge cases
58
+ - Better method organization and code structure
59
+ - Optimized `convert_to_seconds` method using case statement
60
+ - Improved error handling in `constant` method with nested rescue
61
+
62
+ ### Fixed
63
+ - Minor code style inconsistencies
64
+ - Typo in README: `converto_to_clocktime` → `convert_to_clocktime`
65
+
66
+ ## [1.6.0] - Previous Release
67
+
68
+ ### Added
69
+ - Custom error classes for better error handling
70
+ - `NonPositiveInputError` for invalid numeric inputs
71
+ - `InvalidTimeFormatError` for invalid time format inputs
72
+
73
+ ### Changed
74
+ - Improved error handling throughout the gem
75
+
76
+ ## [1.5.0] and earlier
77
+
78
+ See git history for changes in earlier versions.
79
+
80
+ [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.6.0...HEAD
81
+ [1.6.0]: https://github.com/0jonjo/calcpace/releases/tag/v1.6.0
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.6.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.0&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.6.0'
10
+ gem 'calcpace', '~> 1.8.0'
11
11
  ```
12
12
 
13
13
  Then run:
@@ -131,6 +131,183 @@ converter.list_speed
131
131
  # => {:m_s_to_km_h=>"M S to KM H", :km_h_to_m_s=>"KM H to M S", ...}
132
132
  ```
133
133
 
134
+ ### Chain Conversions
135
+
136
+ Perform multiple conversions in sequence with the converter chain feature:
137
+
138
+ ```ruby
139
+ calc = Calcpace.new
140
+
141
+ # Convert kilometers to miles to feet in one call
142
+ calc.convert_chain(1, [:km_to_mi, :mi_to_feet])
143
+ # => 3280.84 (1 km = 0.621 mi = 3280.84 feet)
144
+
145
+ # Convert with description for debugging
146
+ calc.convert_chain_with_description(100, [:meters_to_km, :km_to_mi])
147
+ # => { result: 0.0621371, description: "100 → meters_to_km → km_to_mi → 0.0621" }
148
+
149
+ # Speed conversions
150
+ calc.convert_chain(10, [:m_s_to_km_h, :km_h_to_mi_h])
151
+ # => 22.3694 (10 m/s = 36 km/h = 22.37 mi/h)
152
+ ```
153
+
154
+ ### Race Pace Calculator
155
+
156
+ Calcpace includes a race pace calculator for standard race distances (5K, 10K, half-marathon, and marathon):
157
+
158
+ ```ruby
159
+ calc = Calcpace.new
160
+
161
+ # Calculate finish time for a race given a pace
162
+ calc.race_time(300, '5k') # => 1500.0 (5:00/km pace for 5K = 25:00)
163
+ calc.race_time_clock('05:00', 'marathon') # => '03:30:58' (5:00/km pace for marathon)
164
+
165
+ # Calculate required pace for a target finish time
166
+ calc.race_pace('00:30:00', '5k') # => 360.0 (need 6:00/km to finish 5K in 30:00)
167
+ calc.race_pace_clock('04:00:00', 'marathon') # => '00:05:41' (need 5:41/km for 4-hour marathon)
168
+
169
+ # List available race distances
170
+ calc.list_races
171
+ # => { '5k' => 5.0, '10k' => 10.0, 'half_marathon' => 21.0975, 'marathon' => 42.195 }
172
+ ```
173
+
174
+ Supported race distances:
175
+ - `5k` - 5 kilometers
176
+ - `10k` - 10 kilometers
177
+ - `half_marathon` - 21.0975 kilometers
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)
310
+
134
311
  ### Other Useful Methods
135
312
 
136
313
  Calcpace also provides other useful methods:
@@ -139,7 +316,7 @@ Calcpace also provides other useful methods:
139
316
  converter = Calcpace.new
140
317
  converter.convert_to_seconds('01:00:00') # => 3600
141
318
  converter.convert_to_clocktime(3600) # => '01:00:00'
142
- converter.converto_to_clocktime(100000) # => '1 03:46:40'
319
+ converter.convert_to_clocktime(100000) # => '1 03:46:40'
143
320
  converter.check_time('01:00:00') # => nil
144
321
  ```
145
322
 
data/calcpace.gemspec CHANGED
@@ -15,7 +15,8 @@ Gem::Specification.new do |spec|
15
15
  spec.license = 'MIT'
16
16
  spec.required_ruby_version = '>= 2.7.0'
17
17
 
18
- spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" # TODO: Create a CHANGELOG.md
18
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
19
+ spec.metadata['rubygems_mfa_required'] = 'true'
19
20
 
20
21
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
22
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
@@ -1,7 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Module to calculate time, distance, pace and velocity
4
+ #
5
+ # This module provides methods to perform calculations related to running and pace.
6
+ # All methods that accept numeric inputs validate that values are positive.
7
+ # Methods prefixed with 'checked_' accept time strings in HH:MM:SS or MM:SS format.
8
+ # Methods prefixed with 'clock_' return results in time format (HH:MM:SS).
4
9
  module Calculator
10
+ # Calculates velocity (distance per unit time)
11
+ #
12
+ # @param time [Numeric] time in any unit (e.g., seconds, hours)
13
+ # @param distance [Numeric] distance in any unit (e.g., meters, kilometers)
14
+ # @return [Float] velocity (distance/time)
15
+ # @raise [Calcpace::NonPositiveInputError] if time or distance is not positive
16
+ #
17
+ # @example
18
+ # velocity(3600, 12000) #=> 3.333... (12000 meters / 3600 seconds = 3.33 m/s)
5
19
  def velocity(time, distance)
6
20
  validate_positive({ time: time, distance: distance })
7
21
  distance.to_f / time
@@ -16,6 +30,15 @@ module Calculator
16
30
  convert_to_clocktime(checked_velocity(time, distance))
17
31
  end
18
32
 
33
+ # Calculates pace (time per unit distance)
34
+ #
35
+ # @param time [Numeric] time in any unit (e.g., seconds, minutes)
36
+ # @param distance [Numeric] distance in any unit (e.g., kilometers, miles)
37
+ # @return [Float] pace (time/distance)
38
+ # @raise [Calcpace::NonPositiveInputError] if time or distance is not positive
39
+ #
40
+ # @example
41
+ # pace(3600, 12) #=> 300.0 (3600 seconds / 12 km = 300 seconds/km = 5:00/km)
19
42
  def pace(time, distance)
20
43
  validate_positive({ time: time, distance: distance })
21
44
  time.to_f / distance
@@ -2,8 +2,22 @@
2
2
 
3
3
  require_relative 'errors'
4
4
 
5
- # Module to check if the input is valid or of the correct type
5
+ # Module to validate input values and formats
6
+ #
7
+ # This module provides validation methods for numeric inputs and time format strings
8
+ # used throughout the Calcpace gem.
6
9
  module Checker
10
+ # Validates that a number is positive (greater than zero)
11
+ #
12
+ # @param number [Numeric] the number to validate
13
+ # @param name [String] the name of the parameter for error messages
14
+ # @raise [Calcpace::NonPositiveInputError] if number is not positive
15
+ # @return [void]
16
+ #
17
+ # @example
18
+ # check_positive(10, 'Distance') #=> nil (valid)
19
+ # check_positive(-5, 'Time') #=> raises NonPositiveInputError
20
+ # check_positive(0, 'Speed') #=> raises NonPositiveInputError
7
21
  def check_positive(number, name = 'Input')
8
22
  return if number.is_a?(Numeric) && number.positive?
9
23
 
@@ -11,10 +25,28 @@ module Checker
11
25
  "#{name} must be a positive number"
12
26
  end
13
27
 
28
+ # Validates that a time string is in the correct format
29
+ #
30
+ # Accepted formats:
31
+ # - HH:MM:SS (hours:minutes:seconds) - e.g., "01:30:45"
32
+ # - MM:SS (minutes:seconds) - e.g., "05:30"
33
+ # - H:MM:SS or M:SS (single digit hours/minutes) - e.g., "1:30:45"
34
+ #
35
+ # @param time_string [String] the time string to validate
36
+ # @raise [Calcpace::InvalidTimeFormatError] if format is invalid
37
+ # @return [void]
38
+ #
39
+ # @example
40
+ # check_time('01:30:45') #=> nil (valid)
41
+ # check_time('5:30') #=> nil (valid)
42
+ # check_time('invalid') #=> raises InvalidTimeFormatError
14
43
  def check_time(time_string)
15
- return if time_string =~ /\A\d{1,2}:\d{2}:\d{2}\z/ ||
16
- time_string =~ /\A\d{1,2}:\d{2}\z/
44
+ # Check if string is valid and matches expected patterns
45
+ return if time_string.is_a?(String) &&
46
+ (time_string =~ /\A\d{1,2}:\d{2}:\d{2}\z/ ||
47
+ time_string =~ /\A\d{1,2}:\d{2}\z/)
17
48
 
18
- raise Calcpace::InvalidTimeFormatError, 'It must be a valid time in the XX:XX:XX or XX:XX format'
49
+ raise Calcpace::InvalidTimeFormatError,
50
+ 'It must be a valid time in the XX:XX:XX or XX:XX format'
19
51
  end
20
52
  end
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Module to convert units
3
+ # Module to convert between different units of distance and speed
4
+ #
5
+ # This module provides conversion methods for 42 different unit pairs,
6
+ # including distance units (kilometers, miles, meters, feet, etc.) and
7
+ # speed units (m/s, km/h, mi/h, knots, etc.).
4
8
  module Converter
5
9
  module Distance
6
10
  KM_TO_MI = 0.621371
@@ -50,34 +54,77 @@ module Converter
50
54
  NAUTICAL_MI_H_TO_MI_H = 1.15078
51
55
  end
52
56
 
57
+ # Converts a value from one unit to another
58
+ #
59
+ # @param value [Numeric] the value to convert
60
+ # @param unit [Symbol, String] the conversion unit (e.g., :km_to_mi or 'km to mi')
61
+ # @return [Float] the converted value
62
+ # @raise [Calcpace::NonPositiveInputError] if value is not positive
63
+ # @raise [Calcpace::UnsupportedUnitError] if the unit is not supported
64
+ #
65
+ # @example
66
+ # convert(10, :km_to_mi) #=> 6.21371 (10 km = 6.21 miles)
67
+ # convert(5, 'mi to km') #=> 8.0467 (5 miles = 8.05 km)
53
68
  def convert(value, unit)
54
69
  check_positive(value, 'Value')
55
70
  unit_constant = constant(unit)
56
71
  value * unit_constant
57
72
  end
58
73
 
74
+ # Converts a time string to total seconds
75
+ #
76
+ # @param time [String] time string in HH:MM:SS or MM:SS format
77
+ # @return [Integer] total seconds
78
+ #
79
+ # @example
80
+ # convert_to_seconds('01:30:00') #=> 5400 (1 hour 30 minutes)
81
+ # convert_to_seconds('05:30') #=> 330 (5 minutes 30 seconds)
59
82
  def convert_to_seconds(time)
60
83
  parts = time.split(':').map(&:to_i)
61
- if parts.length == 2
84
+ case parts.length
85
+ when 2
62
86
  minute, seconds = parts
63
87
  (minute * 60) + seconds
64
- else
88
+ when 3
65
89
  hour, minute, seconds = parts
66
90
  (hour * 3600) + (minute * 60) + seconds
91
+ else
92
+ 0
67
93
  end
68
94
  end
69
95
 
96
+ # Converts seconds to a clocktime string
97
+ #
98
+ # @param seconds [Numeric] total seconds
99
+ # @return [String] time in HH:MM:SS format, or "D HH:MM:SS" for durations over 24 hours
100
+ #
101
+ # @example
102
+ # convert_to_clocktime(3600) #=> '01:00:00' (1 hour)
103
+ # convert_to_clocktime(100000) #=> '1 03:46:40' (1 day, 3 hours, 46 minutes, 40 seconds)
70
104
  def convert_to_clocktime(seconds)
71
105
  days = seconds / 86_400
72
106
  format = days.to_i.positive? ? "#{days} %H:%M:%S" : '%H:%M:%S'
73
107
  Time.at(seconds).utc.strftime(format)
74
108
  end
75
109
 
110
+ # Retrieves the conversion constant for a given unit
111
+ #
112
+ # @param unit [Symbol, String] the unit conversion (e.g., :km_to_mi or 'km to mi')
113
+ # @return [Float] the conversion factor
114
+ # @raise [Calcpace::UnsupportedUnitError] if the unit is not supported
115
+ #
116
+ # @example
117
+ # constant(:km_to_mi) #=> 0.621371
118
+ # constant('km to mi') #=> 0.621371
76
119
  def constant(unit)
77
120
  unit = format_unit(unit) if unit.is_a?(String)
78
121
  Distance.const_get(unit.to_s.upcase)
79
122
  rescue NameError
80
- Speed.const_get(unit.to_s.upcase)
123
+ begin
124
+ Speed.const_get(unit.to_s.upcase)
125
+ rescue NameError
126
+ raise Calcpace::UnsupportedUnitError, unit
127
+ end
81
128
  end
82
129
 
83
130
  def list_all
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module for chaining multiple unit conversions
4
+ #
5
+ # This module allows performing multiple conversions in sequence,
6
+ # which is useful for complex unit transformations.
7
+ module ConverterChain
8
+ # Performs a chain of conversions on a value
9
+ #
10
+ # @param value [Numeric] the initial value to convert
11
+ # @param conversions [Array<Symbol, String>] array of conversion units
12
+ # @return [Float] the final converted value
13
+ # @raise [Calcpace::NonPositiveInputError] if value is not positive
14
+ # @raise [Calcpace::UnsupportedUnitError] if any conversion unit is not supported
15
+ #
16
+ # @example Convert kilometers to miles to feet
17
+ # convert_chain(1, [:km_to_mi, :mi_to_feet])
18
+ # #=> 3280.84 (1 km = 0.621 mi = 3280.84 feet)
19
+ #
20
+ # @example Using string format
21
+ # convert_chain(100, ['meters to km', 'km to mi'])
22
+ # #=> 0.0621371 (100 m = 0.1 km = 0.0621 mi)
23
+ def convert_chain(value, conversions)
24
+ check_positive(value, 'Value')
25
+ conversions.reduce(value) do |result, conversion|
26
+ result * constant(conversion)
27
+ end
28
+ end
29
+
30
+ # Performs a chain of conversions and returns a description
31
+ #
32
+ # @param value [Numeric] the initial value to convert
33
+ # @param conversions [Array<Symbol, String>] array of conversion units
34
+ # @return [Hash] hash with :result and :description keys
35
+ #
36
+ # @example
37
+ # convert_chain_with_description(1, [:km_to_mi, :mi_to_feet])
38
+ # #=> { result: 3280.84, description: "1.0 → km_to_mi → mi_to_feet → 3280.84" }
39
+ def convert_chain_with_description(value, conversions)
40
+ initial_value = value
41
+ result = convert_chain(value, conversions)
42
+ conversion_names = conversions.map(&:to_s).join(' → ')
43
+ description = "#{initial_value} → #{conversion_names} → #{result.round(4)}"
44
+
45
+ { result: result, description: description }
46
+ end
47
+ end
@@ -1,9 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Calcpace custom error classes for better error handling
3
4
  class Calcpace
5
+ # Base error class for all Calcpace errors
4
6
  class Error < StandardError; end
5
7
 
6
- class InvalidTimeFormatError < Error; end
8
+ # Raised when time string format is invalid
9
+ # Expected formats: HH:MM:SS or MM:SS
10
+ class InvalidTimeFormatError < Error
11
+ def initialize(msg = 'Invalid time format. Expected HH:MM:SS or MM:SS format.')
12
+ super
13
+ end
14
+ end
7
15
 
8
- class NonPositiveInputError < Error; end
16
+ # Raised when a numeric input is not positive (zero or negative)
17
+ class NonPositiveInputError < Error
18
+ def initialize(msg = 'Input must be a positive number.')
19
+ super
20
+ end
21
+ end
22
+
23
+ # Raised when an unsupported unit conversion is requested
24
+ class UnsupportedUnitError < Error
25
+ def initialize(unit = nil)
26
+ msg = unit ? "Unsupported unit conversion: #{unit}" : 'Unsupported unit conversion'
27
+ super(msg)
28
+ end
29
+ end
9
30
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module for calculating race times and paces for standard distances
4
+ #
5
+ # This module provides convenience methods for calculating finish times
6
+ # and paces for common race distances like 5K, 10K, half-marathon, and marathon.
7
+ module PaceCalculator
8
+ # Standard race distances in kilometers
9
+ RACE_DISTANCES = {
10
+ '5k' => 5.0,
11
+ '10k' => 10.0,
12
+ 'half_marathon' => 21.0975,
13
+ 'marathon' => 42.195,
14
+ '1mile' => 1.60934,
15
+ '5mile' => 8.04672,
16
+ '10mile' => 16.0934
17
+ }.freeze
18
+
19
+ # Calculates the finish time for a race given a pace per kilometer
20
+ #
21
+ # @param pace_per_km [Numeric, String] pace in seconds per km or time string (MM:SS)
22
+ # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon')
23
+ # @return [Float] total time in seconds
24
+ # @raise [ArgumentError] if race distance is not recognized
25
+ #
26
+ # @example
27
+ # race_time(300, :5k) #=> 1500.0 (5:00/km pace for 5K = 25:00)
28
+ # race_time('05:00', :marathon) #=> 12658.5 (5:00/km pace for marathon = 3:30:58)
29
+ def race_time(pace_per_km, race)
30
+ distance = race_distance(race)
31
+ pace_seconds = pace_per_km.is_a?(String) ? convert_to_seconds(pace_per_km) : pace_per_km
32
+ check_positive(pace_seconds, 'Pace')
33
+ distance * pace_seconds
34
+ end
35
+
36
+ # Calculates the finish time for a race and returns it as a clock time string
37
+ #
38
+ # @param pace_per_km [Numeric, String] pace in seconds per km or time string (MM:SS)
39
+ # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon')
40
+ # @return [String] finish time in HH:MM:SS format
41
+ #
42
+ # @example
43
+ # race_time_clock('05:00', :marathon) #=> '03:30:58'
44
+ # race_time_clock(300, :half_marathon) #=> '01:45:17'
45
+ def race_time_clock(pace_per_km, race)
46
+ convert_to_clocktime(race_time(pace_per_km, race))
47
+ end
48
+
49
+ # Calculates the required pace per kilometer to finish a race in a target time
50
+ #
51
+ # @param target_time [Numeric, String] target finish time in seconds or time string (HH:MM:SS)
52
+ # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon')
53
+ # @return [Float] required pace in seconds per kilometer
54
+ #
55
+ # @example
56
+ # race_pace('03:30:00', :marathon) #=> 297.48... (4:57/km to finish in 3:30)
57
+ # race_pace(1800, :5k) #=> 360.0 (6:00/km to finish in 30:00)
58
+ def race_pace(target_time, race)
59
+ distance = race_distance(race)
60
+ time_seconds = target_time.is_a?(String) ? convert_to_seconds(target_time) : target_time
61
+ check_positive(time_seconds, 'Time')
62
+ time_seconds / distance
63
+ end
64
+
65
+ # Calculates the required pace and returns it as a clock time string
66
+ #
67
+ # @param target_time [Numeric, String] target finish time in seconds or time string (HH:MM:SS)
68
+ # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon')
69
+ # @return [String] required pace in MM:SS format
70
+ #
71
+ # @example
72
+ # race_pace_clock('03:30:00', :marathon) #=> '00:04:57'
73
+ def race_pace_clock(target_time, race)
74
+ convert_to_clocktime(race_pace(target_time, race))
75
+ end
76
+
77
+ # Lists all available standard race distances
78
+ #
79
+ # @return [Hash] hash of race names and distances in kilometers
80
+ #
81
+ # @example
82
+ # list_races #=> { '5k' => 5.0, '10k' => 10.0, ... }
83
+ def list_races
84
+ RACE_DISTANCES.dup
85
+ end
86
+
87
+ private
88
+
89
+ # Gets the distance for a standard race
90
+ #
91
+ # @param race [String, Symbol] race name
92
+ # @return [Float] distance in kilometers
93
+ # @raise [ArgumentError] if race is not recognized
94
+ def race_distance(race)
95
+ key = race.to_s.downcase
96
+ RACE_DISTANCES.fetch(key) do
97
+ raise ArgumentError,
98
+ "Unknown race: #{race}. Available races: #{RACE_DISTANCES.keys.join(', ')}"
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,96 @@
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
+ # Conversion factor: 1 mile = 1.60934 kilometers
9
+ MI_TO_KM = 1.60934
10
+ KM_TO_MI = 0.621371
11
+
12
+ # Converts pace from one unit to another
13
+ #
14
+ # @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS)
15
+ # @param conversion [Symbol, String] conversion type (:km_to_mi, :mi_to_km, 'km to mi', 'mi to km')
16
+ # @return [String] converted pace in MM:SS format
17
+ # @raise [ArgumentError] if conversion type is not supported
18
+ # @raise [Calcpace::NonPositiveInputError] if pace is not positive
19
+ #
20
+ # @example
21
+ # convert_pace('05:00', :km_to_mi) #=> '08:02' (5:00/km = 8:02/mi)
22
+ # convert_pace('08:00', :mi_to_km) #=> '04:58' (8:00/mi ≈ 4:58/km)
23
+ # convert_pace(300, 'km to mi') #=> '08:02' (300s/km = 482s/mi)
24
+ def convert_pace(pace, conversion)
25
+ pace_seconds = pace.is_a?(String) ? convert_to_seconds(pace) : pace
26
+ check_positive(pace_seconds, 'Pace')
27
+
28
+ conversion_type = normalize_conversion(conversion)
29
+ converted_seconds = apply_pace_conversion(pace_seconds, conversion_type)
30
+
31
+ convert_to_clocktime(converted_seconds)
32
+ end
33
+
34
+ # Converts pace from kilometers to miles
35
+ #
36
+ # @param pace_per_km [Numeric, String] pace in seconds per km or time string (MM:SS)
37
+ # @return [String] pace per mile in MM:SS format
38
+ #
39
+ # @example
40
+ # pace_km_to_mi('05:00') #=> '08:02' (5:00/km = 8:02/mi)
41
+ # pace_km_to_mi(300) #=> '08:02' (300s/km = 482s/mi)
42
+ def pace_km_to_mi(pace_per_km)
43
+ convert_pace(pace_per_km, :km_to_mi)
44
+ end
45
+
46
+ # Converts pace from miles to kilometers
47
+ #
48
+ # @param pace_per_mi [Numeric, String] pace in seconds per mile or time string (MM:SS)
49
+ # @return [String] pace per kilometer in MM:SS format
50
+ #
51
+ # @example
52
+ # pace_mi_to_km('08:00') #=> '04:58' (8:00/mi ≈ 4:58/km)
53
+ # pace_mi_to_km(480) #=> '04:58' (480s/mi = 298s/km)
54
+ def pace_mi_to_km(pace_per_mi)
55
+ convert_pace(pace_per_mi, :mi_to_km)
56
+ end
57
+
58
+ private
59
+
60
+ # Normalizes conversion string/symbol to standard format
61
+ #
62
+ # @param conversion [Symbol, String] conversion type
63
+ # @return [Symbol] normalized conversion symbol
64
+ # @raise [ArgumentError] if conversion type is not supported
65
+ def normalize_conversion(conversion)
66
+ normalized = if conversion.is_a?(String)
67
+ conversion.downcase.gsub(/\s+/, '_').to_sym
68
+ else
69
+ conversion.to_sym
70
+ end
71
+
72
+ unless %i[km_to_mi mi_to_km].include?(normalized)
73
+ raise ArgumentError,
74
+ "Unsupported pace conversion: #{conversion}. " \
75
+ "Supported conversions: km_to_mi, mi_to_km"
76
+ end
77
+
78
+ normalized
79
+ end
80
+
81
+ # Applies the pace conversion
82
+ #
83
+ # @param pace_seconds [Numeric] pace in seconds
84
+ # @param conversion_type [Symbol] conversion type (:km_to_mi or :mi_to_km)
85
+ # @return [Float] converted pace in seconds
86
+ def apply_pace_conversion(pace_seconds, conversion_type)
87
+ case conversion_type
88
+ when :km_to_mi
89
+ # If running at X seconds per km, pace per mile = X * (miles to km ratio)
90
+ pace_seconds * MI_TO_KM
91
+ when :mi_to_km
92
+ # If running at X seconds per mile, pace per km = X / (miles to km ratio)
93
+ pace_seconds * KM_TO_MI
94
+ end
95
+ end
96
+ 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,208 @@
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
+ half_distance = total_distance / 2.0
140
+ avg_pace = target_seconds / total_distance
141
+
142
+ # First half: 4% slower, second half: 4% faster
143
+ first_half_pace = avg_pace * 1.04
144
+ second_half_pace = avg_pace * 0.96
145
+
146
+ splits = []
147
+ distance_covered = 0.0
148
+ cumulative_time = 0.0
149
+
150
+ while distance_covered < total_distance - 0.001
151
+ distance_covered += split_km
152
+ distance_covered = total_distance if distance_covered > total_distance
153
+
154
+ if distance_covered <= half_distance
155
+ # First half
156
+ cumulative_time = distance_covered * first_half_pace
157
+ else
158
+ # Second half
159
+ time_at_halfway = half_distance * first_half_pace
160
+ distance_in_second_half = distance_covered - half_distance
161
+ cumulative_time = time_at_halfway + (distance_in_second_half * second_half_pace)
162
+ end
163
+
164
+ splits << convert_to_clocktime(cumulative_time.round)
165
+ end
166
+
167
+ splits
168
+ end
169
+
170
+ # Calculates positive splits (first half faster than second half)
171
+ # First half is ~4% faster, second half is ~4% slower
172
+ #
173
+ # @param total_distance [Float] total race distance in kilometers
174
+ # @param target_seconds [Float] target finish time in seconds
175
+ # @param split_km [Float] split distance in kilometers
176
+ # @return [Array<String>] array of cumulative split times
177
+ def calculate_positive_splits(total_distance, target_seconds, split_km)
178
+ half_distance = total_distance / 2.0
179
+ avg_pace = target_seconds / total_distance
180
+
181
+ # First half: 4% faster, second half: 4% slower
182
+ first_half_pace = avg_pace * 0.96
183
+ second_half_pace = avg_pace * 1.04
184
+
185
+ splits = []
186
+ distance_covered = 0.0
187
+ cumulative_time = 0.0
188
+
189
+ while distance_covered < total_distance - 0.001
190
+ distance_covered += split_km
191
+ distance_covered = total_distance if distance_covered > total_distance
192
+
193
+ if distance_covered <= half_distance
194
+ # First half
195
+ cumulative_time = distance_covered * first_half_pace
196
+ else
197
+ # Second half
198
+ time_at_halfway = half_distance * first_half_pace
199
+ distance_in_second_half = distance_covered - half_distance
200
+ cumulative_time = time_at_halfway + (distance_in_second_half * second_half_pace)
201
+ end
202
+
203
+ splits << convert_to_clocktime(cumulative_time.round)
204
+ end
205
+
206
+ splits
207
+ end
208
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.6.0'
4
+ VERSION = '1.8.0'
5
5
  end
data/lib/calcpace.rb CHANGED
@@ -3,13 +3,45 @@
3
3
  require_relative 'calcpace/calculator'
4
4
  require_relative 'calcpace/checker'
5
5
  require_relative 'calcpace/converter'
6
+ require_relative 'calcpace/converter_chain'
6
7
  require_relative 'calcpace/errors'
8
+ require_relative 'calcpace/pace_calculator'
9
+ require_relative 'calcpace/pace_converter'
10
+ require_relative 'calcpace/race_predictor'
11
+ require_relative 'calcpace/race_splits'
7
12
 
8
- # Main class to calculate velocity, pace, time, distance and velocity
13
+ # Calcpace - A Ruby gem for pace, distance, and time calculations
14
+ #
15
+ # Calcpace provides methods to calculate and convert values related to pace,
16
+ # distance, time, and speed. It supports various time formats and 42 different
17
+ # unit conversions.
18
+ #
19
+ # @example Basic velocity calculation
20
+ # calc = Calcpace.new
21
+ # calc.velocity(3600, 12000) #=> 3.333... (12000m in 3600s = 3.33 m/s)
22
+ #
23
+ # @example Time string calculations
24
+ # calc = Calcpace.new
25
+ # calc.checked_pace('01:00:00', 10) #=> 360.0 (1 hour / 10 km = 6:00/km pace)
26
+ # calc.clock_pace('01:00:00', 10) #=> '00:06:00'
27
+ #
28
+ # @example Unit conversions
29
+ # calc = Calcpace.new
30
+ # calc.convert(10, :km_to_mi) #=> 6.21371 (10 km = 6.21 miles)
31
+ # calc.convert(1, 'm_s_to_km_h') #=> 3.6 (1 m/s = 3.6 km/h)
32
+ #
33
+ # @see https://github.com/0jonjo/calcpace
9
34
  class Calcpace
10
35
  include Calculator
11
36
  include Checker
12
37
  include Converter
38
+ include ConverterChain
39
+ include PaceCalculator
40
+ include PaceConverter
41
+ include RacePredictor
42
+ include RaceSplits
13
43
 
14
- def initialize; end
44
+ # Creates a new Calcpace instance
45
+ #
46
+ # @return [Calcpace] a new instance ready to perform calculations
15
47
  end
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.6.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - João Gilberto Saraiva
@@ -19,8 +19,10 @@ extra_rdoc_files: []
19
19
  files:
20
20
  - ".github/workflows/tests.yml"
21
21
  - ".gitignore"
22
+ - ".rubocop.yml"
22
23
  - ".ruby-version"
23
24
  - ".tool-versions"
25
+ - CHANGELOG.md
24
26
  - Gemfile
25
27
  - Gemfile.lock
26
28
  - README.md
@@ -30,7 +32,12 @@ files:
30
32
  - lib/calcpace/calculator.rb
31
33
  - lib/calcpace/checker.rb
32
34
  - lib/calcpace/converter.rb
35
+ - lib/calcpace/converter_chain.rb
33
36
  - lib/calcpace/errors.rb
37
+ - lib/calcpace/pace_calculator.rb
38
+ - lib/calcpace/pace_converter.rb
39
+ - lib/calcpace/race_predictor.rb
40
+ - lib/calcpace/race_splits.rb
34
41
  - lib/calcpace/version.rb
35
42
  homepage: https://github.com/0jonjo/calcpace
36
43
  licenses:
@@ -38,6 +45,7 @@ licenses:
38
45
  metadata:
39
46
  source_code_uri: https://github.com/0jonjo/calcpace
40
47
  changelog_uri: https://github.com/0jonjo/calcpace/blob/main/CHANGELOG.md
48
+ rubygems_mfa_required: 'true'
41
49
  rdoc_options: []
42
50
  require_paths:
43
51
  - lib