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.
Files changed (44) hide show
  1. checksums.yaml +5 -5
  2. data/.rubocop.yml +10 -0
  3. data/.travis.yml +9 -2
  4. data/Gemfile +4 -0
  5. data/README.md +16 -5
  6. data/Rakefile +40 -6
  7. data/bin/_guard-core +17 -16
  8. data/bin/guard +17 -16
  9. data/bin/runbypace +15 -0
  10. data/lib/runby_pace/cli/cli.rb +127 -0
  11. data/lib/runby_pace/cli/config.rb +82 -0
  12. data/lib/runby_pace/distance.rb +135 -0
  13. data/lib/runby_pace/distance_unit.rb +89 -0
  14. data/lib/runby_pace/golden_pace_set.rb +50 -0
  15. data/lib/runby_pace/pace.rb +152 -0
  16. data/lib/runby_pace/{pace_data.rb → pace_calculator.rb} +29 -13
  17. data/lib/runby_pace/pace_range.rb +27 -9
  18. data/lib/runby_pace/run_math.rb +14 -0
  19. data/lib/runby_pace/run_type.rb +12 -4
  20. data/lib/runby_pace/run_types/all_run_types.g.rb +14 -12
  21. data/lib/runby_pace/run_types/all_run_types.template +6 -4
  22. data/lib/runby_pace/run_types/distance_run.rb +55 -0
  23. data/lib/runby_pace/run_types/easy_run.rb +31 -10
  24. data/lib/runby_pace/run_types/fast_tempo_run.rb +23 -0
  25. data/lib/runby_pace/run_types/find_divisor.rb +13 -17
  26. data/lib/runby_pace/run_types/five_kilometer_race_run.rb +22 -0
  27. data/lib/runby_pace/run_types/long_run.rb +32 -10
  28. data/lib/runby_pace/run_types/mile_race_run.rb +24 -0
  29. data/lib/runby_pace/run_types/slow_tempo_run.rb +22 -0
  30. data/lib/runby_pace/run_types/tempo_run.rb +54 -0
  31. data/lib/runby_pace/run_types/ten_kilometer_race_run.rb +23 -0
  32. data/lib/runby_pace/runby_range.rb +22 -0
  33. data/lib/runby_pace/runby_time.rb +138 -0
  34. data/lib/runby_pace/runby_time_parser.rb +80 -0
  35. data/lib/runby_pace/speed.rb +97 -0
  36. data/lib/runby_pace/speed_range.rb +30 -0
  37. data/lib/runby_pace/utility/parameter_sanitizer.rb +29 -0
  38. data/lib/runby_pace/version.rb +17 -2
  39. data/lib/runby_pace/version.seed +5 -0
  40. data/lib/runby_pace.rb +4 -1
  41. data/misc/runbypace_logo.png +0 -0
  42. data/runby_pace.gemspec +5 -6
  43. metadata +32 -9
  44. data/lib/runby_pace/pace_time.rb +0 -110
@@ -1,47 +1,43 @@
1
- module RunbyPace
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 PaceData for each run type to
8
- # dial in the height of the curve. (See RunbyPace::PaceData)
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 [Hash] golden_paces
13
+ # @param [GoldenPaceSet] golden_pace_set
13
14
  # @param [String] allowable_deviation
14
15
  # @return [decimal]
15
- def self.find_divisor(golden_paces, allowable_deviation = '00:01')
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
- golden_paces.each do |five_k, golden_pace|
24
- five_k_time = RunbyPace::PaceTime.new(five_k.to_s)
25
- pace_data = RunbyPace::PaceData.new(first_pace, last_pace, candidate_divisor)
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
- if !calculated_pace.almost_equals?(golden_pace, allowable_deviation)
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
- if viable_divisor != nil
35
- viable_divisors << viable_divisor
36
- end
33
+ viable_divisors << viable_divisor unless viable_divisor.nil?
37
34
  end
38
35
 
39
- if viable_divisors.length > 0
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
- module RunbyPace
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 :slow_pace_data, :fast_pace_data
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
- @fast_pace_data = PaceData.new(GoldenPaces::fast[:'14:00'], GoldenPaces::fast[:'42:00'], 2.125)
14
- @slow_pace_data = PaceData.new(GoldenPaces::slow[:'14:00'], GoldenPaces::slow[:'42:00'], 1.55)
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 pace(five_k_time)
18
- fast = @fast_pace_data.calc(five_k_time)
19
- slow = @slow_pace_data.calc(five_k_time)
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
- { '14:00': '04:00', '15:00': '04:16', '20:00': '05:31', '25:00': '06:44', '30:00': '07:54', '35:00':'09:01', '40:00':'10:07', '42:00':'10:32'}
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
- { '14:00': '04:39', '15:00': '04:57', '20:00': '06:22', '25:00': '07:43', '30:00': '09:00', '35:00':'10:15', '40:00':'11:26', '42:00':'11:53'}
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