calcpace 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5eefef398417bfe2d045b2afd7ee78991b443c7c9bc1eabd536e81d03789908c
4
- data.tar.gz: 7b676576e7e3af9b9d4ce75430af80835a35535b2c8b971ed5ef764ddd608853
3
+ metadata.gz: 1b4581502040b195baf42c3dbf64bdb8cc6902d2fd1f4011d7982c18961bbf5f
4
+ data.tar.gz: 0dff0cbdd1604680521987db6a08b96ec136546074d76fe7f45b7d4028f9f8bf
5
5
  SHA512:
6
- metadata.gz: 2b7481ff8c0c4b91f724af723eacfd9357905fa2207adc0871dd745d7fc4a4beac52349f0051703a91288c724dd2b6c00298beb585d08949b156833e9fc70af7
7
- data.tar.gz: 7d58f1c452924bbc9c7a895a5ed5102e1ae298b002984d9708aea23b4f54f7339fa2b4dd6708b678cf5f1dd45d5cfd8cc8dd5c3e869df6b07856f08eaf6c720c
6
+ metadata.gz: 0d4106b957c4da8334d470d67aa5a350f243bac65d0ca5767a356f93e62063a197f7fe338fa2474dcf4079bc5e366f3cfb3a98125820020f3b4bacdb7c4fd856
7
+ data.tar.gz: d8f85dd081aa9e1941bcea8931d70d994bb195f9dd5e46fbc47cb2a3ace57df99c545c8283ba76f0a3b4e53b35ff880e8f663d6a464cc85276a3a03e3e7ab1b1
data/.gitignore CHANGED
@@ -3,3 +3,5 @@ coverage/
3
3
  calcpace-*.gem
4
4
  !calcpace.gemspec
5
5
  improvements_plan.md
6
+
7
+ .claude/
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,101 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.17.0] - 2026-09-05
11
+
12
+ ### Added
13
+ - `time_in_zones(heartrate:, time:, zones:)` — splits a recorded heart-rate
14
+ series into the seconds spent in each of the five zones returned by `hr_zones`
15
+ or `hr_zones_from_max`, plus each zone's share of the counted time. Inputs are
16
+ two plain arrays, so a Strava `heartrate`/`time` stream pair fits without
17
+ translation and so does the same pair read out of a FIT file.
18
+
19
+ ```ruby
20
+ zones = calc.hr_zones_from_max(hr_max: 190)
21
+
22
+ in_zones = calc.time_in_zones(
23
+ heartrate: [120, 120, 140, 140, 160],
24
+ time: [0, 60, 120, 180, 240],
25
+ zones: zones
26
+ )
27
+
28
+ in_zones.map(&:seconds) # => [0, 120, 120, 60, 0]
29
+ in_zones.map(&:share) # => [0.0, 0.4, 0.4, 0.2, 0.0]
30
+ ```
31
+
32
+ A sample lasts until the next one, and the last sample inherits the previous
33
+ delta so a series does not lose its final seconds. **A pause is a gap in
34
+ `time`, and the whole gap is booked to the sample before it** — there is no
35
+ `max_gap` to guess a cut-off with, so a caller holding Strava's `moving`
36
+ stream should nil the heart rate of every paused sample first.
37
+
38
+ A sample with a nil or non-positive heart rate contributes nothing — its
39
+ duration is dropped, never reassigned to a neighbour. Shares are rounded
40
+ together, largest remainder first, so the five of them are whole thousandths
41
+ adding up to 1000 — a bar chart fills its track.
42
+
43
+ - `hr_zone_for(bpm, zones)` — the zone lookup `time_in_zones` uses, exposed on
44
+ its own. Returns the `HrZone`, or nil when the reading is nil or not positive.
45
+
46
+ ```ruby
47
+ calc.hr_zone_for(150, calc.hr_zones_from_max(hr_max: 190)).zone # => 3
48
+ calc.hr_zone_for(114, calc.hr_zones_from_max(hr_max: 190)).zone # => 2
49
+ calc.hr_zone_for(205, calc.hr_zones_from_max(hr_max: 190)).zone # => 5
50
+ ```
51
+
52
+ **Note for calcpace.app:** the site's own lookup walks the zones with
53
+ `between?`, which differs from this one in two places. Zones are contiguous,
54
+ so their bounds are shared: `between?` gives 114 bpm to Z1, while
55
+ `hr_zone_for` gives it to Z2, the way a watch reads it. And a reading above
56
+ `hr_max` returns nil from `between?` but Z5 here — a reading above the maximum
57
+ means the maximum is wrong, not that the beat did not happen. Consumers should
58
+ migrate to `hr_zone_for` so that a zone split and a live zone badge cannot
59
+ disagree about the same beat.
60
+
61
+ - `interval_structure(laps, unit: :km)` — new `LapAnalyzer` module. Detects a
62
+ structured interval session in a watch's laps by **contrast**, never by a
63
+ label: a lap is work when it covers at least 0.1 km and is at least 15% faster
64
+ than every lap touching it. What comes before the first work lap is warm-up,
65
+ what follows the last is cool-down, and what sits between two work laps is
66
+ rest.
67
+
68
+ ```ruby
69
+ laps = [{ distance: 2.0, elapsed: 720 }] +
70
+ ([{ distance: 1.0, elapsed: 252 }, { distance: 0.4, elapsed: 156 }] * 6) +
71
+ [{ distance: 1.5, elapsed: 495 }]
72
+
73
+ calc.interval_structure(laps)
74
+ # => #<struct reps=6, work_distance=1.0, ... rest_duration=156>
75
+ ```
76
+
77
+ A warm-up or a cool-down has only one neighbour, so contrast alone would be a
78
+ free pass — a 5:30/km cool-down beats the 6:30/km jog it touches and walks in
79
+ as an extra rep. An edge lap is therefore admitted only if it also agrees with
80
+ the reps found in the interior: within ±25% of their median distance and no
81
+ more than 10% slower than their pace.
82
+
83
+ It returns `nil` when the laps describe no structure — fewer than two work
84
+ laps, work laps more than ±25% away from their median distance (a fartlek or a
85
+ hilly run), or no work lap that ever beat a *finite* pace. That last rule
86
+ matters because a standing lap has an infinite pace and everything is 15%
87
+ faster than infinity: without it, an easy run with two red-light lap presses
88
+ would come back as three reps. Most runs are not intervals, and inventing reps
89
+ out of ordinary pace variation would make every easy run look like a workout.
90
+
91
+ A distance of `0` is a legal standing recovery, and makes `rest_pace` nil
92
+ rather than infinite. A lap over 100 km is rejected: it is a caller who passed
93
+ metres. `unit: :mi` converts both paces; distances stay in kilometres.
94
+
95
+ ## [1.16.0] - 2026-09-05
96
+
97
+ ### Added
98
+ - `stride_length(pace, cadence, unit: :km)` — metres per step from a pace (clock
99
+ string or seconds per unit) and a cadence in steps per minute counting **both
100
+ feet**; Strava's API reports cadence as one-leg RPM, so callers reading it from
101
+ there must double it first.
102
+ - `cadence_for_stride(pace, stride, unit: :km)` — the inverse: the both-feet
103
+ cadence in steps per minute that a given stride length implies at a given pace.
104
+
10
105
  ## [1.15.0] - 2026-08-30
11
106
 
12
107
  ### Added
@@ -505,7 +600,9 @@ predictors are untouched.
505
600
 
506
601
  See git history for changes in earlier versions.
507
602
 
508
- [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.15.0...HEAD
603
+ [Unreleased]: https://github.com/0jonjo/calcpace/compare/v1.17.0...HEAD
604
+ [1.17.0]: https://github.com/0jonjo/calcpace/compare/v1.16.0...v1.17.0
605
+ [1.16.0]: https://github.com/0jonjo/calcpace/compare/v1.15.0...v1.16.0
509
606
  [1.15.0]: https://github.com/0jonjo/calcpace/compare/v1.14.0...v1.15.0
510
607
  [1.14.0]: https://github.com/0jonjo/calcpace/compare/v1.13.0...v1.14.0
511
608
  [1.13.0]: https://github.com/0jonjo/calcpace/compare/v1.12.1...v1.13.0
data/README.md CHANGED
@@ -415,6 +415,181 @@ natively (not converted from the km bands), so they can differ by ±1 s from
415
415
  All mile factors derive from the exact international mile (1609.344 m), so distances,
416
416
  pace bands, and age-grading tolerances agree to the metre.
417
417
 
418
+ #### Time in heart-rate zones
419
+
420
+ `time_in_zones` splits a recorded heart-rate series into the time spent in each zone.
421
+ It takes two plain arrays and the zones, so a Strava `heartrate`/`time` stream pair
422
+ fits without translation, and so does the same pair read out of a FIT file:
423
+
424
+ ```ruby
425
+ zones = calc.hr_zones_from_max(hr_max: 190)
426
+
427
+ in_zones = calc.time_in_zones(
428
+ heartrate: [120, 120, 140, 140, 160],
429
+ time: [0, 60, 120, 180, 240],
430
+ zones: zones
431
+ )
432
+
433
+ in_zones.map(&:seconds) # => [0, 120, 120, 60, 0]
434
+ in_zones.map(&:share) # => [0.0, 0.4, 0.4, 0.2, 0.0]
435
+ in_zones[1].zone # => 2
436
+
437
+ # The same call in one line, if you would rather not name the zones
438
+ 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]
439
+ 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]
440
+ ```
441
+
442
+ Five rows always come back, in zone order, zeros included — `seconds` whole, `share`
443
+ a fraction of the counted time with three decimals. The shares are whole thousandths
444
+ that add up to 1000 — rounded together, largest remainder first — so a bar chart
445
+ drawn from them fills its track (the Float sum may sit one ulp from 1.0).
446
+
447
+ **A sample lasts until the next one** (`time[i + 1] - time[i]`), and the last sample,
448
+ which has no next, is given the previous delta so a series does not lose its final
449
+ seconds; a single sample lasts 0 s.
450
+
451
+ That rule has a consequence worth knowing before trusting the numbers. **A pause is a
452
+ gap in `time`, and the whole gap is booked to the sample before it** — stop five
453
+ minutes at a café and those five minutes land in whatever zone the last beat before
454
+ the pause was in. There is no `max_gap` here to guess a cut-off with. If you have
455
+ Strava's `moving` stream, nil the heart rate of every paused sample before calling,
456
+ which hands them to the next rule.
457
+
458
+ A sample with a nil or non-positive heart rate contributes nothing: its duration is
459
+ **dropped, not reassigned**, because a dropout says nothing about which zone the
460
+ runner was in — so the counted time can be less than the wall clock, and the shares
461
+ are shares of what was counted.
462
+
463
+ Mismatched array lengths, a series that is not an array, a nil inside `time`, a
464
+ `time` that goes backwards, or a heart rate that is neither nil nor a number all
465
+ raise `Calcpace::Error`. Empty arrays return the five zero rows.
466
+
467
+ #### Which zone is this beat in?
468
+
469
+ `hr_zone_for` is the lookup `time_in_zones` uses, exposed on its own:
470
+
471
+ ```ruby
472
+ calc.hr_zone_for(150, calc.hr_zones_from_max(hr_max: 190)).zone # => 3
473
+ calc.hr_zone_for(114, calc.hr_zones_from_max(hr_max: 190)).zone # => 2
474
+ calc.hr_zone_for(205, calc.hr_zones_from_max(hr_max: 190)).zone # => 5
475
+ ```
476
+
477
+ It returns the `HrZone`, or nil when the reading is nil or not positive — a sensor
478
+ dropout. Two rules are worth stating because a hand-rolled `between?` lookup gets
479
+ both wrong:
480
+
481
+ - **On a shared boundary the higher zone wins.** Zones are contiguous, so 114 bpm is
482
+ both the top of Z1 and the bottom of Z2; it counts as Z2, the way a watch reads it.
483
+ - **Readings outside the range are clamped**, below Z1 to Z1 and above Z5 to Z5. A
484
+ reading above `hr_max` means the `hr_max` is wrong, not that the beat did not
485
+ happen — and an `hr_max` a few beats off is the most common thing an athlete
486
+ carries around. Clamping keeps that a distortion of the split instead of making
487
+ minutes of a run disappear.
488
+
489
+ ---
490
+
491
+ ### Lap Analysis
492
+
493
+ A watch records laps; it does not record intent. `interval_structure` reads the shape
494
+ of a session out of the laps themselves, by **contrast** — never by a label:
495
+
496
+ ```ruby
497
+ # Warm-up, 6 x (1 km hard / 400 m jog), cool-down
498
+ laps = [{ distance: 2.0, elapsed: 720 }] +
499
+ ([{ distance: 1.0, elapsed: 252 }, { distance: 0.4, elapsed: 156 }] * 6) +
500
+ [{ distance: 1.5, elapsed: 495 }]
501
+
502
+ structure = calc.interval_structure(laps)
503
+ # => #<struct reps=6, work_distance=1.0, ... rest_duration=156>
504
+
505
+ structure.reps # => 6
506
+ structure.work_distance # => 1.0 (km, median rep)
507
+ structure.work_pace # => 252 (seconds per km, distance-weighted)
508
+ structure.rest_pace # => 390
509
+ structure.rest_duration # => 156 (mean rest lap, seconds)
510
+ ```
511
+
512
+ `to_a` gives all five at once, which is short enough to show whole. This is the
513
+ smallest session that has a structure — two reps and the jog between them:
514
+
515
+ ```ruby
516
+ 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]
517
+
518
+ # unit: converts both paces; distances stay in kilometres
519
+ 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]
520
+ ```
521
+
522
+ Laps are plain hashes of a distance in kilometres and an elapsed time in seconds —
523
+ what a Strava lap, a FIT lap and a hand-written array all reduce to. Distance `0` is
524
+ legal: it is a standing recovery, and it means an infinite pace.
525
+
526
+ A lap counts as **work** when it covers at least 0.1 km and is at least 15% faster
527
+ than every lap touching it. Everything before the first work lap is warm-up,
528
+ everything after the last is cool-down, and a lap between two work laps is rest. Two
529
+ work laps can never touch — each would have to be 0.85 of the other — so every pair
530
+ of reps has a recovery between it.
531
+
532
+ An **edge lap** — the first or the last — has only one neighbour, so contrast alone
533
+ is a free pass: a 5:30/km cool-down beats the 6:30/km jog it happens to touch and
534
+ walks in as an extra rep. So an edge lap is admitted only if it also agrees with the
535
+ reps found in the middle: within ±25% of their median distance, and no more than 10%
536
+ slower than their pace. When the interior found nothing there is nothing to agree
537
+ with, and the plain contrast rule stands — which is why the three-lap example above
538
+ still reads as two reps.
539
+
540
+ The method returns **nil** when the laps describe no structure, and most runs do not:
541
+
542
+ ```ruby
543
+ # A steady 10 km, ten laps of 1 km within five seconds of each other
544
+ steady = [300, 298, 302, 296, 304, 300, 299, 301, 305, 295].map do |elapsed|
545
+ { distance: 1.0, elapsed: elapsed }
546
+ end
547
+
548
+ calc.interval_structure(steady) # => nil
549
+
550
+ # An easy 3 km with two red lights: fast next to a pause is not a rep
551
+ calc.interval_structure([{ distance: 1.0, elapsed: 300 }, { distance: 0.0, elapsed: 45 }, { distance: 1.0, elapsed: 300 }]) # => nil
552
+ ```
553
+
554
+ Nil is returned when there are fewer than two work laps, when the work laps disagree
555
+ about distance — more than ±25% from their median makes it a fartlek or a hilly run,
556
+ which has fast laps but no set to report — or when **no work lap ever beat a finite
557
+ pace**. That last rule is what the red-light example trips: a zero-distance lap has
558
+ an infinite pace and everything is 15% faster than infinity, so without it every easy
559
+ run with a paused lap would come back as a set of reps.
560
+
561
+ `rest_pace` is nil when the recoveries covered no distance at all; a standing
562
+ recovery has a duration but no pace, and reporting infinity would be worse than
563
+ reporting nothing. A lap missing `:distance` or `:elapsed`, a negative distance, a
564
+ distance over 100 km (a caller who passed metres), or a non-positive elapsed time
565
+ raises `Calcpace::Error`; an empty array returns nil.
566
+
567
+ ---
568
+
569
+ ### Stride & Cadence
570
+
571
+ Pace, cadence and stride length are one identity, so any two of them give the third:
572
+
573
+ ```ruby
574
+ calc.stride_length('05:00', 170) # => 1.18
575
+ calc.stride_length('04:00', 180) # => 1.39
576
+ calc.stride_length('08:02', 170, unit: :mi) # => 1.18
577
+
578
+ calc.cadence_for_stride('05:00', 1.18) # => 169.5
579
+ calc.cadence_for_stride('05:30', 1.15) # => 158.1
580
+ ```
581
+
582
+ `stride_length` returns metres per step (2 decimals); `cadence_for_stride` is its
583
+ inverse and returns steps per minute (1 decimal). Pace takes the same forms as
584
+ everywhere else — a clock string (`'05:00'`, `'00:05:00'`) or seconds per unit
585
+ (`300`) — and `unit:` says which unit that pace is per: `:km` (default) or `:mi`.
586
+ `8:02/mi` is `5:00/km` rounded down to the second (exactly 8:02.8), so the two strides
587
+ agree to the centimetre at this cadence.
588
+
589
+ Cadence is steps per minute counting **both feet** — the number a watch shows during a
590
+ run, typically 160–185 spm. Strava's API reports cadence as one-leg RPM, so a value
591
+ read from there must be doubled before it is passed in.
592
+
418
593
  ---
419
594
 
420
595
  ### Fitness Predictor (race times from VO2max)
@@ -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
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Module relating pace, cadence and stride length
4
+ #
5
+ # The three quantities are one identity, not three measurements:
6
+ # speed (m/min) = unit_metres / pace_seconds * 60
7
+ # stride (m) = speed / cadence
8
+ # so any two of them give the third. Cadence here is steps per minute counting
9
+ # BOTH feet (spm) — the number a watch shows during a run. Strava's API reports
10
+ # cadence as one-leg RPM, so a caller reading it from there must double it
11
+ # before passing it in.
12
+ module StrideCalculator
13
+ # Calculates stride length from pace and cadence
14
+ #
15
+ # @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS or HH:MM:SS)
16
+ # @param cadence [Numeric] cadence in steps per minute, both feet (must be > 0)
17
+ # @param unit [Symbol, String] unit the pace is expressed in — :km (default) or :mi
18
+ # @return [Float] stride length in metres per step, rounded to 2 decimals
19
+ # @raise [Calcpace::InvalidTimeFormatError] if pace is a string that is not a valid clock
20
+ # @raise [Calcpace::NonPositiveInputError] if pace or cadence is not positive
21
+ # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
22
+ #
23
+ # @example
24
+ # # 300 s/km → 200 m/min; 200/170
25
+ # calc.stride_length('05:00', 170) #=> 1.18
26
+ # # numeric seconds per km
27
+ # calc.stride_length(300, 170) #=> 1.18
28
+ # # 8:02/mi is 5:00/km rounded down to the second
29
+ # calc.stride_length('08:02', 170, unit: :mi) #=> 1.18
30
+ def stride_length(pace, cadence, unit: :km)
31
+ speed = speed_meters_per_minute(pace, unit)
32
+ check_positive(cadence, 'Cadence')
33
+
34
+ (speed / cadence).round(2)
35
+ end
36
+
37
+ # Calculates the cadence a given stride length implies at a given pace
38
+ #
39
+ # The inverse of #stride_length: it answers what turnover a runner needs to
40
+ # hold a pace with the stride they actually have.
41
+ #
42
+ # @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS or HH:MM:SS)
43
+ # @param stride [Numeric] stride length in metres per step (must be > 0)
44
+ # @param unit [Symbol, String] unit the pace is expressed in — :km (default) or :mi
45
+ # @return [Float] cadence in steps per minute, both feet, rounded to 1 decimal
46
+ # @raise [Calcpace::InvalidTimeFormatError] if pace is a string that is not a valid clock
47
+ # @raise [Calcpace::NonPositiveInputError] if pace or stride is not positive
48
+ # @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
49
+ #
50
+ # @example
51
+ # # 200 m/min / 1.18 m
52
+ # calc.cadence_for_stride('05:00', 1.18) #=> 169.5
53
+ # calc.cadence_for_stride('05:30', 1.15) #=> 158.1
54
+ def cadence_for_stride(pace, stride, unit: :km)
55
+ speed = speed_meters_per_minute(pace, unit)
56
+ check_positive(stride, 'Stride')
57
+
58
+ (speed / stride).round(1)
59
+ end
60
+
61
+ private
62
+
63
+ # Turns a pace (clock string or seconds per unit) into a running speed
64
+ #
65
+ # A String pace is checked as a clock before it is parsed, the way
66
+ # Calculator#validate_time, Vo2maxEstimator and AgeGrading check theirs — so
67
+ # '05:xx' is a format error, not a silently truncated pace. The unit is
68
+ # resolved first, so a bad unit wins over a bad pace, as it does in
69
+ # FitnessPredictor#race_times_from_vo2max.
70
+ #
71
+ # @param pace [Numeric, String] pace in seconds per unit or time string
72
+ # @param unit [Symbol, String] :km or :mi
73
+ # @return [Float] speed in metres per minute
74
+ def speed_meters_per_minute(pace, unit)
75
+ meters = pace_unit_meters(unit)
76
+ pace_seconds = pace_seconds_from(pace)
77
+ check_positive(pace_seconds, 'Pace')
78
+
79
+ meters / pace_seconds * 60.0
80
+ end
81
+
82
+ # @param pace [Numeric, String] pace in seconds per unit or time string
83
+ # @return [Numeric] the pace in seconds
84
+ # @raise [Calcpace::InvalidTimeFormatError] if a string pace is not a valid clock
85
+ def pace_seconds_from(pace)
86
+ return pace unless pace.is_a?(String)
87
+
88
+ check_time(pace)
89
+ convert_to_seconds(pace)
90
+ end
91
+ 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|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Calcpace
4
- VERSION = '1.15.0'
4
+ VERSION = '1.17.0'
5
5
  end
data/lib/calcpace.rb CHANGED
@@ -9,10 +9,12 @@ 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'
15
16
  require_relative 'calcpace/race_splits'
17
+ require_relative 'calcpace/stride_calculator'
16
18
  require_relative 'calcpace/track_calculator'
17
19
  require_relative 'calcpace/training_zones'
18
20
  require_relative 'calcpace/vo2max_estimator'
@@ -47,10 +49,12 @@ class Calcpace
47
49
  include Converter
48
50
  include ConverterChain
49
51
  include FitnessPredictor
52
+ include LapAnalyzer
50
53
  include PaceCalculator
51
54
  include PaceConverter
52
55
  include RacePredictor
53
56
  include RaceSplits
57
+ include StrideCalculator
54
58
  include TrackCalculator
55
59
  include TrainingZones
56
60
  include Vo2maxEstimator
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.15.0
4
+ version: 1.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - João Gilberto Saraiva
@@ -45,10 +45,12 @@ 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
51
52
  - lib/calcpace/race_splits.rb
53
+ - lib/calcpace/stride_calculator.rb
52
54
  - lib/calcpace/track_calculator.rb
53
55
  - lib/calcpace/training_zones.rb
54
56
  - lib/calcpace/version.rb