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 +4 -4
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +183 -1
- data/README.md +113 -15
- data/lib/calcpace/age_grading.rb +13 -3
- data/lib/calcpace/cameron_predictor.rb +18 -19
- data/lib/calcpace/pace_calculator.rb +66 -13
- data/lib/calcpace/pace_converter.rb +30 -17
- data/lib/calcpace/race_predictor.rb +26 -27
- data/lib/calcpace/race_splits.rb +10 -6
- data/lib/calcpace/track_calculator.rb +51 -13
- data/lib/calcpace/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5eefef398417bfe2d045b2afd7ee78991b443c7c9bc1eabd536e81d03789908c
|
|
4
|
+
data.tar.gz: 7b676576e7e3af9b9d4ce75430af80835a35535b2c8b971ed5ef764ddd608853
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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:
|
|
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:
|
|
139
|
-
calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:
|
|
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)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
calc.
|
|
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:
|
|
250
|
+
# age_grade_percent: 69.0,
|
|
175
251
|
# category: "Local Class",
|
|
176
|
-
# age_graded_time_seconds:
|
|
177
|
-
# age_graded_time_clock: "00:
|
|
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.
|
|
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) # =>
|
|
185
|
-
calc.age_grade_label(
|
|
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.
|
|
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
|
data/lib/calcpace/age_grading.rb
CHANGED
|
@@ -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)
|
|
149
|
-
#
|
|
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 <= [
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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,
|
|
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]
|
|
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:
|
|
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]
|
|
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) #=>
|
|
58
|
-
# race_pace(1800,
|
|
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]
|
|
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:
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
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
|
-
|
|
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
|
-
# @
|
|
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')
|
|
37
|
-
# pace_km_to_mi(300)
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
# @
|
|
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')
|
|
49
|
-
# pace_mi_to_km(480)
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
# #=>
|
|
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
|
-
# #=>
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
# #=>
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
111
|
-
# time_clock: "00:20:
|
|
112
|
-
# pace:
|
|
113
|
-
# pace_clock: "00:04:
|
|
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
|
|
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
|
|
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:
|
|
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, **)
|
data/lib/calcpace/race_splits.rb
CHANGED
|
@@ -7,16 +7,18 @@
|
|
|
7
7
|
module RaceSplits
|
|
8
8
|
# Calculates split times for a race
|
|
9
9
|
#
|
|
10
|
-
# @param race [String, Symbol]
|
|
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:
|
|
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
|
+
# 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
data/lib/calcpace/version.rb
CHANGED