runby_pace 0.2.50 → 0.2.50.111
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.rubocop.yml +10 -0
- data/.travis.yml +9 -2
- data/Gemfile +4 -0
- data/README.md +16 -5
- data/Rakefile +40 -6
- data/bin/_guard-core +17 -16
- data/bin/guard +17 -16
- data/bin/runbypace +15 -0
- data/lib/runby_pace/cli/cli.rb +127 -0
- data/lib/runby_pace/cli/config.rb +82 -0
- data/lib/runby_pace/distance.rb +135 -0
- data/lib/runby_pace/distance_unit.rb +89 -0
- data/lib/runby_pace/golden_pace_set.rb +50 -0
- data/lib/runby_pace/pace.rb +152 -0
- data/lib/runby_pace/{pace_data.rb → pace_calculator.rb} +29 -13
- data/lib/runby_pace/pace_range.rb +27 -9
- data/lib/runby_pace/run_math.rb +14 -0
- data/lib/runby_pace/run_type.rb +12 -4
- data/lib/runby_pace/run_types/all_run_types.g.rb +14 -12
- data/lib/runby_pace/run_types/all_run_types.template +6 -4
- data/lib/runby_pace/run_types/distance_run.rb +55 -0
- data/lib/runby_pace/run_types/easy_run.rb +31 -10
- data/lib/runby_pace/run_types/fast_tempo_run.rb +23 -0
- data/lib/runby_pace/run_types/find_divisor.rb +13 -17
- data/lib/runby_pace/run_types/five_kilometer_race_run.rb +22 -0
- data/lib/runby_pace/run_types/long_run.rb +32 -10
- data/lib/runby_pace/run_types/mile_race_run.rb +24 -0
- data/lib/runby_pace/run_types/slow_tempo_run.rb +22 -0
- data/lib/runby_pace/run_types/tempo_run.rb +54 -0
- data/lib/runby_pace/run_types/ten_kilometer_race_run.rb +23 -0
- data/lib/runby_pace/runby_range.rb +22 -0
- data/lib/runby_pace/runby_time.rb +138 -0
- data/lib/runby_pace/runby_time_parser.rb +80 -0
- data/lib/runby_pace/speed.rb +97 -0
- data/lib/runby_pace/speed_range.rb +30 -0
- data/lib/runby_pace/utility/parameter_sanitizer.rb +29 -0
- data/lib/runby_pace/version.rb +17 -2
- data/lib/runby_pace/version.seed +5 -0
- data/lib/runby_pace.rb +4 -1
- data/misc/runbypace_logo.png +0 -0
- data/runby_pace.gemspec +5 -6
- metadata +32 -9
- data/lib/runby_pace/pace_time.rb +0 -110
@@ -1,47 +1,43 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
+
module Runby
|
4
|
+
# Extend RunTypes with additional behavior. (See comments for details)
|
3
5
|
module RunTypes
|
4
|
-
|
5
6
|
# Currently, to find the radius of the curve in the pace table data for a given run time,
|
6
7
|
# we start with a radius equal to that of the midpoint of the X axis for the data when
|
7
|
-
# plotted on a graph. Then we use a radius divisor for the
|
8
|
-
# dial in the height of the curve. (See
|
8
|
+
# plotted on a graph. Then we use a radius divisor for the PaceCalculator for each run type to
|
9
|
+
# dial in the height of the curve. (See Runby::PaceCalculator)
|
9
10
|
# This method, #find_divisor, accepts a hash of "golden paces" for a run type along with
|
10
11
|
# the number of seconds of allowable deviation from the golden pace. Then it proceeds
|
11
12
|
# to brute force the divisor.
|
12
|
-
# @param [
|
13
|
+
# @param [GoldenPaceSet] golden_pace_set
|
13
14
|
# @param [String] allowable_deviation
|
14
15
|
# @return [decimal]
|
15
|
-
def self.find_divisor(
|
16
|
-
_, first_pace = golden_paces.first
|
17
|
-
last_pace = golden_paces[:'42:00']
|
16
|
+
def self.find_divisor(golden_pace_set, allowable_deviation = '00:01')
|
18
17
|
viable_divisors = []
|
19
18
|
|
20
19
|
(1.0..5.0).step(0.025) do |candidate_divisor|
|
21
20
|
viable_divisor = nil
|
22
21
|
|
23
|
-
|
24
|
-
five_k_time =
|
25
|
-
pace_data =
|
22
|
+
golden_pace_set.each do |five_k, golden_pace|
|
23
|
+
five_k_time = Runby::RunbyTime.new(five_k.to_s)
|
24
|
+
pace_data = Runby::PaceCalculator.new(golden_pace_set, candidate_divisor)
|
26
25
|
calculated_pace = pace_data.calc(five_k_time)
|
27
|
-
|
26
|
+
unless calculated_pace.almost_equals?(golden_pace, allowable_deviation)
|
28
27
|
viable_divisor = nil
|
29
28
|
break
|
30
29
|
end
|
31
30
|
viable_divisor = candidate_divisor
|
32
31
|
end
|
33
32
|
|
34
|
-
|
35
|
-
viable_divisors << viable_divisor
|
36
|
-
end
|
33
|
+
viable_divisors << viable_divisor unless viable_divisor.nil?
|
37
34
|
end
|
38
35
|
|
39
|
-
|
36
|
+
unless viable_divisors.empty?
|
40
37
|
# puts viable_divisors
|
41
38
|
midpoint = (viable_divisors.length - 1) / 2
|
42
39
|
return viable_divisors[midpoint]
|
43
40
|
end
|
44
41
|
end
|
45
|
-
|
46
42
|
end
|
47
43
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Runby
|
4
|
+
module RunTypes
|
5
|
+
# Your 5K race pace, which is also useful for running repetitions at this pace
|
6
|
+
class FiveKilometerRaceRun < RunType
|
7
|
+
def description
|
8
|
+
'5K Race Pace'
|
9
|
+
end
|
10
|
+
|
11
|
+
def explanation
|
12
|
+
'The 5K race (~3.1 miles) is an excellent gauge of overall fitness. Running repetitions of varying duration at 5K race pace can boost stroke volume, blood volume, mitochondrial and capillary density, etc.'
|
13
|
+
end
|
14
|
+
|
15
|
+
def lookup_pace(five_k_time, distance_units = :km)
|
16
|
+
five_k_time = RunbyTime.new(five_k_time)
|
17
|
+
pace = Pace.new(five_k_time / 5, '1km').convert_to(distance_units)
|
18
|
+
PaceRange.new(pace, pace)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -1,31 +1,53 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
+
module Runby
|
3
4
|
module RunTypes
|
4
|
-
|
5
|
+
# Arguably one of the most important run types, the "long run" is harder than an "easy run", but easier than
|
6
|
+
# a "distance run". It should remain conversational.
|
5
7
|
class LongRun < RunType
|
6
|
-
attr_reader :
|
8
|
+
attr_reader :slow_pace_calculator, :fast_pace_calculator
|
7
9
|
|
8
10
|
def description
|
9
11
|
'Long Run'
|
10
12
|
end
|
11
13
|
|
14
|
+
def explanation
|
15
|
+
'For many runners, the long run is the favorite run of the week. It is usually only ran once per week, and accounts for 20-25% of your weekly training volume. Remember that it\'s not a race. It should remain comfortable.'
|
16
|
+
end
|
17
|
+
|
12
18
|
def initialize
|
13
|
-
@
|
14
|
-
@
|
19
|
+
@fast_pace_calculator = PaceCalculator.new(GoldenPaces.fast, 2.125)
|
20
|
+
@slow_pace_calculator = PaceCalculator.new(GoldenPaces.slow, 1.55)
|
15
21
|
end
|
16
22
|
|
17
|
-
def
|
18
|
-
fast = @
|
19
|
-
slow = @
|
23
|
+
def lookup_pace(five_k_time, distance_units = :km)
|
24
|
+
fast = @fast_pace_calculator.calc(five_k_time, distance_units)
|
25
|
+
slow = @slow_pace_calculator.calc(five_k_time, distance_units)
|
20
26
|
PaceRange.new(fast, slow)
|
21
27
|
end
|
22
28
|
|
29
|
+
# Used in testing, contains hashes mapping 5K race times with the recommended pace-per-km for this run type.
|
23
30
|
class GoldenPaces
|
24
31
|
def self.fast
|
25
|
-
|
32
|
+
GoldenPaceSet.new('14:00': '04:00',
|
33
|
+
'15:00': '04:16',
|
34
|
+
'20:00': '05:31',
|
35
|
+
'25:00': '06:44',
|
36
|
+
'30:00': '07:54',
|
37
|
+
'35:00': '09:01',
|
38
|
+
'40:00': '10:07',
|
39
|
+
'42:00': '10:32')
|
26
40
|
end
|
41
|
+
|
27
42
|
def self.slow
|
28
|
-
|
43
|
+
GoldenPaceSet.new('14:00': '04:39',
|
44
|
+
'15:00': '04:57',
|
45
|
+
'20:00': '06:22',
|
46
|
+
'25:00': '07:43',
|
47
|
+
'30:00': '09:00',
|
48
|
+
'35:00': '10:15',
|
49
|
+
'40:00': '11:26',
|
50
|
+
'42:00': '11:53')
|
29
51
|
end
|
30
52
|
end
|
31
53
|
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Runby
|
4
|
+
module RunTypes
|
5
|
+
# Your mile race pace, which is also useful for running repetitions at this pace
|
6
|
+
class MileRaceRun < RunType
|
7
|
+
def description
|
8
|
+
'Mile Race Pace'
|
9
|
+
end
|
10
|
+
|
11
|
+
def explanation
|
12
|
+
'Repetitions run at a pace you would use to race one mile can increase the stroke volume of your heart, strengthen your lungs, increase the number of capillaries around your intermediate and fast twitch fibers, and increase mitochondrial densities around the same.'
|
13
|
+
end
|
14
|
+
|
15
|
+
def lookup_pace(five_k_time, distance_units = :km)
|
16
|
+
five_k_time = RunbyTime.new(five_k_time)
|
17
|
+
mile = Distance.new('1 mile')
|
18
|
+
mile_time = RunMath.predict_race_time('5K', five_k_time, mile)
|
19
|
+
pace = Pace.new(mile_time, mile).convert_to(distance_units)
|
20
|
+
PaceRange.new(pace, pace)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative 'tempo_run'
|
3
|
+
|
4
|
+
module Runby
|
5
|
+
module RunTypes
|
6
|
+
# The "slow tempo" pace roughly equates to your marathon pace.
|
7
|
+
class SlowTempoRun < TempoRun
|
8
|
+
def description
|
9
|
+
'Slow Tempo Run'
|
10
|
+
end
|
11
|
+
|
12
|
+
def explanation
|
13
|
+
'The slow tempo run is an interval workout of 20-40 minutes per repetition. The pace roughly corresponds to that of your marathon race pace.'
|
14
|
+
end
|
15
|
+
|
16
|
+
def lookup_pace(five_k_time, distance_units = :km)
|
17
|
+
slow = @slow_pace_calculator.calc(five_k_time, distance_units)
|
18
|
+
PaceRange.new(slow, slow)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Runby
|
4
|
+
module RunTypes
|
5
|
+
# Combines the fast and slow tempo runs into one convenient range of paces
|
6
|
+
class TempoRun < RunType
|
7
|
+
attr_reader :slow_pace_calculator, :fast_pace_calculator
|
8
|
+
|
9
|
+
def description
|
10
|
+
'Tempo Run'
|
11
|
+
end
|
12
|
+
|
13
|
+
def explanation
|
14
|
+
'Ran at a comfortably hard pace that you could maintain for about an hour, if pressed. However, tempo runs are interval workouts, so you won\'t run for longer than 15-40 minutes per repetition'
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@fast_pace_calculator = PaceCalculator.new(GoldenPaces.fast, 4.025)
|
19
|
+
@slow_pace_calculator = PaceCalculator.new(GoldenPaces.slow, 3.725)
|
20
|
+
end
|
21
|
+
|
22
|
+
def lookup_pace(five_k_time, distance_units = :km)
|
23
|
+
fast = @fast_pace_calculator.calc(five_k_time, distance_units)
|
24
|
+
slow = @slow_pace_calculator.calc(five_k_time, distance_units)
|
25
|
+
PaceRange.new(fast, slow)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Used in testing, contains hashes mapping 5K race times with the recommended pace-per-km for this run type.
|
29
|
+
class GoldenPaces
|
30
|
+
def self.fast
|
31
|
+
GoldenPaceSet.new('14:00': '03:07',
|
32
|
+
'15:00': '03:20',
|
33
|
+
'20:00': '04:21',
|
34
|
+
'25:00': '05:20',
|
35
|
+
'30:00': '06:19',
|
36
|
+
'35:00': '07:16',
|
37
|
+
'40:00': '08:12',
|
38
|
+
'42:00': '08:35')
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.slow
|
42
|
+
GoldenPaceSet.new('14:00': '03:18',
|
43
|
+
'15:00': '03:31',
|
44
|
+
'20:00': '04:35',
|
45
|
+
'25:00': '05:37',
|
46
|
+
'30:00': '06:38',
|
47
|
+
'35:00': '07:38',
|
48
|
+
'40:00': '08:36',
|
49
|
+
'42:00': '08:59')
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Runby
|
4
|
+
module RunTypes
|
5
|
+
# Your 10K race pace, which is also useful for running repetitions at this pace
|
6
|
+
class TenKilometerRaceRun < RunType
|
7
|
+
def description
|
8
|
+
'10K Race Pace'
|
9
|
+
end
|
10
|
+
|
11
|
+
def explanation
|
12
|
+
'Repetitions ran at 10K race pace (10 km is about 6.2 miles) are like 5K pace repetitions, but less intense. They elicit many of the same benefits and help increase speed in races from the 10K to the half-marathon.'
|
13
|
+
end
|
14
|
+
|
15
|
+
def lookup_pace(five_k_time, distance_units = :km)
|
16
|
+
five_k_time = RunbyTime.new(five_k_time)
|
17
|
+
ten_k_time = RunMath.predict_race_time('5K', five_k_time, '10K')
|
18
|
+
pace = Pace.new(ten_k_time / 10, '1km').convert_to(distance_units)
|
19
|
+
PaceRange.new(pace, pace)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Runby
|
4
|
+
# Base class for ranges of Runby data, e.g. PaceRange, SpeedRange, ...
|
5
|
+
class RunbyRange
|
6
|
+
attr_reader :fast, :slow
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@fast = nil
|
10
|
+
@slow = nil
|
11
|
+
raise 'RunbyRange is a base class for PaceRange and SpeedRange. Instantiate one of them instead.'
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_s(format: :short)
|
15
|
+
if @fast == @slow
|
16
|
+
@fast.to_s(format: format)
|
17
|
+
else
|
18
|
+
"#{@fast}-#{@slow}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Runby
|
4
|
+
# Represents a human-readable time in the format MM:ss
|
5
|
+
class RunbyTime
|
6
|
+
include Comparable
|
7
|
+
|
8
|
+
attr_reader :time_s, :hours_part, :minutes_part, :seconds_part
|
9
|
+
|
10
|
+
def self.new(time)
|
11
|
+
return time if time.is_a? RunbyTime
|
12
|
+
return RunbyTime.parse time if time.is_a?(String) || time.is_a?(Symbol)
|
13
|
+
return from_minutes(time) if time.is_a? Numeric
|
14
|
+
super
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(time)
|
18
|
+
init_from_parts time if time.is_a? RunbyTimeParser::TimeParts
|
19
|
+
freeze
|
20
|
+
end
|
21
|
+
|
22
|
+
# @param [numeric] total_seconds
|
23
|
+
def self.from_seconds(total_seconds)
|
24
|
+
hours = total_seconds.abs.to_i / 60 / 60
|
25
|
+
minutes = (total_seconds.abs.to_i / 60) % 60
|
26
|
+
seconds = total_seconds.abs.to_i % 60
|
27
|
+
if hours.positive?
|
28
|
+
RunbyTime.new format('%d:%02d:%02d', hours, minutes, seconds)
|
29
|
+
else
|
30
|
+
RunbyTime.new format('%02d:%02d', minutes, seconds)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# @param [numeric] total_minutes
|
35
|
+
def self.from_minutes(total_minutes)
|
36
|
+
from_seconds(total_minutes * 60.0)
|
37
|
+
end
|
38
|
+
|
39
|
+
# @param [numeric] total_hours
|
40
|
+
def self.from_hours(total_hours)
|
41
|
+
from_seconds(total_hours * 60.0 * 60.0)
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.parse(str)
|
45
|
+
RunbyTimeParser.parse str
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.try_parse(str, is_five_k = false)
|
49
|
+
time, error_message, warning_message = nil
|
50
|
+
begin
|
51
|
+
time = parse str
|
52
|
+
rescue StandardError => ex
|
53
|
+
error_message = "#{ex.message} (#{str})"
|
54
|
+
end
|
55
|
+
warning_message = check_5k_sanity(time) if !time.nil? && is_five_k
|
56
|
+
{ time: time, error: error_message, warning: warning_message }
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.check_5k_sanity(time)
|
60
|
+
return unless time.is_a? RunbyTime
|
61
|
+
return '5K times of less than 14:00 are unlikely' if time.minutes_part < 14
|
62
|
+
return '5K times of greater than 42:00 are not fully supported' if time.total_seconds > (42 * 60)
|
63
|
+
end
|
64
|
+
|
65
|
+
def to_s
|
66
|
+
@time_s
|
67
|
+
end
|
68
|
+
|
69
|
+
def total_hours
|
70
|
+
@hours_part + (@minutes_part / 60.0) + (@seconds_part / 60.0 / 60.0)
|
71
|
+
end
|
72
|
+
|
73
|
+
def total_seconds
|
74
|
+
@hours_part * 60 * 60 + @minutes_part * 60 + @seconds_part
|
75
|
+
end
|
76
|
+
|
77
|
+
def total_minutes
|
78
|
+
@hours_part * 60 + @minutes_part + (@seconds_part / 60.0)
|
79
|
+
end
|
80
|
+
|
81
|
+
# @param [RunbyTime] other
|
82
|
+
def -(other)
|
83
|
+
raise "Cannot subtract #{other.class} from a Runby::RunbyTime" unless other.is_a?(RunbyTime)
|
84
|
+
RunbyTime.from_seconds(total_seconds - other.total_seconds)
|
85
|
+
end
|
86
|
+
|
87
|
+
# @param [RunbyTime] other
|
88
|
+
def +(other)
|
89
|
+
raise "Cannot add Runby::RunbyTime to a #{other.class}" unless other.is_a?(RunbyTime)
|
90
|
+
RunbyTime.from_seconds(total_seconds + other.total_seconds)
|
91
|
+
end
|
92
|
+
|
93
|
+
# @param [Numeric] other
|
94
|
+
# @return [RunbyTime]
|
95
|
+
def *(other)
|
96
|
+
raise "Cannot multiply Runby::RunbyTime with a #{other.class}" unless other.is_a?(Numeric)
|
97
|
+
RunbyTime.from_minutes(total_minutes * other)
|
98
|
+
end
|
99
|
+
|
100
|
+
# @param [RunbyTime, Numeric] other
|
101
|
+
# @return [Numeric, RunbyTime]
|
102
|
+
def /(other)
|
103
|
+
raise "Cannot divide Runby::RunbyTime by #{other.class}" unless other.is_a?(RunbyTime) || other.is_a?(Numeric)
|
104
|
+
case other
|
105
|
+
when RunbyTime
|
106
|
+
total_seconds / other.total_seconds
|
107
|
+
when Numeric
|
108
|
+
RunbyTime.from_seconds(total_seconds / other)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def <=>(other)
|
113
|
+
raise "Unable to compare Runby::RunbyTime to #{other.class}(#{other})" unless [RunbyTime, String].include? other.class
|
114
|
+
if other.is_a? RunbyTime
|
115
|
+
total_seconds <=> other.total_seconds
|
116
|
+
elsif other.is_a? String
|
117
|
+
return 0 if @time_s == other
|
118
|
+
total_seconds <=> RunbyTime.parse(other).total_seconds
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def almost_equals?(other_time, tolerance_time = '00:01')
|
123
|
+
other_time = RunbyTime.new(other_time) if other_time.is_a?(String)
|
124
|
+
tolerance = RunbyTime.new(tolerance_time)
|
125
|
+
self >= (other_time - tolerance) && self <= (other_time + tolerance)
|
126
|
+
end
|
127
|
+
|
128
|
+
private
|
129
|
+
|
130
|
+
# @param [RunbyTimeParser::TimeParts] parts
|
131
|
+
def init_from_parts(parts)
|
132
|
+
@time_s = parts.format
|
133
|
+
@hours_part = parts[:hours]
|
134
|
+
@minutes_part = parts[:minutes]
|
135
|
+
@seconds_part = parts[:seconds]
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Runby
|
4
|
+
# Helper class which parses strings and returns new RunbyTime(s)
|
5
|
+
class RunbyTimeParser
|
6
|
+
def self.parse(str)
|
7
|
+
time = str.to_s.strip.chomp
|
8
|
+
if time_string?(time)
|
9
|
+
parts = TimeParts.new(time.split(':').reverse)
|
10
|
+
elsif integer?(time)
|
11
|
+
parts = extract_minutes_from_integer time
|
12
|
+
elsif decimal?(time)
|
13
|
+
parts = extract_minutes_from_decimal time
|
14
|
+
else
|
15
|
+
raise 'Invalid time format'
|
16
|
+
end
|
17
|
+
RunbyTime.new(parts)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.decimal?(str)
|
21
|
+
str.match?(/^\d+[,. ]\d+$/)
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.integer?(str)
|
25
|
+
str.match?(/^\d+$/)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.time_string?(str)
|
29
|
+
str.match?(/^\d?\d(:\d\d)+$/)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.extract_minutes_from_decimal(decimal_str)
|
33
|
+
decimal_parts = decimal_str.split(/[,. ]/)
|
34
|
+
minutes = decimal_parts[0].to_i
|
35
|
+
seconds = (decimal_parts[1].to_i / 10.0 * 60).to_i
|
36
|
+
TimeParts.new([seconds, minutes])
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.extract_minutes_from_integer(integer_str)
|
40
|
+
TimeParts.new([0, integer_str.to_i])
|
41
|
+
end
|
42
|
+
|
43
|
+
# Encapsulates the parts of a time string
|
44
|
+
class TimeParts
|
45
|
+
def initialize(parts_array)
|
46
|
+
@keys = { seconds: 0, minutes: 1, hours: 2 }
|
47
|
+
@parts = Array.new(@keys.count, 0)
|
48
|
+
Range.new(0, parts_array.count - 1).each { |i| @parts[i] = parts_array[i] }
|
49
|
+
validate
|
50
|
+
freeze
|
51
|
+
end
|
52
|
+
|
53
|
+
def [](key)
|
54
|
+
i = @keys[key]
|
55
|
+
@parts[i].to_i
|
56
|
+
end
|
57
|
+
|
58
|
+
def format
|
59
|
+
time_f = +''
|
60
|
+
@parts.reverse_each do |part|
|
61
|
+
time_f << ':' << part.to_s.rjust(2, '0')
|
62
|
+
end
|
63
|
+
# Remove leading ':'
|
64
|
+
time_f.slice!(0)
|
65
|
+
# Remove leading '00:00...'
|
66
|
+
time_f.sub!(/^(?:00:)+(\d\d:\d\d)/, '\1') if time_f.length > 5
|
67
|
+
# If the time looks like 00:48, only show 0:48
|
68
|
+
time_f.slice!(0) if time_f.slice(0) == '0'
|
69
|
+
time_f
|
70
|
+
end
|
71
|
+
|
72
|
+
def validate
|
73
|
+
raise 'Hours must be less than 24' if self[:hours] > 23
|
74
|
+
raise 'Minutes must be less than 60 if hours are supplied' if self[:hours].positive? && self[:minutes] > 59
|
75
|
+
raise 'Minutes must be less than 99 if no hours are supplied' if self[:hours].zero? && self[:minutes] > 99
|
76
|
+
raise 'Seconds must be less than 60' if self[:seconds] > 59
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Runby
|
4
|
+
# Represents a speed consisting of a distance and a unit of time in which that distance was covered
|
5
|
+
class Speed
|
6
|
+
include Comparable
|
7
|
+
|
8
|
+
attr_reader :distance
|
9
|
+
|
10
|
+
def self.new(distance_or_multiplier, units = :km)
|
11
|
+
return distance_or_multiplier if distance_or_multiplier.is_a? Speed
|
12
|
+
if distance_or_multiplier.is_a? String
|
13
|
+
parsed_speed = Speed.try_parse(distance_or_multiplier)
|
14
|
+
return parsed_speed[:speed] unless parsed_speed[:error]
|
15
|
+
end
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(distance_or_multiplier, units = :km)
|
20
|
+
case distance_or_multiplier
|
21
|
+
when Distance
|
22
|
+
init_from_distance distance_or_multiplier
|
23
|
+
when String
|
24
|
+
# Already tried to parse it as a Speed string. Try parsing it as a Distance string.
|
25
|
+
init_from_distance_string distance_or_multiplier
|
26
|
+
when Numeric
|
27
|
+
init_from_multiplier(distance_or_multiplier, units)
|
28
|
+
else
|
29
|
+
raise "Unable to initialize Runby::Speed from #{distance_or_multiplier}"
|
30
|
+
end
|
31
|
+
freeze
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_s(format: :short)
|
35
|
+
distance = @distance.to_s(format: format)
|
36
|
+
case format
|
37
|
+
when :short then "#{distance}/ph"
|
38
|
+
when :long then "#{distance} per hour"
|
39
|
+
else raise "Invalid string format #{format}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def as_pace
|
44
|
+
time = Runby::RunbyTime.from_minutes(60.0 / @distance.multiplier)
|
45
|
+
Runby::Pace.new(time, @distance.uom)
|
46
|
+
end
|
47
|
+
|
48
|
+
# @param [String] str is either a long-form speed such as "7.5 miles per hour" or a short-form speed like "7.5mi/ph"
|
49
|
+
def self.parse(str)
|
50
|
+
str = str.to_s.strip.chomp
|
51
|
+
match = str.match(%r{^(?<distance>\d+(?:\.\d+)? ?[A-Za-z]+)(?: per hour|\/ph)$})
|
52
|
+
raise "Invalid speed format (#{str})" unless match
|
53
|
+
distance = Runby::Distance.new(match[:distance])
|
54
|
+
Speed.new distance
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.try_parse(str)
|
58
|
+
speed = nil
|
59
|
+
error_message = nil
|
60
|
+
warning_message = nil
|
61
|
+
begin
|
62
|
+
speed = Speed.parse str
|
63
|
+
rescue StandardError => ex
|
64
|
+
error_message = "#{ex.message} (#{str})"
|
65
|
+
end
|
66
|
+
{ speed: speed, error: error_message, warning: warning_message }
|
67
|
+
end
|
68
|
+
|
69
|
+
def <=>(other)
|
70
|
+
raise "Unable to compare Runby::Speed to #{other.class}(#{other})" unless [Speed, String].include? other.class
|
71
|
+
if other.is_a? String
|
72
|
+
return 0 if to_s == other || to_s(format: :long) == other
|
73
|
+
self <=> try_parse(other)[:speed]
|
74
|
+
end
|
75
|
+
@distance <=> other.distance
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def init_from_distance(distance)
|
81
|
+
@distance = distance
|
82
|
+
end
|
83
|
+
|
84
|
+
def init_from_multiplier(multiplier, uom)
|
85
|
+
@distance = Distance.new(uom, multiplier)
|
86
|
+
end
|
87
|
+
|
88
|
+
def init_from_distance_string(str)
|
89
|
+
results = Distance.try_parse(str)
|
90
|
+
unless results[:error]
|
91
|
+
@distance = results[:distance]
|
92
|
+
return
|
93
|
+
end
|
94
|
+
raise "'#{str}' is not recognized as a Speed or a Distance"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'runby_range'
|
4
|
+
|
5
|
+
module Runby
|
6
|
+
# Represents a range of speeds, from fast to slow.
|
7
|
+
class SpeedRange < RunbyRange
|
8
|
+
def initialize(fast, slow)
|
9
|
+
raise "Invalid fast speed value: #{fast}" unless fast.is_a?(Numeric) || fast.is_a?(Speed)
|
10
|
+
raise "Invalid slow speed value: #{slow}" unless slow.is_a?(Numeric) || slow.is_a?(Speed)
|
11
|
+
@fast = Runby::Speed.new(fast)
|
12
|
+
@slow = Runby::Speed.new(slow)
|
13
|
+
freeze
|
14
|
+
end
|
15
|
+
|
16
|
+
def as_pace_range
|
17
|
+
Runby::PaceRange.new @fast.as_pace, @slow.as_pace
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_s(format: :short)
|
21
|
+
if @fast == @slow
|
22
|
+
@fast.to_s(format: format)
|
23
|
+
else
|
24
|
+
fast_multiplier = format('%g', @fast.distance.multiplier.round(2))
|
25
|
+
slow_multiplier = format('%g', @slow.distance.multiplier.round(2))
|
26
|
+
@fast.to_s(format: format).sub(fast_multiplier.to_s, "#{fast_multiplier}-#{slow_multiplier}")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Runby
|
4
|
+
module Utility
|
5
|
+
# Helps sanitize method parameters. (See RSpec documentation for examples)
|
6
|
+
class ParameterSanitizer
|
7
|
+
attr_reader :parameter
|
8
|
+
|
9
|
+
def initialize(parameter)
|
10
|
+
@parameter = parameter
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.sanitize(parameter)
|
14
|
+
ParameterSanitizer.new parameter
|
15
|
+
end
|
16
|
+
|
17
|
+
def as(type)
|
18
|
+
return @parameter if @parameter.is_a?(type)
|
19
|
+
raise "Unable to sanitize parameter of type #{type}. Missing 'parse' method." unless type.respond_to? :parse
|
20
|
+
type.parse(@parameter)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Just a shortcut method
|
26
|
+
def self.sanitize(parameter)
|
27
|
+
Runby::Utility::ParameterSanitizer.sanitize(parameter)
|
28
|
+
end
|
29
|
+
end
|