calcpace 1.9.1 → 1.9.3
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 +1 -0
- data/README.md +98 -309
- data/calcpace.gemspec +5 -2
- data/lib/calcpace/track_calculator.rb +268 -0
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace/vo2max_estimator.rb +83 -0
- data/lib/calcpace.rb +4 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ee49b6b99023d43bef8396cb0c1ab9ba4ef99e6c96285b4c4405eb3d163807d
|
|
4
|
+
data.tar.gz: 442169e5414c141390261da86e5cc312eda98c685bbc64530ca7692d39773119
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae31a9047296eb8848030d08d35af80bd9af22f5ae5ac65de727e99b7448155918198caa2467edb5a3371c92d6f3f4b4c54adef1d0d8da51bc19b4b9cafb680d
|
|
7
|
+
data.tar.gz: 57b06d49de20b1415ec230c1d2bfd3ac495dc4c8c3ed25e684fcdf3072fef142728394942706aa39892b69eea3ce9b1b1e9f8c2fdb3efea7fbd3bd7e90016fa3
|
data/.rubocop.yml
CHANGED
data/README.md
CHANGED
|
@@ -1,404 +1,193 @@
|
|
|
1
|
-
# Calcpace [](https://badge.fury.io/rb/calcpace)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Ruby gem for running and cycling calculations: pace, time, distance, unit conversions, race predictions, GPS track analysis, and VO2max estimation.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
|
-
### Add to your Gemfile
|
|
8
|
-
|
|
9
7
|
```ruby
|
|
10
8
|
gem 'calcpace', '~> 1.9'
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
Then run:
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
bundle install
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
### Install the gem manually
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
gem install calcpace
|
|
23
|
-
```
|
|
24
|
-
|
|
25
11
|
## Usage
|
|
26
12
|
|
|
27
|
-
Before performing any calculations or conversions, create a new instance of Calcpace:
|
|
28
|
-
|
|
29
13
|
```ruby
|
|
30
14
|
require 'calcpace'
|
|
31
|
-
|
|
32
|
-
calculate = Calcpace.new
|
|
15
|
+
calc = Calcpace.new
|
|
33
16
|
```
|
|
34
17
|
|
|
35
|
-
|
|
18
|
+
---
|
|
36
19
|
|
|
37
|
-
|
|
20
|
+
### Basic Calculations
|
|
38
21
|
|
|
39
22
|
```ruby
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Tip: Use the `round` method to round a float. For example:
|
|
23
|
+
calc.velocity(3625, 12275) # => 3.386 (distance / time)
|
|
24
|
+
calc.pace(3665, 12) # => 305.4 (time / distance)
|
|
25
|
+
calc.time(210, 12) # => 2520.0 (pace × distance)
|
|
26
|
+
calc.distance(9660, 120) # => 80.5 (velocity × time)
|
|
47
27
|
|
|
48
|
-
|
|
49
|
-
|
|
28
|
+
# Clocktime input/output (HH:MM:SS or MM:SS)
|
|
29
|
+
calc.clock_pace('01:00:00', 10) # => "00:06:00"
|
|
30
|
+
calc.clock_time('00:05:31', 12.6) # => "01:09:30"
|
|
31
|
+
calc.checked_distance('01:21:32', '00:06:27') # => 12.64
|
|
50
32
|
```
|
|
51
33
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
- Velocity is the distance divided by the time (e.g., m/s or km/h).
|
|
55
|
-
- Pace is the time divided by the distance (e.g., minutes/km or minutes/miles).
|
|
56
|
-
- Total time is the distance divided by the velocity.
|
|
57
|
-
- Distance is the velocity multiplied by the time.
|
|
34
|
+
---
|
|
58
35
|
|
|
59
|
-
###
|
|
36
|
+
### Unit Conversions
|
|
60
37
|
|
|
61
|
-
|
|
38
|
+
30+ units supported. String or symbol format:
|
|
62
39
|
|
|
63
40
|
```ruby
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
calculate.checked_time('00:05:31', 12.6) # => 4170.599999999999
|
|
68
|
-
|
|
69
|
-
calculate.checked_distance('01:21:32', '00:06:27') # => 12.640826873385013
|
|
41
|
+
calc.convert(10, :km_to_mi) # => 6.21371
|
|
42
|
+
calc.convert(10, 'mi to km') # => 16.0934
|
|
43
|
+
calc.convert(1, :m_s_to_km_h) # => 3.6
|
|
70
44
|
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
calculate.clock_velocity('01:00:00', 10317) # => "00:00:02"
|
|
74
|
-
calculate.clock_time('00:05:31', 12.6) # => "01:09:30"
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Note: Using the `clock` methods may be less precise than using other methods due to conversions.
|
|
78
|
-
|
|
79
|
-
You can also use BigDecimal for more precise calculations. For example:
|
|
80
|
-
|
|
81
|
-
```ruby
|
|
82
|
-
require 'bigdecimal'
|
|
83
|
-
calculate.checked_velocity('10:00:00', 10317).to_d # => #<BigDecimal:7f9f1b8b1d08,'0.2865833333 333333E1',27(36)>
|
|
45
|
+
# Chain conversions
|
|
46
|
+
calc.convert_chain(1, [:km_to_mi, :mi_to_feet]) # => 3280.84
|
|
84
47
|
```
|
|
85
48
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
### Convert Distances and Velocities
|
|
89
|
-
|
|
90
|
-
Use the `convert` method to convert a distance or velocity. The first parameter is the value to be converted, and the second parameter is the unit to which the value will be converted. The unit can be a string (e.g. 'km to meters') or a symbol (e.g. :km_to_meters). The gem supports 42 different units, including kilometers, miles, meters, knots, and feet.
|
|
91
|
-
|
|
92
|
-
Here are some examples:
|
|
93
|
-
|
|
94
|
-
```ruby
|
|
95
|
-
converter.convert(10, :km_to_meters) # => 1000
|
|
96
|
-
converter.convert(10, 'mi to km') # => 16.0934
|
|
97
|
-
converter.convert(1, :nautical_mi_to_km) # => 1.852
|
|
98
|
-
converter.convert(1, 'km h to m s') # => 0.277778
|
|
99
|
-
converter.convert(1, :m_s_to_mi_h) # => 2.23694
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
| Conversion Unit | Description |
|
|
103
|
-
|----------------------|-----------------------------|
|
|
104
|
-
| :km_to_mi | Kilometers to Miles |
|
|
105
|
-
| :mi_to_km | Miles to Kilometers |
|
|
106
|
-
| :nautical_mi_to_km | Nautical Miles to Kilometers |
|
|
107
|
-
| :km_to_nautical_mi | Kilometers to Nautical Miles |
|
|
108
|
-
| :meters_to_km | Meters to Kilometers |
|
|
109
|
-
| :km_to_meters | Kilometers to Meters |
|
|
110
|
-
| :meters_to_mi | Meters to Miles |
|
|
111
|
-
| :mi_to_meters | Miles to Meters |
|
|
112
|
-
| :m_s_to_km_h | Meters per Second to Kilometers per Hour |
|
|
113
|
-
| :km_h_to_m_s | Kilometers per Hour to Meters per Second |
|
|
114
|
-
| :m_s_to_mi_h | Meters per Second to Miles per Hour |
|
|
115
|
-
| :mi_h_to_m_s | Miles per Hour to Meters per Second |
|
|
116
|
-
| :m_s_to_feet_s | Meters per Second to Feet per Second |
|
|
117
|
-
| :feet_s_to_m_s | Feet per Second to Meters per Second |
|
|
118
|
-
| :km_h_to_mi_h | Kilometers per Hour to Miles per Hour |
|
|
119
|
-
| :mi_h_to_km_h | Miles per Hour to Kilometers per Hour |
|
|
120
|
-
|
|
121
|
-
You can list all the available units [here](/lib/calcpace/converter.rb), or using `list` methods. The return will be a hash with the unit and a description of the unit.
|
|
122
|
-
|
|
123
|
-
```ruby
|
|
124
|
-
converter.list_all
|
|
125
|
-
# => {:km_to_mi=>"KM to MI", :mi_to_km=>"MI to KM", ...}
|
|
126
|
-
|
|
127
|
-
converter.list_distance
|
|
128
|
-
# => {:km_to_mi=>"KM to MI", :mi_to_km=>"MI to KM", ...}
|
|
129
|
-
|
|
130
|
-
converter.list_speed
|
|
131
|
-
# => {:m_s_to_km_h=>"M S to KM H", :km_h_to_m_s=>"KM H to M S", ...}
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
### Chain Conversions
|
|
135
|
-
|
|
136
|
-
Perform multiple conversions in sequence with the converter chain feature:
|
|
137
|
-
|
|
138
|
-
```ruby
|
|
139
|
-
calc = Calcpace.new
|
|
140
|
-
|
|
141
|
-
# Convert kilometers to miles to feet in one call
|
|
142
|
-
calc.convert_chain(1, [:km_to_mi, :mi_to_feet])
|
|
143
|
-
# => 3280.84 (1 km = 0.621 mi = 3280.84 feet)
|
|
144
|
-
|
|
145
|
-
# Convert with description for debugging
|
|
146
|
-
calc.convert_chain_with_description(100, [:meters_to_km, :km_to_mi])
|
|
147
|
-
# => { result: 0.0621371, description: "100 → meters_to_km → km_to_mi → 0.0621" }
|
|
148
|
-
|
|
149
|
-
# Speed conversions
|
|
150
|
-
calc.convert_chain(10, [:m_s_to_km_h, :km_h_to_mi_h])
|
|
151
|
-
# => 22.3694 (10 m/s = 36 km/h = 22.37 mi/h)
|
|
152
|
-
```
|
|
49
|
+
See all units: `calc.list_all`, `calc.list_distance`, `calc.list_speed`.
|
|
153
50
|
|
|
154
|
-
|
|
51
|
+
---
|
|
155
52
|
|
|
156
|
-
|
|
53
|
+
### Pace Conversions
|
|
157
54
|
|
|
158
55
|
```ruby
|
|
159
|
-
calc
|
|
160
|
-
|
|
161
|
-
# Calculate finish time for a race given a pace
|
|
162
|
-
calc.race_time(300, '5k') # => 1500.0 (5:00/km pace for 5K = 25:00)
|
|
163
|
-
calc.race_time_clock('05:00', 'marathon') # => '03:30:58' (5:00/km pace for marathon)
|
|
164
|
-
|
|
165
|
-
# Calculate required pace for a target finish time
|
|
166
|
-
calc.race_pace('00:30:00', '5k') # => 360.0 (need 6:00/km to finish 5K in 30:00)
|
|
167
|
-
calc.race_pace_clock('04:00:00', 'marathon') # => '00:05:41' (need 5:41/km for 4-hour marathon)
|
|
168
|
-
|
|
169
|
-
# List available race distances
|
|
170
|
-
calc.list_races
|
|
171
|
-
# => { '5k' => 5.0, '10k' => 10.0, 'half_marathon' => 21.0975, 'marathon' => 42.195 }
|
|
56
|
+
calc.pace_km_to_mi('05:00') # => "00:08:02"
|
|
57
|
+
calc.pace_mi_to_km('08:00') # => "00:04:58"
|
|
172
58
|
```
|
|
173
59
|
|
|
174
|
-
|
|
175
|
-
- `5k` - 5 kilometers
|
|
176
|
-
- `10k` - 10 kilometers
|
|
177
|
-
- `half_marathon` - 21.0975 kilometers
|
|
178
|
-
- `marathon` - 42.195 kilometers
|
|
179
|
-
- `1mile` - 1.60934 kilometers
|
|
180
|
-
- `5mile` - 8.04672 kilometers
|
|
181
|
-
- `10mile` - 16.0934 kilometers
|
|
60
|
+
---
|
|
182
61
|
|
|
183
|
-
### Pace
|
|
184
|
-
|
|
185
|
-
Convert running pace between kilometers and miles:
|
|
62
|
+
### Race Pace & Time
|
|
186
63
|
|
|
187
64
|
```ruby
|
|
188
|
-
calc
|
|
189
|
-
|
|
190
|
-
#
|
|
191
|
-
calc.pace_km_to_mi('05:00') # => '00:08:02' (5:00/km = 8:02/mi)
|
|
192
|
-
calc.convert_pace('05:00', :km_to_mi) # => '00:08:02' (same as above)
|
|
193
|
-
|
|
194
|
-
# Convert pace from miles to km
|
|
195
|
-
calc.pace_mi_to_km('08:00') # => '00:04:58' (8:00/mi ≈ 4:58/km)
|
|
196
|
-
calc.convert_pace('08:00', :mi_to_km) # => '00:04:58' (same as above)
|
|
197
|
-
|
|
198
|
-
# Works with numeric input (seconds) as well
|
|
199
|
-
calc.pace_km_to_mi(300) # => '00:08:02' (300 seconds/km)
|
|
200
|
-
calc.pace_mi_to_km(480) # => '00:04:58' (480 seconds/mi)
|
|
201
|
-
|
|
202
|
-
# String format also supported
|
|
203
|
-
calc.convert_pace('05:00', 'km to mi') # => '00:08:02'
|
|
65
|
+
calc.race_time_clock('05:00', 'marathon') # => "03:30:58"
|
|
66
|
+
calc.race_pace_clock('04:00:00', 'marathon') # => "00:05:41"
|
|
67
|
+
calc.list_races # => { '5k' => 5.0, '10k' => 10.0, 'half_marathon' => 21.0975, ... }
|
|
204
68
|
```
|
|
205
69
|
|
|
206
|
-
|
|
207
|
-
- Planning races in different countries (metric vs imperial)
|
|
208
|
-
- Comparing training paces with international runners
|
|
209
|
-
- Converting workout plans between pace formats
|
|
70
|
+
---
|
|
210
71
|
|
|
211
72
|
### Race Splits
|
|
212
73
|
|
|
213
|
-
Calculate split times for races to help pace your race strategy:
|
|
214
|
-
|
|
215
74
|
```ruby
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
# Even pace splits for half marathon (every 5k)
|
|
75
|
+
# Even pace — default
|
|
219
76
|
calc.race_splits('half_marathon', target_time: '01:30:00', split_distance: '5k')
|
|
220
77
|
# => ["00:21:20", "00:42:40", "01:03:59", "01:25:19", "01:30:00"]
|
|
221
78
|
|
|
222
|
-
#
|
|
223
|
-
calc.race_splits('10k', target_time: '00:40:00', split_distance: '1k')
|
|
224
|
-
# => ["00:04:00", "00:08:00", "00:12:00", ..., "00:40:00"]
|
|
225
|
-
|
|
226
|
-
# Mile splits for marathon
|
|
227
|
-
calc.race_splits('marathon', target_time: '03:30:00', split_distance: '1mile')
|
|
228
|
-
# => ["00:08:02", "00:16:04", ..., "03:30:00"]
|
|
229
|
-
|
|
230
|
-
# Negative splits strategy (second half faster)
|
|
79
|
+
# Strategies: :even (default), :negative (second half faster), :positive (first half faster)
|
|
231
80
|
calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :negative)
|
|
232
|
-
# => ["00:20:48", "00:40:00"]
|
|
233
|
-
|
|
234
|
-
# Positive splits strategy (first half faster)
|
|
235
|
-
calc.race_splits('10k', target_time: '00:40:00', split_distance: '5k', strategy: :positive)
|
|
236
|
-
# => ["00:19:12", "00:40:00"] # First 5k faster, second 5k slower
|
|
81
|
+
# => ["00:20:48", "00:40:00"]
|
|
237
82
|
```
|
|
238
83
|
|
|
239
|
-
|
|
240
|
-
- `:even` - Constant pace throughout (default)
|
|
241
|
-
- `:negative` - Second half ~4% faster (progressive race)
|
|
242
|
-
- `:positive` - First half ~4% faster (conservative start)
|
|
243
|
-
|
|
244
|
-
Split distances can be:
|
|
245
|
-
- Standard race distances: `'5k'`, `'10k'`, `'1mile'`, etc.
|
|
246
|
-
- Custom distances: `2.5` (in kilometers), `'3k'`, etc.
|
|
84
|
+
---
|
|
247
85
|
|
|
248
86
|
### Race Time Predictions
|
|
249
87
|
|
|
250
|
-
|
|
88
|
+
**Riegel formula** (`T2 = T1 × (D2/D1)^1.06`):
|
|
251
89
|
|
|
252
90
|
```ruby
|
|
253
|
-
calc
|
|
254
|
-
|
|
255
|
-
# Predict marathon time from a 5K result
|
|
256
|
-
calc.predict_time_clock('5k', '00:20:00', 'marathon')
|
|
257
|
-
# => "03:11:49" (predicts 3:11:49 marathon from 20:00 5K)
|
|
258
|
-
|
|
259
|
-
# Predict 10K time from half marathon
|
|
260
|
-
calc.predict_time_clock('half_marathon', '01:30:00', '10k')
|
|
261
|
-
# => "00:40:47"
|
|
262
|
-
|
|
263
|
-
# Get predicted pace for target race
|
|
264
|
-
calc.predict_pace_clock('5k', '00:20:00', 'marathon')
|
|
265
|
-
# => "00:04:32" (4:32/km pace for predicted marathon)
|
|
266
|
-
|
|
267
|
-
# Get complete equivalent performance info
|
|
91
|
+
calc.predict_time_clock('5k', '00:20:00', 'marathon') # => "03:11:49"
|
|
92
|
+
calc.predict_pace_clock('5k', '00:20:00', 'marathon') # => "00:04:32"
|
|
268
93
|
calc.equivalent_performance('10k', '00:42:00', '5k')
|
|
269
|
-
# => {
|
|
270
|
-
# time: 1209.0,
|
|
271
|
-
# time_clock: "00:20:09",
|
|
272
|
-
# pace: 241.8,
|
|
273
|
-
# pace_clock: "00:04:02"
|
|
274
|
-
# }
|
|
94
|
+
# => { time: 1209.0, time_clock: "00:20:09", pace: 241.8, pace_clock: "00:04:02" }
|
|
275
95
|
```
|
|
276
96
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
The Riegel formula is a mathematical model that predicts race performance across different distances:
|
|
97
|
+
**Cameron formula** (exponential correction — tends to be more conservative from short distances):
|
|
280
98
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
- **T2** = predicted time at distance D2
|
|
286
|
-
- **D1** = known race distance (in km)
|
|
287
|
-
- **D2** = target race distance (in km)
|
|
288
|
-
- **1.06** = fatigue/endurance factor
|
|
99
|
+
```ruby
|
|
100
|
+
calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon') # => "02:57:46"
|
|
101
|
+
calc.predict_pace_cameron_clock('10k', '00:42:00', 'marathon') # => "00:04:13"
|
|
102
|
+
```
|
|
289
103
|
|
|
290
|
-
|
|
104
|
+
---
|
|
291
105
|
|
|
292
|
-
|
|
293
|
-
- T1 = 1200 seconds (20:00)
|
|
294
|
-
- D1 = 5 km
|
|
295
|
-
- D2 = 42.195 km (marathon)
|
|
296
|
-
- T2 = 1200 × (42.195/5)^1.06
|
|
297
|
-
- T2 = 1200 × 9.591 = 11,509 seconds
|
|
298
|
-
- T2 = **3:11:49**
|
|
106
|
+
### GPS Track Analysis
|
|
299
107
|
|
|
300
|
-
|
|
301
|
-
- Distances from 5K to marathon
|
|
302
|
-
- Recent race results (within 6-8 weeks)
|
|
303
|
-
- Similar terrain and weather conditions
|
|
304
|
-
- Well-trained runners with consistent pacing
|
|
108
|
+
Accepts an array of hashes with `:lat`, `:lon`, and optionally `:ele` (metres) and `:time` (`Time`):
|
|
305
109
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
110
|
+
```ruby
|
|
111
|
+
points = [
|
|
112
|
+
{ lat: -23.5505, lon: -46.6333, ele: 760.0, time: Time.parse('2024-01-01 07:00:00') },
|
|
113
|
+
{ lat: -23.5510, lon: -46.6400, ele: 765.0, time: Time.parse('2024-01-01 07:05:00') },
|
|
114
|
+
{ lat: -23.5520, lon: -46.6480, ele: 758.0, time: Time.parse('2024-01-01 07:10:00') },
|
|
115
|
+
]
|
|
310
116
|
|
|
311
|
-
|
|
117
|
+
calc.haversine_distance(-23.5505, -46.6333, -23.5510, -46.6340) # => 0.089 km
|
|
118
|
+
calc.track_distance(points) # => 0.87 km
|
|
119
|
+
calc.elevation_gain(points) # => { gain: 5.0, loss: 7.0 }
|
|
120
|
+
calc.track_splits(points, 1.0) # => [{ km: 1, elapsed: 312, pace: "05:12" }, ...]
|
|
121
|
+
```
|
|
312
122
|
|
|
313
|
-
|
|
123
|
+
**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.
|
|
314
124
|
|
|
315
|
-
|
|
316
|
-
calc = Calcpace.new
|
|
125
|
+
---
|
|
317
126
|
|
|
318
|
-
|
|
319
|
-
calc.predict_time_cameron_clock('10k', '00:42:00', 'marathon')
|
|
320
|
-
# => "02:57:46"
|
|
127
|
+
### VO2max Estimation
|
|
321
128
|
|
|
322
|
-
|
|
323
|
-
calc.predict_time_cameron_clock('5k', '00:20:00', '10k')
|
|
324
|
-
# => "00:42:24"
|
|
129
|
+
Estimate aerobic fitness from a race result using the **Daniels & Gilbert formula** (1979):
|
|
325
130
|
|
|
326
|
-
|
|
327
|
-
calc.
|
|
328
|
-
# =>
|
|
131
|
+
```ruby
|
|
132
|
+
calc.estimate_vo2max(10.0, '00:40:00') # => 51.9 ml/kg/min
|
|
133
|
+
calc.estimate_vo2max(42.195, '03:30:00') # => 44.8
|
|
134
|
+
calc.estimate_vo2max(5.0, 2400) # also accepts total seconds
|
|
329
135
|
|
|
330
|
-
#
|
|
331
|
-
calc.predict_time_cameron('5k', '00:20:00', 'half_marathon')
|
|
332
|
-
# => 5382.7 (approximately 1:29:42)
|
|
136
|
+
calc.vo2max_label(51.9) # => "Very Good"
|
|
333
137
|
```
|
|
334
138
|
|
|
335
|
-
|
|
139
|
+
| VO2max (ml/kg/min) | Level |
|
|
140
|
+
|--------------------|-----------|
|
|
141
|
+
| ≥ 70 | Elite |
|
|
142
|
+
| 60–69 | Excellent |
|
|
143
|
+
| 50–59 | Very Good |
|
|
144
|
+
| 40–49 | Good |
|
|
145
|
+
| 30–39 | Fair |
|
|
146
|
+
| < 30 | Beginner |
|
|
336
147
|
|
|
337
|
-
**Formula:**
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
The exponential correction factor `f(d) = a + b × e^(-d/c)` decreases as distance grows. When predicting from a short race to a long one, `f(D1) > f(D2)`, making `T2` grow slightly faster than a pure linear extrapolation — accounting for the greater fatigue at longer distances.
|
|
148
|
+
**Formula:**
|
|
149
|
+
```
|
|
150
|
+
velocity (m/min) = distance_m / time_min
|
|
151
|
+
VO2 = −4.60 + 0.182258·v + 0.000104·v²
|
|
152
|
+
%VO2max = 0.8 + 0.1894393·e^(−0.012778·t) + 0.2989558·e^(−0.1932605·t)
|
|
153
|
+
VO2max = VO2 / %VO2max
|
|
154
|
+
```
|
|
345
155
|
|
|
346
|
-
**
|
|
347
|
-
- Predicting from short distances (5K): Cameron tends to be more conservative (slower prediction) — acknowledges that 5K speed has a larger anaerobic component
|
|
348
|
-
- Predicting from moderate distances (10K): Cameron tends to be slightly more optimistic — 10K is already a strong predictor of marathon aerobic capacity
|
|
349
|
-
- Both formulas are estimates; real performance depends on training specificity, conditions, and individual physiology
|
|
156
|
+
Accuracy: ±3–5 ml/kg/min vs. laboratory testing. Best with efforts between **5 and 60 minutes** at near-maximal pace.
|
|
350
157
|
|
|
351
|
-
|
|
158
|
+
---
|
|
352
159
|
|
|
353
|
-
|
|
160
|
+
### Other Utilities
|
|
354
161
|
|
|
355
162
|
```ruby
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
converter.convert_to_clocktime(100000) # => '1 03:46:40'
|
|
360
|
-
converter.check_time('01:00:00') # => nil
|
|
163
|
+
calc.convert_to_seconds('01:00:00') # => 3600
|
|
164
|
+
calc.convert_to_clocktime(3600) # => "01:00:00"
|
|
165
|
+
calc.check_time('01:00:00') # => nil (valid)
|
|
361
166
|
```
|
|
362
167
|
|
|
363
|
-
|
|
168
|
+
---
|
|
364
169
|
|
|
365
|
-
|
|
170
|
+
### Errors
|
|
366
171
|
|
|
367
|
-
|
|
368
|
-
- `Calcpace::InvalidTimeFormatError`: Raised when a time string is not in the expected `HH:MM:SS` or `MM:SS` format.
|
|
172
|
+
All errors inherit from `Calcpace::Error`:
|
|
369
173
|
|
|
370
|
-
|
|
174
|
+
- `Calcpace::NonPositiveInputError` — numeric input is zero or negative
|
|
175
|
+
- `Calcpace::InvalidTimeFormatError` — time string not in `HH:MM:SS` or `MM:SS` format
|
|
371
176
|
|
|
372
|
-
|
|
373
|
-
begin
|
|
374
|
-
calculate.pace(945, -1)
|
|
375
|
-
rescue Calcpace::NonPositiveInputError => e
|
|
376
|
-
puts e.message # => "Distance must be a positive number"
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
begin
|
|
380
|
-
calculate.checked_time('string', 10)
|
|
381
|
-
rescue Calcpace::InvalidTimeFormatError => e
|
|
382
|
-
puts e.message # => "It must be a valid time in the XX:XX:XX or XX:XX format"
|
|
383
|
-
end
|
|
384
|
-
```
|
|
177
|
+
---
|
|
385
178
|
|
|
386
179
|
### Testing
|
|
387
180
|
|
|
388
|
-
To run the tests, clone the repository and run:
|
|
389
|
-
|
|
390
181
|
```bash
|
|
391
182
|
bundle exec rake
|
|
392
183
|
```
|
|
393
184
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
The tests are run using Ruby versions from 2.7.8 to 3.4.2, as specified in the `.github/workflows/test.yml` file.
|
|
185
|
+
Requires Ruby >= 3.2.0.
|
|
397
186
|
|
|
398
187
|
## Contributing
|
|
399
188
|
|
|
400
|
-
|
|
189
|
+
Clone the repo and submit a pull request. Please include tests.
|
|
401
190
|
|
|
402
191
|
## License
|
|
403
192
|
|
|
404
|
-
|
|
193
|
+
[MIT License](https://opensource.org/licenses/MIT)
|
data/calcpace.gemspec
CHANGED
|
@@ -8,8 +8,11 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ['João Gilberto Saraiva']
|
|
9
9
|
spec.email = ['joaogilberto@tuta.io']
|
|
10
10
|
|
|
11
|
-
spec.summary = '
|
|
12
|
-
spec.description = 'Ruby gem for
|
|
11
|
+
spec.summary = 'Pace, distance, GPS track analysis, and VO2max calculations for runners and cyclists.'
|
|
12
|
+
spec.description = 'Ruby gem for running and cycling calculations: pace, time, distance, ' \
|
|
13
|
+
'unit conversions (30+ units), race predictions (Riegel & Cameron), ' \
|
|
14
|
+
'GPS track analysis (Haversine, elevation gain, per-km splits), ' \
|
|
15
|
+
'and VO2max estimation (Daniels & Gilbert).'
|
|
13
16
|
spec.homepage = 'https://github.com/0jonjo/calcpace'
|
|
14
17
|
spec.metadata['source_code_uri'] = spec.homepage
|
|
15
18
|
spec.license = 'MIT'
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Module for GPS track calculations
|
|
4
|
+
#
|
|
5
|
+
# This module provides pure mathematical methods for computing distances,
|
|
6
|
+
# elevation changes, and pace splits from arrays of GPS coordinate points.
|
|
7
|
+
# It does not perform any file I/O or GPX parsing — callers are responsible
|
|
8
|
+
# for supplying arrays of hashes with the required keys.
|
|
9
|
+
#
|
|
10
|
+
# @example Calculate total distance of a track
|
|
11
|
+
# calc = Calcpace.new
|
|
12
|
+
# points = [
|
|
13
|
+
# { lat: -23.5505, lon: -46.6333 },
|
|
14
|
+
# { lat: -23.5510, lon: -46.6340 },
|
|
15
|
+
# { lat: -23.5520, lon: -46.6350 }
|
|
16
|
+
# ]
|
|
17
|
+
# calc.track_distance(points) #=> 0.17 (km)
|
|
18
|
+
#
|
|
19
|
+
# @example Calculate elevation gain and loss
|
|
20
|
+
# points = [
|
|
21
|
+
# { lat: -23.5505, lon: -46.6333, ele: 760.0 },
|
|
22
|
+
# { lat: -23.5510, lon: -46.6340, ele: 763.5 },
|
|
23
|
+
# { lat: -23.5515, lon: -46.6347, ele: 758.0 }
|
|
24
|
+
# ]
|
|
25
|
+
# calc.elevation_gain(points) #=> { gain: 3.5, loss: 5.5 }
|
|
26
|
+
module TrackCalculator
|
|
27
|
+
# Mean radius of the Earth in kilometers (IAU standard)
|
|
28
|
+
EARTH_RADIUS_KM = 6371.0
|
|
29
|
+
|
|
30
|
+
# Computes the great-circle distance between two GPS coordinates using
|
|
31
|
+
# the Haversine formula.
|
|
32
|
+
#
|
|
33
|
+
# The Haversine formula calculates the shortest distance over the Earth's
|
|
34
|
+
# surface between two points defined by latitude and longitude. It assumes
|
|
35
|
+
# a spherical Earth (error < 0.3% vs. WGS84 ellipsoid), which is accurate
|
|
36
|
+
# enough for running and cycling purposes.
|
|
37
|
+
#
|
|
38
|
+
# Formula:
|
|
39
|
+
# a = sin²(Δlat/2) + cos(lat1) × cos(lat2) × sin²(Δlon/2)
|
|
40
|
+
# c = 2 × atan2(√a, √(1−a))
|
|
41
|
+
# d = R × c
|
|
42
|
+
#
|
|
43
|
+
# @param lat1 [Numeric] latitude of first point in decimal degrees
|
|
44
|
+
# @param lon1 [Numeric] longitude of first point in decimal degrees
|
|
45
|
+
# @param lat2 [Numeric] latitude of second point in decimal degrees
|
|
46
|
+
# @param lon2 [Numeric] longitude of second point in decimal degrees
|
|
47
|
+
# @return [Float] distance in kilometers
|
|
48
|
+
# @raise [ArgumentError] if any coordinate is outside valid range (lat ±90, lon ±180)
|
|
49
|
+
#
|
|
50
|
+
# @example Distance between two points in São Paulo
|
|
51
|
+
# haversine_distance(-23.5505, -46.6333, -23.5510, -46.6340)
|
|
52
|
+
# #=> 0.089 (km)
|
|
53
|
+
def haversine_distance(lat1, lon1, lat2, lon2)
|
|
54
|
+
validate_coordinates(lat1, lon1)
|
|
55
|
+
validate_coordinates(lat2, lon2)
|
|
56
|
+
haversine_km(lat1, lon1, lat2, lon2)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Calculates the total distance of a GPS track by summing Haversine distances
|
|
60
|
+
# between consecutive points.
|
|
61
|
+
#
|
|
62
|
+
# @param points [Array<Hash>] array of points with :lat and :lon keys (String or Symbol)
|
|
63
|
+
# @return [Float] total distance in kilometers, rounded to 2 decimal places
|
|
64
|
+
# @raise [ArgumentError] if any point has coordinates outside valid range
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# points = [
|
|
68
|
+
# { lat: -23.5505, lon: -46.6333 },
|
|
69
|
+
# { lat: -23.5510, lon: -46.6340 },
|
|
70
|
+
# { lat: -23.5520, lon: -46.6350 }
|
|
71
|
+
# ]
|
|
72
|
+
# track_distance(points) #=> 0.17
|
|
73
|
+
def track_distance(points)
|
|
74
|
+
return 0.0 if points.nil? || points.size < 2
|
|
75
|
+
|
|
76
|
+
total = points.each_cons(2).sum do |a, b|
|
|
77
|
+
haversine_distance(fetch_coord(a, :lat), fetch_coord(a, :lon),
|
|
78
|
+
fetch_coord(b, :lat), fetch_coord(b, :lon))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
total.round(2)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Calculates cumulative elevation gain and loss along a GPS track.
|
|
85
|
+
#
|
|
86
|
+
# Only consecutive pairs where both points have an :ele value are considered.
|
|
87
|
+
# Points missing :ele are silently skipped.
|
|
88
|
+
#
|
|
89
|
+
# @param points [Array<Hash>] array of points with optional :ele key (meters)
|
|
90
|
+
# @return [Hash] hash with :gain and :loss keys, both Floats rounded to 1 decimal
|
|
91
|
+
#
|
|
92
|
+
# @example
|
|
93
|
+
# points = [
|
|
94
|
+
# { lat: 0, lon: 0, ele: 100.0 },
|
|
95
|
+
# { lat: 0, lon: 0, ele: 105.0 },
|
|
96
|
+
# { lat: 0, lon: 0, ele: 102.0 }
|
|
97
|
+
# ]
|
|
98
|
+
# elevation_gain(points) #=> { gain: 5.0, loss: 3.0 }
|
|
99
|
+
def elevation_gain(points)
|
|
100
|
+
gain = 0.0
|
|
101
|
+
loss = 0.0
|
|
102
|
+
return { gain: gain, loss: loss } if points.nil? || points.size < 2
|
|
103
|
+
|
|
104
|
+
points.each_cons(2) do |a, b|
|
|
105
|
+
gain, loss = accumulate_elevation(gain, loss, fetch_ele(a), fetch_ele(b))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
{ gain: gain.round(1), loss: loss.round(1) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Calculates pace splits at regular distance intervals along a GPS track.
|
|
112
|
+
#
|
|
113
|
+
# Accumulates Haversine distance between consecutive points until the target
|
|
114
|
+
# split distance is reached, then records elapsed time and pace for that split.
|
|
115
|
+
# Any remaining distance at the end is included as a partial split.
|
|
116
|
+
#
|
|
117
|
+
# @param points [Array<Hash>] array of points with :lat, :lon, and :time keys.
|
|
118
|
+
# :time must respond to #to_f (Unix timestamp) or be a Time object.
|
|
119
|
+
# @param split_km [Numeric] split interval in kilometers (default: 1.0)
|
|
120
|
+
# @return [Array<Hash>] array of split hashes, each with:
|
|
121
|
+
# - :km [Float] cumulative distance at split end
|
|
122
|
+
# - :elapsed [Integer] elapsed seconds from start of track to end of split
|
|
123
|
+
# - :pace [String] pace for this split in MM:SS format
|
|
124
|
+
# @raise [ArgumentError] if split_km is not positive
|
|
125
|
+
# @raise [ArgumentError] if any point is missing a :time key
|
|
126
|
+
#
|
|
127
|
+
# @example 5 km track with 1 km splits
|
|
128
|
+
# calc.track_splits(points, 1.0)
|
|
129
|
+
# #=> [
|
|
130
|
+
# { km: 1.0, elapsed: 312, pace: "05:12" },
|
|
131
|
+
# { km: 2.0, elapsed: 624, pace: "05:12" },
|
|
132
|
+
# ...
|
|
133
|
+
# ]
|
|
134
|
+
def track_splits(points, split_km = 1.0)
|
|
135
|
+
raise ArgumentError, 'split_km must be positive' unless split_km.is_a?(Numeric) && split_km.positive?
|
|
136
|
+
return [] if points.nil? || points.size < 2
|
|
137
|
+
|
|
138
|
+
validate_points_have_time(points)
|
|
139
|
+
collect_splits(points, split_km)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def haversine_km(lat1, lon1, lat2, lon2)
|
|
145
|
+
dlat = deg_to_rad(lat2 - lat1)
|
|
146
|
+
dlon = deg_to_rad(lon2 - lon1)
|
|
147
|
+
a = haversine_a(dlat, dlon, lat1, lat2)
|
|
148
|
+
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
149
|
+
EARTH_RADIUS_KM * c
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def haversine_a(dlat, dlon, lat1, lat2)
|
|
153
|
+
(Math.sin(dlat / 2)**2) +
|
|
154
|
+
(Math.cos(deg_to_rad(lat1)) * Math.cos(deg_to_rad(lat2)) *
|
|
155
|
+
(Math.sin(dlon / 2)**2))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def deg_to_rad(degrees)
|
|
159
|
+
degrees * Math::PI / 180.0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_coordinates(lat, lon)
|
|
163
|
+
unless lat.is_a?(Numeric) && lat >= -90 && lat <= 90
|
|
164
|
+
raise ArgumentError, "Invalid latitude: #{lat}. Must be between -90 and 90."
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return if lon.is_a?(Numeric) && lon >= -180 && lon <= 180
|
|
168
|
+
|
|
169
|
+
raise ArgumentError, "Invalid longitude: #{lon}. Must be between -180 and 180."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def accumulate_elevation(gain, loss, ele_a, ele_b)
|
|
173
|
+
return [gain, loss] if ele_a.nil? || ele_b.nil?
|
|
174
|
+
|
|
175
|
+
diff = ele_b - ele_a
|
|
176
|
+
if diff.positive?
|
|
177
|
+
[gain + diff, loss]
|
|
178
|
+
else
|
|
179
|
+
[gain, loss + diff.abs]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def fetch_coord(point, key)
|
|
184
|
+
point[key] || point[key.to_s]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def fetch_ele(point)
|
|
188
|
+
val = point[:ele] || point['ele']
|
|
189
|
+
val&.to_f
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validate_points_have_time(points)
|
|
193
|
+
points.each_with_index do |pt, i|
|
|
194
|
+
next if pt[:time] || pt['time']
|
|
195
|
+
|
|
196
|
+
raise ArgumentError, "Point at index #{i} is missing :time key required for splits"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def point_time(point)
|
|
201
|
+
t = point[:time] || point['time']
|
|
202
|
+
t.respond_to?(:to_f) ? t.to_f : t
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def interpolate_time(point_a, point_b, segment_km, distance_into_segment)
|
|
206
|
+
return point_time(point_a) if segment_km.zero?
|
|
207
|
+
|
|
208
|
+
t_a = point_time(point_a)
|
|
209
|
+
t_b = point_time(point_b)
|
|
210
|
+
t_a + ((t_b - t_a) * (distance_into_segment / segment_km))
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def seconds_to_pace(seconds, km)
|
|
214
|
+
return '00:00' if km.zero?
|
|
215
|
+
|
|
216
|
+
pace_seconds = (seconds.to_f / km).round
|
|
217
|
+
format('%<min>02d:%<sec>02d', min: pace_seconds / 60, sec: pace_seconds % 60)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def collect_splits(points, split_km)
|
|
221
|
+
state = { splits: [], start_time: point_time(points.first),
|
|
222
|
+
split_start_time: point_time(points.first),
|
|
223
|
+
accumulated_km: 0.0, split_number: 1 }
|
|
224
|
+
|
|
225
|
+
points.each_cons(2) { |a, b| process_segment(a, b, split_km, state) }
|
|
226
|
+
append_partial_split(points.last, split_km, state)
|
|
227
|
+
state[:splits]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def process_segment(point_a, point_b, split_km, state)
|
|
231
|
+
segment_km = haversine_distance(fetch_coord(point_a, :lat), fetch_coord(point_a, :lon),
|
|
232
|
+
fetch_coord(point_b, :lat), fetch_coord(point_b, :lon))
|
|
233
|
+
state[:accumulated_km] += segment_km
|
|
234
|
+
|
|
235
|
+
while state[:accumulated_km] >= split_km * state[:split_number]
|
|
236
|
+
record_split(point_a, point_b, segment_km, split_km, state)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def record_split(point_a, point_b, segment_km, split_km, state)
|
|
241
|
+
offset = (split_km * state[:split_number]) - (state[:accumulated_km] - segment_km)
|
|
242
|
+
boundary_time = interpolate_time(point_a, point_b, segment_km, offset)
|
|
243
|
+
state[:splits] << build_split_entry(boundary_time, split_km, state)
|
|
244
|
+
state[:split_start_time] = boundary_time
|
|
245
|
+
state[:split_number] += 1
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def build_split_entry(boundary_time, split_km, state)
|
|
249
|
+
split_elapsed = (boundary_time - state[:split_start_time]).round
|
|
250
|
+
{
|
|
251
|
+
km: (split_km * state[:split_number]).round(2),
|
|
252
|
+
elapsed: (boundary_time - state[:start_time]).round,
|
|
253
|
+
pace: seconds_to_pace(split_elapsed, split_km)
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def append_partial_split(last_point, split_km, state)
|
|
258
|
+
remaining_km = state[:accumulated_km] - (split_km * (state[:split_number] - 1))
|
|
259
|
+
return unless remaining_km > 0.001
|
|
260
|
+
|
|
261
|
+
last_time = point_time(last_point)
|
|
262
|
+
state[:splits] << {
|
|
263
|
+
km: state[:accumulated_km].round(2),
|
|
264
|
+
elapsed: (last_time - state[:start_time]).round,
|
|
265
|
+
pace: seconds_to_pace((last_time - state[:split_start_time]).round, remaining_km)
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
end
|
data/lib/calcpace/version.rb
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Module for estimating VO2max from a race performance
|
|
4
|
+
#
|
|
5
|
+
# Uses the Daniels & Gilbert formula (1979), which relates running velocity
|
|
6
|
+
# and exercise duration to oxygen consumption as a percentage of VO2max.
|
|
7
|
+
#
|
|
8
|
+
# Formula:
|
|
9
|
+
# velocity (m/min) = distance_m / time_min
|
|
10
|
+
# VO2 = -4.60 + 0.182258 * v + 0.000104 * v²
|
|
11
|
+
# %VO2max = 0.8 + 0.1894393·e^(-0.012778·t) + 0.2989558·e^(-0.1932605·t)
|
|
12
|
+
# VO2max = VO2 / %VO2max
|
|
13
|
+
#
|
|
14
|
+
# Accuracy: ±3–5 ml/kg/min vs laboratory testing. Best results with efforts
|
|
15
|
+
# between 5 and 60 minutes at race pace (i.e. near-maximal effort).
|
|
16
|
+
module Vo2maxEstimator
|
|
17
|
+
VO2MAX_LABELS = [
|
|
18
|
+
{ min: 70, label: 'Elite' },
|
|
19
|
+
{ min: 60, label: 'Excellent' },
|
|
20
|
+
{ min: 50, label: 'Very Good' },
|
|
21
|
+
{ min: 40, label: 'Good' },
|
|
22
|
+
{ min: 30, label: 'Fair' },
|
|
23
|
+
{ min: 0, label: 'Beginner' }
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# Estimates VO2max from a race performance using Daniels & Gilbert formula
|
|
27
|
+
#
|
|
28
|
+
# @param distance_km [Numeric] race distance in kilometres (must be > 0)
|
|
29
|
+
# @param time [String, Integer] finish time as "HH:MM:SS" / "MM:SS", or total seconds (must be > 0)
|
|
30
|
+
# @return [Float] estimated VO2max in ml/kg/min, rounded to one decimal place
|
|
31
|
+
# @raise [Calcpace::NonPositiveInputError] if distance or time are not positive
|
|
32
|
+
# @raise [Calcpace::InvalidTimeFormatError] if time string is not in HH:MM:SS or MM:SS format
|
|
33
|
+
#
|
|
34
|
+
# @example 10 km in 40:00 → ~51.9 ml/kg/min
|
|
35
|
+
# calc = Calcpace.new
|
|
36
|
+
# calc.estimate_vo2max(10.0, '00:40:00') #=> 51.9
|
|
37
|
+
def estimate_vo2max(distance_km, time)
|
|
38
|
+
distance_m = distance_km.to_f * 1000
|
|
39
|
+
time_min = parse_time_minutes(time)
|
|
40
|
+
|
|
41
|
+
check_positive(distance_m, 'Distance')
|
|
42
|
+
check_positive(time_min, 'Time')
|
|
43
|
+
|
|
44
|
+
velocity = distance_m / time_min
|
|
45
|
+
vo2 = vo2_at_velocity(velocity)
|
|
46
|
+
pct_vo2max = percent_vo2max(time_min)
|
|
47
|
+
|
|
48
|
+
(vo2 / pct_vo2max).round(1)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns a descriptive label for a given VO2max value
|
|
52
|
+
#
|
|
53
|
+
# @param value [Numeric] VO2max in ml/kg/min
|
|
54
|
+
# @return [String] label: "Beginner", "Fair", "Good", "Very Good", "Excellent", or "Elite"
|
|
55
|
+
# @raise [ArgumentError] if value is not positive
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# calc.vo2max_label(51.9) #=> "Very Good"
|
|
59
|
+
def vo2max_label(value)
|
|
60
|
+
check_positive(value.to_f, 'VO2max value')
|
|
61
|
+
|
|
62
|
+
VO2MAX_LABELS.find { |entry| value.to_f >= entry[:min] }[:label]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def vo2_at_velocity(velocity)
|
|
68
|
+
-4.60 + (0.182258 * velocity) + (0.000104 * (velocity**2))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def percent_vo2max(time_min)
|
|
72
|
+
0.8 +
|
|
73
|
+
(0.1894393 * Math.exp(-0.012778 * time_min)) +
|
|
74
|
+
(0.2989558 * Math.exp(-0.1932605 * time_min))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse_time_minutes(time)
|
|
78
|
+
return time.to_f / 60.0 if time.is_a?(Numeric)
|
|
79
|
+
|
|
80
|
+
check_time(time.to_s)
|
|
81
|
+
convert_to_seconds(time.to_s) / 60.0
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/calcpace.rb
CHANGED
|
@@ -10,6 +10,8 @@ require_relative 'calcpace/pace_calculator'
|
|
|
10
10
|
require_relative 'calcpace/pace_converter'
|
|
11
11
|
require_relative 'calcpace/race_predictor'
|
|
12
12
|
require_relative 'calcpace/race_splits'
|
|
13
|
+
require_relative 'calcpace/track_calculator'
|
|
14
|
+
require_relative 'calcpace/vo2max_estimator'
|
|
13
15
|
|
|
14
16
|
# Calcpace - A Ruby gem for pace, distance, and time calculations
|
|
15
17
|
#
|
|
@@ -42,6 +44,8 @@ class Calcpace
|
|
|
42
44
|
include PaceConverter
|
|
43
45
|
include RacePredictor
|
|
44
46
|
include RaceSplits
|
|
47
|
+
include TrackCalculator
|
|
48
|
+
include Vo2maxEstimator
|
|
45
49
|
|
|
46
50
|
# Creates a new Calcpace instance
|
|
47
51
|
#
|
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.9.
|
|
4
|
+
version: 1.9.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- João Gilberto Saraiva
|
|
@@ -9,8 +9,9 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description: Ruby gem for
|
|
13
|
-
units
|
|
12
|
+
description: 'Ruby gem for running and cycling calculations: pace, time, distance,
|
|
13
|
+
unit conversions (30+ units), race predictions (Riegel & Cameron), GPS track analysis
|
|
14
|
+
(Haversine, elevation gain, per-km splits), and VO2max estimation (Daniels & Gilbert).'
|
|
14
15
|
email:
|
|
15
16
|
- joaogilberto@tuta.io
|
|
16
17
|
executables: []
|
|
@@ -40,7 +41,9 @@ files:
|
|
|
40
41
|
- lib/calcpace/pace_converter.rb
|
|
41
42
|
- lib/calcpace/race_predictor.rb
|
|
42
43
|
- lib/calcpace/race_splits.rb
|
|
44
|
+
- lib/calcpace/track_calculator.rb
|
|
43
45
|
- lib/calcpace/version.rb
|
|
46
|
+
- lib/calcpace/vo2max_estimator.rb
|
|
44
47
|
homepage: https://github.com/0jonjo/calcpace
|
|
45
48
|
licenses:
|
|
46
49
|
- MIT
|
|
@@ -64,5 +67,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
64
67
|
requirements: []
|
|
65
68
|
rubygems_version: 4.0.6
|
|
66
69
|
specification_version: 4
|
|
67
|
-
summary:
|
|
70
|
+
summary: Pace, distance, GPS track analysis, and VO2max calculations for runners and
|
|
71
|
+
cyclists.
|
|
68
72
|
test_files: []
|