calcpace 1.13.0 → 1.15.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: 76b9c7242a528134ed36832a7be8fc3e83ccb65fc7e139232d2987a78116498b
4
- data.tar.gz: f87752c75360bfa9dca40ae93bfdeaafc1522f7592d9ba3ca236cd9fb1d63a56
3
+ metadata.gz: 5eefef398417bfe2d045b2afd7ee78991b443c7c9bc1eabd536e81d03789908c
4
+ data.tar.gz: 7b676576e7e3af9b9d4ce75430af80835a35535b2c8b971ed5ef764ddd608853
5
5
  SHA512:
6
- metadata.gz: 63421f8934eb0c94aa0713056fb6014ee7fa1ef7e1f611d16477ab3c84fb9235909b4f2e82e0e6e38a59d66ce801d109a76215ffbe08dddafd19523330327172
7
- data.tar.gz: 9754c81c00f31f76e0250ebd0388dd7779cac95312513945364fc8f3efe914efd2f871c47afda9afbe442fdcc68d0c1eee3454348119bbd998f168c258af449c
6
+ metadata.gz: 2b7481ff8c0c4b91f724af723eacfd9357905fa2207adc0871dd745d7fc4a4beac52349f0051703a91288c724dd2b6c00298beb585d08949b156833e9fc70af7
7
+ data.tar.gz: 7d58f1c452924bbc9c7a895a5ed5102e1ae298b002984d9708aea23b4f54f7339fa2b4dd6708b678cf5f1dd45d5cfd8cc8dd5c3e869df6b07856f08eaf6c720c
data/.rubocop.yml CHANGED
@@ -80,3 +80,11 @@ Naming/MethodParameterName:
80
80
  AllowedNames:
81
81
  - _
82
82
  - e
83
+
84
+ # The documented-examples test evaluates snippets scraped from our own README
85
+ # and docstrings — that is the entire point of the file, and the cop's required
86
+ # comment format cannot be satisfied without it then reporting the disable as
87
+ # redundant.
88
+ Style/DocumentDynamicEvalDefinition:
89
+ Exclude:
90
+ - 'test/calcpace/test_documented_examples.rb'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,186 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.15.0] - 2026-08-30
11
+
12
+ ### Added
13
+ - Every method that takes a race now accepts a **plain distance in kilometers**,
14
+ not only one of the eight standard race names. Most races are not a 5K or a
15
+ marathon, and a formula does not care what a distance is called:
16
+
17
+ ```ruby
18
+ calc.race_time_clock('05:00', 7.79) # => "00:38:57"
19
+ calc.race_pace_clock('00:26:59', 7.79) # => "00:03:27"
20
+ calc.predict_time_clock(7.79, '00:26:59', 'half_marathon') # => "01:17:34"
21
+ calc.predict_time_clock('10k', '00:42:00', 15) # => "01:04:33"
22
+ calc.predict_time_clock(7.79, '00:26:59', 15) # => "00:54:02"
23
+ calc.predict_time_cameron_clock(7.79, '00:26:59', 'half_marathon') # => "01:13:44"
24
+ calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k')
25
+ # => ["00:03:28", "00:06:56", "00:10:23", "00:13:51", "00:17:19", "00:20:47", "00:24:15", "00:26:59"]
26
+ ```
27
+
28
+ Both ends of a prediction follow the same rule, so all four combinations work:
29
+ name to name, name to number, number to name, number to number. The methods
30
+ that reach a distance through `race_distance` inherit it: `race_time`,
31
+ `race_pace`, their `_clock` variants, `predict_time`, `predict_pace`,
32
+ `equivalent_performance`, the Cameron equivalents, the `_adjusted` variants,
33
+ `race_splits`, and `race_times_from_vo2max`.
34
+
35
+ A numeric **string** counts as a distance: `'7.79'` and `7.79` mean the same
36
+ thing. This is not a new convention — `training_paces_from_race` and
37
+ `predict_time_from_vo2max` have read numeric strings as distances since they
38
+ were written, and `race_distance` was the one place that disagreed. A string
39
+ that is not a number is still a race name, so `'7.79k'` remains
40
+ `ArgumentError: Unknown race`. Only `Numeric` and `String` are read as
41
+ distances; a Symbol is always a name, as in the two methods above.
42
+
43
+ A numeric distance must be positive, and reports itself the way every other
44
+ distance in the gem does:
45
+
46
+ ```ruby
47
+ calc.race_time(300, 0) # => Calcpace::NonPositiveInputError: Distance must be a positive number
48
+ ```
49
+
50
+ Before this release those calls raised `ArgumentError: Unknown race: 0`. The
51
+ input was an error either way; only the class and the message changed.
52
+
53
+ ### Breaking
54
+ - **A non-positive distance now raises `Calcpace::NonPositiveInputError`
55
+ instead of `ArgumentError`.** Calls like `race_time(300, 0)` or
56
+ `race_splits('0', ...)` used to fail with `ArgumentError: Unknown race: 0`,
57
+ because `0` was not a race name; now `0` is read as a distance and rejected
58
+ as one. `Calcpace::NonPositiveInputError` inherits from `Calcpace::Error`,
59
+ **not** from `ArgumentError`, so a caller that wraps this library in
60
+ `rescue ArgumentError` — a form field arriving as `"0"`, for example — will
61
+ see the exception escape instead of being caught.
62
+ The alternative was to make this one path raise `ArgumentError` for
63
+ consistency with the old behaviour, which would have made the library
64
+ inconsistent with itself: every other non-positive input in the gem already
65
+ raises `NonPositiveInputError`. Wrapping in `rescue Calcpace::Error,
66
+ ArgumentError` handles both this and any future version.
67
+
68
+ ### Changed
69
+ - The "from and to must be different distances" guard in `predict_time` and
70
+ `predict_time_cameron` no longer compares distances with `==`. Two distances
71
+ now count as the same race when they differ by less than
72
+ `PaceCalculator::SAME_DISTANCE_TOLERANCE_RATIO` (1e-9, relative), so a
73
+ distance that only differs by floating-point noise still raises instead of
74
+ returning a prediction of the same time back:
75
+
76
+ ```ruby
77
+ calc.predict_time(10.0, 2520, '10k')
78
+ # => ArgumentError: From and to races must be different distances (both are 10.0km)
79
+ ```
80
+
81
+ The window is deliberately narrow: it absorbs representation noise and
82
+ nothing else. `predict_time(10.0, 2520, 10.2)` is a legitimate 200 m
83
+ extrapolation and still answers.
84
+
85
+ - Age grading matches a numeric distance to a standard within **2%**, up from
86
+ 0.5% (`AgeGrading::STANDARD_DISTANCE_TOLERANCE_RATIO`, previously an
87
+ unnamed literal). GPS rarely reads a 5K as exactly 5.000 km, and 2% is the
88
+ window calcpace.app already uses to decide a run "is a 5K" — the two used to
89
+ disagree about the same run:
90
+
91
+ ```ruby
92
+ calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # => 51.9
93
+ calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 51.9
94
+ ```
95
+
96
+ Nothing else about age grading changed, on purpose. A distance outside the
97
+ window still raises, and that is the intended answer rather than a missing
98
+ feature: the WMA 2023 tables publish a factor per *specific* distance, so
99
+ there is no standard for 7.79 km to compare against, and interpolating one
100
+ would produce a number with the look of an official standard and none of the
101
+ authority.
102
+
103
+ ```ruby
104
+ calc.age_grade(7.79, '00:26:59', age: 36, sex: :male)
105
+ # => ArgumentError: Unsupported distance 7.79km. Supported: 5.0, 10.0, 21.0975, 42.195 km
106
+ ```
107
+
108
+ Riegel and Cameron both degrade as the jump between distances grows — a
109
+ marathon predicted from a 1 km time is arithmetic, not a forecast. This
110
+ release adds no guard against that: the gem never warned about it for the
111
+ standard race names either, and inventing a threshold now would be a new
112
+ opinion, not a fix. The note is here and in the README so the caller can
113
+ weigh it.
114
+
115
+ ### Fixed
116
+ - Documentation only, no behavior change: several `@example` values in
117
+ `PaceCalculator`, `RacePredictor` and `CameronPredictor`, and their
118
+ counterparts in the README, had drifted from what the code returns — the
119
+ worst of them by more than six minutes (`predict_time_cameron_clock` was
120
+ documented as `'00:02:32'` where it returns `'00:04:15'`). Two `@example`
121
+ lines also used `:5k`, which is not valid Ruby syntax, and now use `'5k'`.
122
+ - An adversarial review of this release found three more that the first pass
123
+ had missed, including the README block for `equivalent_performance` — which
124
+ contradicted the docstring corrected in this very release — and the main
125
+ age-grading example, wrong in four of its eight fields. Every example was
126
+ then executed and compared line by line, README and `@example` alike. The
127
+ lesson was acted on rather than recorded: `test_documented_examples.rb` now
128
+ executes every single-line example in the README and in the docstrings and
129
+ compares it with what the code returns, so a drifted example fails the suite
130
+ instead of reaching a reader. It pins two numbers — how many examples it
131
+ finds and how many it actually compares — because a scanner that silently
132
+ matches nothing would pass forever.
133
+
134
+ ## [1.14.0] - 2026-08-29
135
+
136
+ ### Added
137
+ - `compact:` keyword on every pace-producing method, so a caller can ask for the
138
+ display format `convert_to_clocktime(compact: true)` introduced in 1.13.0
139
+ without reformatting the string itself
140
+ - `convert_pace(pace, conversion, compact: false)`
141
+ - `pace_km_to_mi(pace_per_km, compact: false)`
142
+ - `pace_mi_to_km(pace_per_mi, compact: false)`
143
+ - `track_splits(points, split_km = 1.0, compact: false)` — only the `:pace`
144
+ value changes; `:km` and `:elapsed` are numbers and stay as they are
145
+
146
+ ```ruby
147
+ calc.pace_km_to_mi('05:00') # => "00:08:02"
148
+ calc.pace_km_to_mi('05:00', compact: true) # => "8:02"
149
+ calc.track_splits(points, 1.0, compact: true)
150
+ # => [{ km: 1.0, elapsed: 312, pace: "5:12" }, ...]
151
+ ```
152
+
153
+ The default stays `compact: false` everywhere, byte-for-byte the previous
154
+ output — the one exception is the negative-split fix below, which corrects a
155
+ value that was arithmetically wrong. Input validation is untouched:
156
+ a zero or negative pace still raises `Calcpace::NonPositiveInputError` and an
157
+ unknown conversion still raises `ArgumentError` in both modes.
158
+
159
+ Three places the two formats disagree about more than padding, all of them
160
+ now reachable through the pace APIs:
161
+
162
+ - A split pace slower than an hour per unit: the padded format keeps counting
163
+ minutes (`"66:33"`), as `track_splits` always has, while the compact one
164
+ rolls them into an hour field (`"1:06:33"`), consistent with every other
165
+ compact duration in the gem. Past 24 hours per unit the gap widens —
166
+ `"2248:18"` padded against `"37:28:18"` compact.
167
+ - Durations past 24 hours, the day-prefix rule 1.13.0 documented for
168
+ `convert_to_clocktime` alone, now visible through `convert_pace` too:
169
+ `convert_pace(100_000, :km_to_mi)` #=> `"1 20:42:14"`, against
170
+ `"44:42:14"` compact.
171
+ - A negative split (see Fixed below), signed in both formats but padded to a
172
+ different width: `"-00:40"` against `"-0:40"`.
173
+
174
+ ### Fixed
175
+ - `track_splits` no longer misreports a negative split pace. A GPS track can
176
+ step backwards in time — a watch resyncing its clock, a device paused and
177
+ restarted, two segments merged out of order — which makes a split's elapsed
178
+ time negative. The padded format rendered that through Ruby's floor division,
179
+ so a −40 s split printed as `"-1:20"`; it now prints `"-00:40"`, and the
180
+ compact format prints `"-0:40"`. Neither mode raises: bad GPS data has always
181
+ been reported rather than blown up, and `compact: true` does not change that.
182
+ - `convert_pace`, `pace_km_to_mi` and `pace_mi_to_km` documented their return
183
+ value as `'08:02'` when they have always returned the padded `'00:08:02'`.
184
+ The docs now match the code; the code is unchanged.
185
+ - README and YARD examples for `track_distance`, `haversine_distance` and
186
+ `track_splits` printed numbers their own input never produced (`0.87` km for
187
+ a 1.51 km track, a `"05:12"` split for a `"06:55"` one). Every example is now
188
+ the real output of the code above it.
189
+
10
190
  ## [1.13.0] - 2026-08-28
11
191
 
12
192
  ### Added
@@ -325,7 +505,9 @@ predictors are untouched.
325
505
 
326
506
  See git history for changes in earlier versions.
327
507
 
328
- [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.13.0...HEAD
508
+ [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.15.0...HEAD
509
+ [1.15.0]: https://github.com/0jonjo/calcpace/compare/v1.14.0...v1.15.0
510
+ [1.14.0]: https://github.com/0jonjo/calcpace/compare/v1.13.0...v1.14.0
329
511
  [1.13.0]: https://github.com/0jonjo/calcpace/compare/v1.12.1...v1.13.0
330
512
  [1.12.0]: https://github.com/0jonjo/calcpace/compare/v1.11.0...v1.12.0
331
513
  [1.11.0]: https://github.com/0jonjo/calcpace/compare/v1.10.0...v1.11.0
data/README.md CHANGED
@@ -22,7 +22,7 @@ calc = Calcpace.new
22
22
  ```ruby
23
23
  calc.velocity(3625, 12275) # => 3.386 (distance / time)
24
24
  calc.pace(3665, 12) # => 305.4 (time / distance)
25
- calc.time(210, 12) # => 2520.0 (pace × distance)
25
+ calc.time(210, 12) # => 2520 (pace × distance)
26
26
  calc.distance(9660, 120) # => 80.5 (velocity × time)
27
27
 
28
28
  # Clocktime input/output (HH:MM:SS or MM:SS)
@@ -93,18 +93,40 @@ See all units: `calc.list_all`, `calc.list_distance`, `calc.list_speed`.
93
93
  ```ruby
94
94
  calc.pace_km_to_mi('05:00') # => "00:08:02"
95
95
  calc.pace_mi_to_km('08:00') # => "00:04:58"
96
+ calc.convert_pace(300, :km_to_mi) # => "00:08:02"
97
+ ```
98
+
99
+ All three take a `compact:` keyword for the display format a runner reads on a
100
+ screen, the same one `convert_to_clocktime` offers:
101
+
102
+ ```ruby
103
+ calc.pace_km_to_mi('05:00', compact: true) # => "8:02"
104
+ calc.pace_mi_to_km(480, compact: true) # => "4:58"
105
+ calc.convert_pace('05:00', :km_to_mi, compact: true) # => "8:02"
96
106
  ```
97
107
 
98
108
  ---
99
109
 
100
110
  ### Race Pace & Time
101
111
 
112
+ Every method that takes a race accepts either a standard race name or a plain
113
+ distance in kilometers — most races are not a 5K or a marathon.
114
+
102
115
  ```ruby
103
116
  calc.race_time_clock('05:00', 'marathon') # => "03:30:58"
104
117
  calc.race_pace_clock('04:00:00', 'marathon') # => "00:05:41"
105
118
  calc.list_races # => { '5k' => 5.0, '10k' => 10.0, 'half_marathon' => 21.0975, 'marathon' => 42.195, '100k' => 100.0, ... }
119
+
120
+ # Any distance, named or not — 7.79 km and '7.79' mean the same thing
121
+ calc.race_time_clock('05:00', 7.79) # => "00:38:57"
122
+ calc.race_time_clock('05:00', '7.79') # => "00:38:57"
123
+ calc.race_pace_clock('00:26:59', 7.79) # => "00:03:27"
106
124
  ```
107
125
 
126
+ A distance must be positive (`Calcpace::NonPositiveInputError` otherwise), and
127
+ anything that is neither a number nor a known race name still raises
128
+ `ArgumentError` — `'7.79k'` is a typo, not a distance.
129
+
108
130
  ---
109
131
 
110
132
  ### Race Splits
@@ -117,6 +139,10 @@ calc.race_splits('half_marathon', target_time: '01:30:00', split_distance: '5k')
117
139
  # Strategies: :even (default), :negative (second half faster), :positive (first half faster)
118
140
  calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative)
119
141
  # => ["00:20:48", "00:40:00"]
142
+
143
+ # The race may be a plain distance too; the last split is always the finish
144
+ calc.race_splits(7.79, target_time: '00:26:59', split_distance: '1k')
145
+ # => ["00:03:28", "00:06:56", "00:10:23", "00:13:51", "00:17:19", "00:20:47", "00:24:15", "00:26:59"]
120
146
  ```
121
147
 
122
148
  ---
@@ -129,16 +155,45 @@ calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy:
129
155
  calc.predict_time_clock('5k', '00:20:00', 'marathon') # => "03:11:49"
130
156
  calc.predict_pace_clock('5k', '00:20:00', 'marathon') # => "00:04:32"
131
157
  calc.equivalent_performance('10k', '00:42:00', '5k')
132
- # => { time: 1209.0, time_clock: "00:20:09", pace: 241.8, pace_clock: "00:04:02" }
158
+ # => { time: 1208.67, time_clock: "00:20:08", pace: 241.73, pace_clock: "00:04:01" }
133
159
  ```
134
160
 
135
161
  **Cameron formula** (exponential correction — tends to be more conservative from short distances):
136
162
 
137
163
  ```ruby
138
- calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "02:57:46"
139
- calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:13"
164
+ calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "02:57:34"
165
+ calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:12"
140
166
  ```
141
167
 
168
+ **Any distance, on either end.** Both formulas are arithmetic on two distances,
169
+ so neither end has to be a standard race:
170
+
171
+ ```ruby
172
+ # From a 7.79 km club race in 26:59
173
+ calc.predict_time_clock(7.79, '00:26:59', 'half_marathon') # => "01:17:34"
174
+ calc.predict_time_cameron_clock(7.79, '00:26:59', 'half_marathon') # => "01:13:44"
175
+
176
+ # To an unnamed distance, and between two of them
177
+ calc.predict_time_clock('10k', '00:42:00', 15) # => "01:04:33"
178
+ calc.predict_time_clock(7.79, '00:26:59', 15) # => "00:54:02"
179
+
180
+ calc.equivalent_performance(7.79, '00:26:59', '10k')
181
+ # => { time: 2109.682710043339, time_clock: "00:35:09", pace: 210.96827100433387, pace_clock: "00:03:30" }
182
+ ```
183
+
184
+ Predicting a distance from itself has no answer, so it raises — for a name, a
185
+ number, or one of each:
186
+
187
+ ```ruby
188
+ calc.predict_time(10.0, 2520, '10k')
189
+ # => ArgumentError: From and to races must be different distances (both are 10.0km)
190
+ ```
191
+
192
+ Both formulas were fitted around race distances and degrade as the jump grows:
193
+ a marathon predicted from a 1 km time is arithmetic, not a forecast. The gem
194
+ computes what you ask for and does not second-guess the gap — that judgement is
195
+ the caller's, and it always was, standard race names included.
196
+
142
197
  ---
143
198
 
144
199
  ### GPS Track Analysis
@@ -152,12 +207,33 @@ points = [
152
207
  { lat: -23.5520, lon: -46.6480, ele: 758.0, time: Time.parse('2024-01-01 07:10:00') },
153
208
  ]
154
209
 
155
- calc.haversine_distance(-23.5505, -46.6333, -23.5510, -46.6340) # => 0.089 km
156
- calc.track_distance(points) # => 0.87 km
157
- calc.elevation_gain(points) # => { gain: 5.0, loss: 7.0 }
158
- calc.track_splits(points, 1.0) # => [{ km: 1, elapsed: 312, pace: "05:12" }, ...]
210
+ calc.haversine_distance(-23.5505, -46.6333, -23.5510, -46.6340)
211
+ # => 0.09045636644035066 (km)
212
+
213
+ calc.track_distance(points) # => 1.51 (km)
214
+ calc.elevation_gain(points) # => { gain: 5.0, loss: 7.0 }
215
+
216
+ calc.track_splits(points, 1.0)
217
+ # => [{ km: 1.0, elapsed: 415, pace: "06:55" },
218
+ # { km: 1.51, elapsed: 600, pace: "06:04" }]
219
+
220
+ # Compact pace for display; :km and :elapsed are unchanged
221
+ calc.track_splits(points, 1.0, compact: true)
222
+ # => [{ km: 1.0, elapsed: 415, pace: "6:55" },
223
+ # { km: 1.51, elapsed: 600, pace: "6:04" }]
159
224
  ```
160
225
 
226
+ The last entry is the partial split — the leftover distance after the last full
227
+ one, so its `:km` is the track total rather than a multiple of `split_km`.
228
+
229
+ Two things to know about `compact:` here. A split slower than an hour per unit
230
+ is where the formats stop differing by padding alone: the padded one keeps
231
+ counting minutes (`"66:33"`), as `track_splits` always has, while the compact
232
+ one rolls them into an hour field (`"1:06:33"`). And a track that steps
233
+ backwards in time — a watch resyncing its clock, a paused device, two segments
234
+ merged out of order — produces a negative split, reported with a leading minus
235
+ in both formats (`"-00:40"` / `"-0:40"`) rather than raising.
236
+
161
237
  **Haversine formula** — great-circle distance on a sphere (R = 6,371 km). Accuracy: ~0.3% of GPS/WGS84. Best for running and cycling distances; not for geodetic surveying.
162
238
 
163
239
  ---
@@ -171,22 +247,40 @@ age factors and open standards.
171
247
  result = calc.age_grade(10.0, '00:45:00', age: 55, sex: :male)
172
248
  # numeric distances also accepted in miles: calc.age_grade(6.21371, '00:45:00', age: 55, sex: :male, distance_unit: :mi)
173
249
  # => {
174
- # age_grade_percent: 64.6,
250
+ # age_grade_percent: 69.0,
175
251
  # category: "Local Class",
176
- # age_graded_time_seconds: 2376.0,
177
- # age_graded_time_clock: "00:39:36",
252
+ # age_graded_time_seconds: 2278.26,
253
+ # age_graded_time_clock: "00:37:58",
178
254
  # open_standard_seconds: 1571.0,
179
255
  # open_standard_clock: "00:26:11",
180
- # factor: 0.88,
256
+ # factor: 0.8438,
181
257
  # table_version: "WMA_2023_ONE_YEAR_FACTORS_V1"
182
258
  # }
183
259
 
184
- calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 74.1
185
- calc.age_grade_label(74.1) # => "Regional Class"
260
+ calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.2
261
+ calc.age_grade_label(65.2) # => "Local Class"
186
262
  ```
187
263
 
188
264
  Supported distances: 5K, 10K, half marathon, marathon.
189
265
 
266
+ A numeric distance within **2%** of one of those is graded as that standard —
267
+ a GPS watch rarely reads a 5K as exactly 5.000 km:
268
+
269
+ ```ruby
270
+ calc.age_grade_percent(5.0, '00:25:00', age: 40, sex: :male) # => 51.9
271
+ calc.age_grade_percent(5.0374, '00:25:00', age: 40, sex: :male) # => 51.9
272
+
273
+ calc.age_grade(7.79, '00:26:59', age: 36, sex: :male)
274
+ # => ArgumentError: Unsupported distance 7.79km. Supported: 5.0, 10.0, 21.0975, 42.195 km
275
+ ```
276
+
277
+ That refusal is deliberate, and it is where age grading parts ways with the
278
+ predictors above. A prediction is a formula and works at any distance; an age
279
+ grade is a lookup in the WMA table, which publishes a factor per *specific*
280
+ distance. There is no world standard for 7.79 km, so there is no honest
281
+ percentage to return — interpolating one would produce a number with the look
282
+ of an official standard and none of the authority.
283
+
190
284
  Age factors are based on WMA 2023 one-year age grading tables:
191
285
  https://world-masters-athletics.org/documents/competition-rules/
192
286
 
@@ -207,7 +301,7 @@ Estimate aerobic fitness from a race result using the **Daniels & Gilbert formul
207
301
 
208
302
  ```ruby
209
303
  calc.estimate_vo2max(10.0, '00:40:00') # => 51.9 ml/kg/min
210
- calc.estimate_vo2max(42.195, '03:30:00') # => 44.8
304
+ calc.estimate_vo2max(42.195, '03:30:00') # => 44.6
211
305
  calc.estimate_vo2max(5.0, 2400) # also accepts total seconds
212
306
  calc.estimate_vo2max(6.21371, '00:40:00', distance_unit: :mi) # => 51.9 (miles input)
213
307
 
@@ -382,6 +476,10 @@ prefixes a day count (`"1 03:46:40"`). Fractional seconds truncate in both.
382
476
  A negative number of seconds raises `Calcpace::NonPositiveInputError`; zero is a
383
477
  valid duration (`"00:00:00"` / `"0:00"`).
384
478
 
479
+ The same `compact:` keyword is accepted by `convert_pace`, `pace_km_to_mi`,
480
+ `pace_mi_to_km`, and `track_splits`. It always defaults to `false`, so every
481
+ call without it returns exactly what it returned before.
482
+
385
483
  ---
386
484
 
387
485
  ### Errors
@@ -47,6 +47,15 @@ module AgeGrading
47
47
 
48
48
  SUPPORTED_DISTANCES_KM = DISTANCE_TO_METERS.keys.freeze
49
49
 
50
+ # How far a distance may sit from a standard and still be graded as it. 2% is
51
+ # the same window calcpace.app uses to decide a run "is a 5K", so the gem and
52
+ # the site never disagree about the same run. It stays a matching tolerance,
53
+ # not an interpolation: a distance outside it has no WMA factor and is refused
54
+ STANDARD_DISTANCE_TOLERANCE_RATIO = 0.02
55
+
56
+ # Floor for the window above, so a future shorter standard still matches
57
+ MINIMUM_DISTANCE_TOLERANCE_KM = 0.001
58
+
50
59
  # Returns a full age-grading report for a race performance
51
60
  #
52
61
  # @param distance [Numeric, String, Symbol] race distance in kilometres
@@ -145,10 +154,11 @@ module AgeGrading
145
154
  end
146
155
  end
147
156
 
148
- # Runners write rounded distances (3.1 mi, 13.1 mi, 26.2 mi), so the match
149
- # window is relative 0.5% of the standard distance, never below 1 metre
157
+ # Runners write rounded distances (3.1 mi, 13.1 mi, 26.2 mi) and GPS watches
158
+ # rarely read a 5K as exactly 5.000 km, so the match window is relative
150
159
  def standard_distance?(distance, standard)
151
- (distance - standard).abs <= [0.001, standard * 0.005].max
160
+ (distance - standard).abs <= [MINIMUM_DISTANCE_TOLERANCE_KM,
161
+ standard * STANDARD_DISTANCE_TOLERANCE_RATIO].max
152
162
  end
153
163
 
154
164
  def parse_time_seconds(time)
@@ -24,27 +24,26 @@ module CameronPredictor
24
24
 
25
25
  # Predicts race time using the Cameron formula
26
26
  #
27
- # @param from_race [String, Symbol] known race distance ('5k', '10k', 'half_marathon', 'marathon', '100k', etc.)
27
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers (7.79,
28
+ # '7.79') or a standard race name ('5k', '10k', 'half_marathon', 'marathon', '100k', etc.)
28
29
  # @param from_time [String, Numeric] time achieved at known distance (HH:MM:SS or seconds)
29
- # @param to_race [String, Symbol] target race distance to predict
30
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
30
31
  # @return [Float] predicted time in seconds
31
- # @raise [ArgumentError] if races are invalid or distances are the same
32
+ # @raise [ArgumentError] if a race name is invalid or the distances are the same
33
+ # @raise [Calcpace::NonPositiveInputError] if a numeric distance is not positive
32
34
  #
33
35
  # @example Predict marathon time from 10K
34
36
  # predict_time_cameron('10k', '00:42:00', 'marathon')
35
- # #=> ~10,666 seconds (approximately 2:57:46)
37
+ # #=> ~10,654 seconds (approximately 2:57:34)
36
38
  #
37
39
  # @example Predict 10K time from 5K
38
40
  # predict_time_cameron('5k', '00:20:00', '10k')
39
- # #=> ~2,544 seconds (approximately 42:24)
41
+ # #=> ~2,546 seconds (approximately 42:26)
40
42
  def predict_time_cameron(from_race, from_time, to_race)
41
43
  from_distance = race_distance(from_race)
42
44
  to_distance = race_distance(to_race)
43
45
 
44
- if from_distance == to_distance
45
- raise ArgumentError,
46
- "From and to races must be different distances (both are #{from_distance}km)"
47
- end
46
+ ensure_different_distances!(from_distance, to_distance)
48
47
 
49
48
  time_seconds = from_time.is_a?(String) ? convert_to_seconds(from_time) : from_time
50
49
  check_positive(time_seconds, 'Time')
@@ -56,23 +55,23 @@ module CameronPredictor
56
55
 
57
56
  # Predicts race time using the Cameron formula, returned as a clock time string
58
57
  #
59
- # @param from_race [String, Symbol] known race distance
58
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
60
59
  # @param from_time [String, Numeric] time achieved at known distance
61
- # @param to_race [String, Symbol] target race distance to predict
60
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
62
61
  # @return [String] predicted time in HH:MM:SS format
63
62
  #
64
63
  # @example
65
64
  # predict_time_cameron_clock('10k', '00:42:00', 'marathon')
66
- # #=> '02:57:46'
65
+ # #=> '02:57:34'
67
66
  def predict_time_cameron_clock(from_race, from_time, to_race)
68
67
  convert_to_clocktime(predict_time_cameron(from_race, from_time, to_race))
69
68
  end
70
69
 
71
70
  # Predicts pace per kilometer using the Cameron formula
72
71
  #
73
- # @param from_race [String, Symbol] known race distance
72
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
74
73
  # @param from_time [String, Numeric] time achieved at known distance
75
- # @param to_race [String, Symbol] target race distance to predict
74
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
76
75
  # @return [Float] predicted pace in seconds per kilometer
77
76
  #
78
77
  # @example
@@ -84,23 +83,23 @@ module CameronPredictor
84
83
 
85
84
  # Predicts pace per kilometer using the Cameron formula, returned as a clock time string
86
85
  #
87
- # @param from_race [String, Symbol] known race distance
86
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
88
87
  # @param from_time [String, Numeric] time achieved at known distance
89
- # @param to_race [String, Symbol] target race distance to predict
88
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
90
89
  # @return [String] predicted pace in HH:MM:SS format
91
90
  #
92
91
  # @example
93
92
  # predict_pace_cameron_clock('5k', '00:20:00', 'marathon')
94
- # #=> '00:02:32'
93
+ # #=> '00:04:15'
95
94
  def predict_pace_cameron_clock(from_race, from_time, to_race)
96
95
  convert_to_clocktime(predict_pace_cameron(from_race, from_time, to_race))
97
96
  end
98
97
 
99
98
  # Predicts race time adjusted for environmental conditions using Cameron formula
100
99
  #
101
- # @param from_race [String, Symbol] known race distance
100
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
102
101
  # @param from_time [String, Numeric] time achieved at known distance
103
- # @param to_race [String, Symbol] target race distance to predict
102
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
104
103
  # @param options [Hash] environmental options (temperature, altitude, etc.)
105
104
  # @return [Hash] hash with adjusted prediction and penalty details
106
105
  def predict_time_cameron_adjusted(from_race, from_time, to_race, **)
@@ -17,15 +17,22 @@ module PaceCalculator
17
17
  '10mile' => 10 * Converter::Distance::MI_TO_KM
18
18
  }.freeze
19
19
 
20
+ # Relative window under which two distances count as the same race. It exists
21
+ # only to absorb floating-point noise, never to call two different distances
22
+ # equal — 10.0 km and 10.2 km are a legitimate prediction, not a mistake
23
+ SAME_DISTANCE_TOLERANCE_RATIO = 1e-9
24
+
20
25
  # Calculates the finish time for a race given a pace per kilometer
21
26
  #
22
27
  # @param pace_per_km [Numeric, String] pace in seconds per km or time string (MM:SS)
23
- # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon', '100k')
28
+ # @param race [Numeric, String, Symbol] distance in kilometers (7.79, '7.79') or a
29
+ # standard race name ('5k', '10k', 'half_marathon', 'marathon', '100k', ...)
24
30
  # @return [Float] total time in seconds
25
- # @raise [ArgumentError] if race distance is not recognized
31
+ # @raise [ArgumentError] if a race name is not recognized
32
+ # @raise [Calcpace::NonPositiveInputError] if a numeric distance is not positive
26
33
  #
27
34
  # @example
28
- # race_time(300, :5k) #=> 1500.0 (5:00/km pace for 5K = 25:00)
35
+ # race_time(300, '5k') #=> 1500.0 (5:00/km pace for 5K = 25:00)
29
36
  # race_time('05:00', :marathon) #=> 12658.5 (5:00/km pace for marathon = 3:30:58)
30
37
  def race_time(pace_per_km, race)
31
38
  distance = race_distance(race)
@@ -37,12 +44,13 @@ module PaceCalculator
37
44
  # Calculates the finish time for a race and returns it as a clock time string
38
45
  #
39
46
  # @param pace_per_km [Numeric, String] pace in seconds per km or time string (MM:SS)
40
- # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon', '100k')
47
+ # @param race [Numeric, String, Symbol] distance in kilometers (7.79, '7.79') or a
48
+ # standard race name ('5k', '10k', 'half_marathon', 'marathon', '100k', ...)
41
49
  # @return [String] finish time in HH:MM:SS format
42
50
  #
43
51
  # @example
44
52
  # race_time_clock('05:00', :marathon) #=> '03:30:58'
45
- # race_time_clock(300, :half_marathon) #=> '01:45:17'
53
+ # race_time_clock(300, :half_marathon) #=> '01:45:29'
46
54
  def race_time_clock(pace_per_km, race)
47
55
  convert_to_clocktime(race_time(pace_per_km, race))
48
56
  end
@@ -50,12 +58,13 @@ module PaceCalculator
50
58
  # Calculates the required pace per kilometer to finish a race in a target time
51
59
  #
52
60
  # @param target_time [Numeric, String] target finish time in seconds or time string (HH:MM:SS)
53
- # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon', '100k')
61
+ # @param race [Numeric, String, Symbol] distance in kilometers (7.79, '7.79') or a
62
+ # standard race name ('5k', '10k', 'half_marathon', 'marathon', '100k', ...)
54
63
  # @return [Float] required pace in seconds per kilometer
55
64
  #
56
65
  # @example
57
- # race_pace('03:30:00', :marathon) #=> 297.48... (4:57/km to finish in 3:30)
58
- # race_pace(1800, :5k) #=> 360.0 (6:00/km to finish in 30:00)
66
+ # race_pace('03:30:00', :marathon) #=> 298.61... (4:58/km to finish in 3:30)
67
+ # race_pace(1800, '5k') #=> 360.0 (6:00/km to finish in 30:00)
59
68
  def race_pace(target_time, race)
60
69
  distance = race_distance(race)
61
70
  time_seconds = target_time.is_a?(String) ? convert_to_seconds(target_time) : target_time
@@ -66,11 +75,12 @@ module PaceCalculator
66
75
  # Calculates the required pace and returns it as a clock time string
67
76
  #
68
77
  # @param target_time [Numeric, String] target finish time in seconds or time string (HH:MM:SS)
69
- # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon', '100k')
78
+ # @param race [Numeric, String, Symbol] distance in kilometers (7.79, '7.79') or a
79
+ # standard race name ('5k', '10k', 'half_marathon', 'marathon', '100k', ...)
70
80
  # @return [String] required pace in MM:SS format
71
81
  #
72
82
  # @example
73
- # race_pace_clock('03:30:00', :marathon) #=> '00:04:57'
83
+ # race_pace_clock('03:30:00', :marathon) #=> '00:04:58'
74
84
  def race_pace_clock(target_time, race)
75
85
  convert_to_clocktime(race_pace(target_time, race))
76
86
  end
@@ -87,18 +97,61 @@ module PaceCalculator
87
97
 
88
98
  private
89
99
 
90
- # Gets the distance for a standard race
100
+ # Resolves a race argument to a distance in kilometers
101
+ #
102
+ # A standard race name resolves through RACE_DISTANCES; a distance resolves
103
+ # to itself. Numeric strings ('7.79') count as distances — the convention
104
+ # TrainingZones#training_paces_from_race and FitnessPredictor already follow,
105
+ # so 7.79 and '7.79' can never mean two different things.
91
106
  #
92
- # @param race [String, Symbol] race name
107
+ # @param race [Numeric, String, Symbol] race name or distance in kilometers
93
108
  # @return [Float] distance in kilometers
94
- # @raise [ArgumentError] if race is not recognized
109
+ # @raise [ArgumentError] if a race name is not recognized
110
+ # @raise [Calcpace::NonPositiveInputError] if a numeric distance is not positive
95
111
  def race_distance(race)
112
+ numeric = numeric_distance(race)
113
+ return numeric if numeric
114
+
96
115
  RACE_DISTANCES.fetch(normalize_race_key(race)) do
97
116
  raise ArgumentError,
98
117
  "Unknown race: #{race}. Available races: #{RACE_DISTANCES.keys.join(', ')}"
99
118
  end
100
119
  end
101
120
 
121
+ # Reads a race argument as a plain distance, or nil when it is a race name
122
+ #
123
+ # @param race [Numeric, String, Symbol] race name or distance in kilometers
124
+ # @return [Float, nil] the distance in kilometers, nil for a non-numeric input
125
+ # @raise [Calcpace::NonPositiveInputError] if the distance is not positive
126
+ def numeric_distance(race)
127
+ value = race.is_a?(Numeric) ? race.to_f : Float(race, exception: false)
128
+ return nil unless value
129
+
130
+ check_positive(value, 'Distance')
131
+ value
132
+ end
133
+
134
+ # Guards the "predict a distance from itself" call, which has no answer to give
135
+ #
136
+ # @param from_distance [Float] known distance in kilometers
137
+ # @param to_distance [Float] target distance in kilometers
138
+ # @raise [ArgumentError] if both distances are the same race
139
+ def ensure_different_distances!(from_distance, to_distance)
140
+ return unless same_distance?(from_distance, to_distance)
141
+
142
+ raise ArgumentError,
143
+ "From and to races must be different distances (both are #{from_distance}km)"
144
+ end
145
+
146
+ # Two distances are the same race only when they differ by representation
147
+ # noise — a window narrow enough that any distance a runner actually types is
148
+ # outside it. Deciding when two genuinely different distances are close
149
+ # enough to count as the same race is age grading's business, and it has its
150
+ # own, much wider, explicit tolerance
151
+ def same_distance?(one, other)
152
+ (one - other).abs <= [one.abs, other.abs].max * SAME_DISTANCE_TOLERANCE_RATIO
153
+ end
154
+
102
155
  # Single normalization for every race-name lookup in the gem (here and in
103
156
  # AgeGrading), so ' 10K ' and :marathon always resolve like '10k' and 'marathon'
104
157
  #
@@ -7,48 +7,61 @@
7
7
  module PaceConverter
8
8
  # Converts pace from one unit to another
9
9
  #
10
+ # The pace comes back in the same two formats #convert_to_clocktime offers:
11
+ # the padded HH:MM:SS by default, and the compact display format a runner
12
+ # reads on a screen with <tt>compact: true</tt>.
13
+ #
10
14
  # @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS)
11
15
  # @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
16
+ # @param compact [Boolean] when true, return the compact display format
17
+ # @return [String] converted pace in HH:MM:SS format, or 'M:SS' / 'H:MM:SS' with compact: true
13
18
  # @raise [ArgumentError] if conversion type is not supported
14
19
  # @raise [Calcpace::NonPositiveInputError] if pace is not positive
15
20
  #
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
+ # @example padded (default)
22
+ # convert_pace('05:00', :km_to_mi) #=> '00:08:02' (5:00/km = 8:02/mi)
23
+ # convert_pace('08:00', :mi_to_km) #=> '00:04:58' (8:00/mi ≈ 4:58/km)
24
+ # convert_pace(300, 'km to mi') #=> '00:08:02' (300s/km = 482s/mi)
25
+ #
26
+ # @example compact
27
+ # convert_pace('05:00', :km_to_mi, compact: true) #=> '8:02'
28
+ # convert_pace(300, 'km to mi', compact: true) #=> '8:02'
29
+ def convert_pace(pace, conversion, compact: false)
21
30
  pace_seconds = pace.is_a?(String) ? convert_to_seconds(pace) : pace
22
31
  check_positive(pace_seconds, 'Pace')
23
32
 
24
33
  conversion_type = normalize_conversion(conversion)
25
34
  converted_seconds = apply_pace_conversion(pace_seconds, conversion_type)
26
35
 
27
- convert_to_clocktime(converted_seconds)
36
+ convert_to_clocktime(converted_seconds, compact: compact)
28
37
  end
29
38
 
30
39
  # Converts pace from kilometers to miles
31
40
  #
32
41
  # @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
42
+ # @param compact [Boolean] when true, return the compact display format
43
+ # @return [String] pace per mile in HH:MM:SS format, or 'M:SS' / 'H:MM:SS' with compact: true
34
44
  #
35
45
  # @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)
46
+ # pace_km_to_mi('05:00') #=> '00:08:02' (5:00/km = 8:02/mi)
47
+ # pace_km_to_mi(300) #=> '00:08:02' (300s/km = 482s/mi)
48
+ # pace_km_to_mi('05:00', compact: true) #=> '8:02'
49
+ def pace_km_to_mi(pace_per_km, compact: false)
50
+ convert_pace(pace_per_km, :km_to_mi, compact: compact)
40
51
  end
41
52
 
42
53
  # Converts pace from miles to kilometers
43
54
  #
44
55
  # @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
56
+ # @param compact [Boolean] when true, return the compact display format
57
+ # @return [String] pace per kilometer in HH:MM:SS format, or 'M:SS' / 'H:MM:SS' with compact: true
46
58
  #
47
59
  # @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)
60
+ # pace_mi_to_km('08:00') #=> '00:04:58' (8:00/mi ≈ 4:58/km)
61
+ # pace_mi_to_km(480) #=> '00:04:58' (480s/mi = 298s/km)
62
+ # pace_mi_to_km('08:00', compact: true) #=> '4:58'
63
+ def pace_mi_to_km(pace_per_mi, compact: false)
64
+ convert_pace(pace_per_mi, :mi_to_km, compact: compact)
52
65
  end
53
66
 
54
67
  private
@@ -19,27 +19,26 @@ module RacePredictor
19
19
  # - D2 = target distance
20
20
  # - 1.06 = endurance/fatigue factor (longer races require proportionally more time)
21
21
  #
22
- # @param from_race [String, Symbol] known race distance ('5k', '10k', 'half_marathon', 'marathon', '100k', etc.)
22
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers (7.79,
23
+ # '7.79') or a standard race name ('5k', '10k', 'half_marathon', 'marathon', '100k', etc.)
23
24
  # @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
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
25
26
  # @return [Float] predicted time in seconds
26
- # @raise [ArgumentError] if races are invalid or distances are the same
27
+ # @raise [ArgumentError] if a race name is invalid or the distances are the same
28
+ # @raise [Calcpace::NonPositiveInputError] if a numeric distance is not positive
27
29
  #
28
30
  # @example Predict marathon time from 5K
29
31
  # predict_time('5k', '00:20:00', 'marathon')
30
- # #=> 11123.4 (approximately 3:05:23)
32
+ # #=> 11509.32 (approximately 3:11:49)
31
33
  #
32
34
  # @example Predict 10K time from half marathon
33
35
  # predict_time('half_marathon', '01:30:00', '10k')
34
- # #=> 2565.8 (approximately 42:46)
36
+ # #=> 2447.42 (approximately 40:47)
35
37
  def predict_time(from_race, from_time, to_race)
36
38
  from_distance = race_distance(from_race)
37
39
  to_distance = race_distance(to_race)
38
40
 
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
41
+ ensure_different_distances!(from_distance, to_distance)
43
42
 
44
43
  time_seconds = from_time.is_a?(String) ? convert_to_seconds(from_time) : from_time
45
44
  check_positive(time_seconds, 'Time')
@@ -50,14 +49,14 @@ module RacePredictor
50
49
 
51
50
  # Predicts race time and returns it as a clock time string
52
51
  #
53
- # @param from_race [String, Symbol] known race distance
52
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
54
53
  # @param from_time [String, Numeric] time achieved at known distance
55
- # @param to_race [String, Symbol] target race distance to predict
54
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
56
55
  # @return [String] predicted time in HH:MM:SS format
57
56
  #
58
57
  # @example
59
58
  # predict_time_clock('5k', '00:20:00', 'marathon')
60
- # #=> '03:05:23'
59
+ # #=> '03:11:49'
61
60
  def predict_time_clock(from_race, from_time, to_race)
62
61
  predicted_seconds = predict_time(from_race, from_time, to_race)
63
62
  convert_to_clocktime(predicted_seconds)
@@ -65,14 +64,14 @@ module RacePredictor
65
64
 
66
65
  # Predicts the pace per kilometer for a target race
67
66
  #
68
- # @param from_race [String, Symbol] known race distance
67
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
69
68
  # @param from_time [String, Numeric] time achieved at known distance
70
- # @param to_race [String, Symbol] target race distance to predict
69
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
71
70
  # @return [Float] predicted pace in seconds per kilometer
72
71
  #
73
72
  # @example
74
73
  # predict_pace('5k', '00:20:00', 'marathon')
75
- # #=> 263.6 (approximately 4:24/km)
74
+ # #=> 272.77 (approximately 4:32/km)
76
75
  def predict_pace(from_race, from_time, to_race)
77
76
  predicted_seconds = predict_time(from_race, from_time, to_race)
78
77
  to_distance = race_distance(to_race)
@@ -81,14 +80,14 @@ module RacePredictor
81
80
 
82
81
  # Predicts the pace per kilometer and returns it as a clock time string
83
82
  #
84
- # @param from_race [String, Symbol] known race distance
83
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
85
84
  # @param from_time [String, Numeric] time achieved at known distance
86
- # @param to_race [String, Symbol] target race distance to predict
85
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
87
86
  # @return [String] predicted pace in MM:SS format
88
87
  #
89
88
  # @example
90
89
  # predict_pace_clock('5k', '00:20:00', 'marathon')
91
- # #=> '00:04:24' (4:24/km)
90
+ # #=> '00:04:32' (4:32/km)
92
91
  def predict_pace_clock(from_race, from_time, to_race)
93
92
  pace_seconds = predict_pace(from_race, from_time, to_race)
94
93
  convert_to_clocktime(pace_seconds)
@@ -99,18 +98,18 @@ module RacePredictor
99
98
  # This is useful for comparing performances across different race distances.
100
99
  # For example, "My 10K time is equivalent to what 5K time?"
101
100
  #
102
- # @param from_race [String, Symbol] known race distance
101
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
103
102
  # @param from_time [String, Numeric] time achieved at known distance
104
- # @param to_race [String, Symbol] target race distance for comparison
103
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
105
104
  # @return [Hash] hash with :time (seconds), :time_clock (HH:MM:SS), :pace (/km), :pace_clock
106
105
  #
107
106
  # @example
108
107
  # equivalent_performance('10k', '00:42:00', '5k')
109
108
  # #=> {
110
- # time: 1228.5,
111
- # time_clock: "00:20:28",
112
- # pace: 245.7,
113
- # pace_clock: "00:04:06"
109
+ # time: 1208.67,
110
+ # time_clock: "00:20:08",
111
+ # pace: 241.73,
112
+ # pace_clock: "00:04:01"
114
113
  # }
115
114
  def equivalent_performance(from_race, from_time, to_race)
116
115
  predicted_time = predict_time(from_race, from_time, to_race)
@@ -126,9 +125,9 @@ module RacePredictor
126
125
 
127
126
  # Predicts race time adjusted for environmental conditions
128
127
  #
129
- # @param from_race [String, Symbol] known race distance
128
+ # @param from_race [Numeric, String, Symbol] known distance in kilometers or race name
130
129
  # @param from_time [String, Numeric] time achieved at known distance
131
- # @param to_race [String, Symbol] target race distance to predict
130
+ # @param to_race [Numeric, String, Symbol] target distance in kilometers or race name
132
131
  # @param options [Hash] environmental options:
133
132
  # - :temperature [Numeric]
134
133
  # - :temperature_unit [Symbol, String] :c or :f
@@ -137,7 +136,7 @@ module RacePredictor
137
136
  #
138
137
  # @example Predict marathon time from 5K adjusted for heat (25C)
139
138
  # predict_time_adjusted('5k', '00:20:00', 'marathon', temperature: 25)
140
- # #=> { adjusted_time: 12213.4, penalty_percent: 6.0, ... }
139
+ # #=> { adjusted_time: 13140.19, penalty_percent: 14.17, ... }
141
140
  def predict_time_adjusted(from_race, from_time, to_race, **)
142
141
  predicted_seconds = predict_time(from_race, from_time, to_race)
143
142
  adjust_time(predicted_seconds, **)
@@ -7,16 +7,18 @@
7
7
  module RaceSplits
8
8
  # Calculates split times for a race
9
9
  #
10
- # @param race [String, Symbol] race distance ('5k', '10k', 'half_marathon', 'marathon', '100k', etc.)
10
+ # @param race [Numeric, String, Symbol] distance in kilometers (7.79, '7.79') or a
11
+ # standard race name ('5k', '10k', 'half_marathon', 'marathon', '100k', etc.)
11
12
  # @param target_time [String] target finish time in HH:MM:SS or MM:SS format
12
13
  # @param split_distance [String, Numeric] distance for each split ('5k', '1k', '1mile', or numeric in km)
13
14
  # @param strategy [Symbol] pacing strategy - :even (default), :negative, or :positive
14
15
  # @return [Array<String>] array of cumulative split times in HH:MM:SS format
15
- # @raise [ArgumentError] if race or split_distance is invalid
16
+ # @raise [ArgumentError] if a race or split_distance name is invalid
17
+ # @raise [Calcpace::NonPositiveInputError] if a numeric distance is not positive
16
18
  #
17
19
  # @example Even pace splits for half marathon
18
20
  # 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"]
21
+ # #=> ["00:21:20", "00:42:40", "01:03:59", "01:25:19", "01:30:00"]
20
22
  #
21
23
  # @example Negative splits (second half faster)
22
24
  # race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative)
@@ -48,8 +50,9 @@ module RaceSplits
48
50
  # Check if it's a standard race distance
49
51
  begin
50
52
  return race_distance(distance_key)
51
- rescue ArgumentError
52
- # Not a race distance, try to parse as numeric
53
+ rescue ArgumentError, Calcpace::NonPositiveInputError
54
+ # Not a usable race distance, try to parse as numeric so that the split
55
+ # rules below (positive, not longer than the race) report the problem
53
56
  end
54
57
 
55
58
  # Try to parse as number with optional 'k' or 'km'
@@ -68,7 +71,8 @@ module RaceSplits
68
71
  #
69
72
  # @param split_km [Float] split distance in kilometers
70
73
  # @param total_distance [Float] total race distance in kilometers
71
- # @raise [ArgumentError] if split distance is invalid
74
+ # @raise [ArgumentError] if a split distance name is invalid
75
+ # @raise [Calcpace::NonPositiveInputError] if a numeric split distance is not positive
72
76
  def validate_split_distance(split_km, total_distance)
73
77
  raise ArgumentError, "Split distance must be positive" if split_km <= 0
74
78
 
@@ -14,7 +14,7 @@
14
14
  # { lat: -23.5510, lon: -46.6340 },
15
15
  # { lat: -23.5520, lon: -46.6350 }
16
16
  # ]
17
- # calc.track_distance(points) #=> 0.17 (km)
17
+ # calc.track_distance(points) #=> 0.24 (km)
18
18
  #
19
19
  # @example Calculate elevation gain and loss
20
20
  # points = [
@@ -49,7 +49,7 @@ module TrackCalculator
49
49
  #
50
50
  # @example Distance between two points in São Paulo
51
51
  # haversine_distance(-23.5505, -46.6333, -23.5510, -46.6340)
52
- # #=> 0.089 (km)
52
+ # #=> 0.09045636644035066 (km)
53
53
  def haversine_distance(lat1, lon1, lat2, lon2)
54
54
  validate_coordinates(lat1, lon1)
55
55
  validate_coordinates(lat2, lon2)
@@ -69,7 +69,7 @@ module TrackCalculator
69
69
  # { lat: -23.5510, lon: -46.6340 },
70
70
  # { lat: -23.5520, lon: -46.6350 }
71
71
  # ]
72
- # track_distance(points) #=> 0.17
72
+ # track_distance(points) #=> 0.24
73
73
  def track_distance(points)
74
74
  return 0.0 if points.nil? || points.size < 2
75
75
 
@@ -114,13 +114,26 @@ module TrackCalculator
114
114
  # split distance is reached, then records elapsed time and pace for that split.
115
115
  # Any remaining distance at the end is included as a partial split.
116
116
  #
117
+ # Only :pace changes with compact: — :km and :elapsed are numbers, not
118
+ # formatted strings, and are the same in both modes.
119
+ #
120
+ # The two pace formats differ by more than padding once a split is slower than
121
+ # an hour per unit: the padded format keeps counting minutes ('66:33'), as it
122
+ # always has, while the compact one rolls them into an hour field ('1:06:33'),
123
+ # like every other compact duration in the gem. A track that steps backwards
124
+ # in time (a watch clock resync, a paused device, merged segments) yields a
125
+ # negative split, reported with a leading minus in both formats ('-00:40' /
126
+ # '-0:40') rather than raising.
127
+ #
117
128
  # @param points [Array<Hash>] array of points with :lat, :lon, and :time keys.
118
129
  # :time must respond to #to_f (Unix timestamp) or be a Time object.
119
130
  # @param split_km [Numeric] split interval in kilometers (default: 1.0)
131
+ # @param compact [Boolean] when true, :pace uses the compact display format
120
132
  # @return [Array<Hash>] array of split hashes, each with:
121
133
  # - :km [Float] cumulative distance at split end
122
134
  # - :elapsed [Integer] elapsed seconds from start of track to end of split
123
- # - :pace [String] pace for this split in MM:SS format
135
+ # - :pace [String] pace for this split in MM:SS format, or 'M:SS' / 'H:MM:SS'
136
+ # with compact: true
124
137
  # @raise [ArgumentError] if split_km is not positive
125
138
  # @raise [ArgumentError] if any point is missing a :time key
126
139
  #
@@ -131,12 +144,16 @@ module TrackCalculator
131
144
  # { km: 2.0, elapsed: 624, pace: "05:12" },
132
145
  # ...
133
146
  # ]
134
- def track_splits(points, split_km = 1.0)
147
+ #
148
+ # @example compact pace
149
+ # calc.track_splits(points, 1.0, compact: true)
150
+ # #=> [{ km: 1.0, elapsed: 312, pace: "5:12" }, ...]
151
+ def track_splits(points, split_km = 1.0, compact: false)
135
152
  raise ArgumentError, 'split_km must be positive' unless split_km.is_a?(Numeric) && split_km.positive?
136
153
  return [] if points.nil? || points.size < 2
137
154
 
138
155
  validate_points_have_time(points)
139
- collect_splits(points, split_km)
156
+ collect_splits(points, split_km, compact: compact)
140
157
  end
141
158
 
142
159
  private
@@ -209,17 +226,37 @@ module TrackCalculator
209
226
  t_a + ((t_b - t_a) * (distance_into_segment / segment_km))
210
227
  end
211
228
 
212
- def seconds_to_pace(seconds, km)
213
- return '00:00' if km.zero?
214
-
229
+ # Formats a split pace, in either format, without ever raising.
230
+ #
231
+ # A GPS track can step backwards in time — a watch resyncing its clock, a
232
+ # device paused and restarted, two segments merged out of order — which makes
233
+ # a split elapsed time negative. That is bad data, not a caller error, and
234
+ # track_splits has always reported it rather than blowing up; #sign_of keeps
235
+ # it that way now that the compact format goes through #convert_to_clocktime,
236
+ # which rejects negative durations.
237
+ def seconds_to_pace(seconds, km, compact: false)
215
238
  pace_seconds = (seconds.to_f / km).round
239
+ "#{sign_of(pace_seconds)}#{format_pace(pace_seconds.abs, compact: compact)}"
240
+ end
241
+
242
+ def sign_of(pace_seconds)
243
+ pace_seconds.negative? ? '-' : ''
244
+ end
245
+
246
+ # The padded format keeps the historical MM:SS, where the minutes keep
247
+ # counting past 60 (a 66-minute hiking split reads '66:33'); the compact
248
+ # format defers to #convert_to_clocktime, which rolls those minutes into an
249
+ # hour field ('1:06:33') exactly as it does everywhere else.
250
+ def format_pace(pace_seconds, compact:)
251
+ return convert_to_clocktime(pace_seconds, compact: true) if compact
252
+
216
253
  format('%<min>02d:%<sec>02d', min: pace_seconds / 60, sec: pace_seconds % 60)
217
254
  end
218
255
 
219
- def collect_splits(points, split_km)
256
+ def collect_splits(points, split_km, compact:)
220
257
  state = { splits: [], start_time: point_time(points.first),
221
258
  split_start_time: point_time(points.first),
222
- accumulated_km: 0.0, split_number: 1 }
259
+ accumulated_km: 0.0, split_number: 1, compact: compact }
223
260
 
224
261
  points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) }
225
262
  append_partial_split(points.last, split_km, state)
@@ -249,7 +286,7 @@ module TrackCalculator
249
286
  {
250
287
  km: (split_km * state[:split_number]).round(2),
251
288
  elapsed: (boundary_time - state[:start_time]).round,
252
- pace: seconds_to_pace(split_elapsed, split_km)
289
+ pace: seconds_to_pace(split_elapsed, split_km, compact: state[:compact])
253
290
  }
254
291
  end
255
292
 
@@ -261,7 +298,8 @@ module TrackCalculator
261
298
  state[:splits] << {
262
299
  km: state[:accumulated_km].round(2),
263
300
  elapsed: (last_time - state[:start_time]).round,
264
- pace: seconds_to_pace((last_time - state[:split_start_time]).round, remaining_km)
301
+ pace: seconds_to_pace((last_time - state[:split_start_time]).round, remaining_km,
302
+ compact: state[:compact])
265
303
  }
266
304
  end
267
305
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.13.0'
4
+ VERSION = '1.15.0'
5
5
  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.13.0
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - João Gilberto Saraiva