calcpace 1.16.0 → 1.18.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/.gitignore +2 -0
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +107 -1
- data/README.md +170 -1
- data/lib/calcpace/data/wma_2023_open_standards.yml +9 -1
- data/lib/calcpace/lap_analyzer.rb +246 -0
- data/lib/calcpace/training_zones.rb +174 -0
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace.rb +2 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: def270498a0b653086ac234f0eb1ad4337f961cc2b93729a4bf32c7e48df78ba
|
|
4
|
+
data.tar.gz: '075049703348c3cea1194b90a9f64f6cc37552fe360ce51e0d446a9edca5e729'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '083ff71a2666e55994ef03bd8184c435b1006c2029c4af97ab3546683f2d64e1422fb140c6828c001f00ad630bfc05f1caf98af53fbb9ab4d29ec603cad6ef04'
|
|
7
|
+
data.tar.gz: '00833e0d9c636730852506c360e08cfec7ecc1de2b1993a4a2560dd0f0941328c48c79e6047ca143babe3545f4b57bfd8e1e47b7c1f864c10d42ab85133390e0'
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
|
@@ -69,6 +69,10 @@ Metrics/ModuleLength:
|
|
|
69
69
|
Exclude:
|
|
70
70
|
- 'lib/calcpace/race_splits.rb'
|
|
71
71
|
- 'lib/calcpace/track_calculator.rb'
|
|
72
|
+
# Training zones now cover paces, heart-rate bands and time in those bands.
|
|
73
|
+
# Splitting them would put hr_zones and time_in_zones in different files,
|
|
74
|
+
# which is worse than a long module: one produces exactly what the other reads.
|
|
75
|
+
- 'lib/calcpace/training_zones.rb'
|
|
72
76
|
|
|
73
77
|
# Allow both single and double quotes for strings
|
|
74
78
|
Style/StringLiteralsInInterpolation:
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.18.0] - 2026-09-06
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- `age_grade_label(percent)` — and therefore `age_grade`'s `:category` — splits
|
|
14
|
+
the old single `"Developing"` band below 60% into three: `"Intermediate"`
|
|
15
|
+
(50–59.9%), `"Recreational"` (40–49.9%) and `"Active Beginner"` (below 40%).
|
|
16
|
+
Most recreational runners grade under 60%, so the old catch-all told nearly
|
|
17
|
+
every one of them the same thing.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
calc.age_grade_label(55.0) # => "Intermediate" (was "Developing")
|
|
21
|
+
calc.age_grade_label(45.0) # => "Recreational" (was "Developing")
|
|
22
|
+
calc.age_grade_label(30.0) # => "Active Beginner" (was "Developing")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The bands at 60% and above are untouched, and no numeric output changes —
|
|
26
|
+
`age_grade_percent`, `age_graded_time_seconds` and the rest are identical.
|
|
27
|
+
This is a label-only change, so callers that match on the string
|
|
28
|
+
`"Developing"` need updating.
|
|
29
|
+
|
|
30
|
+
## [1.17.0] - 2026-09-05
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- `time_in_zones(heartrate:, time:, zones:)` — splits a recorded heart-rate
|
|
34
|
+
series into the seconds spent in each of the five zones returned by `hr_zones`
|
|
35
|
+
or `hr_zones_from_max`, plus each zone's share of the counted time. Inputs are
|
|
36
|
+
two plain arrays, so a Strava `heartrate`/`time` stream pair fits without
|
|
37
|
+
translation and so does the same pair read out of a FIT file.
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
zones = calc.hr_zones_from_max(hr_max: 190)
|
|
41
|
+
|
|
42
|
+
in_zones = calc.time_in_zones(
|
|
43
|
+
heartrate: [120, 120, 140, 140, 160],
|
|
44
|
+
time: [0, 60, 120, 180, 240],
|
|
45
|
+
zones: zones
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
in_zones.map(&:seconds) # => [0, 120, 120, 60, 0]
|
|
49
|
+
in_zones.map(&:share) # => [0.0, 0.4, 0.4, 0.2, 0.0]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A sample lasts until the next one, and the last sample inherits the previous
|
|
53
|
+
delta so a series does not lose its final seconds. **A pause is a gap in
|
|
54
|
+
`time`, and the whole gap is booked to the sample before it** — there is no
|
|
55
|
+
`max_gap` to guess a cut-off with, so a caller holding Strava's `moving`
|
|
56
|
+
stream should nil the heart rate of every paused sample first.
|
|
57
|
+
|
|
58
|
+
A sample with a nil or non-positive heart rate contributes nothing — its
|
|
59
|
+
duration is dropped, never reassigned to a neighbour. Shares are rounded
|
|
60
|
+
together, largest remainder first, so the five of them are whole thousandths
|
|
61
|
+
adding up to 1000 — a bar chart fills its track.
|
|
62
|
+
|
|
63
|
+
- `hr_zone_for(bpm, zones)` — the zone lookup `time_in_zones` uses, exposed on
|
|
64
|
+
its own. Returns the `HrZone`, or nil when the reading is nil or not positive.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
calc.hr_zone_for(150, calc.hr_zones_from_max(hr_max: 190)).zone # => 3
|
|
68
|
+
calc.hr_zone_for(114, calc.hr_zones_from_max(hr_max: 190)).zone # => 2
|
|
69
|
+
calc.hr_zone_for(205, calc.hr_zones_from_max(hr_max: 190)).zone # => 5
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Note for calcpace.app:** the site's own lookup walks the zones with
|
|
73
|
+
`between?`, which differs from this one in two places. Zones are contiguous,
|
|
74
|
+
so their bounds are shared: `between?` gives 114 bpm to Z1, while
|
|
75
|
+
`hr_zone_for` gives it to Z2, the way a watch reads it. And a reading above
|
|
76
|
+
`hr_max` returns nil from `between?` but Z5 here — a reading above the maximum
|
|
77
|
+
means the maximum is wrong, not that the beat did not happen. Consumers should
|
|
78
|
+
migrate to `hr_zone_for` so that a zone split and a live zone badge cannot
|
|
79
|
+
disagree about the same beat.
|
|
80
|
+
|
|
81
|
+
- `interval_structure(laps, unit: :km)` — new `LapAnalyzer` module. Detects a
|
|
82
|
+
structured interval session in a watch's laps by **contrast**, never by a
|
|
83
|
+
label: a lap is work when it covers at least 0.1 km and is at least 15% faster
|
|
84
|
+
than every lap touching it. What comes before the first work lap is warm-up,
|
|
85
|
+
what follows the last is cool-down, and what sits between two work laps is
|
|
86
|
+
rest.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
laps = [{ distance: 2.0, elapsed: 720 }] +
|
|
90
|
+
([{ distance: 1.0, elapsed: 252 }, { distance: 0.4, elapsed: 156 }] * 6) +
|
|
91
|
+
[{ distance: 1.5, elapsed: 495 }]
|
|
92
|
+
|
|
93
|
+
calc.interval_structure(laps)
|
|
94
|
+
# => #<struct reps=6, work_distance=1.0, ... rest_duration=156>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A warm-up or a cool-down has only one neighbour, so contrast alone would be a
|
|
98
|
+
free pass — a 5:30/km cool-down beats the 6:30/km jog it touches and walks in
|
|
99
|
+
as an extra rep. An edge lap is therefore admitted only if it also agrees with
|
|
100
|
+
the reps found in the interior: within ±25% of their median distance and no
|
|
101
|
+
more than 10% slower than their pace.
|
|
102
|
+
|
|
103
|
+
It returns `nil` when the laps describe no structure — fewer than two work
|
|
104
|
+
laps, work laps more than ±25% away from their median distance (a fartlek or a
|
|
105
|
+
hilly run), or no work lap that ever beat a *finite* pace. That last rule
|
|
106
|
+
matters because a standing lap has an infinite pace and everything is 15%
|
|
107
|
+
faster than infinity: without it, an easy run with two red-light lap presses
|
|
108
|
+
would come back as three reps. Most runs are not intervals, and inventing reps
|
|
109
|
+
out of ordinary pace variation would make every easy run look like a workout.
|
|
110
|
+
|
|
111
|
+
A distance of `0` is a legal standing recovery, and makes `rest_pace` nil
|
|
112
|
+
rather than infinite. A lap over 100 km is rejected: it is a caller who passed
|
|
113
|
+
metres. `unit: :mi` converts both paces; distances stay in kilometres.
|
|
114
|
+
|
|
10
115
|
## [1.16.0] - 2026-09-05
|
|
11
116
|
|
|
12
117
|
### Added
|
|
@@ -515,7 +620,8 @@ predictors are untouched.
|
|
|
515
620
|
|
|
516
621
|
See git history for changes in earlier versions.
|
|
517
622
|
|
|
518
|
-
[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.
|
|
623
|
+
[Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.17.0...HEAD
|
|
624
|
+
[1.17.0]: https://github.com/0jonjo/calcpace/compare/v1.16.0...v1.17.0
|
|
519
625
|
[1.16.0]: https://github.com/0jonjo/calcpace/compare/v1.15.0...v1.16.0
|
|
520
626
|
[1.15.0]: https://github.com/0jonjo/calcpace/compare/v1.14.0...v1.15.0
|
|
521
627
|
[1.14.0]: https://github.com/0jonjo/calcpace/compare/v1.13.0...v1.14.0
|
data/README.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
A Ruby gem for runners: pace, time, and distance calculations, unit conversions, race predictions, GPS track analysis, age grading, VO2max estimation, and training zones.
|
|
4
4
|
|
|
5
|
+
> **See it in action:** [calcpace.app](https://calcpace.app) — free running calculators, race predictors and a training log, all powered by this gem.
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
```ruby
|
|
8
|
-
gem 'calcpace', '~> 1.
|
|
10
|
+
gem 'calcpace', '~> 1.18.0'
|
|
9
11
|
```
|
|
10
12
|
|
|
11
13
|
## Usage
|
|
@@ -261,6 +263,24 @@ calc.age_grade_percent(5.0, '00:22:30', age: 40, sex: :female) # => 65.2
|
|
|
261
263
|
calc.age_grade_label(65.2) # => "Local Class"
|
|
262
264
|
```
|
|
263
265
|
|
|
266
|
+
`category` (and `age_grade_label`) returns one of:
|
|
267
|
+
|
|
268
|
+
| Age grade | Category |
|
|
269
|
+
| --- | --- |
|
|
270
|
+
| 100%+ | Approximate World Record Level |
|
|
271
|
+
| 90–99.9% | World Class |
|
|
272
|
+
| 80–89.9% | National Class |
|
|
273
|
+
| 70–79.9% | Regional Class |
|
|
274
|
+
| 60–69.9% | Local Class |
|
|
275
|
+
| 50–59.9% | Intermediate |
|
|
276
|
+
| 40–49.9% | Recreational |
|
|
277
|
+
| below 40% | Active Beginner |
|
|
278
|
+
|
|
279
|
+
The bands from Local Class up are the WMA / Alan Jones convention. Those tables
|
|
280
|
+
define nothing below Local Class, so the three bands under 60% are calcpace's
|
|
281
|
+
own extension — most recreational runners land there, and one catch-all label
|
|
282
|
+
for everybody under 60% tells them nothing.
|
|
283
|
+
|
|
264
284
|
Supported distances: 5K, 10K, half marathon, marathon.
|
|
265
285
|
|
|
266
286
|
A numeric distance within **2%** of one of those is graded as that standard —
|
|
@@ -415,6 +435,155 @@ natively (not converted from the km bands), so they can differ by ±1 s from
|
|
|
415
435
|
All mile factors derive from the exact international mile (1609.344 m), so distances,
|
|
416
436
|
pace bands, and age-grading tolerances agree to the metre.
|
|
417
437
|
|
|
438
|
+
#### Time in heart-rate zones
|
|
439
|
+
|
|
440
|
+
`time_in_zones` splits a recorded heart-rate series into the time spent in each zone.
|
|
441
|
+
It takes two plain arrays and the zones, so a Strava `heartrate`/`time` stream pair
|
|
442
|
+
fits without translation, and so does the same pair read out of a FIT file:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
zones = calc.hr_zones_from_max(hr_max: 190)
|
|
446
|
+
|
|
447
|
+
in_zones = calc.time_in_zones(
|
|
448
|
+
heartrate: [120, 120, 140, 140, 160],
|
|
449
|
+
time: [0, 60, 120, 180, 240],
|
|
450
|
+
zones: zones
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
in_zones.map(&:seconds) # => [0, 120, 120, 60, 0]
|
|
454
|
+
in_zones.map(&:share) # => [0.0, 0.4, 0.4, 0.2, 0.0]
|
|
455
|
+
in_zones[1].zone # => 2
|
|
456
|
+
|
|
457
|
+
# The same call in one line, if you would rather not name the zones
|
|
458
|
+
calc.time_in_zones(heartrate: [120, 140], time: [0, 60], zones: calc.hr_zones_from_max(hr_max: 190)).map(&:seconds) # => [0, 60, 60, 0, 0]
|
|
459
|
+
calc.time_in_zones(heartrate: [120, 140], time: [0, 60], zones: calc.hr_zones_from_max(hr_max: 190)).map(&:share) # => [0.0, 0.5, 0.5, 0.0, 0.0]
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Five rows always come back, in zone order, zeros included — `seconds` whole, `share`
|
|
463
|
+
a fraction of the counted time with three decimals. The shares are whole thousandths
|
|
464
|
+
that add up to 1000 — rounded together, largest remainder first — so a bar chart
|
|
465
|
+
drawn from them fills its track (the Float sum may sit one ulp from 1.0).
|
|
466
|
+
|
|
467
|
+
**A sample lasts until the next one** (`time[i + 1] - time[i]`), and the last sample,
|
|
468
|
+
which has no next, is given the previous delta so a series does not lose its final
|
|
469
|
+
seconds; a single sample lasts 0 s.
|
|
470
|
+
|
|
471
|
+
That rule has a consequence worth knowing before trusting the numbers. **A pause is a
|
|
472
|
+
gap in `time`, and the whole gap is booked to the sample before it** — stop five
|
|
473
|
+
minutes at a café and those five minutes land in whatever zone the last beat before
|
|
474
|
+
the pause was in. There is no `max_gap` here to guess a cut-off with. If you have
|
|
475
|
+
Strava's `moving` stream, nil the heart rate of every paused sample before calling,
|
|
476
|
+
which hands them to the next rule.
|
|
477
|
+
|
|
478
|
+
A sample with a nil or non-positive heart rate contributes nothing: its duration is
|
|
479
|
+
**dropped, not reassigned**, because a dropout says nothing about which zone the
|
|
480
|
+
runner was in — so the counted time can be less than the wall clock, and the shares
|
|
481
|
+
are shares of what was counted.
|
|
482
|
+
|
|
483
|
+
Mismatched array lengths, a series that is not an array, a nil inside `time`, a
|
|
484
|
+
`time` that goes backwards, or a heart rate that is neither nil nor a number all
|
|
485
|
+
raise `Calcpace::Error`. Empty arrays return the five zero rows.
|
|
486
|
+
|
|
487
|
+
#### Which zone is this beat in?
|
|
488
|
+
|
|
489
|
+
`hr_zone_for` is the lookup `time_in_zones` uses, exposed on its own:
|
|
490
|
+
|
|
491
|
+
```ruby
|
|
492
|
+
calc.hr_zone_for(150, calc.hr_zones_from_max(hr_max: 190)).zone # => 3
|
|
493
|
+
calc.hr_zone_for(114, calc.hr_zones_from_max(hr_max: 190)).zone # => 2
|
|
494
|
+
calc.hr_zone_for(205, calc.hr_zones_from_max(hr_max: 190)).zone # => 5
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
It returns the `HrZone`, or nil when the reading is nil or not positive — a sensor
|
|
498
|
+
dropout. Two rules are worth stating because a hand-rolled `between?` lookup gets
|
|
499
|
+
both wrong:
|
|
500
|
+
|
|
501
|
+
- **On a shared boundary the higher zone wins.** Zones are contiguous, so 114 bpm is
|
|
502
|
+
both the top of Z1 and the bottom of Z2; it counts as Z2, the way a watch reads it.
|
|
503
|
+
- **Readings outside the range are clamped**, below Z1 to Z1 and above Z5 to Z5. A
|
|
504
|
+
reading above `hr_max` means the `hr_max` is wrong, not that the beat did not
|
|
505
|
+
happen — and an `hr_max` a few beats off is the most common thing an athlete
|
|
506
|
+
carries around. Clamping keeps that a distortion of the split instead of making
|
|
507
|
+
minutes of a run disappear.
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
### Lap Analysis
|
|
512
|
+
|
|
513
|
+
A watch records laps; it does not record intent. `interval_structure` reads the shape
|
|
514
|
+
of a session out of the laps themselves, by **contrast** — never by a label:
|
|
515
|
+
|
|
516
|
+
```ruby
|
|
517
|
+
# Warm-up, 6 x (1 km hard / 400 m jog), cool-down
|
|
518
|
+
laps = [{ distance: 2.0, elapsed: 720 }] +
|
|
519
|
+
([{ distance: 1.0, elapsed: 252 }, { distance: 0.4, elapsed: 156 }] * 6) +
|
|
520
|
+
[{ distance: 1.5, elapsed: 495 }]
|
|
521
|
+
|
|
522
|
+
structure = calc.interval_structure(laps)
|
|
523
|
+
# => #<struct reps=6, work_distance=1.0, ... rest_duration=156>
|
|
524
|
+
|
|
525
|
+
structure.reps # => 6
|
|
526
|
+
structure.work_distance # => 1.0 (km, median rep)
|
|
527
|
+
structure.work_pace # => 252 (seconds per km, distance-weighted)
|
|
528
|
+
structure.rest_pace # => 390
|
|
529
|
+
structure.rest_duration # => 156 (mean rest lap, seconds)
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
`to_a` gives all five at once, which is short enough to show whole. This is the
|
|
533
|
+
smallest session that has a structure — two reps and the jog between them:
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
calc.interval_structure([{ distance: 1.0, elapsed: 252 }, { distance: 0.4, elapsed: 156 }, { distance: 1.0, elapsed: 252 }]).to_a # => [2, 1.0, 252, 390, 156]
|
|
537
|
+
|
|
538
|
+
# unit: converts both paces; distances stay in kilometres
|
|
539
|
+
calc.interval_structure([{ distance: 1.0, elapsed: 252 }, { distance: 0.4, elapsed: 156 }, { distance: 1.0, elapsed: 252 }], unit: :mi).to_a # => [2, 1.0, 406, 628, 156]
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Laps are plain hashes of a distance in kilometres and an elapsed time in seconds —
|
|
543
|
+
what a Strava lap, a FIT lap and a hand-written array all reduce to. Distance `0` is
|
|
544
|
+
legal: it is a standing recovery, and it means an infinite pace.
|
|
545
|
+
|
|
546
|
+
A lap counts as **work** when it covers at least 0.1 km and is at least 15% faster
|
|
547
|
+
than every lap touching it. Everything before the first work lap is warm-up,
|
|
548
|
+
everything after the last is cool-down, and a lap between two work laps is rest. Two
|
|
549
|
+
work laps can never touch — each would have to be 0.85 of the other — so every pair
|
|
550
|
+
of reps has a recovery between it.
|
|
551
|
+
|
|
552
|
+
An **edge lap** — the first or the last — has only one neighbour, so contrast alone
|
|
553
|
+
is a free pass: a 5:30/km cool-down beats the 6:30/km jog it happens to touch and
|
|
554
|
+
walks in as an extra rep. So an edge lap is admitted only if it also agrees with the
|
|
555
|
+
reps found in the middle: within ±25% of their median distance, and no more than 10%
|
|
556
|
+
slower than their pace. When the interior found nothing there is nothing to agree
|
|
557
|
+
with, and the plain contrast rule stands — which is why the three-lap example above
|
|
558
|
+
still reads as two reps.
|
|
559
|
+
|
|
560
|
+
The method returns **nil** when the laps describe no structure, and most runs do not:
|
|
561
|
+
|
|
562
|
+
```ruby
|
|
563
|
+
# A steady 10 km, ten laps of 1 km within five seconds of each other
|
|
564
|
+
steady = [300, 298, 302, 296, 304, 300, 299, 301, 305, 295].map do |elapsed|
|
|
565
|
+
{ distance: 1.0, elapsed: elapsed }
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
calc.interval_structure(steady) # => nil
|
|
569
|
+
|
|
570
|
+
# An easy 3 km with two red lights: fast next to a pause is not a rep
|
|
571
|
+
calc.interval_structure([{ distance: 1.0, elapsed: 300 }, { distance: 0.0, elapsed: 45 }, { distance: 1.0, elapsed: 300 }]) # => nil
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
Nil is returned when there are fewer than two work laps, when the work laps disagree
|
|
575
|
+
about distance — more than ±25% from their median makes it a fartlek or a hilly run,
|
|
576
|
+
which has fast laps but no set to report — or when **no work lap ever beat a finite
|
|
577
|
+
pace**. That last rule is what the red-light example trips: a zero-distance lap has
|
|
578
|
+
an infinite pace and everything is 15% faster than infinity, so without it every easy
|
|
579
|
+
run with a paused lap would come back as a set of reps.
|
|
580
|
+
|
|
581
|
+
`rest_pace` is nil when the recoveries covered no distance at all; a standing
|
|
582
|
+
recovery has a duration but no pace, and reporting infinity would be worse than
|
|
583
|
+
reporting nothing. A lap missing `:distance` or `:elapsed`, a negative distance, a
|
|
584
|
+
distance over 100 km (a caller who passed metres), or a non-positive elapsed time
|
|
585
|
+
raises `Calcpace::Error`; an empty array returns nil.
|
|
586
|
+
|
|
418
587
|
---
|
|
419
588
|
|
|
420
589
|
### Stride & Cadence
|
|
@@ -3,6 +3,10 @@ meta:
|
|
|
3
3
|
url: "https://howardgrubb.co.uk/athletics/wmatnf23.html"
|
|
4
4
|
table_version: "WMA_2023_ONE_YEAR_FACTORS_V1"
|
|
5
5
|
|
|
6
|
+
# The bands from Local Class up follow the WMA / Alan Jones convention. The
|
|
7
|
+
# WMA tables define nothing below Local Class, so the three bands under 60%
|
|
8
|
+
# are Calcpace's own extension, added to give recreational runners a
|
|
9
|
+
# meaningful label instead of a single catch-all.
|
|
6
10
|
age_grade_classifications:
|
|
7
11
|
- min: 100.0
|
|
8
12
|
label: "Approximate World Record Level"
|
|
@@ -14,8 +18,12 @@ age_grade_classifications:
|
|
|
14
18
|
label: "Regional Class"
|
|
15
19
|
- min: 60.0
|
|
16
20
|
label: "Local Class"
|
|
21
|
+
- min: 50.0
|
|
22
|
+
label: "Intermediate"
|
|
23
|
+
- min: 40.0
|
|
24
|
+
label: "Recreational"
|
|
17
25
|
- min: 0.0
|
|
18
|
-
label: "
|
|
26
|
+
label: "Active Beginner"
|
|
19
27
|
|
|
20
28
|
open_standards_seconds:
|
|
21
29
|
M:
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Module for reading the shape of a session out of its laps
|
|
4
|
+
#
|
|
5
|
+
# A watch records laps; it does not record intent. Nothing in the data says
|
|
6
|
+
# "6 × 1 km", and the label a runner typed into an app is not evidence — most
|
|
7
|
+
# sessions carry none, and the ones that do are often wrong. What is always in
|
|
8
|
+
# the data is **contrast**: a rep is a lap that is much faster than the laps
|
|
9
|
+
# touching it, and the recovery between two reps is the lap that is not.
|
|
10
|
+
#
|
|
11
|
+
# So the detection here is purely relative. A lap is work when it is at least
|
|
12
|
+
# WORK_PACE_RATIO faster than every lap beside it; everything before the first
|
|
13
|
+
# work lap is warm-up, everything after the last is cool-down, and what sits
|
|
14
|
+
# between two work laps is rest. A session is only called structured when the
|
|
15
|
+
# reps agree with each other on distance — otherwise it is a fartlek or a hilly
|
|
16
|
+
# run, which has fast laps but no structure to report.
|
|
17
|
+
#
|
|
18
|
+
# Format-agnostic by design: laps are plain hashes of a distance in kilometres
|
|
19
|
+
# and an elapsed time in seconds, which is what a Strava lap, a FIT lap and a
|
|
20
|
+
# hand-written array all reduce to.
|
|
21
|
+
module LapAnalyzer
|
|
22
|
+
# A detected interval session. Paces are seconds per unit, distances km.
|
|
23
|
+
IntervalStructure = Struct.new(:reps, :work_distance, :work_pace, :rest_pace, :rest_duration)
|
|
24
|
+
|
|
25
|
+
# One lap reduced to the three numbers the detection reasons about
|
|
26
|
+
LapPace = Struct.new(:distance, :elapsed, :pace)
|
|
27
|
+
|
|
28
|
+
# How much faster than its neighbours a lap must be to count as work
|
|
29
|
+
WORK_PACE_RATIO = 0.85
|
|
30
|
+
|
|
31
|
+
# Below this a lap is a split, not a rep — a hand-pressed button, a GPS
|
|
32
|
+
# hiccup, the last metres of a track lap recorded on their own
|
|
33
|
+
MIN_WORK_DISTANCE_KM = 0.1
|
|
34
|
+
|
|
35
|
+
# How far a rep may sit from the median rep distance and still belong to the
|
|
36
|
+
# same set. Wider than this and the fast laps are not one workout
|
|
37
|
+
WORK_DISTANCE_TOLERANCE = 0.25
|
|
38
|
+
|
|
39
|
+
# How much slower than the reps found in the interior an edge lap may be and
|
|
40
|
+
# still be read as one of them. A warm-up or cool-down only ever has one
|
|
41
|
+
# neighbour, so contrast alone would let it in on beating a single jog
|
|
42
|
+
EDGE_PACE_TOLERANCE = 1.10
|
|
43
|
+
|
|
44
|
+
# A lap longer than this was measured in metres by a caller who thinks they
|
|
45
|
+
# are kilometres. No lap of a running session is 100 km
|
|
46
|
+
MAX_LAP_DISTANCE_KM = 100
|
|
47
|
+
|
|
48
|
+
# Detects a structured interval session in a list of laps
|
|
49
|
+
#
|
|
50
|
+
# Returns nil whenever the laps do not describe one: a steady run, a single
|
|
51
|
+
# hard effort, a fartlek whose surges have nothing in common. Nil is the
|
|
52
|
+
# honest answer — most runs are not intervals, and inventing reps out of
|
|
53
|
+
# ordinary pace variation would make every easy run look like a workout.
|
|
54
|
+
#
|
|
55
|
+
# @param laps [Array<Hash>] laps in order, each with :distance in kilometres
|
|
56
|
+
# (Numeric, 0 up to 100, and 0 means a standing recovery) and :elapsed in
|
|
57
|
+
# seconds (Numeric, must be positive). String keys are accepted too
|
|
58
|
+
# @param unit [Symbol, String] unit of the returned paces — :km (default) or :mi.
|
|
59
|
+
# Distances stay in kilometres in both
|
|
60
|
+
# @return [IntervalStructure, nil] nil when the laps have no structure, else a
|
|
61
|
+
# struct of: reps, the number of work laps; work_distance, their median
|
|
62
|
+
# distance in km rounded to 2 decimals; work_pace, their distance-weighted
|
|
63
|
+
# pace (total elapsed over total distance) in seconds per unit, rounded;
|
|
64
|
+
# rest_pace, the same over the rest laps, or nil when they covered no
|
|
65
|
+
# distance; and rest_duration, the mean rest lap in whole seconds
|
|
66
|
+
# @raise [Calcpace::Error] if a lap is missing :distance or :elapsed, its
|
|
67
|
+
# distance is negative or over 100 km, or its elapsed time is not positive
|
|
68
|
+
# @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
|
|
69
|
+
#
|
|
70
|
+
# @example warm-up, 6 × (1 km hard / 400 m jog), cool-down
|
|
71
|
+
# laps = [{ distance: 2.0, elapsed: 720 }] +
|
|
72
|
+
# ([{ distance: 1.0, elapsed: 252 }, { distance: 0.4, elapsed: 156 }] * 6) +
|
|
73
|
+
# [{ distance: 1.5, elapsed: 495 }]
|
|
74
|
+
# structure = calc.interval_structure(laps)
|
|
75
|
+
# structure.reps #=> 6
|
|
76
|
+
# structure.work_distance #=> 1.0
|
|
77
|
+
# structure.work_pace #=> 252
|
|
78
|
+
# structure.rest_pace #=> 390
|
|
79
|
+
# structure.rest_duration #=> 156
|
|
80
|
+
#
|
|
81
|
+
# @example the smallest session that has a structure, every field at once
|
|
82
|
+
# calc.interval_structure([{ distance: 1.0, elapsed: 252 },
|
|
83
|
+
# { distance: 0.4, elapsed: 156 },
|
|
84
|
+
# { distance: 1.0, elapsed: 252 }]).to_a
|
|
85
|
+
# #=> [2, 1.0, 252, 390, 156]
|
|
86
|
+
#
|
|
87
|
+
# @example the same session with paces per mile
|
|
88
|
+
# calc.interval_structure(laps, unit: :mi).work_pace #=> 406
|
|
89
|
+
#
|
|
90
|
+
# @example a steady 10 km, ten laps of 1 km within five seconds of each other
|
|
91
|
+
# steady = [300, 298, 302, 296, 304, 300, 299, 301, 305, 295].map do |elapsed|
|
|
92
|
+
# { distance: 1.0, elapsed: elapsed }
|
|
93
|
+
# end
|
|
94
|
+
# calc.interval_structure(steady) #=> nil
|
|
95
|
+
def interval_structure(laps, unit: :km)
|
|
96
|
+
meters = pace_unit_meters(unit)
|
|
97
|
+
parsed = parse_laps(laps)
|
|
98
|
+
work = work_lap_indexes(parsed)
|
|
99
|
+
return nil unless structured?(parsed, work)
|
|
100
|
+
|
|
101
|
+
build_interval_structure(parsed, work, meters)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# @param laps [Array<Hash>] raw laps
|
|
107
|
+
# @return [Array<LapPace>] the same laps with their pace in seconds per km
|
|
108
|
+
def parse_laps(laps)
|
|
109
|
+
Array(laps).map do |lap|
|
|
110
|
+
distance = lap_value(lap, :distance)
|
|
111
|
+
elapsed = lap_value(lap, :elapsed)
|
|
112
|
+
check_lap(distance, elapsed)
|
|
113
|
+
|
|
114
|
+
LapPace.new(distance: distance, elapsed: elapsed,
|
|
115
|
+
pace: distance.positive? ? elapsed / distance : Float::INFINITY)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @raise [Calcpace::Error] if the key is missing or does not hold a number
|
|
120
|
+
def lap_value(lap, key)
|
|
121
|
+
value = lap.is_a?(Hash) ? (lap[key] || lap[key.to_s]) : nil
|
|
122
|
+
return value.to_f if value.is_a?(Numeric)
|
|
123
|
+
|
|
124
|
+
raise Calcpace::Error, "Every lap needs a numeric :#{key} (got #{value.inspect})"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def check_lap(distance, elapsed)
|
|
128
|
+
raise Calcpace::Error, "Lap distance cannot be negative (got #{distance})" if distance.negative?
|
|
129
|
+
raise Calcpace::Error, "Lap elapsed time must be positive (got #{elapsed})" unless elapsed.positive?
|
|
130
|
+
return if distance <= MAX_LAP_DISTANCE_KM
|
|
131
|
+
|
|
132
|
+
raise Calcpace::Error, "Lap distance #{distance} is too long — lap distances are kilometres, not metres"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Interior laps are judged on contrast alone. An edge lap — the first or the
|
|
136
|
+
# last — has only one neighbour, so contrast is a free pass: a cool-down beats
|
|
137
|
+
# the jog it happens to touch and walks in as an extra rep, or as a rep of the
|
|
138
|
+
# wrong length that makes the whole session look unstructured. It is admitted
|
|
139
|
+
# only if it also looks like the reps already found in the middle.
|
|
140
|
+
#
|
|
141
|
+
# When the interior found nothing there is nothing to agree with, so the plain
|
|
142
|
+
# contrast rule stands and the three-lap session (work, rest, work) still reads.
|
|
143
|
+
#
|
|
144
|
+
# @return [Array<Integer>] indexes of the laps that stand out as reps
|
|
145
|
+
def work_lap_indexes(laps)
|
|
146
|
+
interior = (1...(laps.size - 1)).select { |index| work_lap?(laps, index) }
|
|
147
|
+
return laps.each_index.select { |index| work_lap?(laps, index) } if interior.empty?
|
|
148
|
+
|
|
149
|
+
edges = [0, laps.size - 1].uniq.select do |index|
|
|
150
|
+
work_lap?(laps, index) && matches_interior?(laps, index, interior)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
(interior + edges).sort
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def matches_interior?(laps, index, interior)
|
|
157
|
+
lap = laps[index]
|
|
158
|
+
median = median(interior.map { |i| laps[i].distance })
|
|
159
|
+
|
|
160
|
+
(lap.distance - median).abs <= WORK_DISTANCE_TOLERANCE * median &&
|
|
161
|
+
lap.pace <= EDGE_PACE_TOLERANCE * weighted_pace(laps, interior)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Two work laps can never touch: each would have to be 0.85 of the other.
|
|
165
|
+
# That is what guarantees a rest lap between every pair of reps.
|
|
166
|
+
def work_lap?(laps, index)
|
|
167
|
+
lap = laps[index]
|
|
168
|
+
return false if lap.distance < MIN_WORK_DISTANCE_KM
|
|
169
|
+
|
|
170
|
+
neighbour_paces(laps, index).all? { |pace| lap.pace <= WORK_PACE_RATIO * pace }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def neighbour_paces(laps, index)
|
|
174
|
+
[index - 1, index + 1].select { |i| i >= 0 && i < laps.size }
|
|
175
|
+
.map { |i| laps[i].pace }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def structured?(laps, work)
|
|
179
|
+
return false if work.size < 2
|
|
180
|
+
return false unless beat_a_real_pace?(laps, work)
|
|
181
|
+
|
|
182
|
+
distances = work.map { |index| laps[index].distance }
|
|
183
|
+
median = median(distances)
|
|
184
|
+
|
|
185
|
+
distances.all? { |distance| (distance - median).abs <= WORK_DISTANCE_TOLERANCE * median }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A lap of zero distance has an infinite pace, and everything is 15% faster
|
|
189
|
+
# than infinity. So an easy run with two red-light lap presses looks exactly
|
|
190
|
+
# like three reps around two standing recoveries. At least one rep has to have
|
|
191
|
+
# beaten a pace that was actually run; otherwise the contrast is an artefact
|
|
192
|
+
# of the pauses and there is no evidence of a workout at all.
|
|
193
|
+
def beat_a_real_pace?(laps, work)
|
|
194
|
+
work.any? { |index| neighbour_paces(laps, index).any?(&:finite?) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def build_interval_structure(laps, work, meters)
|
|
198
|
+
rest = rest_lap_indexes(work)
|
|
199
|
+
|
|
200
|
+
IntervalStructure.new(
|
|
201
|
+
reps: work.size,
|
|
202
|
+
work_distance: median(work.map { |index| laps[index].distance }).round(2),
|
|
203
|
+
work_pace: converted_pace(weighted_pace(laps, work), meters),
|
|
204
|
+
rest_pace: converted_pace(weighted_pace(laps, rest), meters),
|
|
205
|
+
rest_duration: rest.empty? ? nil : mean(rest.map { |index| laps[index].elapsed }).round
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Laps sitting between two consecutive reps. Anything outside the reps is
|
|
210
|
+
# warm-up or cool-down, which is not part of the session's structure.
|
|
211
|
+
#
|
|
212
|
+
# Never empty in practice once there are two reps, because two work laps can
|
|
213
|
+
# never be adjacent — but reps and rest are counted separately here so that a
|
|
214
|
+
# change to the work rule shows up as a nil field, not as a NaN.
|
|
215
|
+
def rest_lap_indexes(work)
|
|
216
|
+
work.each_cons(2).flat_map { |from, to| ((from + 1)...to).to_a }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Distance-weighted pace, so a longer rep counts for more than a short one.
|
|
220
|
+
# Nil when the laps covered no distance at all — a standing recovery has a
|
|
221
|
+
# duration but no pace, and reporting infinity would be worse than reporting
|
|
222
|
+
# nothing.
|
|
223
|
+
def weighted_pace(laps, indexes)
|
|
224
|
+
distance = indexes.sum { |index| laps[index].distance }
|
|
225
|
+
return nil unless distance.positive?
|
|
226
|
+
|
|
227
|
+
indexes.sum { |index| laps[index].elapsed } / distance
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def converted_pace(pace_per_km, meters)
|
|
231
|
+
return nil if pace_per_km.nil?
|
|
232
|
+
|
|
233
|
+
(pace_per_km * meters / 1000.0).round
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def median(values)
|
|
237
|
+
sorted = values.sort
|
|
238
|
+
middle = sorted.size / 2
|
|
239
|
+
|
|
240
|
+
sorted.size.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def mean(values)
|
|
244
|
+
values.sum / values.size.to_f
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -33,6 +33,14 @@ module TrainingZones
|
|
|
33
33
|
# One heart-rate training zone (1 = recovery … 5 = maximal)
|
|
34
34
|
HrZone = Struct.new(:zone, :min_bpm, :max_bpm)
|
|
35
35
|
|
|
36
|
+
# Time spent in one heart-rate zone: the zone number, whole seconds, and the
|
|
37
|
+
# fraction of the counted time that fell in it
|
|
38
|
+
TimeInZone = Struct.new(:zone, :seconds, :share)
|
|
39
|
+
|
|
40
|
+
# Shares are kept as thousandths while they are rounded, so that the five of
|
|
41
|
+
# them can be made to add up to exactly 1.0 before they become Floats
|
|
42
|
+
SHARE_UNITS = 1000
|
|
43
|
+
|
|
36
44
|
# Derives training pace bands from a VO2max value
|
|
37
45
|
#
|
|
38
46
|
# @param vo2max [Numeric] VO2max in ml/kg/min (must be > 0)
|
|
@@ -144,8 +152,174 @@ module TrainingZones
|
|
|
144
152
|
build_hr_zones(HR_ZONE_BOUNDARIES.map { |pct| (pct * max).round })
|
|
145
153
|
end
|
|
146
154
|
|
|
155
|
+
# Finds the training zone a heart-rate reading belongs to
|
|
156
|
+
#
|
|
157
|
+
# The zones handed in are contiguous, so their boundaries are shared: 114 bpm
|
|
158
|
+
# is both the top of zone 1 and the bottom of zone 2. The higher zone wins,
|
|
159
|
+
# the way a watch reads it. Readings outside the range are clamped — below
|
|
160
|
+
# zone 1 counts as zone 1, above zone 5 as zone 5 — because a reading above
|
|
161
|
+
# hr_max means the hr_max is wrong, not that the beat did not happen.
|
|
162
|
+
#
|
|
163
|
+
# A `between?` lookup written against the zone bounds does neither: it gives a
|
|
164
|
+
# boundary to the lower zone and returns nothing at all above hr_max.
|
|
165
|
+
#
|
|
166
|
+
# @param bpm [Numeric, nil] the reading in beats per minute
|
|
167
|
+
# @param zones [Array<HrZone>] the zones from #hr_zones or #hr_zones_from_max
|
|
168
|
+
# @return [HrZone, nil] the zone holding the reading; nil only when bpm is nil
|
|
169
|
+
# or not positive, which is what a sensor dropout looks like
|
|
170
|
+
# @raise [Calcpace::Error] if bpm is neither nil nor Numeric, or zones is empty
|
|
171
|
+
#
|
|
172
|
+
# @example
|
|
173
|
+
# calc.hr_zone_for(150, calc.hr_zones_from_max(hr_max: 190)).zone #=> 3
|
|
174
|
+
# calc.hr_zone_for(114, calc.hr_zones_from_max(hr_max: 190)).zone #=> 2
|
|
175
|
+
# calc.hr_zone_for(80, calc.hr_zones_from_max(hr_max: 190)).zone #=> 1
|
|
176
|
+
# calc.hr_zone_for(205, calc.hr_zones_from_max(hr_max: 190)).zone #=> 5
|
|
177
|
+
def hr_zone_for(bpm, zones)
|
|
178
|
+
index = hr_zone_index(bpm, zones)
|
|
179
|
+
|
|
180
|
+
index && zones[index]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Splits a recorded heart-rate series into the time spent in each zone
|
|
184
|
+
#
|
|
185
|
+
# Format-agnostic on purpose: two plain arrays and the zones to sort them
|
|
186
|
+
# into. A Strava `heartrate`/`time` stream pair fits without translation, and
|
|
187
|
+
# so does the same pair read out of a FIT file.
|
|
188
|
+
#
|
|
189
|
+
# A sample lasts until the next one — <tt>time[i + 1] - time[i]</tt> — and the
|
|
190
|
+
# last sample, which has no next, is given the previous delta so that a series
|
|
191
|
+
# does not lose its final seconds. A single sample therefore lasts 0 s.
|
|
192
|
+
#
|
|
193
|
+
# That rule has one consequence worth knowing before trusting the numbers: a
|
|
194
|
+
# **pause is a gap in `time`, and the whole gap is booked to the sample before
|
|
195
|
+
# it**. Stop for five minutes at a café and those five minutes land in
|
|
196
|
+
# whatever zone the last beat before the pause was in. There is no max_gap
|
|
197
|
+
# here to guess a cut-off with. A caller who has Strava's `moving` stream
|
|
198
|
+
# should nil the heart rate of every paused sample before calling, which is
|
|
199
|
+
# exactly what the next rule then does with them.
|
|
200
|
+
#
|
|
201
|
+
# A sample with a nil or non-positive heart rate contributes nothing at all:
|
|
202
|
+
# its duration is dropped, never handed to a neighbour, because a dropout says
|
|
203
|
+
# nothing about which zone the runner was in. So the counted time can be less
|
|
204
|
+
# than the wall clock, and the shares are shares of what was counted.
|
|
205
|
+
#
|
|
206
|
+
# Readings outside the zones are clamped and boundaries go to the higher zone,
|
|
207
|
+
# exactly as #hr_zone_for describes — an hr_max that is a few beats wrong is
|
|
208
|
+
# the most common thing an athlete carries around, and clamping keeps that a
|
|
209
|
+
# distortion of the split instead of making minutes of a run vanish.
|
|
210
|
+
#
|
|
211
|
+
# @param heartrate [Array<Numeric, nil>] heart rate in bpm, one entry per sample
|
|
212
|
+
# @param time [Array<Numeric>] seconds since the start, non-decreasing, same length
|
|
213
|
+
# @param zones [Array<HrZone>] the five zones from #hr_zones or #hr_zones_from_max
|
|
214
|
+
# @return [Array<TimeInZone>] one row per zone in zone order, zeros included;
|
|
215
|
+
# seconds are Integer, shares Float with 3 decimals that are whole
|
|
216
|
+
# thousandths adding up to 1000 over the counted time (all zero when
|
|
217
|
+
# nothing was counted)
|
|
218
|
+
# @raise [Calcpace::Error] if either series is not an Array, if they differ in
|
|
219
|
+
# length, if time holds a non-numeric entry or goes backwards, if a heart
|
|
220
|
+
# rate is neither nil nor Numeric, or if zones is empty
|
|
221
|
+
#
|
|
222
|
+
# @example four minutes of a run against zones for a 190 bpm maximum
|
|
223
|
+
# zones = calc.hr_zones_from_max(hr_max: 190)
|
|
224
|
+
# in_zones = calc.time_in_zones(heartrate: [120, 120, 140, 140, 160],
|
|
225
|
+
# time: [0, 60, 120, 180, 240],
|
|
226
|
+
# zones: zones)
|
|
227
|
+
# in_zones.map(&:seconds) #=> [0, 120, 120, 60, 0]
|
|
228
|
+
# in_zones.map(&:share) #=> [0.0, 0.4, 0.4, 0.2, 0.0]
|
|
229
|
+
# in_zones[1].zone #=> 2
|
|
230
|
+
def time_in_zones(heartrate:, time:, zones:)
|
|
231
|
+
check_hr_series(heartrate, time, zones)
|
|
232
|
+
|
|
233
|
+
totals = zone_seconds(heartrate, time, zones)
|
|
234
|
+
shares = zone_shares(totals)
|
|
235
|
+
|
|
236
|
+
zones.each_with_index.map do |zone, index|
|
|
237
|
+
TimeInZone.new(zone: zone.zone, seconds: totals[index].round, share: shares[index])
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
147
241
|
private
|
|
148
242
|
|
|
243
|
+
# @return [Array<Float>] seconds accumulated in each zone, in zone order
|
|
244
|
+
def zone_seconds(heartrate, time, zones)
|
|
245
|
+
totals = Array.new(zones.size, 0.0)
|
|
246
|
+
|
|
247
|
+
heartrate.each_with_index do |bpm, index|
|
|
248
|
+
zone = hr_zone_index(bpm, zones)
|
|
249
|
+
next if zone.nil?
|
|
250
|
+
|
|
251
|
+
totals[zone] += sample_duration(time, index)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
totals
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Rounded on its own each share is out by up to half a thousandth, and five of
|
|
258
|
+
# those leave a bar chart that does not fill its track. Largest-remainder
|
|
259
|
+
# rounding spends the residue on the zones that lost the most to rounding, so
|
|
260
|
+
# the shares add up to exactly 1.0. Ties go to the earlier zone, so the same
|
|
261
|
+
# series always splits the same way.
|
|
262
|
+
def zone_shares(totals)
|
|
263
|
+
counted = totals.sum
|
|
264
|
+
return Array.new(totals.size, 0.0) unless counted.positive?
|
|
265
|
+
|
|
266
|
+
scaled = totals.map { |seconds| seconds * SHARE_UNITS / counted }
|
|
267
|
+
units = scaled.map(&:floor)
|
|
268
|
+
largest_remainders(scaled, units, SHARE_UNITS - units.sum).each { |index| units[index] += 1 }
|
|
269
|
+
|
|
270
|
+
units.map { |unit| unit / SHARE_UNITS.to_f }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# @return [Array<Integer>] the indexes that give up the most to rounding down
|
|
274
|
+
def largest_remainders(scaled, units, residue)
|
|
275
|
+
return [] unless residue.positive?
|
|
276
|
+
|
|
277
|
+
scaled.each_index.sort_by { |index| [units[index] - scaled[index], index] }.first(residue)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The last sample has no successor, so it inherits the previous delta
|
|
281
|
+
def sample_duration(time, index)
|
|
282
|
+
last = time.size - 1
|
|
283
|
+
return 0.0 if last.zero?
|
|
284
|
+
|
|
285
|
+
from = index == last ? last - 1 : index
|
|
286
|
+
(time[from + 1] - time[from]).to_f
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The highest zone the reading reaches, clamped to zone 1 below the bottom
|
|
290
|
+
def hr_zone_index(bpm, zones)
|
|
291
|
+
check_zones(zones)
|
|
292
|
+
return nil if bpm.nil?
|
|
293
|
+
|
|
294
|
+
raise Calcpace::Error, "Heart rate must be numeric or nil (got #{bpm.inspect})" unless bpm.is_a?(Numeric)
|
|
295
|
+
|
|
296
|
+
bpm.positive? ? (zones.rindex { |zone| bpm >= zone.min_bpm } || 0) : nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def check_hr_series(heartrate, time, zones)
|
|
300
|
+
unless heartrate.is_a?(Array) && time.is_a?(Array)
|
|
301
|
+
raise Calcpace::Error, 'Heart rate and time series must both be arrays'
|
|
302
|
+
end
|
|
303
|
+
raise Calcpace::Error, 'Heart rate and time series must have the same length' if heartrate.size != time.size
|
|
304
|
+
|
|
305
|
+
check_zones(zones)
|
|
306
|
+
check_time_series(time)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# A nil in the middle of the time stream is not a zero-length sample — it is a
|
|
310
|
+
# stream we cannot measure any duration from, so it is an error, not a drop
|
|
311
|
+
def check_time_series(time)
|
|
312
|
+
time.each do |seconds|
|
|
313
|
+
raise Calcpace::Error, "Every time entry must be numeric (got #{seconds.inspect})" unless seconds.is_a?(Numeric)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
raise Calcpace::Error, 'Time series must be non-decreasing' if time.each_cons(2).any? { |a, b| b < a }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def check_zones(zones)
|
|
320
|
+
raise Calcpace::Error, 'At least one heart-rate zone is required' if zones.nil? || zones.empty?
|
|
321
|
+
end
|
|
322
|
+
|
|
149
323
|
# Turns six ascending bpm boundary points into five contiguous HrZone structs
|
|
150
324
|
def build_hr_zones(points)
|
|
151
325
|
points.each_cons(2).with_index(1).map do |(min_bpm, max_bpm), zone|
|
data/lib/calcpace/version.rb
CHANGED
data/lib/calcpace.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative 'calcpace/converter'
|
|
|
9
9
|
require_relative 'calcpace/converter_chain'
|
|
10
10
|
require_relative 'calcpace/errors'
|
|
11
11
|
require_relative 'calcpace/fitness_predictor'
|
|
12
|
+
require_relative 'calcpace/lap_analyzer'
|
|
12
13
|
require_relative 'calcpace/pace_calculator'
|
|
13
14
|
require_relative 'calcpace/pace_converter'
|
|
14
15
|
require_relative 'calcpace/race_predictor'
|
|
@@ -48,6 +49,7 @@ class Calcpace
|
|
|
48
49
|
include Converter
|
|
49
50
|
include ConverterChain
|
|
50
51
|
include FitnessPredictor
|
|
52
|
+
include LapAnalyzer
|
|
51
53
|
include PaceCalculator
|
|
52
54
|
include PaceConverter
|
|
53
55
|
include RacePredictor
|
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.
|
|
4
|
+
version: 1.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- João Gilberto Saraiva
|
|
@@ -45,6 +45,7 @@ files:
|
|
|
45
45
|
- lib/calcpace/environmental_adjuster.rb
|
|
46
46
|
- lib/calcpace/errors.rb
|
|
47
47
|
- lib/calcpace/fitness_predictor.rb
|
|
48
|
+
- lib/calcpace/lap_analyzer.rb
|
|
48
49
|
- lib/calcpace/pace_calculator.rb
|
|
49
50
|
- lib/calcpace/pace_converter.rb
|
|
50
51
|
- lib/calcpace/race_predictor.rb
|